The previous commit stops `gh stack push` from recording a base the branch does
not contain, but every stack that has already been through that path still
carries a bad value on disk. Those stacks would keep hitting the conflict on
their next rebase, because neither the parent's current tip nor the recorded
base is a boundary the branch actually has.
`resolveOntoOldBase` now also considers `git merge-base --fork-point`, which
reads the parent's reflog and so still finds where the branch diverged after
the parent was amended, rebased, or force-pushed — exactly the record the stack
file lost. It is only a candidate: the ancestry check still gates it, and a
fresh clone or an expired reflog simply falls through to the merge bases as
before.
Verified on a real stack whose metadata had been corrupted by the previous
build: the rebase now completes, replaying one commit instead of two, and the
recorded bases are genuine ancestors again afterwards.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f5959297-80fd-4732-aeae-aa9a4b6a7755
Amending a commit on a lower branch, pushing, and then rebasing replayed the
old version of that commit into every branch above it — a conflict at best, a
duplicated commit at worst (#250, #193). The same defect made a squash-merged
bottom PR conflict against the branches above its direct child (#309).
Root cause: `updateBaseSHAs` unconditionally recorded the parent's *current*
tip as each child's base, even when the child had never been rebased onto it.
`gh stack push` calls it, so the recipe "amend, push, rebase" corrupted the
metadata before the rebase ever ran:
after init: b2.base = <b1 original tip> correct
after push: b2.base = <b1 amended tip> b2 does not contain this
`branches[].base` is the `git rebase --onto <newBase> <upstream>` boundary for
the next cascade, so recording a commit the branch does not contain makes git
fall back to a merge base and replay the parent's superseded commits.
`updateBaseSHAs` now only advances a base when the parent's tip really is in
the branch's history, so the record keeps describing where the branch actually
sits. `Head` is still always updated — it is the branch's own tip.
`cascadeRebase` gains `resolveOntoOldBase`, which picks the latest boundary the
branch genuinely contains: the parent's current tip, else the recorded metadata
base, else a merge base. It replaces the ad-hoc staleness guard that existed
only on the merged-PR path, and now covers the plain path too, which had none.
Also fixes a regression from the previous commit: a trunk that was never pushed
(a stack based on a local integration branch) was treated like a trunk that had
been deleted upstream and failed outright. The two are now distinguished by
whether the trunk has an upstream configured — a tracked trunk that has
disappeared is still a hard error, an untracked one is used as-is.
Verified against real git: amend at the bottom, in the middle, and at two depths
at once; extra commits; a dropped commit force-pushed; squash-merge with three
branches above it; and each combined with a trunk that moved, diverged, or was
locked by another worktree.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f5959297-80fd-4732-aeae-aa9a4b6a7755
`gh stack sync` and `gh stack rebase` could print a full success report
while leaving the stack based on the trunk it was created from. Two
independent causes.
1. A rebase git refused was reported as a success.
`tryAutoResolveRebase` returned nil whenever no rebase was in progress.
That is only a valid success signal after an auto-`--continue`; on the
first check it means `git rebase` exited non-zero without ever starting.
Every rebase funnels through it, so the cascade printed `✓ Rebased X onto
Y` for a rebase that never ran — a dirty tree, a branch checked out in
another worktree, an unresolvable upstream, or stale rebase state.
A rebase that never started is now a typed `*git.RebaseStartError`, which
the cascade and `modify` treat as fatal rather than as a conflict, so no
bogus recovery state is written and git's own message is surfaced.
2. The trunk ref was never verified against the remote.
`fastForwardTrunk` only warned when it could not move the local trunk
(checked out in another worktree, diverged, remote ref gone), and the
cascade then rebased onto the stale local trunk. Every freshness check
compared branches to the *local* trunk, so they all passed: `rebase`
claimed "rebased locally with main" and `sync` concluded nothing was
stale, force-pushed anyway, and said "Branches synced".
`resolveTrunkTarget` now resolves the ref the cascade must target. When
the local trunk cannot be updated it returns `<remote>/<trunk>` and says
why, so the stack ends up current regardless of why the local ref is
stuck. When the trunk no longer exists on the remote it fails with an
actionable message instead of silently rebasing onto a stale trunk. The
post-cascade check measures against that ref, and `sync` runs it before
pushing so an unrebased stack is never force-pushed.
Also fixed along the way:
- A remote-qualified trunk (`gh stack init --base origin/main`) is
normalized instead of being re-qualified into `origin/origin/main`,
in both commands and before fetch refspecs are built.
- `git rebase <option> --continue` is a usage error (exit 129), so
`--preserve-dates` broke every `--continue`. git persists the option
in the rebase state, so `--continue` alone honors it.
- `FetchBranches` reports real failures instead of `sync` printing
"Fetched latest changes" regardless.
- Preflight checks for a rebase in progress and a dirty tree, with
`--autostash` to opt out. Untracked files are not treated as dirty:
git rebases fine with them present. `--autostash` stashes once around
the whole cascade — git's own `--autostash` pops after every
individual rebase, landing the changes on the wrong branch.
- Both summaries now name the trunk ref and SHA the stack landed on.
Fixes#155, #176. Addresses discussion #215.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f5959297-80fd-4732-aeae-aa9a4b6a7755
* Add cherry-pick abort/quit/in-progress git primitives
Introduce IsCherryPickInProgress() (detects .git/CHERRY_PICK_HEAD) and split
the existing cherry-pick reset into two distinct operations:
- CherryPickQuit() runs `git cherry-pick --quit`, clearing the sequencer
state without touching the index (used to clear stale state before starting
a fresh cherry-pick).
- CherryPickAbort() now runs `git cherry-pick --abort`, which fully restores
the working tree and index to the pre-cherry-pick state.
The previous CherryPickAbort() ran --quit, which leaves an unmerged index and
therefore cannot recover a conflicted fold-down. Integration tests cover the
in-progress detection, the full abort restore, and the --quit-leaves-index
behavior.
* Fix modify --abort leaving a broken stack after a conflict
When `gh stack modify` hit a rebase or cherry-pick conflict it saved state
with phase "conflict" and told the user to run `gh stack modify --abort` to
restore. But runModifyAbort had no case for PhaseConflict, so it fell into the
default branch that merely printed "unexpected modify state phase" and deleted
the state file without unwinding. The in-flight rebase/cherry-pick stayed
active, branches were left partially rewritten, and the deleted state file also
made --continue impossible: the stack was stuck in limbo.
Fixes:
- runModifyAbort now unwinds on PhaseConflict (same recovery as PhaseApplying),
aborting the in-progress operation, resetting branch tips to their pre-modify
SHAs, restoring stack metadata, and clearing state.
- Unwind now also aborts an in-progress cherry-pick (fold-down conflicts), not
just a rebase. Without this the restore checkouts would fail on the unmerged
cherry-pick index.
- ContinueApply now records a subsequent cascade-rebase conflict as
ConflictType "rebase" instead of leaving a stale "cherry_pick", so the next
--continue calls RebaseContinue rather than failing in CherryPickContinue.
Adds coverage for the conflict-phase abort, pending-submit no-op abort, Unwind
aborting an active cherry-pick, and the cherry-pick to rebase ConflictType
transition.
* Persist fold-branch removal when a post-fold cascade rebase conflicts
ContinueApply removes the folded branch from the in-memory stack after a
fold-down cherry-pick is resolved, but a subsequent cascade rebase conflict
only saved the modify state file, not the stack metadata. On the next
--continue the on-disk metadata (folded branch still present) was re-read, and
because ConflictType is now "rebase" the fold-removal block was skipped, so the
final save resurrected the folded branch as a phantom entry pointing at an
orphaned tip.
Persist the stack file alongside the state file on a cascade-rebase conflict,
mirroring ApplyPlan's save-on-conflict, so the fold removal survives recovery.
Adds an end-to-end regression test covering the fold-then-cascade-conflict path
across two --continue calls.
* Fully-qualify branch refspecs when pushing
gh-stack builds `git push` arguments from stack branch names in `internal/git`.
The force path passed `<branch>:refs/heads/<branch>`, and the non-force path
passed bare branch names. A git refspec treats a leading `+` as "force update",
and Git allows branch names that begin with `+`, so a branch named `+feature`
was parsed as refspec syntax for `feature`: the force path pushed local
`feature` into remote `+feature`, and the non-force path force-updated remote
`feature`.
Build fully-qualified refspecs for both the source and destination of every
push: `refs/heads/<branch>:refs/heads/<branch>`. A branch name can no longer be
reinterpreted as a refspec modifier. Force updates are still requested via the
existing `--force-with-lease` flags, whose ref names were already
fully-qualified. `DeleteRemoteBranch` is fully-qualified the same way.
The `Push` signature and every call site are unchanged. Add real-git
integration tests covering the force and non-force paths with a `+`-prefixed
branch.
* rm redundant if and simplify
* Save selected remote to gh-stack.remote git config
Users with multiple git remotes are prompted to choose a remote on
every gh stack operation, which is tedious. This adds the ability to
persist that choice so it only needs to be made once.
When a user interactively selects a remote (because multiple remotes
exist and none is configured as a push default), they are now shown a
Y/n follow-up prompt offering to save that remote for all future gh
stack operations. If accepted, the choice is written to the local git
config key `gh-stack.remote`, and instructions for changing or clearing
it are printed.
The saved remote is checked in `ResolveRemote` after the standard git
push config keys (branch.<name>.pushRemote, remote.pushDefault,
branch.<name>.remote) but before falling back to listing all remotes.
This means per-branch git push configuration still takes precedence,
and the --remote flag on individual commands continues to override
everything.
All commands that resolve a remote (push, submit, sync, rebase,
checkout, link, modify, trunk) go through the shared `pickRemote`
helper, so they all benefit automatically.
Changes:
- Add GetSavedRemote, SaveRemote, ClearRemote to the git Ops interface,
defaultOps implementation, public wrappers, and MockOps
- Check gh-stack.remote in ResolveRemote's priority chain
- Move pickRemote from push.go to utils.go as a shared helper
- Add save-remote confirmation prompt after interactive remote selection
- Add unit tests for pickRemote save/decline/skip/override flows
- Add integration tests for ResolveRemote with saved remote and
precedence, and for the SaveRemote/GetSavedRemote/ClearRemote
lifecycle
* add error message for save failure
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
gh stack sync could rebase a stack successfully then fail the final
force push with "stale info" when a branch lacked a local tracking ref
(refs/remotes/<remote>/<branch>). This happened because:
1. FetchBranches pre-filtered branches by existing tracking ref, so a
branch with no tracking ref was never fetched and never gained one.
2. Push used a bare --force-with-lease flag, which has no lease basis
for a branch without a tracking ref, causing git to reject the push.
FetchBranches now uses explicit refspecs for every branch:
+refs/heads/<branch>:refs/remotes/<remote>/<branch>
This creates or updates tracking refs regardless of prior state. The
fast-path (single fetch) and per-branch fallback (for branches absent
on the remote) are preserved.
Push now builds explicit per-branch lease arguments when force=true:
--force-with-lease=refs/heads/<branch>:<tracking-ref-sha>
for branches with a tracking ref, or:
--force-with-lease=refs/heads/<branch>:
(empty expected value = "must not exist") for branches absent on the
remote. Explicit destination refspecs (<branch>:refs/heads/<branch>)
remove dependence on push.default and upstream configuration. The
non-force push path is unchanged.
Added 6 integration tests using real bare git remotes:
- Branch with current tracking ref: push succeeds
- Tracking ref deleted locally (regression test for #118): push succeeds
- Remote advanced by another client: push rejected (safety preserved)
- New branch absent on remote: created via empty-expect lease
- New branch race condition: rejected (safety preserved)
- Mixed stack (tracked + untracked branches): all succeed after fetch
Fixes#118
* Add --committer-date-is-author-date flag to gh stack rebase
Introduce an opt-in `--committer-date-is-author-date` flag (with
`--preserve-dates` alias) for `gh stack rebase`. The flag is passed
through to every underlying `git rebase` invocation in the cascade,
keeping committer dates equal to author dates so that identical content
rebased onto an identical parent produces stable SHAs. This reduces
spurious force-push notifications and noisy review timelines, especially
in deep stacks where bottom branches get re-rebased on every merge.
Git layer changes:
- Add `RebaseOpts` struct with `CommitterDateIsAuthorDate` field to
`internal/git/gitops.go`
- Update `Ops` interface, `defaultOps`, public wrappers, and
`rebaseContinueOnce`/`tryAutoResolveRebase` helpers to accept and
forward the flag
- Update `MockOps` to match the new signatures
Command layer changes:
- Register `--committer-date-is-author-date` and `--preserve-dates`
flags on the cobra command in `cmd/rebase.go`
- Add `CommitterDateIsAuthorDate` to `cascadeRebaseOpts` and thread it
to all `git.Rebase`/`git.RebaseOnto` calls in `cmd/utils.go`
- Persist the flag in `rebaseState` JSON so `--continue` resumes with
the same behavior; pass it to `RebaseContinue` and subsequent cascade
calls
- Update `internal/modify/apply.go` callers to pass zero-value
`RebaseOpts{}`
Tests:
- Update all existing mock signatures in rebase, sync, and modify tests
- Add tests for flag passthrough, `--preserve-dates` alias, state
round-trip, `--continue` flag restoration, and conflict state
persistence
Docs:
- Update flag tables and examples in README.md and
docs/src/content/docs/reference/cli.md
* clearer wording in docs and help text
* prune merged branches
* interactively prompt for prune
* delete remote tracking ref too
* disable selecting merged branches in TUIs
* include full list (including merged PRs) in PUT request to stacks API
* add prune to docs
* addressing review comments
* increment skill file version
* run fetch before push operations
* ignore if ref doesn't exist on remote
* rm fetch from link
* more durable fetch by trying all first and falling back to individual fetches
* run fetch before sync
* git primitives for modify cmd
* extract reusable TUI parts
* modify cmd
* recreate stack after modify
* add checks to prevent other modifications while modify is applying
* modify continue for resuming after resolving conflicts
* fix bug with duplicate stack entries after modifying
* reuse conflict resolution help msg from rebase
* additional confirmation before overwriting stack on remote
* fix recreate order of operations
Co-authored-by: Copilot <copilot@github.com>
* move base commit instead of cherry picking for fold up
* check to ensure we aren't left with zero branches
* unify and dedupe across view and modify tui
* more detailed help instructions
Co-authored-by: Copilot <copilot@github.com>
* only recommend submit if stack exists on remote
Co-authored-by: Copilot <copilot@github.com>
* tests for modify tui, apply modifications, submit modifications
* refactor submit for regular and pending modifications
* rename recover to abort
Co-authored-by: Copilot <copilot@github.com>
* docs for modify cmd
* tui styling updates
* updated tui screenshot
* addressing review comments
* Fix 4 bugs from code review
Bug 1: Move RevParseMap error check before using originalRefs.
The error from git.RevParseMap() was deferred past iteration of
originalRefs, which could panic on a nil map.
Bug 2: Differentiate cherry-pick vs rebase conflicts in modify.
Cherry-pick conflicts don't save state as 'conflict' phase, so
--continue won't work. Now prints --abort-only instructions for
cherry-pick conflicts.
Bug 3: Unwind now cleans up branches created by renames.
After restoring snapshot branches, Unwind deletes renamed branch
names that don't belong to the original snapshot.
Bug 4: Simplify push message in submit command.
Changed from 'Pushing N branches to remote...' to 'Pushing to
remote...' since individual branches may fail.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Fix 7 nit issues from code review
11: Add named constants for phase strings (PhaseApplying, PhaseConflict,
PhasePendingSubmit) in state.go; replace remaining raw literals in
state.go CheckStateGuard.
14: Fix bottomLines comment mismatch — listed 3 items but value is 2.
15: Extract magic number 88 to MinWidthForArt constant in header.go.
16: Remove unused stackview import anchor in model.go — the import
is used via types.go where BranchNode is embedded.
17: Simplify CheckStackLinearity parent resolution — ActiveBaseBranch
already handles skipping merged branches.
18: Fix rename undo matching any rename — add NewName check so only
the specific rename being undone is matched.
20: Add TestUndoRename and TestUndoRename_DoesNotAffectOtherRenames
to validate rename undo behavior.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Make cherry-pick conflicts recoverable via --continue
Previously, cherry-pick conflicts during fold-down operations could only
be resolved with --abort. Now they save full conflict state (phase,
conflict type, fold branch/target, remaining branches) to the state file,
enabling recovery via 'gh stack modify --continue'.
Changes:
- Add ConflictType field to StateFile (rebase or cherry_pick)
- Add FoldBranch/FoldTarget fields for cherry-pick context
- Add CherryPickContinue to git package (cherry-pick --continue)
- Save cherry-pick conflict state in ApplyPlan with remaining branches
- ContinueApply handles both rebase and cherry-pick conflicts
- Unified conflict messaging in cmd/modify.go (both types show --continue)
- Updated test to verify cherry-pick conflict state is saved correctly
* Apply suggestions from code review
Co-authored-by: Luke Ghenco <lukeghenco@github.com>
Co-authored-by: Sameen Karim <skarim@github.com>
---------
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Luke Ghenco <lukeghenco@github.com>