-
-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathsync.go
More file actions
489 lines (426 loc) · 15.9 KB
/
sync.go
File metadata and controls
489 lines (426 loc) · 15.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
// cmd/sync.go
package cmd
import (
"errors"
"fmt"
"os"
"slices"
"github.com/boneskull/gh-stack/internal/config"
"github.com/boneskull/gh-stack/internal/git"
"github.com/boneskull/gh-stack/internal/github"
"github.com/boneskull/gh-stack/internal/prompt"
"github.com/boneskull/gh-stack/internal/state"
"github.com/boneskull/gh-stack/internal/style"
"github.com/boneskull/gh-stack/internal/tree"
"github.com/spf13/cobra"
)
var syncCmd = &cobra.Command{
Use: "sync",
Short: "Fetch, detect merged PRs, retarget orphaned branches, restack all",
Long: `Fetch from origin, detect merged PRs, retarget orphaned branches to trunk, and restack all branches.`,
RunE: runSync,
}
var (
syncNoRestackFlag bool
syncDryRunFlag bool
syncWorktreesFlag bool
)
func init() {
syncCmd.Flags().BoolVar(&syncNoRestackFlag, "no-restack", false, "skip restacking branches")
syncCmd.Flags().BoolVar(&syncDryRunFlag, "dry-run", false, "show what would be done")
syncCmd.Flags().BoolVar(&syncWorktreesFlag, "worktrees", false, "rebase branches checked out in linked worktrees in-place")
rootCmd.AddCommand(syncCmd)
}
// updateStackComments updates the navigation comment on all PRs in the stack.
func updateStackComments(cfg *config.Config, g *git.Git, gh *github.Client, s *style.Style) error {
trunk, err := cfg.GetTrunk()
if err != nil {
return err
}
root, err := tree.Build(cfg)
if err != nil {
return err
}
// Fetch all PR titles in a single request
prNumbers := github.CollectPRNumbers(root)
prInfo, err := gh.GetPRTitles(prNumbers)
if err != nil {
// Non-fatal: we can still render without titles
prInfo = make(map[int]github.PRInfo)
}
// Build remote branches set for filtering local-only branches
remoteBranches, rbErr := g.ListRemoteBranches()
if rbErr != nil {
// Non-fatal: render without filtering
fmt.Printf("%s could not list remote branches: %v\n", s.WarningIcon(), rbErr)
}
// Walk tree and update each PR's comment
return walkTreeAndUpdateComments(root, root, trunk, gh, prInfo, remoteBranches, s)
}
func walkTreeAndUpdateComments(node, root *tree.Node, trunk string, gh *github.Client, prInfo map[int]github.PRInfo, remoteBranches map[string]bool, s *style.Style) error {
if node.PR > 0 {
comment := github.GenerateStackComment(root, node.Name, trunk, gh.RepoURL(), prInfo, remoteBranches)
if comment != "" {
if err := gh.CreateOrUpdateStackComment(node.PR, comment); err != nil {
fmt.Printf("%s failed to update comment on PR #%d: %v\n", s.WarningIcon(), node.PR, err)
// Continue with other PRs
}
}
}
for _, child := range node.Children {
if err := walkTreeAndUpdateComments(child, root, trunk, gh, prInfo, remoteBranches, s); err != nil {
return err
}
}
return nil
}
func runSync(cmd *cobra.Command, args []string) error {
s := style.New()
cwd, err := os.Getwd()
if err != nil {
return err
}
cfg, err := config.Load(cwd)
if err != nil {
return err
}
gh, err := github.NewClient()
if err != nil {
return err
}
g := git.New(cwd)
trunk, err := cfg.GetTrunk()
if err != nil {
return err
}
// Capture the starting branch to return to after sync completes
// Note: CurrentBranch() returns "HEAD" when in detached HEAD state
startingBranch, _ := g.CurrentBranch() //nolint:errcheck // empty string is fine
if startingBranch == "HEAD" {
startingBranch = "" // Treat detached HEAD as "no starting branch"
}
// Save undo snapshot of all tracked branches (unless dry-run)
// This captures state before any modifications (fetch, delete, rebase)
var stashRef string
if !syncDryRunFlag {
allBranches, listErr := cfg.ListTrackedBranches()
if listErr == nil && len(allBranches) > 0 {
var saveErr error
stashRef, saveErr = saveUndoSnapshotByName(g, cfg, allBranches, nil, "sync", "gh stack sync", s)
if saveErr != nil {
fmt.Printf("%s could not save undo state: %v\n", s.WarningIcon(), saveErr)
}
}
}
// Track if we hit a conflict (stash is saved in state for conflicts)
var hitConflict bool
// Ensure stash is restored on any exit (success or error, except conflicts)
// This defer must be after stashRef is set
defer func() {
if stashRef != "" && !hitConflict {
fmt.Println("Restoring auto-stashed changes...")
if popErr := g.StashPop(stashRef); popErr != nil {
fmt.Printf("%s could not restore stashed changes (commit %s): %v\n", s.WarningIcon(), git.AbbrevSHA(stashRef), popErr)
}
}
}()
// Fetch
fmt.Println("Fetching from origin...")
if !syncDryRunFlag {
if fetchErr := g.Fetch(); fetchErr != nil {
return fmt.Errorf("fetch failed: %w", fetchErr)
}
}
// Fast-forward trunk
currentBranch, _ := g.CurrentBranch() //nolint:errcheck // empty string is fine
fmt.Printf("Fast-forwarding %s...\n", s.Branch(trunk))
if !syncDryRunFlag {
if ffErr := g.FastForward(trunk); ffErr != nil {
fmt.Printf("%s could not fast-forward %s: %v\n", s.WarningIcon(), s.Branch(trunk), ffErr)
}
// Return to original branch
_ = g.Checkout(currentBranch) //nolint:errcheck // best effort
}
// Check for merged PRs
branches, err := cfg.ListTrackedBranches()
if err != nil {
return err
}
var merged []string
for _, branch := range branches {
prNum, prErr := cfg.GetPR(branch)
if prErr != nil || prNum == 0 {
continue
}
pr, getPRErr := gh.GetPR(prNum)
if getPRErr != nil {
fmt.Printf("%s could not fetch PR #%d: %v\n", s.WarningIcon(), prNum, getPRErr)
continue
}
if pr.Merged {
merged = append(merged, branch)
}
}
// Content-based detection for squash merges (fallback when PR detection fails)
// Uses git diff to detect when a branch's content is already in trunk
for _, branch := range branches {
// Skip already detected via PR
if slices.Contains(merged, branch) {
continue
}
// Check if branch content is identical to trunk (squash merge detection)
isContentMerged, diffErr := g.IsContentMerged(branch, trunk)
if diffErr != nil {
// Can't determine, let restack try
continue
}
if isContentMerged {
// Tree content is identical - branch was squash-merged
merged = append(merged, branch)
}
}
// Check for branches whose parent doesn't exist on the remote
// This can happen if a parent branch was deleted without merging, or never pushed
for _, branch := range branches {
parent, parentErr := cfg.GetParent(branch)
if parentErr != nil {
continue
}
// Skip if parent is trunk (trunk should always exist on remote)
if parent == trunk {
continue
}
// Skip if parent is already marked as merged (will be handled)
if slices.Contains(merged, parent) {
continue
}
// Check if parent exists on remote
if !g.RemoteBranchExists(parent) {
fmt.Printf("\n%s parent branch %s of %s does not exist on remote.\n", s.WarningIcon(), s.Branch(parent), s.Branch(branch))
if prompt.IsInteractive() {
retarget, _ := prompt.Confirm(fmt.Sprintf("Retarget %s to %s?", branch, trunk), true) //nolint:errcheck // default is fine
if retarget {
_ = cfg.SetParent(branch, trunk) //nolint:errcheck // best effort
fmt.Printf("%s Retargeted %s to %s\n", s.SuccessIcon(), s.Branch(branch), s.Branch(trunk))
// Update PR base on GitHub if PR exists
prNum, _ := cfg.GetPR(branch) //nolint:errcheck // 0 is fine
if prNum > 0 {
if updateErr := gh.UpdatePRBase(prNum, trunk); updateErr != nil {
fmt.Printf("%s failed to update PR #%d base: %v\n", s.WarningIcon(), prNum, updateErr)
} else {
fmt.Printf("%s Updated PR #%d base to %s\n", s.SuccessIcon(), prNum, s.Branch(trunk))
}
}
}
} else {
fmt.Println(s.Muted(fmt.Sprintf("Run 'git config branch.%s.stackParent %s' to fix.", branch, trunk)))
}
}
}
// Handle merged branches
root, _ := tree.Build(cfg) //nolint:errcheck // nil root is fine, FindNode handles it
// Collect fork points BEFORE deleting merged branches
type retargetInfo struct {
childName string
forkPoint string
childPR int
}
var retargets []retargetInfo
for _, branch := range merged {
node := tree.FindNode(root, branch)
if node == nil {
continue
}
// Handle merged branch with interactive prompt
if syncDryRunFlag {
fmt.Printf("%s Would handle merged branch %s\n", s.Muted("dry-run:"), s.Branch(branch))
} else {
action := handleMergedBranch(g, cfg, branch, trunk, ¤tBranch, s)
if action == mergedActionSkip {
// User chose to skip - don't collect fork points or retarget children
continue
}
}
// For each child, get fork point - prefer stored, fall back to calculated
for _, child := range node.Children {
// Try stored fork point first
forkPoint, fpErr := cfg.GetForkPoint(child.Name)
if fpErr != nil || !g.CommitExists(forkPoint) {
// Fall back to calculating from parent (before it's deleted)
forkPoint, fpErr = g.GetMergeBase(child.Name, branch)
if fpErr != nil {
fmt.Printf("%s could not get fork point for %s: %v\n", s.WarningIcon(), s.Branch(child.Name), fpErr)
forkPoint = "" // Will fall back to simple rebase
}
}
childPR, _ := cfg.GetPR(child.Name) //nolint:errcheck // 0 is fine
retargets = append(retargets, retargetInfo{
childName: child.Name,
forkPoint: forkPoint,
childPR: childPR,
})
}
}
// Retarget children to trunk
for _, rt := range retargets {
if syncDryRunFlag {
fmt.Printf("%s Would retarget %s to %s (fork point: %s)\n", s.Muted("dry-run:"), s.Branch(rt.childName), s.Branch(trunk), rt.forkPoint)
continue
}
fmt.Printf("Retargeting %s to %s\n", s.Branch(rt.childName), s.Branch(trunk))
_ = cfg.SetParent(rt.childName, trunk) //nolint:errcheck // best effort
// Update PR base on GitHub
if rt.childPR > 0 {
if updateErr := gh.UpdatePRBase(rt.childPR, trunk); updateErr != nil {
fmt.Printf("%s failed to update PR #%d base: %v\n", s.WarningIcon(), rt.childPR, updateErr)
}
}
// Rebase using --onto if we have a fork point
if rt.forkPoint != "" && g.CommitExists(rt.forkPoint) {
displayForkPoint := rt.forkPoint
if len(displayForkPoint) > 8 {
displayForkPoint = displayForkPoint[:8]
}
fmt.Printf("Rebasing %s onto %s (from fork point %s)...\n", s.Branch(rt.childName), s.Branch(trunk), displayForkPoint)
if rebaseErr := g.RebaseOnto(trunk, rt.forkPoint, rt.childName); rebaseErr != nil {
fmt.Printf("%s --onto rebase failed, will try normal restack: %v\n", s.WarningIcon(), rebaseErr)
// Don't return error - let restack try
} else {
fmt.Printf("%s Rebased %s successfully\n", s.SuccessIcon(), s.Branch(rt.childName))
// Update fork point to new parent tip after successful rebase
trunkTip, tipErr := g.GetTip(trunk)
if tipErr == nil {
_ = cfg.SetForkPoint(rt.childName, trunkTip) //nolint:errcheck // best effort
}
}
}
}
// Return to original branch after retargeting
if !syncDryRunFlag && currentBranch != "" {
_ = g.Checkout(currentBranch) //nolint:errcheck // best effort
}
// Restack all (if not disabled)
if !syncNoRestackFlag {
fmt.Println(s.Bold("\nRestacking all branches..."))
// Rebuild tree after modifications
root, err = tree.Build(cfg)
if err != nil {
return err
}
// Build worktree map if --worktrees flag is set
var worktrees map[string]string
if syncWorktreesFlag {
var wtErr error
worktrees, wtErr = g.ListWorktrees()
if wtErr != nil {
return fmt.Errorf("failed to list worktrees: %w", wtErr)
}
}
// Restack from trunk's children
for _, child := range root.Children {
allBranches := []*tree.Node{child}
allBranches = append(allBranches, tree.GetDescendants(child)...)
if err := doRestackWithState(g, cfg, allBranches, RestackOptions{
DryRun: syncDryRunFlag,
Operation: state.OperationRestack,
StashRef: stashRef,
Worktrees: worktrees,
}, s); err != nil {
if errors.Is(err, ErrConflict) {
hitConflict = true
}
return err
}
}
}
// Update stack comments on all PRs
if !syncDryRunFlag {
fmt.Println("\nUpdating stack comments...")
if err := updateStackComments(cfg, g, gh, s); err != nil {
fmt.Printf("%s failed to update some comments: %v\n", s.WarningIcon(), err)
}
}
// Return to the starting branch
if !syncDryRunFlag && startingBranch != "" {
// Only switch if we're not already there
currentBranch, _ := g.CurrentBranch() //nolint:errcheck // empty string is fine
if currentBranch != startingBranch {
// Check if the starting branch still exists (it may have been deleted during sync)
if g.BranchExists(startingBranch) {
if checkoutErr := g.Checkout(startingBranch); checkoutErr != nil {
fmt.Printf("%s could not return to starting branch %s: %v\n", s.WarningIcon(), s.Branch(startingBranch), checkoutErr)
}
} else {
fmt.Printf("%s starting ref %s is not a local branch or no longer exists, staying on %s\n", s.WarningIcon(), s.Branch(startingBranch), s.Branch(currentBranch))
}
}
}
fmt.Println()
fmt.Println(s.SuccessMessage("Sync complete!"))
// Stash restoration handled by defer
return nil
}
// mergedAction represents the user's choice for handling a merged branch.
type mergedAction int
const (
mergedActionDelete mergedAction = iota
mergedActionOrphan
mergedActionSkip
)
// handleMergedBranch prompts the user for how to handle a merged branch and executes the choice.
// Returns the action taken. If the user is on the merged branch, it will checkout trunk first.
// The currentBranch pointer is updated if a checkout occurs.
func handleMergedBranch(g *git.Git, cfg *config.Config, branch, trunk string, currentBranch *string, s *style.Style) mergedAction {
fmt.Printf("\nBranch %s appears to be %s into %s.\n", s.Branch(branch), s.Merged("merged"), s.Branch(trunk))
// Default to delete in non-interactive mode
if !prompt.IsInteractive() {
return deleteMergedBranch(g, cfg, branch, trunk, currentBranch, s)
}
// Interactive mode: prompt for action
choice, _ := prompt.Select("What would you like to do?", []string{ //nolint:errcheck // default is fine on error
"Delete branch and remove from stack",
"Orphan (keep branch, remove from stack)",
"Skip (keep in stack, may cause conflicts)",
}, 0)
switch choice {
case 0: // Delete
return deleteMergedBranch(g, cfg, branch, trunk, currentBranch, s)
case 1: // Orphan
return orphanMergedBranch(cfg, branch, s)
case 2: // Skip
fmt.Printf("Skipping %s (keeping in stack)\n", s.Branch(branch))
return mergedActionSkip
default:
return deleteMergedBranch(g, cfg, branch, trunk, currentBranch, s)
}
}
// deleteMergedBranch deletes a merged branch and removes it from stack config.
// If the user is on the branch, it checks out trunk first.
func deleteMergedBranch(g *git.Git, cfg *config.Config, branch, trunk string, currentBranch *string, s *style.Style) mergedAction {
// If user is on the merged branch, checkout trunk first
if *currentBranch == branch {
fmt.Printf("Checking out %s (currently on merged branch)...\n", s.Branch(trunk))
if err := g.Checkout(trunk); err != nil {
fmt.Printf("%s could not checkout %s: %v\n", s.WarningIcon(), s.Branch(trunk), err)
fmt.Println(s.Muted("Falling back to orphan instead of delete."))
return orphanMergedBranch(cfg, branch, s)
}
*currentBranch = trunk
}
fmt.Printf("Deleting merged branch %s\n", s.Branch(branch))
_ = cfg.RemoveParent(branch) //nolint:errcheck // best effort cleanup
_ = cfg.RemovePR(branch) //nolint:errcheck // best effort cleanup
_ = cfg.RemoveForkPoint(branch) //nolint:errcheck // best effort cleanup
if err := g.DeleteBranch(branch); err != nil {
fmt.Printf("%s could not delete branch %s: %v\n", s.WarningIcon(), s.Branch(branch), err)
}
return mergedActionDelete
}
// orphanMergedBranch removes a branch from stack config but keeps the git branch.
func orphanMergedBranch(cfg *config.Config, branch string, s *style.Style) mergedAction {
fmt.Printf("Orphaning %s (branch preserved, removed from stack)\n", s.Branch(branch))
_ = cfg.RemoveParent(branch) //nolint:errcheck // best effort cleanup
_ = cfg.RemovePR(branch) //nolint:errcheck // best effort cleanup
_ = cfg.RemoveForkPoint(branch) //nolint:errcheck // best effort cleanup
return mergedActionOrphan
}