Skip to content

Commit bc8a746

Browse files
authored
fix(restack): pass --update-refs to git rebase by default (#119)
All rebase invocations now pass `--update-refs` or `--no-update-refs` explicitly so that the flag always overrides any ambient `rebase.updateRefs` git config setting. - Untracked bookmark branches pointing into the rebased chain are kept in sync automatically (default behaviour via `--update-refs`). - Adds `--no-update-refs` flag to `restack`, `submit`, and `sync` as a per-invocation escape hatch for users who want to preserve bookmark branches. - When linked worktrees are detected (`--worktrees` with at least one branch actually checked out elsewhere), `--update-refs` is suppressed automatically. Git silently skips refs checked out in other worktrees rather than refusing, which would corrupt the stack with no warning. - The resolved setting is persisted to cascade state so `gh stack continue` resumes remaining branches with the same behaviour after a conflict. - Adds a cached Git version check (requires 2.38+) with a clear error message when the requirement is not met. Closes #112.
1 parent 0309df7 commit bc8a746

10 files changed

Lines changed: 450 additions & 77 deletions

File tree

README.md

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ The catch? Managing these stacks by hand is tedious. When `main` updates, you ne
2323

2424
## Installation
2525

26-
Requires [GitHub CLI][] (`gh`) installed and authenticated.
26+
Requires:
27+
28+
- [GitHub CLI][] (`gh`) installed and authenticated
29+
- Git 2.38 or newer (for `git rebase --update-refs` support; macOS Command Line Tools and current Linux distributions all ship a compatible version)
2730

2831
```bash
2932
gh extension install boneskull/gh-stack
@@ -278,13 +281,14 @@ If a rebase conflict occurs, resolve it and run `gh stack continue`.
278281

279282
| Flag | Description |
280283
| --------------------- | ---------------------------------------------------------------------- |
281-
| `-D, --dry-run` | Show what would happen without doing it |
282-
| `-f, --from [branch]` | Submit from this branch toward leaves (bare `--from` = current branch) |
283-
| `-c, --current` | Only submit the current branch, not descendants |
284-
| `-u, --update` | Only update existing PRs, don't create new ones |
285-
| `-s, --skip-prs` | Skip PR creation/update, only restack and push |
286-
| `-y, --yes` | Skip interactive prompts; use auto-generated PR title/description |
287-
| `--web` | Open created/updated PRs in web browser |
284+
| `-D, --dry-run` | Show what would happen without doing it |
285+
| `-f, --from [branch]` | Submit from this branch toward leaves (bare `--from` = current branch) |
286+
| `-c, --current` | Only submit the current branch, not descendants |
287+
| `-u, --update` | Only update existing PRs, don't create new ones |
288+
| `-s, --skip-prs` | Skip PR creation/update, only restack and push |
289+
| `-y, --yes` | Skip interactive prompts; use auto-generated PR title/description |
290+
| `--web` | Open created/updated PRs in web browser |
291+
| `--no-update-refs` | Do not pass `--update-refs` to git (preserves untracked bookmark branches) |
288292

289293
### restack
290294

@@ -296,11 +300,12 @@ If a rebase conflict occurs, resolve it and run `gh stack continue`.
296300

297301
#### restack Flags
298302

299-
| Flag | Description |
300-
| ----------------- | -------------------------------------------------------- |
301-
| `-c, --current` | Only restack current branch, not descendants |
302-
| `-D, --dry-run` | Show what would be done |
303-
| `-w, --worktrees` | Rebase branches checked out in linked worktrees in-place |
303+
| Flag | Description |
304+
| -------------------- | -------------------------------------------------------------------------- |
305+
| `-c, --current` | Only restack current branch, not descendants |
306+
| `-D, --dry-run` | Show what would be done |
307+
| `-w, --worktrees` | Rebase branches checked out in linked worktrees in-place |
308+
| `--no-update-refs` | Do not pass `--update-refs` to git (preserves untracked bookmark branches) |
304309

305310
### continue
306311

@@ -324,9 +329,10 @@ This is the command to run when upstream changes have occurred (e.g., a PR in yo
324329

325330
| Flag | Description |
326331
| ----------------- | -------------------------------------------------------- |
327-
| `--no-restack` | Skip restacking branches (might not work well!) |
328-
| `-D, --dry-run` | Show what would be done |
329-
| `-w, --worktrees` | Rebase branches checked out in linked worktrees in-place |
332+
| `--no-restack` | Skip restacking branches (might not work well!) |
333+
| `-D, --dry-run` | Show what would be done |
334+
| `-w, --worktrees` | Rebase branches checked out in linked worktrees in-place |
335+
| `--no-update-refs` | Do not pass `--update-refs` to git (preserves untracked bookmark branches) |
330336

331337
### undo
332338

@@ -371,6 +377,10 @@ If a rebase conflict occurs in a worktree branch, **gh-stack** will tell you whi
371377
>
372378
> The `--worktrees` flag is opt-in. Without it, **gh-stack** behaves exactly as before. If none of your stack branches are checked out in linked worktrees, the flag is a harmless no-op.
373379
380+
> [!NOTE]
381+
>
382+
> When linked worktrees are detected, **gh-stack** automatically passes `--no-update-refs` to git. This prevents silent stack corruption: git's `--update-refs` silently skips any ref that is checked out in a linked worktree, which would leave the chain in a broken state. If no branches are checked out in linked worktrees (even with `--worktrees` set), **gh-stack** passes `--update-refs` normally so that any untracked bookmark branches pointing into the stack are kept in sync.
383+
374384
## How It Works
375385

376386
**gh-stack** stores metadata in your local `.git/config`:

cmd/continue.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,14 @@ func runContinue(cmd *cobra.Command, args []string) error {
9797
_ = state.Remove(g.GetGitDir()) //nolint:errcheck // cleanup
9898

9999
if restackErr := doRestackWithState(g, cfg, branches, RestackOptions{
100-
Operation: st.Operation,
101-
UpdateOnly: st.UpdateOnly,
102-
OpenWeb: st.Web,
103-
PushOnly: st.PushOnly,
104-
Branches: st.Branches,
105-
StashRef: st.StashRef,
106-
Worktrees: st.Worktrees,
100+
Operation: st.Operation,
101+
UpdateOnly: st.UpdateOnly,
102+
OpenWeb: st.Web,
103+
PushOnly: st.PushOnly,
104+
Branches: st.Branches,
105+
StashRef: st.StashRef,
106+
Worktrees: st.Worktrees,
107+
NoUpdateRefs: !st.UpdateRefs,
107108
}, s); restackErr != nil {
108109
// Stash handling is done by doRestackWithState (conflict saves in state, errors restore)
109110
if !errors.Is(restackErr, ErrConflict) && st.StashRef != "" {

cmd/restack.go

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,17 @@ var restackCmd = &cobra.Command{
2727
}
2828

2929
var (
30-
restackOnlyFlag bool
31-
restackDryRunFlag bool
32-
restackWorktreesFlag bool
30+
restackOnlyFlag bool
31+
restackDryRunFlag bool
32+
restackWorktreesFlag bool
33+
restackNoUpdateRefsFlag bool
3334
)
3435

3536
func init() {
3637
restackCmd.Flags().BoolVarP(&restackOnlyFlag, "current", "c", false, "only restack current branch, not descendants")
3738
restackCmd.Flags().BoolVarP(&restackDryRunFlag, "dry-run", "D", false, "show what would be done")
3839
restackCmd.Flags().BoolVarP(&restackWorktreesFlag, "worktrees", "w", false, "rebase branches checked out in linked worktrees in-place")
40+
restackCmd.Flags().BoolVar(&restackNoUpdateRefsFlag, "no-update-refs", false, "do not pass --update-refs to git (preserves untracked bookmark branches pointing into the stack)")
3941
rootCmd.AddCommand(restackCmd)
4042
}
4143

@@ -104,10 +106,11 @@ func runRestack(cmd *cobra.Command, args []string) error {
104106
}
105107

106108
err = doRestackWithState(g, cfg, branches, RestackOptions{
107-
DryRun: restackDryRunFlag,
108-
Operation: state.OperationRestack,
109-
StashRef: stashRef,
110-
Worktrees: worktrees,
109+
DryRun: restackDryRunFlag,
110+
Operation: state.OperationRestack,
111+
StashRef: stashRef,
112+
Worktrees: worktrees,
113+
NoUpdateRefs: restackNoUpdateRefsFlag,
111114
}, s)
112115

113116
// Restore auto-stashed changes after operation (unless conflict, which saves stash in state)
@@ -150,6 +153,11 @@ type RestackOptions struct {
150153
// present in the map are rebased directly in their worktree directory instead
151154
// of being checked out in the main working tree.
152155
Worktrees map[string]string
156+
// NoUpdateRefs suppresses --update-refs on rebase invocations when true.
157+
// --update-refs is also suppressed automatically when Worktrees is non-empty
158+
// because git silently skips refs checked out in other worktrees, which
159+
// would corrupt the stack without any error or warning.
160+
NoUpdateRefs bool
153161
}
154162

155163
// doRestackWithState performs restack and saves state with the given operation type.
@@ -163,6 +171,14 @@ func doRestackWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, o
163171
return err
164172
}
165173

174+
// Resolve the effective --update-refs setting once for this operation.
175+
// We always pass the flag explicitly (either --update-refs or --no-update-refs)
176+
// to override any ambient rebase.updateRefs git config setting.
177+
// Suppress when linked worktrees are detected (Worktrees map non-empty):
178+
// git silently skips refs that are checked out in another worktree rather
179+
// than refusing, which would leave the stack in a broken state with no error.
180+
updateRefs := !opts.NoUpdateRefs && len(opts.Worktrees) == 0
181+
166182
for i, b := range branches {
167183
parent, err := cfg.GetParent(b.Name)
168184
if err != nil {
@@ -254,13 +270,15 @@ func doRestackWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, o
254270

255271
var rebaseErr error
256272
if wtPath != "" {
257-
// Branch is checked out in a linked worktree -- rebase there directly
273+
// Branch is checked out in a linked worktree -- rebase there directly.
274+
// updateRefs is already false when worktrees are active (see resolution
275+
// rule above), so RebaseHere/RebaseOntoHere pass --no-update-refs.
258276
fmt.Printf(" %s\n", s.Muted(fmt.Sprintf("Using worktree at %s for %s", wtPath, b.Name)))
259277
gitWt := git.New(wtPath)
260278
if useOnto {
261-
rebaseErr = gitWt.RebaseOntoHere(parent, storedForkPoint)
279+
rebaseErr = gitWt.RebaseOntoHere(parent, storedForkPoint, updateRefs)
262280
} else {
263-
rebaseErr = gitWt.RebaseHere(parent)
281+
rebaseErr = gitWt.RebaseHere(parent, updateRefs)
264282
}
265283
// If git failed for a non-conflict reason (e.g. worktree dir was removed),
266284
// wrap the error with context so the user knows which worktree we tried.
@@ -274,9 +292,9 @@ func doRestackWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, o
274292
}
275293

276294
if useOnto {
277-
rebaseErr = g.RebaseOnto(parent, storedForkPoint, b.Name)
295+
rebaseErr = g.RebaseOnto(parent, storedForkPoint, b.Name, updateRefs)
278296
} else {
279-
rebaseErr = g.Rebase(parent)
297+
rebaseErr = g.Rebase(parent, updateRefs)
280298
}
281299
}
282300

@@ -298,6 +316,7 @@ func doRestackWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, o
298316
Branches: opts.Branches,
299317
StashRef: opts.StashRef,
300318
Worktrees: opts.Worktrees,
319+
UpdateRefs: updateRefs,
301320
}
302321
_ = state.Save(g.GetGitDir(), st) //nolint:errcheck // best effort - user can recover manually
303322

cmd/submit.go

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,14 @@ If a rebase conflict occurs, resolve it and run 'gh stack continue'.`,
3838
}
3939

