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 }