bug(prep): rt prep --dry-run inside a linked worktree stages the whole preview into that worktree's index — the scratch copy shares its .git #1538

Open
opened 2026-10-03 14:53:46 +02:00 by claude · 0 comments
Member

Run inside a linked worktree, rt prep --dry-run rewrites that worktree's git index. The preview's changes end up staged there: the composed CHANGELOG, the VERSION bump, the baked markers and the consumed fragments' deletions. The files on disk stay byte-identical. rt notices, prints its own 🔴 BUG (release-toolkit#708) line, and exits 0.

Measured (reproduced 2026-10-03)

Setup.

  • A scratch clone of release-toolkit at origin/main 8086700, with core.autocrlf=false and its push URL pointed at a path that does not exist.
  • rt built from that commit.
  • A linked worktree added with git worktree add --detach <wt> HEAD.
  • rt prep --dry-run run at the root of the clone, then at the root of the worktree, with no forge token in the environment.
Windows, git 2.55.0.windows.3, native Go Linux, git 2.47.3, golang:1.25
Control: plain clone, git status --porcelain=v1 -uall lines before → after 0 → 0 0 → 0
Linked worktree, the same count, before → after 0 → 28 0 → 28
main checkout of the same repository, after the worktree run 0 0
exit code of the worktree run 0 0

What the worktree shows afterwards (first lines, Windows):