4040
var (
41-
submitDryRunFlag bool
42-
submitCurrentOnlyFlag bool
43-
submitUpdateOnlyFlag bool
44-
submitPushOnlyFlag bool
45-
submitYesFlag bool
46-
submitWebFlag bool
47-
submitFromFlag string
41+
submitDryRunFlag bool
42+
submitCurrentOnlyFlag bool
43+
submitUpdateOnlyFlag bool
44+
submitPushOnlyFlag bool
45+
submitYesFlag bool
46+
submitWebFlag bool
47+
submitFromFlag string
48+
submitNoUpdateRefsFlag bool
4849
)
4950

5051
// prAction describes what we will do for a branch in the PR phase after push.
@@ -86,6 +87,7 @@ func init() {
8687
submitCmd.Flags().BoolVar(&submitWebFlag, "web", false, "open created/updated PRs in web browser")
8788
submitCmd.Flags().StringVarP(&submitFromFlag, "from", "f", "", "submit from this branch toward leaves (default: entire stack; bare --from = current branch)")
8889
submitCmd.Flags().Lookup("from").NoOptDefVal = "HEAD"
90+
submitCmd.Flags().BoolVar(&submitNoUpdateRefsFlag, "no-update-refs", false, "do not pass --update-refs to git (preserves untracked bookmark branches pointing into the stack)")
8991
rootCmd.AddCommand(submitCmd)
9092
}
9193

