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