Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 26 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ The catch? Managing these stacks by hand is tedious. When `main` updates, you ne

## Installation

Requires [GitHub CLI][] (`gh`) installed and authenticated.
Requires:

- [GitHub CLI][] (`gh`) installed and authenticated
- Git 2.38 or newer (for `git rebase --update-refs` support; macOS Command Line Tools and current Linux distributions all ship a compatible version)

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

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

### restack

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

#### restack Flags

| Flag | Description |
| ----------------- | -------------------------------------------------------- |
| `-c, --current` | Only restack current branch, not descendants |
| `-D, --dry-run` | Show what would be done |
| `-w, --worktrees` | Rebase branches checked out in linked worktrees in-place |
| Flag | Description |
| -------------------- | -------------------------------------------------------------------------- |
| `-c, --current` | Only restack current branch, not descendants |
| `-D, --dry-run` | Show what would be done |
| `-w, --worktrees` | Rebase branches checked out in linked worktrees in-place |
| `--no-update-refs` | Do not pass `--update-refs` to git (preserves untracked bookmark branches) |

### continue

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

| Flag | Description |
| ----------------- | -------------------------------------------------------- |
| `--no-restack` | Skip restacking branches (might not work well!) |
| `-D, --dry-run` | Show what would be done |
| `-w, --worktrees` | Rebase branches checked out in linked worktrees in-place |
| `--no-restack` | Skip restacking branches (might not work well!) |
| `-D, --dry-run` | Show what would be done |
| `-w, --worktrees` | Rebase branches checked out in linked worktrees in-place |
| `--no-update-refs` | Do not pass `--update-refs` to git (preserves untracked bookmark branches) |

### undo

Expand Down Expand Up @@ -371,6 +377,10 @@ If a rebase conflict occurs in a worktree branch, **gh-stack** will tell you whi
>
> 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.

> [!NOTE]
>
> 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.

## How It Works