MM .forgejo/workflows/reusable-ac-closure-check.yml
...
MM CHANGELOG.md
MM VERSION
D  changelog.d/1500-recovery-body-1173.fixed.md
...
?? changelog.d/1500-recovery-body-1173.fixed.md
  • Only the index moved. git hash-object on disk equals HEAD: for CHANGELOG.md, VERSION, a fragment and reusable-release.yml. The worktree's .git/worktrees/wt/index changed size (71231 → 70663 bytes). HEAD stayed at 8086700.
  • What a commit would record. git diff --cached --stat shows "24 files changed, 51 insertions(+), 31 deletions(-)". The staged CHANGELOG.md gains ## [v0.65.0] - 2026-10-03. That is what a plain git commit in that worktree would commit.
  • rt detects it. stderr carries [DRY-RUN] 🔴 BUG (release-toolkit#708): the scratch redirection LEAKED - <wt> changed during a dry run: … (cmd/rt/prep.go:176). The exit code stays 0: the check discloses and does not refuse.
  • Cleanup. In this worktree, which was clean before the run, git reset -q brought the status back to 0 lines.

Mechanism

  • ScratchTree copies the checkout, .git included, into %TMP%/$TMPDIR (internal/prep/scratch.go:30-42, copyTree :48-89). Then runPrep points repoRoot at the copy (cmd/rt/prep.go:152-159).

  • In a linked worktree, .git is a regular file, not a directory: gitdir: <main>/.git/worktrees/<name>. copyTree copies that file byte for byte (:81). So the scratch's .git still points at the operator's admin directory.

  • Probed. A directory holding only a copy of the worktree's .git file resolves git rev-parse --git-dir to <main>/.git/worktrees/wt and --git-common-dir to <main>/.git, with --show-toplevel as the copy.

  • So every git write the dry run makes in the scratch lands in the operator's worktree index:

    • ScratchDiff's git add -A (scratch.go:124), called on every successful dry run (prep.go:422). It stages the whole preview, which matches the 28 lines above.
    • The doc-version-ref stage at prep.go:397 on a stable cut.
    • Anything post_bump_hooks stage (prep.go:362-363, which runs in dry-run).

    The prepkg.Add calls at prep.go:681-724 are in commitAndPush, which a dry run skips.

  • The doc comment at scratch.go:23-27 reads ".git is copied rather than shared". In a linked worktree it is shared. The comment at prep.go:164 reads "By construction it cannot have changed". In a linked worktree it can.

Why the tests are green. Only rt prep calls ScratchTree (git grep 'ScratchTree('). Its arms, TestPrep_dryRunLeavesTheCheckoutUntouched (cmd/rt/prep_test.go:332) and TestPrep_dryRunFailurePathLeavesTheCheckoutUntouched (:552), have two blind spots:

  • they build the fixture with git init (setupPrepRepo, :28), never with a linked worktree;
  • they compare file bytes on purpose ("not by mtime or by a git-status summary"). An index-only leak leaves the bytes identical, so even a worktree fixture would pass them.

Why it matters

  • #708's whole promise is that a preview leaves the operator's checkout as it was. In a worktree, the next ordinary git commit there would commit a release prep: a version bump, a composed changelog and deleted fragments. The person running it was told the dry run writes nothing.
  • Agent sessions work in linked worktrees. The operator's own checkout of this repository has 7 of them under .claude/worktrees/ (git worktree list, 2026-10-03). #1531's own body tells reviewers to "Run demos in plain clones until that is looked at".
  • The detector fires, but its line sits in the middle of stderr, and the run exits 0.

Decision (for the implementer)

  • (a) Materialise a standalone repository in the scratch when .git is a gitfile. Copy or git clone --local the common dir, carry over the worktree's own HEAD and index, and replace the copied gitfile with a real .git directory. Right if previews in worktrees must keep working, which is the common agent case. Costs: more code, and a copy of the common dir.
  • (b) Refuse --dry-run in a linked worktree, naming the reason and the plain-clone workaround. Right as a stop-gap: it is cheap and honest. Costs: previews in worktrees are lost.
  • (c) Make the existing leak check change the exit status. This does not stop the write, so it is not enough alone. Right only together with (a) or (b), as a backstop.

Acceptance criteria

  • Run at the root of a linked worktree, rt prep --dry-run leaves git status --porcelain=v1 -uall and the worktree's index identical before and after. Alternatively, it refuses before any write, naming the reason, under decision (b).
  • A regression arm builds a repository and adds a linked worktree. It runs the dry run inside the worktree and asserts git status, not only file bytes. Mutation-verified: on the current ScratchTree it goes red (AGENTS.md section 8, Axis B).
  • The plain-clone preview is unchanged. On a toolkit-self clone at the base commit, stdout stays byte-identical (328 lines at 8086700).
  • The ScratchTree doc comment (scratch.go:11-28) and the prep.go:160-165 comment say how a gitfile .git is handled.

Out of scope

  • Submodule checkouts, whose .git is also a gitfile. The same mechanism applies, but I did not measure it.
  • A worktree with a relative gitdir: (worktree.useRelativePaths), where the copied file would point nowhere. Not measured.
  • post_bump_hooks that write outside their working directory. The flag help already says the scratch does not contain those.
  • What the leak does to refs. Prep's dry run creates no branch or commit in the scratch, and HEAD did not move here. A hook that does could move the worktree's HEAD or the shared refs. Not measured.

Anchor

Reported in PR #1531's "Not in this PR" list as "leaked writes into that worktree in one review run", while reviewing PR #1531 / issue #1529. Reproduced 2026-10-03 on a scratch clone at 8086700, on Windows and in Linux, with a plain-clone control. Origin of the scratch tree: #708.

Run inside a linked worktree, `rt prep --dry-run` rewrites that worktree's git index. The preview's changes end up staged there: the composed CHANGELOG, the VERSION bump, the baked markers and the consumed fragments' deletions. The files on disk stay byte-identical. `rt` notices, prints its own `🔴 BUG (release-toolkit#708)` line, and exits 0. ## Measured (reproduced 2026-10-03) **Setup.** - A scratch clone of release-toolkit at origin/main `8086700`, with `core.autocrlf=false` and its push URL pointed at a path that does not exist. - `rt` built from that commit. - A linked worktree added with `git worktree add --detach <wt> HEAD`. - `rt prep --dry-run` run at the root of the clone, then at the root of the worktree, with no forge token in the environment. | | Windows, git 2.55.0.windows.3, native Go | Linux, git 2.47.3, `golang:1.25` | |---|---|---| | **Control:** plain clone, `git status --porcelain=v1 -uall` lines before → after | 0 → 0 | 0 → 0 | | **Linked worktree,** the same count, before → after | 0 → **28** | 0 → **28** | | main checkout of the same repository, after the worktree run | 0 | 0 | | exit code of the worktree run | 0 | 0 | What the worktree shows afterwards (first lines, Windows): ``` MM .forgejo/workflows/reusable-ac-closure-check.yml ... MM CHANGELOG.md MM VERSION D changelog.d/1500-recovery-body-1173.fixed.md ... ?? changelog.d/1500-recovery-body-1173.fixed.md ``` - **Only the index moved.** `git hash-object` on disk equals `HEAD:` for `CHANGELOG.md`, `VERSION`, a fragment and `reusable-release.yml`. The worktree's `.git/worktrees/wt/index` changed size (71231 → 70663 bytes). HEAD stayed at `8086700`. - **What a commit would record.** `git diff --cached --stat` shows "24 files changed, 51 insertions(+), 31 deletions(-)". The staged `CHANGELOG.md` gains `## [v0.65.0] - 2026-10-03`. That is what a plain `git commit` in that worktree would commit. - **`rt` detects it.** stderr carries `[DRY-RUN] 🔴 BUG (release-toolkit#708): the scratch redirection LEAKED - <wt> changed during a dry run: …` (`cmd/rt/prep.go:176`). The exit code stays 0: the check discloses and does not refuse. - **Cleanup.** In this worktree, which was clean before the run, `git reset -q` brought the status back to 0 lines. ## Mechanism - `ScratchTree` copies the checkout, `.git` included, into `%TMP%`/`$TMPDIR` (`internal/prep/scratch.go:30-42`, `copyTree` :48-89). Then `runPrep` points `repoRoot` at the copy (`cmd/rt/prep.go:152-159`). - In a linked worktree, `.git` is a regular file, not a directory: `gitdir: <main>/.git/worktrees/<name>`. `copyTree` copies that file byte for byte (:81). So the scratch's `.git` still points at the operator's admin directory. - **Probed.** A directory holding only a copy of the worktree's `.git` file resolves `git rev-parse --git-dir` to `<main>/.git/worktrees/wt` and `--git-common-dir` to `<main>/.git`, with `--show-toplevel` as the copy. - So every git write the dry run makes in the scratch lands in the operator's worktree index: - `ScratchDiff`'s `git add -A` (`scratch.go:124`), called on every successful dry run (`prep.go:422`). It stages the whole preview, which matches the 28 lines above. - The doc-version-ref stage at `prep.go:397` on a stable cut. - Anything `post_bump_hooks` stage (`prep.go:362-363`, which runs in dry-run). The `prepkg.Add` calls at `prep.go:681-724` are in `commitAndPush`, which a dry run skips. - The doc comment at `scratch.go:23-27` reads ".git is copied rather than shared". In a linked worktree it is shared. The comment at `prep.go:164` reads "By construction it cannot have changed". In a linked worktree it can. **Why the tests are green.** Only `rt prep` calls `ScratchTree` (`git grep 'ScratchTree('`). Its arms, `TestPrep_dryRunLeavesTheCheckoutUntouched` (`cmd/rt/prep_test.go:332`) and `TestPrep_dryRunFailurePathLeavesTheCheckoutUntouched` (:552), have two blind spots: - they build the fixture with `git init` (`setupPrepRepo`, :28), never with a linked worktree; - they compare file **bytes** on purpose ("not by mtime or by a git-status summary"). An index-only leak leaves the bytes identical, so even a worktree fixture would pass them. ## Why it matters - #708's whole promise is that a preview leaves the operator's checkout as it was. In a worktree, the next ordinary `git commit` there would commit a release prep: a version bump, a composed changelog and deleted fragments. The person running it was told the dry run writes nothing. - Agent sessions work in linked worktrees. The operator's own checkout of this repository has 7 of them under `.claude/worktrees/` (`git worktree list`, 2026-10-03). #1531's own body tells reviewers to "Run demos in plain clones until that is looked at". - The detector fires, but its line sits in the middle of stderr, and the run exits 0. ## Decision (for the implementer) - **(a) Materialise a standalone repository in the scratch** when `.git` is a gitfile. Copy or `git clone --local` the common dir, carry over the worktree's own HEAD and index, and replace the copied gitfile with a real `.git` directory. Right if previews in worktrees must keep working, which is the common agent case. Costs: more code, and a copy of the common dir. - **(b) Refuse `--dry-run` in a linked worktree**, naming the reason and the plain-clone workaround. Right as a stop-gap: it is cheap and honest. Costs: previews in worktrees are lost. - **(c) Make the existing leak check change the exit status.** This does not stop the write, so it is not enough alone. Right only together with (a) or (b), as a backstop. ## Acceptance criteria - [ ] Run at the root of a linked worktree, `rt prep --dry-run` leaves `git status --porcelain=v1 -uall` and the worktree's index identical before and after. Alternatively, it refuses before any write, naming the reason, under decision (b). - [ ] A regression arm builds a repository and adds a linked worktree. It runs the dry run inside the worktree and asserts **git status**, not only file bytes. Mutation-verified: on the current `ScratchTree` it goes red (AGENTS.md section 8, Axis B). - [ ] The plain-clone preview is unchanged. On a toolkit-self clone at the base commit, stdout stays byte-identical (328 lines at `8086700`). - [ ] The `ScratchTree` doc comment (`scratch.go:11-28`) and the `prep.go:160-165` comment say how a gitfile `.git` is handled. ## Out of scope - Submodule checkouts, whose `.git` is also a gitfile. The same mechanism applies, but I did not measure it. - A worktree with a relative `gitdir:` (`worktree.useRelativePaths`), where the copied file would point nowhere. Not measured. - `post_bump_hooks` that write outside their working directory. The flag help already says the scratch does not contain those. - What the leak does to refs. Prep's dry run creates no branch or commit in the scratch, and HEAD did not move here. A hook that does could move the worktree's HEAD or the shared refs. Not measured. ## Anchor Reported in PR #1531's "Not in this PR" list as "leaked writes into that worktree in one review run", while reviewing PR #1531 / issue #1529. Reproduced 2026-10-03 on a scratch clone at `8086700`, on Windows and in Linux, with a plain-clone control. Origin of the scratch tree: #708.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
frankenbit/release-toolkit#1538
No description provided.