Skip to content

Commit cc21c25

Browse files
committed
cascading rebase
1 parent 775d0b3 commit cc21c25

3 files changed

Lines changed: 458 additions & 1 deletion

File tree

cmd/root.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ func RootCmd() *cobra.Command {
2727

2828
// Helper commands
2929
root.AddCommand(ViewCmd(cfg))
30+
root.AddCommand(UpdateCmd(cfg))
31+
32+
// Navigation commands
3033
root.AddCommand(UpCmd(cfg))
3134
root.AddCommand(DownCmd(cfg))
3235
root.AddCommand(TopCmd(cfg))

cmd/update.go

Lines changed: 386 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,386 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
9+
"github.com/github/gh-stack/internal/config"
10+
"github.com/github/gh-stack/internal/git"
11+
"github.com/github/gh-stack/internal/stack"
12+
"github.com/spf13/cobra"
13+
)
14+
15+
type updateOptions struct {
16+
branch string
17+
downstack bool
18+
upstack bool
19+
cont bool
20+
abort bool
21+
}
22+
23+
type rebaseState struct {
24+
CurrentBranchIndex int `json:"currentBranchIndex"`
25+
ConflictBranch string `json:"conflictBranch"`
26+
RemainingBranches []string `json:"remainingBranches"`
27+
OriginalBranch string `json:"originalBranch"`
28+
OriginalRefs map[string]string `json:"originalRefs"`
29+
}
30+
31+
const rebaseStateFile = "gh-stack-rebase-state"
32+
33+
func UpdateCmd(cfg *config.Config) *cobra.Command {
34+
opts := &updateOptions{}
35+
36+
cmd := &cobra.Command{
37+
Use: "update [branch]",
38+
Short: "Update and rebase a stack of branches",
39+
Long: `Pull from remote and do a cascading rebase across the stack.
40+
41+
Ensures that each branch in the stack has the tip of the previous
42+
layer in its commit history, rebasing if necessary.`,
43+
Example: ` $ gh stack update
44+
$ gh stack update --downstack
45+
$ gh stack update --continue
46+
$ gh stack update --abort`,
47+
Args: cobra.MaximumNArgs(1),
48+
RunE: func(cmd *cobra.Command, args []string) error {
49+
if len(args) > 0 {
50+
opts.branch = args[0]
51+
}
52+
return runUpdate(cfg, opts)
53+
},
54+
}
55+
56+
cmd.Flags().BoolVar(&opts.downstack, "downstack", false, "Only update branches from trunk to current branch")
57+
cmd.Flags().BoolVar(&opts.upstack, "upstack", false, "Only update branches from current branch to top")
58+
cmd.Flags().BoolVar(&opts.cont, "continue", false, "Continue rebase after resolving conflicts")
59+
cmd.Flags().BoolVar(&opts.abort, "abort", false, "Abort rebase and restore all branches")
60+
61+
return cmd
62+
}
63+
64+
func runUpdate(cfg *config.Config, opts *updateOptions) error {
65+
gitDir, err := git.GitDir()
66+
if err != nil {
67+
cfg.Errorf("not a git repository")
68+
return nil
69+
}
70+
71+
if opts.cont {
72+
return continueUpdate(cfg, gitDir)
73+
}
74+
75+
if opts.abort {
76+
return abortUpdate(cfg, gitDir)
77+
}
78+
79+
sf, err := stack.Load(gitDir)
80+
if err != nil {
81+
cfg.Errorf("failed to load stack state: %s", err)
82+
return nil
83+
}
84+
85+
currentBranch := opts.branch
86+
if currentBranch == "" {
87+
currentBranch, err = git.CurrentBranch()
88+
if err != nil {
89+
cfg.Errorf("unable to determine current branch: %s", err)
90+
return nil
91+
}
92+
}
93+
94+
s := sf.FindStackForBranch(currentBranch)
95+
if s == nil {
96+
cfg.Errorf("no stack found for branch %s", currentBranch)
97+
return nil
98+
}
99+
100+
cfg.Printf("Fetching origin ...")
101+
if err := git.Fetch("origin"); err != nil {
102+
cfg.Warningf("Failed to fetch origin: %v", err)
103+
} else {
104+
cfg.Successf("Fetching origin")
105+
}
106+
107+
chainParts := []string{s.Trunk.Branch}
108+
for _, b := range s.Branches {
109+
chainParts = append(chainParts, b.Branch)
110+
}
111+
cfg.Printf("Stack detected: %s", joinChain(chainParts))
112+
113+
currentIdx := s.IndexOf(currentBranch)
114+
if currentIdx < 0 {
115+
currentIdx = 0
116+
}
117+
118+
startIdx := 0
119+
endIdx := len(s.Branches)
120+
121+
if opts.downstack {
122+
endIdx = currentIdx + 1
123+
}
124+
if opts.upstack {
125+
startIdx = currentIdx
126+
}
127+
128+
branchesToUpdate := s.Branches[startIdx:endIdx]
129+
130+
if len(branchesToUpdate) == 0 {
131+
cfg.Printf("No branches to update")
132+
return nil
133+
}
134+
135+
cfg.Printf("Updating branches in order, starting from %s to %s",
136+
branchesToUpdate[0].Branch, branchesToUpdate[len(branchesToUpdate)-1].Branch)
137+
138+
originalRefs := make(map[string]string)
139+
for _, b := range s.Branches {
140+
sha, _ := git.HeadSHA(b.Branch)
141+
originalRefs[b.Branch] = sha
142+
}
143+
144+
for i, br := range branchesToUpdate {
145+
var base string
146+
absIdx := startIdx + i
147+
if absIdx == 0 {
148+
base = s.Trunk.Branch
149+
} else {
150+
base = s.Branches[absIdx-1].Branch
151+
}
152+
153+
cfg.Printf("Rebasing %s onto %s ...", br.Branch, base)
154+
155+
if err := git.CheckoutBranch(br.Branch); err != nil {
156+
return fmt.Errorf("checking out %s: %w", br.Branch, err)
157+
}
158+
159+
if err := git.Rebase(base); err != nil {
160+
cfg.Warningf("Rebasing %s onto %s ... conflict", br.Branch, base)
161+
162+
remaining := make([]string, 0)
163+
for j := i + 1; j < len(branchesToUpdate); j++ {
164+
remaining = append(remaining, branchesToUpdate[j].Branch)
165+
}
166+
167+
state := &rebaseState{
168+
CurrentBranchIndex: absIdx,
169+
ConflictBranch: br.Branch,
170+
RemainingBranches: remaining,
171+
OriginalBranch: currentBranch,
172+
OriginalRefs: originalRefs,
173+
}
174+
saveRebaseState(gitDir, state)
175+
176+
printConflictDetails(cfg, base)
177+
cfg.Printf("")
178+
179+
cfg.Printf("Resolve conflicts on %s, then run %s",
180+
br.Branch, cfg.ColorCyan("gh stack update --continue"))
181+
cfg.Printf("Or abort this operation with %s",
182+
cfg.ColorCyan("gh stack update --abort"))
183+
return fmt.Errorf("rebase conflict on %s", br.Branch)
184+
}
185+
186+
cfg.Successf("Rebasing %s onto %s", br.Branch, base)
187+
}
188+
189+
_ = git.CheckoutBranch(currentBranch)
190+
191+
for i := range s.Branches {
192+
sha, _ := git.HeadSHA(s.Branches[i].Branch)
193+
s.Branches[i].Head = sha
194+
}
195+
_ = stack.Save(gitDir, sf)
196+
197+
rangeDesc := "All branches in stack"
198+
if opts.downstack {
199+
rangeDesc = fmt.Sprintf("All downstack branches up to %s", currentBranch)
200+
} else if opts.upstack {
201+
rangeDesc = fmt.Sprintf("All upstack branches from %s", currentBranch)
202+
}
203+
204+
cfg.Printf("%s updated locally with %s", rangeDesc, s.Trunk.Branch)
205+
cfg.Printf("To push up your changes and open/update the stack of PRs, run %s",
206+
cfg.ColorCyan("gh stack push -f"))
207+
208+
return nil
209+
}
210+
211+
func continueUpdate(cfg *config.Config, gitDir string) error {
212+
state, err := loadRebaseState(gitDir)
213+
if err != nil {
214+
cfg.Errorf("no rebase in progress")
215+
return nil
216+
}
217+
218+
sf, err := stack.Load(gitDir)
219+
if err != nil {
220+
cfg.Errorf("failed to load stack state: %s", err)
221+
return nil
222+
}
223+
224+
// Use the saved original branch to find the stack, since git may be in
225+
// a detached HEAD state during an active rebase.
226+
s := sf.FindStackForBranch(state.OriginalBranch)
227+
if s == nil {
228+
return fmt.Errorf("no stack found for branch %s", state.OriginalBranch)
229+
}
230+
231+
// The branch that had the conflict is stored in state; fall back to
232+
// looking it up by index for backwards compatibility with older state files.
233+
conflictBranch := state.ConflictBranch
234+
if conflictBranch == "" && state.CurrentBranchIndex >= 0 && state.CurrentBranchIndex < len(s.Branches) {
235+
conflictBranch = s.Branches[state.CurrentBranchIndex].Branch
236+
}
237+
238+
cfg.Printf("Continuing update of stack, resuming from %s to %s",
239+
conflictBranch, s.Branches[len(s.Branches)-1].Branch)
240+
241+
if git.IsRebaseInProgress() {
242+
if err := git.RebaseContinue(); err != nil {
243+
return fmt.Errorf("rebase continue failed — resolve remaining conflicts and try again: %w", err)
244+
}
245+
}
246+
247+
var baseBranch string
248+
if state.CurrentBranchIndex > 0 {
249+
baseBranch = s.Branches[state.CurrentBranchIndex-1].Branch
250+
} else {
251+
baseBranch = s.Trunk.Branch
252+
}
253+
cfg.Successf("Rebasing %s onto %s", conflictBranch, baseBranch)
254+
255+
for _, branchName := range state.RemainingBranches {
256+
idx := s.IndexOf(branchName)
257+
var base string
258+
if idx == 0 {
259+
base = s.Trunk.Branch
260+
} else {
261+
base = s.Branches[idx-1].Branch
262+
}
263+
264+
cfg.Printf("Rebasing %s onto %s ...", branchName, base)
265+
266+
if err := git.CheckoutBranch(branchName); err != nil {
267+
cfg.Errorf("checking out %s: %s", branchName, err)
268+
return nil
269+
}
270+
271+
if err := git.Rebase(base); err != nil {
272+
remainIdx := -1
273+
for ri, rb := range state.RemainingBranches {
274+
if rb == branchName {
275+
remainIdx = ri
276+
break
277+
}
278+
}
279+
state.RemainingBranches = state.RemainingBranches[remainIdx+1:]
280+
state.CurrentBranchIndex = idx
281+
state.ConflictBranch = branchName
282+
saveRebaseState(gitDir, state)
283+
284+
cfg.Warningf("Rebasing %s onto %s ... conflict", branchName, base)
285+
printConflictDetails(cfg, base)
286+
cfg.Printf("")
287+
cfg.Printf("Resolve conflicts on %s, then run %s",
288+
branchName, cfg.ColorCyan("gh stack update --continue"))
289+
cfg.Printf("Or abort this operation with %s",
290+
cfg.ColorCyan("gh stack update --abort"))
291+
return fmt.Errorf("rebase conflict on %s", branchName)
292+
}
293+
294+
cfg.Successf("Rebasing %s onto %s", branchName, base)
295+
}
296+
297+
clearRebaseState(gitDir)
298+
_ = git.CheckoutBranch(state.OriginalBranch)
299+
300+
for i := range s.Branches {
301+
sha, _ := git.HeadSHA(s.Branches[i].Branch)
302+
s.Branches[i].Head = sha
303+
}
304+
_ = stack.Save(gitDir, sf)
305+
306+
cfg.Printf("All branches in stack updated locally with %s", s.Trunk.Branch)
307+
cfg.Printf("To push up your changes and open/update the stack of PRs, run %s",
308+
cfg.ColorCyan("gh stack push -f"))
309+
310+
return nil
311+
}
312+
313+
func abortUpdate(cfg *config.Config, gitDir string) error {
314+
state, err := loadRebaseState(gitDir)
315+
if err != nil {
316+
cfg.Errorf("no rebase in progress")
317+
return nil
318+
}
319+
320+
if git.IsRebaseInProgress() {
321+
_ = git.RebaseAbort()
322+
}
323+
324+
for branch, sha := range state.OriginalRefs {
325+
_ = git.CheckoutBranch(branch)
326+
_ = git.ResetHard(sha)
327+
}
328+
329+
_ = git.CheckoutBranch(state.OriginalBranch)
330+
clearRebaseState(gitDir)
331+
cfg.Successf("Rebase aborted and branches restored")
332+
333+
return nil
334+
}
335+
336+
func saveRebaseState(gitDir string, state *rebaseState) {
337+
data, _ := json.MarshalIndent(state, "", " ")
338+
_ = os.WriteFile(filepath.Join(gitDir, rebaseStateFile), data, 0644)
339+
}
340+
341+
func loadRebaseState(gitDir string) (*rebaseState, error) {
342+
data, err := os.ReadFile(filepath.Join(gitDir, rebaseStateFile))
343+
if err != nil {
344+
return nil, err
345+
}
346+
var state rebaseState
347+
if err := json.Unmarshal(data, &state); err != nil {
348+
return nil, err
349+
}
350+
return &state, nil
351+
}
352+
353+
func clearRebaseState(gitDir string) {
354+
_ = os.Remove(filepath.Join(gitDir, rebaseStateFile))
355+
}
356+
357+
func printConflictDetails(cfg *config.Config, branch string) {
358+
files, err := git.ConflictedFiles()
359+
if err != nil || len(files) == 0 {
360+
return
361+
}
362+
363+
cfg.Printf("")
364+
cfg.Printf("%s", cfg.ColorBold("Conflicted files:"))
365+
for _, f := range files {
366+
info, err := git.FindConflictMarkers(f)
367+
if err != nil || len(info.Sections) == 0 {
368+
cfg.Printf(" %s %s", cfg.ColorWarning("C"), f)
369+
continue
370+
}
371+
for _, sec := range info.Sections {
372+
cfg.Printf(" %s %s (lines %d–%d)",
373+
cfg.ColorWarning("C"), f, sec.StartLine, sec.EndLine)
374+
}
375+
}
376+
377+
cfg.Printf("")
378+
cfg.Printf("%s", cfg.ColorBold("To resolve:"))
379+
cfg.Printf(" 1. Open each conflicted file and look for conflict markers:")
380+
cfg.Printf(" %s (incoming changes from %s)", cfg.ColorCyan("<<<<<<< HEAD"), branch)
381+
cfg.Printf(" %s", cfg.ColorCyan("======="))
382+
cfg.Printf(" %s (changes being rebased)", cfg.ColorCyan(">>>>>>>"))
383+
cfg.Printf(" 2. Edit the file to keep the desired changes and remove the markers")
384+
cfg.Printf(" 3. Stage resolved files: %s", cfg.ColorCyan("git add <file>"))
385+
cfg.Printf(" 4. Continue the update: %s", cfg.ColorCyan("gh stack update --continue"))
386+
}

0 commit comments

Comments
 (0)