**gh-stack** stores metadata in your local `.git/config`:
Expand Down
15 changes: 8 additions & 7 deletions cmd/continue.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,14 @@ func runContinue(cmd *cobra.Command, args []string) error {
_ = state.Remove(g.GetGitDir()) //nolint:errcheck // cleanup

if restackErr := doRestackWithState(g, cfg, branches, RestackOptions{
Operation: st.Operation,
UpdateOnly: st.UpdateOnly,
OpenWeb: st.Web,
PushOnly: st.PushOnly,
Branches: st.Branches,
StashRef: st.StashRef,
Worktrees: st.Worktrees,
Operation: st.Operation,
UpdateOnly: st.UpdateOnly,
OpenWeb: st.Web,
PushOnly: st.PushOnly,
Branches: st.Branches,
StashRef: st.StashRef,
Worktrees: st.Worktrees,
NoUpdateRefs: !st.UpdateRefs,
}, s); restackErr != nil {
// Stash handling is done by doRestackWithState (conflict saves in state, errors restore)
if !errors.Is(restackErr, ErrConflict) && st.StashRef != "" {
Expand Down
43 changes: 31 additions & 12 deletions cmd/restack.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,17 @@ var restackCmd = &cobra.Command{
}

var (
restackOnlyFlag bool
restackDryRunFlag bool
restackWorktreesFlag bool
restackOnlyFlag bool
restackDryRunFlag bool
restackWorktreesFlag bool
restackNoUpdateRefsFlag bool
)

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

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

err = doRestackWithState(g, cfg, branches, RestackOptions{
DryRun: restackDryRunFlag,
Operation: state.OperationRestack,
StashRef: stashRef,
Worktrees: worktrees,
DryRun: restackDryRunFlag,
Operation: state.OperationRestack,
StashRef: stashRef,
Worktrees: worktrees,
NoUpdateRefs: restackNoUpdateRefsFlag,
}, s)

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

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

// Resolve the effective --update-refs setting once for this operation.
// We always pass the flag explicitly (either --update-refs or --no-update-refs)
// to override any ambient rebase.updateRefs git config setting.
// Suppress when linked worktrees are detected (Worktrees map non-empty):
// git silently skips refs that are checked out in another worktree rather
// than refusing, which would leave the stack in a broken state with no error.
updateRefs := !opts.NoUpdateRefs && len(opts.Worktrees) == 0
Comment thread
boneskull marked this conversation as resolved.

for i, b := range branches {
parent, err := cfg.GetParent(b.Name)
if err != nil {
Expand Down Expand Up @@ -254,13 +270,15 @@ func doRestackWithState(g *git.Git, cfg *config.Config, branches []*tree.Node, o

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

if useOnto {
rebaseErr = g.RebaseOnto(parent, storedForkPoint, b.Name)
rebaseErr = g.RebaseOnto(parent, storedForkPoint, b.Name, updateRefs)
} else {
rebaseErr = g.Rebase(parent)
rebaseErr = g.Rebase(parent, updateRefs)
}
}

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

Expand Down
31 changes: 17 additions & 14 deletions cmd/submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,14 @@ If a rebase conflict occurs, resolve it and run 'gh stack continue'.`,
}

var (
submitDryRunFlag bool
submitCurrentOnlyFlag bool
submitUpdateOnlyFlag bool
submitPushOnlyFlag bool
submitYesFlag bool
submitWebFlag bool
submitFromFlag string
submitDryRunFlag bool
submitCurrentOnlyFlag bool
submitUpdateOnlyFlag bool
submitPushOnlyFlag bool
submitYesFlag bool
submitWebFlag bool
submitFromFlag string
submitNoUpdateRefsFlag bool
)

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

Expand Down Expand Up @@ -205,13 +207,14 @@ func runSubmit(cmd *cobra.Command, args []string) error {
// Phase 1: Restack
fmt.Println(s.Bold("=== Phase 1: Restack ==="))
if restackErr := doRestackWithState(g, cfg, branches, RestackOptions{
DryRun: submitDryRunFlag,
Operation: state.OperationSubmit,
UpdateOnly: submitUpdateOnlyFlag,
OpenWeb: submitWebFlag,
PushOnly: submitPushOnlyFlag,
Branches: branchNames,
StashRef: stashRef,
DryRun: submitDryRunFlag,
Operation: state.OperationSubmit,
UpdateOnly: submitUpdateOnlyFlag,
OpenWeb: submitWebFlag,
PushOnly: submitPushOnlyFlag,
Branches: branchNames,
StashRef: stashRef,
NoUpdateRefs: submitNoUpdateRefsFlag,
}, s); restackErr != nil {
// Stash is saved in state for conflicts; restore on other errors
if !errors.Is(restackErr, ErrConflict) && stashRef != "" {
Expand Down
46 changes: 27 additions & 19 deletions cmd/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,17 @@ var syncCmd = &cobra.Command{
}

var (
syncNoRestackFlag bool
syncDryRunFlag bool
syncWorktreesFlag bool
syncNoRestackFlag bool
syncDryRunFlag bool
syncWorktreesFlag bool
syncNoUpdateRefsFlag bool
)

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

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

// Build worktree map once, used by both the retarget rebase and the main
// restack loop so both apply the same suppression rule: suppress
// --update-refs when any worktrees are active (len > 0).
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)
}
}

// Handle merged branches
root, _ := tree.Build(cfg) //nolint:errcheck // nil root is fine, FindNode handles it

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

// Rebase using --onto if we have a fork point
// Rebase using --onto if we have a fork point.
// Suppress --update-refs when --worktrees is active (same rule as the
// main restack loop) or when the user passed --no-update-refs.
if rt.forkPoint != "" && g.CommitExists(rt.forkPoint) {
displayForkPoint := rt.forkPoint
if len(displayForkPoint) > 8 {
displayForkPoint = displayForkPoint[:8]
}
retargetUpdateRefs := !syncNoUpdateRefsFlag && len(worktrees) == 0
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 {
if rebaseErr := g.RebaseOnto(trunk, rt.forkPoint, rt.childName, retargetUpdateRefs); 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 {
Expand Down Expand Up @@ -361,25 +378,16 @@ func runSync(cmd *cobra.Command, args []string) error {
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,
DryRun: syncDryRunFlag,
Operation: state.OperationRestack,
StashRef: stashRef,
Worktrees: worktrees,
NoUpdateRefs: syncNoUpdateRefsFlag,
}, s); err != nil {
if errors.Is(err, ErrConflict) {
hitConflict = true
Expand Down
Loading
Loading