1package cmd 2 3import ( 4 "fmt" 5 "strings" 6 7 "github.com/git-town/git-town/src/cli" 8 "github.com/git-town/git-town/src/drivers" 9 "github.com/git-town/git-town/src/git" 10 "github.com/git-town/git-town/src/prompt" 11 "github.com/git-town/git-town/src/steps" 12 13 "github.com/spf13/cobra" 14) 15 16type shipConfig struct { 17 pullRequestNumber int64 18 branchToShip string 19 branchToMergeInto string 20 initialBranch string 21 defaultCommitMessage string 22 canShipWithDriver bool 23 hasOrigin bool 24 hasTrackingBranch bool 25 isOffline bool 26 isShippingInitialBranch bool 27 shouldShipDeleteRemoteBranch bool 28 childBranches []string 29} 30 31// optional commit message provided via the command line. 32var commitMessage string 33 34var shipCmd = &cobra.Command{ 35 Use: "ship", 36 Short: "Deliver a completed feature branch", 37 Long: `Deliver a completed feature branch 38 39Squash-merges the current branch, or <branch_name> if given, 40into the main branch, resulting in linear history on the main branch. 41 42- syncs the main branch 43- pulls remote updates for <branch_name> 44- merges the main branch into <branch_name> 45- squash-merges <branch_name> into the main branch 46 with commit message specified by the user 47- pushes the main branch to the remote repository 48- deletes <branch_name> from the local and remote repositories 49 50Ships direct children of the main branch. 51To ship a nested child branch, ship or kill all ancestor branches first. 52 53If you use GitHub, this command can squash merge pull requests via the GitHub API. Setup: 541. Get a GitHub personal access token with the "repo" scope 552. Run 'git config git-town.github-token XXX' (optionally add the '--global' flag) 56Now anytime you ship a branch with a pull request on GitHub, it will squash merge via the GitHub API. 57It will also update the base branch for any pull requests against that branch. 58 59If your origin server deletes shipped branches, for example 60GitHub's feature to automatically delete head branches, 61run "git config git-town.ship-delete-remote-branch false" 62and Git Town will leave it up to your origin server to delete the remote branch.`, 63 Run: func(cmd *cobra.Command, args []string) { 64 driver := drivers.Load(prodRepo.Config, &prodRepo.Silent, cli.PrintDriverAction) 65 config, err := gitShipConfig(args, driver, prodRepo) 66 if err != nil { 67 cli.Exit(err) 68 } 69 stepList, err := getShipStepList(config, prodRepo) 70 if err != nil { 71 cli.Exit(err) 72 } 73 runState := steps.NewRunState("ship", stepList) 74 err = steps.Run(runState, prodRepo, driver) 75 if err != nil { 76 cli.Exit(err) 77 } 78 }, 79 Args: cobra.MaximumNArgs(1), 80 PreRunE: func(cmd *cobra.Command, args []string) error { 81 if err := ValidateIsRepository(prodRepo); err != nil { 82 return err 83 } 84 return validateIsConfigured(prodRepo) 85 }, 86} 87 88// nolint:funlen 89func gitShipConfig(args []string, driver drivers.CodeHostingDriver, repo *git.ProdRepo) (result shipConfig, err error) { 90 result.initialBranch, err = repo.Silent.CurrentBranch() 91 if err != nil { 92 return result, err 93 } 94 if len(args) == 0 { 95 result.branchToShip = result.initialBranch 96 } else { 97 result.branchToShip = args[0] 98 } 99 if result.branchToShip == result.initialBranch { 100 hasOpenChanges, err := repo.Silent.HasOpenChanges() 101 if err != nil { 102 return result, err 103 } 104 if hasOpenChanges { 105 return result, fmt.Errorf("you have uncommitted changes. Did you mean to commit them before shipping?") 106 } 107 } 108 result.hasOrigin, err = repo.Silent.HasRemote("origin") 109 if err != nil { 110 return result, err 111 } 112 if result.hasOrigin && !repo.Config.IsOffline() { 113 err := repo.Logging.Fetch() 114 if err != nil { 115 return result, err 116 } 117 } 118 if result.branchToShip != result.initialBranch { 119 hasBranch, err := repo.Silent.HasLocalOrRemoteBranch(result.branchToShip) 120 if err != nil { 121 return result, err 122 } 123 if !hasBranch { 124 return result, fmt.Errorf("there is no branch named %q", result.branchToShip) 125 } 126 } 127 if !repo.Config.IsFeatureBranch(result.branchToShip) { 128 return result, fmt.Errorf("the branch %q is not a feature branch. Only feature branches can be shipped", result.branchToShip) 129 } 130 err = prompt.EnsureKnowsParentBranches([]string{result.branchToShip}, repo) 131 if err != nil { 132 return result, err 133 } 134 ensureParentBranchIsMainOrPerennialBranch(result.branchToShip) 135 result.hasTrackingBranch, err = repo.Silent.HasTrackingBranch(result.branchToShip) 136 if err != nil { 137 return result, err 138 } 139 result.isOffline = repo.Config.IsOffline() 140 result.isShippingInitialBranch = result.branchToShip == result.initialBranch 141 result.branchToMergeInto = repo.Config.GetParentBranch(result.branchToShip) 142 prInfo, err := getCanShipWithDriver(result.branchToShip, result.branchToMergeInto, driver) 143 result.canShipWithDriver = prInfo.CanMergeWithAPI 144 result.defaultCommitMessage = prInfo.DefaultCommitMessage 145 result.pullRequestNumber = prInfo.PullRequestNumber 146 result.childBranches = repo.Config.GetChildBranches(result.branchToShip) 147 result.shouldShipDeleteRemoteBranch = prodRepo.Config.ShouldShipDeleteRemoteBranch() 148 return result, err 149} 150 151func ensureParentBranchIsMainOrPerennialBranch(branchName string) { 152 parentBranch := prodRepo.Config.GetParentBranch(branchName) 153 if !prodRepo.Config.IsMainBranch(parentBranch) && !prodRepo.Config.IsPerennialBranch(parentBranch) { 154 ancestors := prodRepo.Config.GetAncestorBranches(branchName) 155 ancestorsWithoutMainOrPerennial := ancestors[1:] 156 oldestAncestor := ancestorsWithoutMainOrPerennial[0] 157 cli.Exit(fmt.Errorf(`shipping this branch would ship %q as well, 158please ship %q first`, strings.Join(ancestorsWithoutMainOrPerennial, ", "), oldestAncestor)) 159 } 160} 161 162func getShipStepList(config shipConfig, repo *git.ProdRepo) (result steps.StepList, err error) { 163 syncSteps, err := steps.GetSyncBranchSteps(config.branchToMergeInto, true, repo) 164 if err != nil { 165 return result, err 166 } 167 result.AppendList(syncSteps) 168 syncSteps, err = steps.GetSyncBranchSteps(config.branchToShip, false, repo) 169 if err != nil { 170 return result, err 171 } 172 result.AppendList(syncSteps) 173 result.Append(&steps.EnsureHasShippableChangesStep{BranchName: config.branchToShip}) 174 result.Append(&steps.CheckoutBranchStep{BranchName: config.branchToMergeInto}) 175 if config.canShipWithDriver { 176 result.Append(&steps.PushBranchStep{BranchName: config.branchToShip}) 177 result.Append(&steps.DriverMergePullRequestStep{ 178 BranchName: config.branchToShip, 179 PullRequestNumber: config.pullRequestNumber, 180 CommitMessage: commitMessage, 181 DefaultCommitMessage: config.defaultCommitMessage, 182 }) 183 result.Append(&steps.PullBranchStep{}) 184 } else { 185 result.Append(&steps.SquashMergeBranchStep{BranchName: config.branchToShip, CommitMessage: commitMessage}) 186 } 187 if config.hasOrigin && !config.isOffline { 188 result.Append(&steps.PushBranchStep{BranchName: config.branchToMergeInto, Undoable: true}) 189 } 190 // NOTE: when shipping with a driver, we can always delete the remote branch because: 191 // - we know we have a tracking branch (otherwise there would be no PR to ship via driver) 192 // - we have updated the PRs of all child branches (because we have API access) 193 // - we know we are online 194 if config.canShipWithDriver || (config.hasTrackingBranch && len(config.childBranches) == 0 && !config.isOffline) { 195 if config.shouldShipDeleteRemoteBranch { 196 result.Append(&steps.DeleteRemoteBranchStep{BranchName: config.branchToShip, IsTracking: true}) 197 } 198 } 199 result.Append(&steps.DeleteLocalBranchStep{BranchName: config.branchToShip}) 200 result.Append(&steps.DeleteParentBranchStep{BranchName: config.branchToShip}) 201 for _, child := range config.childBranches { 202 result.Append(&steps.SetParentBranchStep{BranchName: child, ParentBranchName: config.branchToMergeInto}) 203 } 204 if !config.isShippingInitialBranch { 205 result.Append(&steps.CheckoutBranchStep{BranchName: config.initialBranch}) 206 } 207 err = result.Wrap(steps.WrapOptions{RunInGitRoot: true, StashOpenChanges: !config.isShippingInitialBranch}, repo) 208 return result, err 209} 210 211func getCanShipWithDriver(branch, parentBranch string, driver drivers.CodeHostingDriver) (result drivers.PullRequestInfo, err error) { 212 hasOrigin, err := prodRepo.Silent.HasRemote("origin") 213 if err != nil { 214 return result, err 215 } 216 if !hasOrigin { 217 return result, nil 218 } 219 if prodRepo.Config.IsOffline() { 220 return result, nil 221 } 222 if driver == nil { 223 return result, nil 224 } 225 return driver.LoadPullRequestInfo(branch, parentBranch) 226} 227 228func init() { 229 shipCmd.Flags().StringVarP(&commitMessage, "message", "m", "", "Specify the commit message for the squash commit") 230 RootCmd.AddCommand(shipCmd) 231} 232