@@ -205,13 +207,14 @@ func runSubmit(cmd *cobra.Command, args []string) error {
205207
// Phase 1: Restack
206208
fmt.Println(s.Bold("=== Phase 1: Restack ==="))
207209
if restackErr := doRestackWithState(g, cfg, branches, RestackOptions{
208-
DryRun: submitDryRunFlag,
209-
Operation: state.OperationSubmit,
210-
UpdateOnly: submitUpdateOnlyFlag,
211-
OpenWeb: submitWebFlag,
212-
PushOnly: submitPushOnlyFlag,
213-
Branches: branchNames,
214-
StashRef: stashRef,
210+
DryRun: submitDryRunFlag,
211+
Operation: state.OperationSubmit,
212+
UpdateOnly: submitUpdateOnlyFlag,
213+
OpenWeb: submitWebFlag,
214+
PushOnly: submitPushOnlyFlag,
215+
Branches: branchNames,
216+
StashRef: stashRef,
217+
NoUpdateRefs: submitNoUpdateRefsFlag,
215218
}, s); restackErr != nil {
216219
// Stash is saved in state for conflicts; restore on other errors
217220
if !errors.Is(restackErr, ErrConflict) && stashRef != "" {

cmd/sync.go

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,17 @@ var syncCmd = &cobra.Command{
2525
}
2626

2727
var (
28-
syncNoRestackFlag bool
29-
syncDryRunFlag bool
30-
syncWorktreesFlag bool
28+
syncNoRestackFlag bool
29+
syncDryRunFlag bool
30+
syncWorktreesFlag bool
31+
syncNoUpdateRefsFlag bool
3132
)
3233

3334
func init() {
3435
syncCmd.Flags().BoolVar(&syncNoRestackFlag, "no-restack", false, "skip restacking branches")
3536
syncCmd.Flags().BoolVarP(&syncDryRunFlag, "dry-run", "D", false, "show what would be done")
3637
syncCmd.Flags().BoolVarP(&syncWorktreesFlag, "worktrees", "w", false, "rebase branches checked out in linked worktrees in-place")
38+
syncCmd.Flags().BoolVar(&syncNoUpdateRefsFlag, "no-update-refs", false, "do not pass --update-refs to git (preserves untracked bookmark branches pointing into the stack)")
3739
rootCmd.AddCommand(syncCmd)
3840
}
3941

@@ -255,6 +257,18 @@ func runSync(cmd *cobra.Command, args []string) error {
255257
}
256258
}
257259

260+
// Build worktree map once, used by both the retarget rebase and the main
261+
// restack loop so both apply the same suppression rule: suppress
262+
// --update-refs when any worktrees are active (len > 0).
263+
var worktrees map[string]string
264+
if syncWorktreesFlag {
265+
var wtErr error
266+
worktrees, wtErr = g.ListWorktrees()
267+
if wtErr != nil {
268+
return fmt.Errorf("failed to list worktrees: %w", wtErr)
269+
}
270+
}
271+
258272
// Handle merged branches
259273
root, _ := tree.Build(cfg) //nolint:errcheck // nil root is fine, FindNode handles it
260274

@@ -325,14 +339,17 @@ func runSync(cmd *cobra.Command, args []string) error {
325339
}
326340
}
327341

328-
// Rebase using --onto if we have a fork point
342+
// Rebase using --onto if we have a fork point.
343+
// Suppress --update-refs when --worktrees is active (same rule as the
344+
// main restack loop) or when the user passed --no-update-refs.
329345
if rt.forkPoint != "" && g.CommitExists(rt.forkPoint) {
330346
displayForkPoint := rt.forkPoint
331347
if len(displayForkPoint) > 8 {
332348
displayForkPoint = displayForkPoint[:8]
333349
}
350+
retargetUpdateRefs := !syncNoUpdateRefsFlag && len(worktrees) == 0
334351
fmt.Printf("Rebasing %s onto %s (from fork point %s)...\n", s.Branch(rt.childName), s.Branch(trunk), displayForkPoint)
335-
if rebaseErr := g.RebaseOnto(trunk, rt.forkPoint, rt.childName); rebaseErr != nil {
352+
if rebaseErr := g.RebaseOnto(trunk, rt.forkPoint, rt.childName, retargetUpdateRefs); rebaseErr != nil {
336353
fmt.Printf("%s --onto rebase failed, will try normal restack: %v\n", s.WarningIcon(), rebaseErr)
337354
// Don't return error - let restack try
338355
} else {
@@ -361,25 +378,16 @@ func runSync(cmd *cobra.Command, args []string) error {
361378
return err
362379
}
363380

364-
// Build worktree map if --worktrees flag is set
365-
var worktrees map[string]string
366-
if syncWorktreesFlag {
367-
var wtErr error
368-
worktrees, wtErr = g.ListWorktrees()
369-
if wtErr != nil {
370-
return fmt.Errorf("failed to list worktrees: %w", wtErr)
371-
}
372-
}
373-
374381
// Restack from trunk's children
375382
for _, child := range root.Children {
376383
allBranches := []*tree.Node{child}
377384
allBranches = append(allBranches, tree.GetDescendants(child)...)
378385
if err := doRestackWithState(g, cfg, allBranches, RestackOptions{
379-
DryRun: syncDryRunFlag,
380-
Operation: state.OperationRestack,
381-
StashRef: stashRef,
382-
Worktrees: worktrees,
386+
DryRun: syncDryRunFlag,
387+
Operation: state.OperationRestack,
388+
StashRef: stashRef,
389+
Worktrees: worktrees,
390+
NoUpdateRefs: syncNoUpdateRefsFlag,
383391
}, s); err != nil {
384392
if errors.Is(err, ErrConflict) {
385393
hitConflict = true

0 commit comments

Comments
 (0)