Repository navigation
fix(merge): restore autostash by commit SHA, not stash@{0} - #3684
Conversation
wt merge autostashes the target worktree, pushes, then restores. It
captured the stash by its positional reflog selector (stash@{0}) and
popped that position after the push. Any stash pushed into the repo in
that window — a concurrent wt merge, an editor integration — shifts the
indices, so the pop dropped a different entry: the user's uncommitted
work was silently discarded, the interloper's stash restored in its
place, and exit 0 reported with no warning.
Capture the stash's immutable commit SHA instead and restore with
git stash apply <sha> (content-addressed, immune to reordering), then
re-locate the entry by its unique message to drop it. A failed drop only
strands a stash entry — the content is already restored — so it warns
rather than failing.
Closes #3683
worktrunk-bot
left a comment
There was a problem hiding this comment.
I reviewed this end-to-end and the fix looks correct: capturing the immutable commit SHA (%H) and restoring with git stash apply <sha> is content-addressed and immune to the reflog reindexing that made stash pop stash@{0} drop the wrong entry. The apply-then-locate_by_message-then-drop sequence is sound — apply leaves the entry in place, so re-locating by the unique -m message before dropping is the right way to get a valid stash@{n} selector (a SHA can't be passed to drop). The regression test drives the exact capture→restore window: handle_push stashes in MergeContext::prepare, then git push HEAD:<target> fires the post-receive hook (which pushes the interloper stash), then restore_stash() runs — so the reflog is genuinely reordered under the autostash entry. The \n-trim fix on the split records is also a real latent bug.
Holding for human review — this is the data-loss surface. Per this repo's review policy, changes to the autostash-restore path in src/commands/repository_ext.rs — which now includes a git stash drop and governs whether a user's uncommitted work survives a merge — aren't an agent's to sign off on, even when the fix reduces risk. Requesting @max-sixty.
One behavioral note (not blocking, and no worse than the prior code): if git stash apply exits non-zero on a conflict, it still applies with conflict markers and leaves the entry in place, but restore_inner returns Err and the warning tells the user to git stash apply <sha> again — which would re-apply on top of the markers. This path is guarded by the earlier overlap detection, so it should be unreachable in practice; flagging only so the choice is deliberate.
Adds a unit test exercising the two previously-uncovered branches in the autostash restore helper: re-locating an entry that a concurrent stash pushed to a later reflog index, and reporting absence without a false positive.
Covers the restore-failure branch through the real binary: when the stashed path is reoccupied before the push finishes, the merge still succeeds but warns with a recoverable `git stash apply <sha>` and leaves the stash entry intact.
|
CI status, since the
Coverage arithmetic, in case it lands red: on the previous push |
|
@worktrunk-bot what happened with the code coverage job? |
|
The The So: not the diff, not the coverage tooling, not a stuck compute — an The EvidenceJob: Step timeline (from the job API):
Job cancelled Tail of the hung step's log:
Step definition:
|
Split out of #3693 so it can be reviewed on its own. Follows #3684. ## Problem The three tests that install a native git hook — `test_push_autostash_survives_concurrent_stash`, `test_push_autostash_restore_failure_warns`, and `test_merge_target_diverges_during_receive_restores_autostash` — were gated `#[cfg(unix)]`, so the autostash regression they guard went unverified on Windows. Nothing they assert is unix-specific; git runs hooks through a shell it ships on every platform. The gate was carrying two incidental blockers: - `std::os::unix::fs::PermissionsExt`, for an executable bit Windows has none of. - A `root.display()` path interpolated into the hook script, whose Windows backslashes would reach `sh` as escapes. ## Solution `common::write_git_hook` writes the hook and gates only the chmod, so the platform fact lives in one place instead of being re-derived per test. The repo root is interpolated with `path_slash`'s `to_slash_lossy` — the form `step_prune.rs` already uses to embed a path in a hook command that runs on Windows. ## Why un-gating is safe Each test asserts that its own hook fired: the push tests require `INTERLOPER` in the stash list, and the merge test requires the target ref to have advanced. A hook that silently fails to run on Windows therefore fails the test rather than passing vacuously. Confirmed rather than assumed — on the stacked branch these tests ran green on `test (windows)`, and pulling that job's junit artifact shows all three present by name, so they executed rather than being filtered out. ## Sweep The rest of the suite's platform gates were checked and left alone; the remaining ones are gated for real reasons: symlinks, signal delivery, unix permission bits, `lsof`/fsmonitor daemon reaping, shell-script `git` shims on `PATH` (Windows can't exec a shebang script that way), ConPTY output capture, and clap's differing `[experimental]` tag rendering in Windows snapshots (`step_alias.rs`, `help.rs` — already documented in-place). The `#[ignore]`s and elevated-privileges runtime skips are likewise documented and intentional. > _This was written by Claude Code on behalf of max-sixty_
…#3693) Follows #3684. The Windows test un-gating that was stacked here is split out into #3695, now merged; this PR is the exit-code change alone. ## Problem `wt merge` reported success when the target worktree's autostash failed to replay. The warning named the recovery command, but exit 0 said the user's uncommitted changes were back in their worktree while they were still in a stash — and that warning scrolls past under the worktree-removal and post-merge-hook output that follows it. ## Solution The restore outcome travels out through `PushResult`. The command finishes everything it started — ref advanced, worktree removed, hooks run, `--format=json` payload printed — and only then returns `AlreadyDisplayed { exit_code: 1 }`. Aborting at the restore instead would leave a landed merge with its cleanup half-done, trading a recoverable stash for a worse mess. The shell wrapper applies its `cd` directive whenever the directive file is non-empty, independent of exit code, so a non-zero exit strands nobody in a removed directory. Both output channels name the failure: the `--format=json` payloads of `wt merge` and `wt step push` carry `stash_restore_failed`, present on every payload like the other outcome booleans. The exit code alone would leave a consumer reading stdout with a success-shaped object and no signal. This also closes a gap the change surfaced: `handle_no_ff_merge`'s already-up-to-date early return never called `restore_stash`, so a dirty target worktree with nothing to merge restored through `Drop` and reported nothing. It restores on that path too now, which is what makes the guarantee hold — every remaining `Drop` of the guard happens on a path already returning an error. ## On diverging from git `git rebase --autostash` exits 0 in this situation: it prints "applying them resulted in conflicts" and still reports "Successfully rebased". The difference is what the user is left looking at. git's failure leaves conflict markers in the working tree, met immediately; a failed `git stash apply` here can leave the worktree untouched — an untracked path re-created underneath it, for instance — so nothing but the exit code outlives the warning. The reason is recorded on the field the exit code hangs off, so it doesn't read later as an oversight. ## Testing `test_merge_autostash_restore_failure_exits_non_zero_after_cleanup` covers the guarantee end to end: exit 1, `stash_restore_failed` in the JSON, ref advanced, source worktree removed, stash entry still present for recovery. `test_push_autostash_restore_failure_warns` moves from asserting `success()` to asserting exit 1 plus the push having landed. Also verified against a real build outside the suite, on both commands: the merge lands, the worktree is cleaned up, exit is 1, the JSON reports `"stash_restore_failed": true`, and the warning names the exact `git stash apply <sha>`. `wt step push --help` gained the failure contract, since it previously described only the success path; the generated mirrors are regenerated with it. Local (macOS): `cargo run -- hook pre-merge --yes` green — 4500 tests, clippy, fmt, doc sync. > _This was written by Claude Code on behalf of max-sixty_ --------- Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
worktrunk 0.72.0 Created-by: HarmonybrewBot Commit-by: HarmonybrewBot Merged-by: HarmonybrewBot Description: Created by `brew bump` --- Created with `brew bump-formula-pr`.<details> <summary>release notes</summary> <pre>## Release Notes ### Improved - **A host carrying a forge's name anywhere resolves to that forge again**: 0.71.0 required `github`, `gitlab`, or `gitea` as a whole dot-separated label, which read as an ownership check but wasn't one — an attacker controls their own DNS — while shutting out self-hosters with hyphenated names. `github-enterprise.acme.com`, `mygithub.com`, and the `github-personal` SSH alias classify again, so CI status, `wt switch --prs`, and `repo.provider` work with no config. ([#3673](max-sixty/worktrunk#3673)) - **One `[projects."…"]` entry can cover every repository on a host, and can set the forge**: A key containing `*` matches any run of characters, `/` included, so `[projects."git.company.example/*"]` covers a whole host; every matching entry applies, least- to most-specific. The table also gained `forge.platform` and `forge.hostname`, so a self-hosted host needs one entry here rather than a block in every repo. [Docs](https://worktrunk.dev/config/#user-project-specific-settings) ([#3701](max-sixty/worktrunk#3701), thanks @chrishas35 for the request and @witt-bit for the workspace-scoped case it partly serves) - **`wt merge` and `wt step push` leave the target worktree's uncommitted changes in place**: The autostash that held a dirty target's changes restored them as unstaged; both now advance the target with a compare-and-swap `update-ref` and `read-tree -m -u`, which leaves uncommitted work untouched. (Breaking: the fast-forward path no longer runs `git push`, so `pre-push` and the receive-side hooks no longer fire, and a failed sync errors with the ref rolled back.) ([#3703](max-sixty/worktrunk#3703), [#3684](max-sixty/worktrunk#3684), [#3693](max-sixty/worktrunk#3693), thanks @gubasso for reporting) - **Approval state and branch-removal outcomes are machine-readable**: `wt config approvals list --format=json` reports whether a non-interactive run would stop for approval. `wt remove` and `wt step prune` replace `branch_deleted` with `branch_outcome`: `deleted`, `deferred`, `not_attempted`, `retained_unmerged`, `retained_checked_out`, `retained_raced`, `retained_failed`. (Breaking.) ([#3710](max-sixty/worktrunk#3710), thanks @NathanaelRea for the requests) - **Every commit hash worktrunk prints follows `core.abbrev`**: The `wt list` table, `wt switch --prs`'s `log` tab, and `wt config state`'s CI cache table sliced to 8 characters while `--format=json` carried git's `%h`. All now ask git how wide it abbreviates in this repo. ([#3676](max-sixty/worktrunk#3676), [#3677](max-sixty/worktrunk#3677)) - **`wt list --format=json` schema 2 has a published JSON Schema**: [worktrunk.dev/schema/list-v2.json](https://worktrunk.dev/schema/list-v2.json) holds the contract, and `wt list --print-schema` prints the same document. Four fields that were bare strings are now enumerated vocabularies; the emitted JSON is unchanged. ([#3747](max-sixty/worktrunk#3747)) - **A detached worktree is named by its commit, not `-`**: The Branch cell hardcoded `-`, which reads as missing data rather than a state; it now carries the row's abbreviated HEAD, and the picker and statusline name the worktree the same way. ([#3675](max-sixty/worktrunk#3675)) ### Fixed - **Piped output is plain and no longer panics**: `wt list | head -3` exited 101 with `failed printing to stdout: Broken pipe`, and `wt list` wrote ANSI to a pipe unconditionally. Every stdout surface now exits cleanly, and the human-read ones are plain when piped unless `CLICOLOR_FORCE=1`. ([#3746](max-sixty/worktrunk#3746), [#3766](max-sixty/worktrunk#3766)) - **A CI check that hasn't finished no longer reads as passed**: Each forge's status parser missed documented values, so a GitHub PR parked on an approval gate showed green, and GitLab's `canceling` and Azure DevOps's `postponed` read as no CI at all. ([#3741](max-sixty/worktrunk#3741), [#3740](max-sixty/worktrunk#3740)) - **The shell wrappers survive an `rm` alias and a failing `--execute`**: Aliases bake into the wrapper at parse time, so `alias rm='rm -v'` reached its cleanup — noise on zsh and bash, and on nushell an abort that leaked three temp files, as a failing `--execute` body also did. ([#3714](max-sixty/worktrunk#3714), thanks @Ar4l), ([#3732](max-sixty/worktrunk#3732), [#3734](max-sixty/worktrunk#3734)) - **An alias or hook wrapping `wt switch` or `wt remove` keeps your subdirectory**: The user's position came from the `wt` process's cwd, which inside an alias body is the worktree root, so an aliased `wt remove` from `feature/apps/gateway` landed at the primary worktree's root. Fixes [#3723](max-sixty/worktrunk#3723). ([#3724](max-sixty/worktrunk#3724), thanks @vivienm for reporting) - **`wt merge` and `wt step push` refuse a target worktree parked mid-operation**: The two-tree sync refuses an unmerged index but not a stopped cherry-pick or rebase whose conflict was already staged, so the push range could land in a paused target and be committed by `--continue`. ([#3759](max-sixty/worktrunk#3759)) - **The Claude plugin's worktree-remove hook resolves against the worktree path**: The hook anchored at `CLAUDE_PROJECT_DIR`, which the `claude agents` view routinely leaves outside any repository, so `wt remove` died with `not a git repository` and the session became undeletable. Its guard now also requires a `.git` entry. ([#3754](max-sixty/worktrunk#3754), [#3767](max-sixty/worktrunk#3767), thanks @judewang for the fix and the report) - **A Gitea API error is reported as one, not as a parse failure**: `tea api` exits 0 whatever the HTTP status, so both call sites guessed from the body's shape and blamed an API change for an API error. `--include` surfaces the status instead. ([#3713](max-sixty/worktrunk#3713), [#3600](max-sixty/worktrunk#3600)) - **`--print-schema` and the doc-generation help flags name the right command**: All three found the subcommand by scanning `argv` for a `/wt` suffix, which never matches `wt.exe` under a backslash path, so on Windows they read the binary's own path as the command. ([#3762](max-sixty/worktrunk#3762)) - **A multibyte shell name no longer panics**: `extract_filename_from_path` sliced at `len() - 4` to test for `.exe` with no char-boundary check: `SHELL=/bin/日本語 wt config show` panicked, and on macOS every process name goes through it during shell detection. ([#3727](max-sixty/worktrunk#3727)) - **`wt` installed under a dotted name generates shell integration for that name**: `binary_name` used `file_stem`, which cuts at the last dot, so `wt config shell init bash` under `wt.old` emitted a wrapper for `wt`. It now strips only the executable suffix. ([#3719](max-sixty/worktrunk#3719)) - **Concurrent `wt step prune` removals no longer race the worktree registry**: `git worktree remove` reads every sibling under `.git/worktrees/`, so two overlapping removals could have one read a sibling mid-teardown. Registry-mutating removals now serialize behind a second lock. ([#3692](max-sixty/worktrunk#3692)) - **The `wt switch` first-run offer previews the legacy files it removes**: Accepting "Install shell integration?" could delete a deprecated worktrunk-managed wrapper the prompt never named. What gets removed is unchanged. ([#3656](max-sixty/worktrunk#3656)) - **`wt config create --project` writes a resolvable link**: The comment it writes into `.config/wt.toml` carried a raw Zola target, because the link-conversion regex stopped at the first `]` — here the one closing a nested code span. ([#3731](max-sixty/worktrunk#3731)) - **`wt list --branches` counts a local branch containing `/` as local**: The summary tally classified branch-only rows by `branch.contains('/')`, so a local `feature/login` counted under "N remote branches". ([#3687](max-sixty/worktrunk#3687)) - **`wt step relocate`'s human summary counts template-error branches as skipped**: `--format=json` already folded them into `skipped`; the human tally undercounted by the number of branches whose `worktree-path` template failed to expand. ([#3688](max-sixty/worktrunk#3688)) ### Documentation - **SignPath attribution appears with the artifacts it describes**: The code-signing notice and a route to the policy now sit in the install section's Windows block on the README and the docs landing page, as SignPath Foundation's OSS program requires. ([#3709](max-sixty/worktrunk#3709)) - **`wt step copy-ignored`'s `--require-include` example renders as a terminal block**: It was the only `console` block in the command's long help missing the `$ ` prefix. ([#3706](max-sixty/worktrunk#3706)) ### Internal - **Library API rework** (Breaking library API): `cargo-semver-checks` fails five lints — `LegacyForgeAlias` and `Repository::legacy_forge_alias` removed with the forge-classification revert, `Repository::forge_platform_override` removed for one shared resolver, `stage_worktree_removal` gained two parameters, `UserProjectOverrides` gained a `forge` field, and the `real-repo-benches` feature was removed. ([#3673](max-sixty/worktrunk#3673), [#3694](htt See merge request: Harmonybrew/homebrew-core!16275
Problem
wt mergeautostashes non-conflicting changes in the target worktree, pushes, then restores. It captured the stash by its positional reflog selector (stash@{0}) and popped that position after the push — butstash@{0}is a position, not a handle. Any stash pushed into the repo between the capture and the restore shifts the reflog indices, so the pop dropped a different entry: the user's uncommitted work was silently discarded, the interloper's stash was restored in its place, and the command reported success (exit 0) with no warning.Natural triggers are exactly the case autostash exists to support — two concurrent
wt merges in one repo — plus any other stash writer touching the repo mid-merge (vim-fugitive, IntelliJ, a background agent). Reported and diagnosed in detail in #3683.Solution
Capture the stash's immutable commit SHA (
%H) instead of the positional%gdselector, and restore withgit stash apply <sha>, which is content-addressed and immune to any reordering of the stash list.apply(unlikepop) leaves the entry in place, so the entry is then re-located by its unique message and dropped. A failed drop only strands a stash entry — the working-tree content is already restored — so it warns rather than failing.Also fixes a latent parsing bug the SHA path surfaced: git separates stash-list records with
\n, so every selector after the first carries a leading newline;locate_by_messagenow trims it before use.Testing
Reproduced end-to-end with the reporter's mechanism (a
post-receivehook that stashes duringwt's own fast-forwardgit push— the exact capture→restore window): before the fix the user'sPRECIOUS-USER-WORKwas discarded and the autostash stranded atstash@{0}; after, it is restored, the interloper's stash is untouched, and the autostash entry is cleaned up.Added
test_push_autostash_survives_concurrent_stash(push.rs) capturing this regression, plus verified the existing no-interloper autostash tests still pass.Closes #3683 — automated triage