github.com/sqlitebrowser/dio@v0.0.0-20240125125356-b587368e5c6b/cmd/branchRevert.go (about)

     1  package cmd
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"io/ioutil"
     7  	"os"
     8  	"path/filepath"
     9  	"time"
    10  
    11  	"github.com/spf13/cobra"
    12  )
    13  
    14  var (
    15  	branchRevertBranch, branchRevertCommit, branchRevertTag string
    16  	branchRevertForce                                       *bool
    17  )
    18  
    19  // Reverts a database to a prior commit in its history
    20  var branchRevertCmd = &cobra.Command{
    21  	Use:   "revert [database name] --branch xxx --commit yyy",
    22  	Short: "Resets a database branch back to a previous commit",
    23  	RunE: func(cmd *cobra.Command, args []string) error {
    24  		return branchRevert(args)
    25  	},
    26  }
    27  
    28  func init() {
    29  	branchCmd.AddCommand(branchRevertCmd)
    30  	branchRevertCmd.Flags().StringVar(&branchRevertBranch, "branch", "",
    31  		"Branch to operate on")
    32  	branchRevertCmd.Flags().StringVar(&branchRevertCommit, "commit", "",
    33  		"Commit ID for the to revert to")
    34  	branchRevertForce = branchRevertCmd.Flags().BoolP("force", "f", false,
    35  		"Overwrite unsaved changes to the database?")
    36  	branchRevertCmd.Flags().StringVar(&branchRevertTag, "tag", "", "Name of tag to revert to")
    37  }
    38  
    39  func branchRevert(args []string) error {
    40  	// Ensure a database file was given
    41  	var db string
    42  	var err error
    43  	var meta metaData
    44  	if len(args) == 0 {
    45  		db, err = getDefaultDatabase()
    46  		if err != nil {
    47  			return err
    48  		}
    49  		if db == "" {
    50  			// No database name was given on the command line, and we don't have a default database selected
    51  			return errors.New("No database file specified")
    52  		}
    53  	} else {
    54  		db = args[0]
    55  	}
    56  	if len(args) > 1 {
    57  		return errors.New("Only one database can be changed at a time (for now)")
    58  	}
    59  
    60  	// Ensure the required info was given
    61  	if branchRevertCommit == "" && branchRevertTag == "" {
    62  		return errors.New("Either a commit ID or tag must be given.")
    63  	}
    64  
    65  	// Ensure we were given only a commit ID OR a tag
    66  	if branchRevertCommit != "" && branchRevertTag != "" {
    67  		return errors.New("Either a commit ID or tag must be given.  Not both!")
    68  	}
    69  
    70  	// Load the metadata
    71  	meta, err = loadMetadata(db)
    72  	if err != nil {
    73  		return err
    74  	}
    75  
    76  	// Unless --force is specified, check whether the file has changed since the last commit, and let the user know
    77  	if *branchRevertForce == false {
    78  		changed, err := dbChanged(db, meta)
    79  		if err != nil {
    80  			return err
    81  		}
    82  		if changed {
    83  			_, err = fmt.Fprintf(fOut, "%s has been changed since the last commit.  Use --force if you "+
    84  				"really want to overwrite it\n", db)
    85  			return err
    86  		}
    87  	}
    88  
    89  	// If a tag was given, make sure it exists
    90  	if branchRevertTag != "" {
    91  		tagData, ok := meta.Tags[branchRevertTag]
    92  		if !ok {
    93  			return errors.New("That tag doesn't exist")
    94  		}
    95  
    96  		// Use the commit associated with the tag
    97  		branchRevertCommit = tagData.Commit
    98  	}
    99  
   100  	// If no branch name was passed, use the active branch
   101  	if branchRevertBranch == "" {
   102  		branchRevertBranch = meta.ActiveBranch
   103  	}
   104  
   105  	// Make sure the branch exists
   106  	matchFound := false
   107  	head, ok := meta.Branches[branchRevertBranch]
   108  	if ok == false {
   109  		return errors.New("That branch doesn't exist")
   110  	}
   111  	if head.Commit == branchRevertCommit {
   112  		matchFound = true
   113  	}
   114  	delList := map[string]struct{}{}
   115  	if !matchFound {
   116  		delList[head.Commit] = struct{}{} // Start creating a list of the branch commits to be deleted
   117  	}
   118  
   119  	// Build a list of commits in the branch
   120  	commitList := []string{head.Commit}
   121  	c, ok := meta.Commits[head.Commit]
   122  	if ok == false {
   123  		return errors.New("Something has gone wrong.  Head commit for the branch isn't in the commit list")
   124  	}
   125  	for c.Parent != "" {
   126  		c = meta.Commits[c.Parent]
   127  		if c.ID == branchRevertCommit {
   128  			matchFound = true
   129  		}
   130  		if !matchFound {
   131  			delList[c.ID] = struct{}{} // Only commits prior to matchFound should be deleted
   132  		}
   133  		commitList = append(commitList, c.ID)
   134  	}
   135  
   136  	// Make sure the requested commit exists on the selected branch
   137  	if !matchFound {
   138  		return errors.New("The given commit or tag doesn't seem to exist on the selected branch")
   139  	}
   140  
   141  	// Make sure the correct database from the target branch is in local cache
   142  	var shaSum string
   143  	var lastMod time.Time
   144  	if branchRevertCommit != "" {
   145  		shaSum = meta.Commits[branchRevertCommit].Tree.Entries[0].Sha256
   146  		lastMod = meta.Commits[branchRevertCommit].Tree.Entries[0].LastModified
   147  
   148  		// Fetch the database from DBHub.io if it's not in the local cache
   149  		err = checkDBCache(db, shaSum)
   150  		if err != nil {
   151  			return err
   152  		}
   153  	} else {
   154  		return errors.New("Haven't been able to determine branch name.  This shouldn't happen")
   155  	}
   156  
   157  	// Check if deleting the commits would leave isolated tags or releases.  If so, abort and warn the user
   158  	type isolCheck struct {
   159  		safe   bool
   160  		commit string
   161  	}
   162  	var isolatedTags []string
   163  	var isolatedReleases []string
   164  	commitTags := map[string]isolCheck{}
   165  	commitReleases := map[string]isolCheck{}
   166  	for delCommit := range delList {
   167  		// Ensure that deleting this commit won't result in any isolated/unreachable tags
   168  		for tName, tEntry := range meta.Tags {
   169  			// Scan through the database tag list, checking if any of the tags is for the commit we're deleting
   170  			if tEntry.Commit == delCommit {
   171  				commitTags[tName] = isolCheck{safe: false, commit: delCommit}
   172  			}
   173  		}
   174  
   175  		// Ensure that deleting this commit won't result in any isolated/unreachable releases
   176  		for rName, rEntry := range meta.Releases {
   177  			// Scan through the database release list, checking if any of the releases is for the commit we're
   178  			// deleting
   179  			if rEntry.Commit == delCommit {
   180  				commitReleases[rName] = isolCheck{safe: false, commit: delCommit}
   181  			}
   182  		}
   183  	}
   184  
   185  	if len(commitTags) > 0 {
   186  		// If a commit we're deleting has a tag on it, we need to check whether the commit is on other branches too
   187  		//   * If it is, we're ok to proceed as the tag can still be reached from the other branch(es)
   188  		//   * If it isn't, we need to abort this deletion (and tell the user), as the tag would become unreachable
   189  		for bName, bEntry := range meta.Branches {
   190  			if bName == branchRevertBranch {
   191  				// We only run this comparison from "other branches", not the branch whose history we're changing
   192  				continue
   193  			}
   194  			c, ok = meta.Commits[bEntry.Commit]
   195  			if !ok {
   196  				return fmt.Errorf("Broken commit history encountered when checking for isolated tags "+
   197  					"while reverting in branch '%s' of database '%s'\n", branchRevertBranch, db)
   198  			}
   199  			for tName, tEntry := range commitTags {
   200  				if c.ID == tEntry.commit {
   201  					// The commit is also on another branch, so we're ok to delete the commit
   202  					tmp := commitTags[tName]
   203  					tmp.safe = true
   204  					commitTags[tName] = tmp
   205  				}
   206  			}
   207  			for c.Parent != "" {
   208  				c, ok = meta.Commits[c.Parent]
   209  				if !ok {
   210  					return fmt.Errorf("Broken commit history encountered when checking for isolated tags "+
   211  						"while reverting in branch '%s' of database '%s'\n", branchRevertBranch, db)
   212  				}
   213  				for tName, tEntry := range commitTags {
   214  					if c.ID == tEntry.commit {
   215  						// The commit is also on another branch, so we're ok to delete the commit
   216  						tmp := commitTags[tName]
   217  						tmp.safe = true
   218  						commitTags[tName] = tmp
   219  					}
   220  				}
   221  			}
   222  		}
   223  
   224  		// Create a list of would-be-isolated tags
   225  		for tName, tEntry := range commitTags {
   226  			if tEntry.safe == false {
   227  				isolatedTags = append(isolatedTags, tName)
   228  			}
   229  		}
   230  	}
   231  
   232  	if len(commitReleases) > 0 {
   233  		// If a commit we're deleting has a release on it, we need to check whether the commit is on other branches too
   234  		//   * If it is, we're ok to proceed as the release can still be reached from the other branch(es)
   235  		//   * If it isn't, we need to abort this deletion (and tell the user), as the release would become unreachable
   236  		for bName, bEntry := range meta.Branches {
   237  			if bName == branchRevertBranch {
   238  				// We only run this comparison from "other branches", not the branch whose history we're changing
   239  				continue
   240  			}
   241  			c, ok = meta.Commits[bEntry.Commit]
   242  			if !ok {
   243  				return fmt.Errorf("Broken commit history encountered when checking for isolated releases "+
   244  					"while reverting in branch '%s' of database '%s'\n", branchRevertBranch, db)
   245  			}
   246  			for rName, rEntry := range commitReleases {
   247  				if c.ID == rEntry.commit {
   248  					// The commit is also on another branch, so we're ok to delete the commit
   249  					tmp := commitReleases[rName]
   250  					tmp.safe = true
   251  					commitReleases[rName] = tmp
   252  				}
   253  			}
   254  			for c.Parent != "" {
   255  				c, ok = meta.Commits[c.Parent]
   256  				if !ok {
   257  					return fmt.Errorf("Broken commit history encountered when checking for isolated "+
   258  						"releases while reverting in branch '%s' of database '%s'\n", branchRevertBranch, db)
   259  				}
   260  				for rName, rEntry := range commitReleases {
   261  					if c.ID == rEntry.commit {
   262  						// The commit is also on another branch, so we're ok to delete the commit
   263  						tmp := commitReleases[rName]
   264  						tmp.safe = true
   265  						commitReleases[rName] = tmp
   266  					}
   267  				}
   268  			}
   269  		}
   270  
   271  		// Create a list of would-be-isolated releases
   272  		for rName, rEntry := range commitReleases {
   273  			if rEntry.safe == false {
   274  				isolatedReleases = append(isolatedReleases, rName)
   275  			}
   276  		}
   277  	}
   278  
   279  	// If any tags or releases would be isolated, abort
   280  	if len(isolatedTags) > 0 || len(isolatedReleases) > 0 {
   281  		e := fmt.Sprint("You need to remove the following tags and releases before reverting to this " +
   282  			"commit:\n\n")
   283  		for _, j := range isolatedTags {
   284  			e = fmt.Sprintf("%s  * tag '%s'\n", e, j)
   285  		}
   286  		for _, j := range isolatedReleases {
   287  			e = fmt.Sprintf("%s  * release '%s'\n", e, j)
   288  		}
   289  		return errors.New(e)
   290  	}
   291  
   292  	// Count the number of commits in the updated branch
   293  	var commitCount int
   294  	listLen := len(commitList) - 1
   295  	for i := 0; i <= listLen; i++ {
   296  		commitCount++
   297  		if commitList[listLen-i] == branchRevertCommit {
   298  			break
   299  		}
   300  	}
   301  
   302  	// Revert the branch
   303  	// TODO: Remove the no-longer-referenced commits (if any) caused by this revert
   304  	//       * One alternative would be to leave them, and only clean up with with some kind of garbage collection
   305  	//         operation.  Even a "dio gc" to manually trigger it
   306  	newHead := branchEntry{
   307  		Commit:      branchRevertCommit,
   308  		CommitCount: commitCount,
   309  		Description: head.Description,
   310  	}
   311  	meta.Branches[branchRevertBranch] = newHead
   312  
   313  	// Copy the file from local cache to the working directory
   314  	var b []byte
   315  	b, err = ioutil.ReadFile(filepath.Join(".dio", db, "db", shaSum))
   316  	if err != nil {
   317  		return err
   318  	}
   319  	err = ioutil.WriteFile(db, b, 0644)
   320  	if err != nil {
   321  		return err
   322  	}
   323  	err = os.Chtimes(db, time.Now(), lastMod)
   324  	if err != nil {
   325  		return err
   326  	}
   327  
   328  	// Save the updated metadata back to disk
   329  	err = saveMetadata(db, meta)
   330  	if err != nil {
   331  		return err
   332  	}
   333  
   334  	_, err = fmt.Fprintln(fOut, "Branch reverted")
   335  	return err
   336  }