github.com/FollowTheProcess/tag@v0.4.2/app/app.go (about)

     1  // Package app implements the functionality of tag, the CLI calls
     2  // exported members of this package.
     3  package app
     4  
     5  import (
     6  	"bytes"
     7  	"errors"
     8  	"fmt"
     9  	"io"
    10  	"io/fs"
    11  	"os"
    12  	"path/filepath"
    13  	"strings"
    14  
    15  	"github.com/AlecAivazis/survey/v2"
    16  	"github.com/FollowTheProcess/msg"
    17  	"github.com/FollowTheProcess/semver"
    18  	"github.com/FollowTheProcess/tag/config"
    19  	"github.com/FollowTheProcess/tag/git"
    20  	"github.com/FollowTheProcess/tag/hooks"
    21  )
    22  
    23  // ErrAborted is returned whenever an action is aborted by the user.
    24  var ErrAborted = errors.New("Aborted")
    25  
    26  // App represents the tag program.
    27  type App struct {
    28  	Stdout      io.Writer
    29  	Stderr      io.Writer
    30  	Cfg         config.Config
    31  	replaceMode bool
    32  }
    33  
    34  // bumpType is an enum of recognised bump types.
    35  type bumpType int
    36  
    37  const (
    38  	major bumpType = iota
    39  	minor
    40  	patch
    41  )
    42  
    43  // New constructs and returns a new App.
    44  func New(cwd string, stdout, stderr io.Writer) (App, error) {
    45  	path := filepath.Join(cwd, config.Filename)
    46  	replaceMode := true
    47  	cfg, err := config.Load(path)
    48  	if err != nil {
    49  		if errors.Is(err, config.ErrNoConfigFile) {
    50  			replaceMode = false
    51  		} else {
    52  			return App{}, err
    53  		}
    54  	}
    55  
    56  	app := App{
    57  		Stdout:      stdout,
    58  		Stderr:      stderr,
    59  		Cfg:         cfg,
    60  		replaceMode: replaceMode,
    61  	}
    62  
    63  	return app, nil
    64  }
    65  
    66  // List handles the list subcommand.
    67  func (a App) List(limit int) error {
    68  	if err := a.ensureRepo(); err != nil {
    69  		return err
    70  	}
    71  	if limit <= 0 {
    72  		return fmt.Errorf("--limit must be a positive integer")
    73  	}
    74  	tags, limitHit, err := git.ListTags(limit)
    75  	if err != nil {
    76  		return err
    77  	}
    78  
    79  	fmt.Fprintln(a.Stdout, strings.TrimSpace(tags))
    80  	if limitHit {
    81  		fmt.Fprintln(a.Stdout)
    82  		msg.Fwarn(a.Stdout, "Truncated, pass --limit to see more")
    83  	}
    84  
    85  	return nil
    86  }
    87  
    88  // Latest handles the latest subcommand.
    89  func (a App) Latest() error {
    90  	if err := a.ensureRepo(); err != nil {
    91  		return err
    92  	}
    93  	tag, err := git.LatestTag()
    94  	if err != nil {
    95  		return err
    96  	}
    97  	fmt.Fprintln(a.Stdout, tag)
    98  	return nil
    99  }
   100  
   101  // Init handles the init subcommand.
   102  func (a App) Init(cwd string, force bool) error {
   103  	path := filepath.Join(cwd, config.Filename)
   104  	configFileExists, err := exists(path)
   105  	if err != nil {
   106  		return err
   107  	}
   108  
   109  	cfg := config.Config{
   110  		Version: "0.1.0",
   111  		Git: config.Git{
   112  			DefaultBranch:   "main",
   113  			MessageTemplate: "Bump version {{.Current}} -> {{.Next}}",
   114  			TagTemplate:     "v{{.Next}}",
   115  		},
   116  		Hooks: config.Hooks{
   117  			PreReplace: "echo 'I run before doing anything'",
   118  			PreCommit:  "echo 'I run after replacing but before committing changes'",
   119  			PreTag:     "echo 'I run after committing changes but before tagging'",
   120  			PrePush:    "echo 'I run after tagging, but before pushing'",
   121  		},
   122  		Files: []config.File{
   123  			{
   124  				Path:   "pyproject.toml",
   125  				Search: `version = "{{.Current}}"`,
   126  			},
   127  			{
   128  				Path:   "README.md",
   129  				Search: "My project, version {{.Current}}",
   130  			},
   131  		},
   132  	}
   133  
   134  	if !configFileExists {
   135  		// No config file, just go ahead and make one
   136  		if err := cfg.Save(path); err != nil {
   137  			return err
   138  		}
   139  		msg.Fsuccess(a.Stdout, "Config file written to %s", path)
   140  		return nil
   141  	}
   142  
   143  	// Config file does exist, let's ask for overwrite and check force
   144  	if !force {
   145  		confirm := &survey.Confirm{
   146  			Message: fmt.Sprintf("Config file %s already exists. Overwrite?", path),
   147  			Default: false,
   148  		}
   149  		err := survey.AskOne(confirm, &force)
   150  		if err != nil {
   151  			return err
   152  		}
   153  	}
   154  
   155  	// Now if force is still false, user said no -> abort
   156  	if !force {
   157  		return ErrAborted
   158  	}
   159  
   160  	// User has either confirmed or passed --force
   161  	if err := cfg.Save(path); err != nil {
   162  		return err
   163  	}
   164  	msg.Fsuccess(a.Stdout, "Config file written to %s", path)
   165  	return nil
   166  }
   167  
   168  // TODO: When it rewrites the config back, it does the rendered config with all
   169  // the .Current and .Next set to the actual values
   170  // Read the config in from scratch so it's not rendered (or make a new one)
   171  // and then only update the version before saving back
   172  
   173  // Major handles the major subcommand.
   174  func (a App) Major(push, force, dryRun bool) error {
   175  	return a.bump(major, push, force, dryRun)
   176  }
   177  
   178  // Minor handles the minor subcommand.
   179  func (a App) Minor(push, force, dryRun bool) error {
   180  	return a.bump(minor, push, force, dryRun)
   181  }
   182  
   183  // Patch handles the minor subcommand.
   184  func (a App) Patch(push, force, dryRun bool) error {
   185  	return a.bump(patch, push, force, dryRun)
   186  }
   187  
   188  // replaceAll is a helper that performs and reports on file replacement
   189  // as part of bumping.
   190  func (a App) replaceAll(current, next semver.Version, dryRun bool) error {
   191  	originalConfig := a.Cfg
   192  	if err := a.Cfg.Render(current.String(), next.String()); err != nil {
   193  		return err
   194  	}
   195  
   196  	if err := a.replace(dryRun); err != nil {
   197  		return err
   198  	}
   199  
   200  	// Also replace the Version in the config file
   201  	originalConfig.Version = next.String()
   202  	if !dryRun {
   203  		if err := originalConfig.Save(config.Filename); err != nil {
   204  			return err
   205  		}
   206  	}
   207  
   208  	dirty, err := git.IsDirty()
   209  	if err != nil {
   210  		return err
   211  	}
   212  
   213  	if err = a.runHook(hooks.StagePreCommit, dryRun); err != nil {
   214  		return err
   215  	}
   216  
   217  	// Only any point in committing if something has changed
   218  	if dirty {
   219  		if dryRun {
   220  			msg.Finfo(a.Stdout, "(Dry Run) Would commit changes")
   221  			return nil
   222  		}
   223  		msg.Finfo(a.Stdout, "Committing changes")
   224  		if err = git.Add(); err != nil {
   225  			return err
   226  		}
   227  
   228  		commitOut, err := git.Commit(a.Cfg.Git.MessageTemplate)
   229  		if err != nil {
   230  			return errors.New(commitOut)
   231  		}
   232  	}
   233  	return nil
   234  }
   235  
   236  // replace is a helper that performs file replacement.
   237  func (a App) replace(dryRun bool) error {
   238  	for _, file := range a.Cfg.Files {
   239  		var contents []byte
   240  		contents, err := os.ReadFile(file.Path)
   241  		if err != nil {
   242  			return err
   243  		}
   244  
   245  		if !bytes.Contains(contents, []byte(file.Search)) {
   246  			return fmt.Errorf("Could not find %q in %s", file.Search, file.Path)
   247  		}
   248  
   249  		if dryRun {
   250  			msg.Finfo(a.Stdout, "(Dry Run) Would replace %s with %s in %s", file.Search, file.Replace, file.Path)
   251  		} else {
   252  			msg.Finfo(a.Stdout, "Replacing contents in %s", file.Path)
   253  			newContent := bytes.ReplaceAll(contents, []byte(file.Search), []byte(file.Replace))
   254  
   255  			if err = os.WriteFile(file.Path, newContent, os.ModePerm); err != nil {
   256  				return err
   257  			}
   258  		}
   259  	}
   260  	return nil
   261  }
   262  
   263  // getBumpVersions is a helper that gets .Current and .Next from context.
   264  func (a App) getBumpVersions(typ bumpType) (current, next semver.Version, err error) {
   265  	if a.replaceMode {
   266  		// If the config file is present, use the version specified in there
   267  		current, err = semver.Parse(a.Cfg.Version)
   268  		if err != nil {
   269  			return semver.Version{}, semver.Version{}, err
   270  		}
   271  	} else {
   272  		// Otherwise start at the latest semver tag present
   273  		latest, err := git.LatestTag()
   274  		if err != nil {
   275  			if errors.Is(err, git.ErrNoTagsFound) {
   276  				current = semver.Version{} // No tags, no default version, start at v0.0.0
   277  			} else {
   278  				return semver.Version{}, semver.Version{}, err
   279  			}
   280  		} else {
   281  			current, err = semver.Parse(latest)
   282  			if err != nil {
   283  				return semver.Version{}, semver.Version{}, err
   284  			}
   285  		}
   286  	}
   287  
   288  	switch typ {
   289  	case major:
   290  		next = semver.BumpMajor(current)
   291  	case minor:
   292  		next = semver.BumpMinor(current)
   293  	case patch:
   294  		next = semver.BumpPatch(current)
   295  	default:
   296  		return semver.Version{}, semver.Version{}, fmt.Errorf("Unrecognised bump type: %v", typ)
   297  	}
   298  
   299  	return current, next, nil
   300  }
   301  
   302  // bump is a helper that performs logic common to all bump methods.
   303  func (a App) bump(typ bumpType, push, force, dryRun bool) error {
   304  	if err := a.ensureRepo(); err != nil {
   305  		return err
   306  	}
   307  	if err := a.ensureBumpable(); err != nil {
   308  		return err
   309  	}
   310  
   311  	current, next, err := a.getBumpVersions(typ)
   312  	if err != nil {
   313  		return err
   314  	}
   315  
   316  	if !force {
   317  		confirm := &survey.Confirm{
   318  			Message: fmt.Sprintf("This will bump %q to %q. Are you sure?", current, next),
   319  			Default: false,
   320  		}
   321  		err := survey.AskOne(confirm, &force)
   322  		if err != nil {
   323  			return err
   324  		}
   325  	}
   326  
   327  	// Now if force is false, the user said no -> abort
   328  	if !force {
   329  		return ErrAborted
   330  	}
   331  
   332  	if err := a.runHook(hooks.StagePreReplace, dryRun); err != nil {
   333  		return err
   334  	}
   335  
   336  	if a.replaceMode {
   337  		if err := a.replaceAll(current, next, dryRun); err != nil {
   338  			return err
   339  		}
   340  	}
   341  
   342  	if err := a.runHook(hooks.StagePreTag, dryRun); err != nil {
   343  		return err
   344  	}
   345  
   346  	if dryRun {
   347  		msg.Finfo(a.Stdout, "(Dry Run) Would issue new tag %s", next.Tag())
   348  	} else {
   349  		msg.Finfo(a.Stdout, "Issuing new tag %s", next.Tag())
   350  		stdout, err := git.CreateTag(next.Tag(), a.Cfg.Git.TagTemplate)
   351  		if err != nil {
   352  			return errors.New(stdout)
   353  		}
   354  	}
   355  
   356  	// If --push, push the tag and commit
   357  	if push {
   358  		if err := a.runHook(hooks.StagePrePush, dryRun); err != nil {
   359  			return err
   360  		}
   361  		if dryRun {
   362  			msg.Finfo(a.Stdout, "(Dry Run) Would push tag %s", next.Tag())
   363  			return nil
   364  		}
   365  		msg.Finfo(a.Stdout, "Pushing tag %s", next.Tag())
   366  		stdout, err := git.Push()
   367  		if err != nil {
   368  			return errors.New(stdout)
   369  		}
   370  	}
   371  	return nil
   372  }
   373  
   374  // runHook is a helper that runs a particular hook stage (if it is defined)
   375  // and understands --dry-run.
   376  func (a App) runHook(stage hooks.HookStage, dryRun bool) error {
   377  	var hookCmd string
   378  	switch stage {
   379  	case hooks.StagePreReplace:
   380  		hookCmd = a.Cfg.Hooks.PreReplace
   381  	case hooks.StagePreCommit:
   382  		hookCmd = a.Cfg.Hooks.PreCommit
   383  	case hooks.StagePreTag:
   384  		hookCmd = a.Cfg.Hooks.PreTag
   385  	case hooks.StagePrePush:
   386  		hookCmd = a.Cfg.Hooks.PrePush
   387  	default:
   388  		return fmt.Errorf("Unhandled hook type: %s", stage)
   389  	}
   390  
   391  	if hookCmd == "" {
   392  		// No op if the hook is not defined
   393  		return nil
   394  	}
   395  
   396  	if dryRun {
   397  		msg.Finfo(a.Stdout, "(Dry Run) Would run hook %s: %s", stage, hookCmd)
   398  		return nil
   399  	}
   400  	return hooks.Run(stage, hookCmd, a.Stdout, a.Stderr)
   401  }
   402  
   403  // ensureRepo is a helper that will error if the current directory is not
   404  // a git repo.
   405  func (a App) ensureRepo() error {
   406  	if !git.IsRepo() {
   407  		return errors.New("Not a git repo")
   408  	}
   409  	return nil
   410  }
   411  
   412  // ensureBumpable is a helper that will error if the current git state is not
   413  // "bumpable", that is we're on the default branch, and the working tree is clean.
   414  func (a App) ensureBumpable() error {
   415  	dirty, err := git.IsDirty()
   416  	if err != nil {
   417  		return err
   418  	}
   419  	if dirty {
   420  		return errors.New("Working tree is not clean")
   421  	}
   422  
   423  	branch, err := git.Branch()
   424  	if err != nil {
   425  		return err
   426  	}
   427  
   428  	if a.Cfg.Git.DefaultBranch == "" {
   429  		a.Cfg.Git.DefaultBranch = "main" // Default
   430  	}
   431  
   432  	if branch != a.Cfg.Git.DefaultBranch {
   433  		return fmt.Errorf("Not on default branch (%s), currently on: %s", a.Cfg.Git.DefaultBranch, branch)
   434  	}
   435  
   436  	return nil
   437  }
   438  
   439  func exists(path string) (bool, error) {
   440  	_, err := os.Stat(path)
   441  	if err != nil {
   442  		if errors.Is(err, fs.ErrNotExist) {
   443  			return false, nil
   444  		}
   445  		return false, err
   446  	}
   447  	return true, nil
   448  }