Skip to content

fix(merge): restore autostash by commit SHA, not stash@{0} - #3684

Merged
max-sixty merged 3 commits into
mainfrom
fix/issue-3683
Aug 1, 2026
Merged

max-sixty merged 3 commits into
mainfrom
fix/issue-3683

Conversation

@worktrunk-bot

Copy link
Copy Markdown
Collaborator

Problem

wt merge autostashes 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 — but stash@{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 %gd selector, and restore with git stash apply <sha>, which is content-addressed and immune to any reordering of the stash list. apply (unlike pop) 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_message now trims it before use.

Testing

Reproduced end-to-end with the reporter's mechanism (a post-receive hook that stashes during wt's own fast-forward git push — the exact capture→restore window): before the fix the user's PRECIOUS-USER-WORK was discarded and the autostash stranded at stash@{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

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 worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@worktrunk-bot
worktrunk-bot requested a review from max-sixty August 1, 2026 03:21
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.
@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

CI status, since the code-coverage job is running long on this head:

  • All required checks are green: test (linux), test (macos), test (windows).
  • code-coverage has been in_progress for ~35 min (it completed in 4–7 min on the two earlier pushes here), so codecov/patch hasn't posted yet on this head. That looks like a slow/stuck runner rather than anything in the diff — a rerun of that job should clear it.

Coverage arithmetic, in case it lands red: on the previous push codecov/patch was 96.92%, with the only 2 missed lines being the restore-failure warning_message branch in repository_ext.rs. This push adds test_push_autostash_restore_failure_warns, a subprocess test that drives exactly that branch (verified it prints Failed to restore stashed changes … git stash apply <sha> and leaves the stash recoverable), so patch should reach 100% once the job uploads.

@max-sixty
max-sixty merged commit 194edd5 into main Aug 1, 2026
34 of 35 checks passed
@max-sixty
max-sixty deleted the fix/issue-3683 branch August 1, 2026 16:44
@max-sixty

Copy link
Copy Markdown
Owner

@worktrunk-bot what happened with the code coverage job?

@worktrunk-bot

Copy link
Copy Markdown
Collaborator Author

The code-coverage job hung in apt-get update, not in the coverage compute — so my earlier "slow/stuck runner, a rerun should clear it" guess was wrong. Here's what actually happened.

The code-coverage job got through install/cache in ~30s, then stalled inside the Install shells (zsh, fish) step's sudo apt-get update. The last line of output is a package-index fetch at 03:57:44; nothing more prints until GitHub kills the job at 09:56:51 — the 6-hour maximum job runtime. Since the job has no timeout-minutes, a transient apt/mirror hang escalated straight to the 6h hard cap and cancelled. The Generate coverage report, Upload, and Upload to codecov.io steps were all skipped, so codecov/patch never posted on this head. The PR was merged on green required checks (test (linux|macos|windows) all passed) before any rerun.

So: not the diff, not the coverage tooling, not a stuck compute — an apt-get update network stall that a missing job timeout let run for 6 hours.

The code-coverage job normally finishes in 4–7 min, so a timeout-minutes well under the 6h cap (e.g. 30) would turn this failure mode from a silent 6-hour cancellation into a fast fail that's trivially rerunnable. The same unguarded sudo apt-get update && sudo apt-get install -y zsh fish also appears in benchmarks.yaml and nightly.yaml. Happy to open a PR adding timeout-minutes (and optionally an apt retry) if you'd like.

Evidence

Job: code-coverage, run 30682956130, attempt 1, head b446cff.

Step timeline (from the job API):

step result started completed
Set up / checkout / installs / cache success 03:56:39 03:57:05
Install shells (zsh, fish) cancelled 03:57:05 09:56:51
Install nushell → Generate → Upload → codecov skipped 09:56:51 09:56:51

Job cancelled 09:56:53, having started 03:56:38 — 6h00m, the GitHub Actions hard limit.

Tail of the hung step's log:

2026-08-01T03:57:44.2118385Z Get:5 https://archive.ubuntu.com/ubuntu noble-security InRelease [126 kB]
2026-08-01T09:56:51.3062963Z ##[error]The operation was canceled.

apt-get update had already Ign'd the azure.archive.ubuntu.com mirror and fallen back to archive.ubuntu.com; it stalled mid-fetch of the noble-security index and never returned.

Step definition: coverage.yaml line 54. The job (coverage.yaml line 29) has no timeout-minutes.

coverage.yaml also runs on push to main, so the merge commit 194edd5 triggers a fresh coverage run that will post project/patch numbers for the merged code regardless of this PR-head run's fate.

max-sixty added a commit that referenced this pull request Aug 1, 2026
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_
max-sixty added a commit that referenced this pull request Aug 2, 2026
…#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>
social4hyq pushed a commit to social4hyq/homebrew-core that referenced this pull request Sep 20, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automated-fix Automated CI fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

wt merge: autostash restored by positional stash@{0} — silently discards uncommitted work

2 participants