Skip to content

fix(list): ignore untracked files in conflict probes - #3906

Merged
max-sixty merged 7 commits into
mainfrom
codex/list-conflict-probe
Aug 25, 2026
Merged

max-sixty merged 7 commits into
mainfrom
codex/list-conflict-probe

Conversation

@max-sixty

@max-sixty max-sixty commented Aug 25, 2026 •

Copy link
Copy Markdown
Owner

Fixes #3883.

This replaces #3884. Thanks @srobroek for the report, reproduction, and original fix; the commit retains co-author credit.

Problem

wt list and wt list statusline synthesize trees for their advisory conflict checks. Git writes the blobs, trees, and commits used by those checks into the real object database even though nothing references them. A changing large untracked artifact can therefore add another full copy on every invocation.

Redirecting all probe objects solves the growth, but including untracked content still spends time hashing and compressing artifacts that are outside the useful scope of a best-effort conflict estimate.

Approach

  • Treat untracked porcelain entries as status-only. They remain visible as ? changes but do not enter the synthetic conflict tree.
  • Preserve staged-only tracked changes with write-tree against a copied index. When unstaged tracked changes must be included, run pathspec-free git add -u --sparse against the copy before Git's existing merge-tree simulation.
  • Fall back to the committed-HEAD conflict probe for clean and untracked-only worktrees, so an untracked artifact cannot suppress a committed conflict.
  • Redirect every object-producing list and statusline probe to one invocation-scoped temporary object database. Temporary probe storage prefers the system temp directory and falls back to Git metadata: the common directory for objects and the worktree's Git directory for indexes. If neither location is writable, the command fails instead of writing probe objects into the real database.
  • Keep the effective real object database and inherited alternates readable through Git's C-style quoted GIT_ALTERNATE_OBJECT_DIRECTORIES format.
  • Hide the exact Worktrunk-owned temporary directory from status and preview tasks when TMPDIR is inside a worktree, and unregister it when the final redirected clone drops.

The temporary store has no persistent cache, reuse, pruning, or lifecycle policy.

The estimate deliberately ignores the case where the target adds a path currently occupied by an untracked file. The real merge still retains Git's overwrite protection and refuses to destroy that file.

Validation

  • Current-head focused matrix: 14 passed, covering tracked conflicts, staged deletion of every file, sparse and missing indexes, untracked fallback, both list entry points, invalid TMPDIR fallback, read-only stores, relative and absolute inherited object directories, unusual paths, temporary-directory lifetime, and object neutrality.
  • Documentation sync and formatting pass on the current head; the earlier full pre-merge run also passed help snapshots, doctests, rustdoc, and repository checks.
  • On the real CLI repro, three changing 1 MiB untracked states grew main from 3 loose objects / 12 KiB to 12 objects / 3.05 MiB. This branch remained at 3 objects / 12 KiB.

The local pre-merge wrapper still reports the unchanged Rust 1.98 chunks_exact_to_as_chunks Clippy lint in src/git/repository/diff.rs; the project toolchain contract and CI use Rust 1.97.

This was written by Codex on behalf of @max-sixty

Co-authored-by: Sjors Robroek <s.robroek@vxsan.com>

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Two things worth a look before this lands; the object-redirect mechanism itself (the C-style alternate quoting in particular) checks out — I confirmed against git 2.55 that an unquoted alternate containing : or " fails with unable to normalize alternate object path and the quoted form resolves, so that escaping is load-bearing rather than defensive.

wt list and wt list statusline now hard-fail when no temp directory is available. redirect_objects_for_observation returns Err on tempdir() failure and both collect() and populate_item() propagate it with ?, so the whole command aborts before rendering anything. The code it replaces degraded instead: redirect_objects_if_read_only returned None for a writable object database without ever creating a temp dir, and with_temporary_object_directory's contract was explicitly "Returns None when the temporary store can't be created (no writable temp dir), leaving the caller on the real database" — so unwrap_or(repo) kept the command working. Concretely: TMPDIR pointing at a directory that no longer exists, or a container with a read-only /tmp, used to give a fully-rendered wt list; it now gives an error. The blast radius is worst on wt list statusline, which has no error fallback in src/commands/statusline.rs (the populate_item call is ?-propagated straight out) and renders on every Claude Code prompt redraw. Under the old code the same broken TMPDIR only cost the per-worktree tasks that need temp_index() — those map their errors through ctx.error(...) into a degraded row, not a failed command. If failing loudly is the intent here (better than silently reintroducing the unreachable objects this PR removes), that's defensible, but it's a deliberate availability trade the PR description doesn't mention.

Was dropping the staged-only fast path deliberate? WorkingTreeConflictsTask used to branch on needs_working_tree and run bare write-tree against the real index when every porcelain line had Y == ' '; write_tracked_worktree_tree now always copies the index and runs a pathspec-free git add -u. The redirect applies to repo.run_command too, so the fast path would still have written its tree into the temporary store — and the "avoids creating a missing real index" rationale in the new comment doesn't reach the staged-only case (a missing index makes every tracked path show as D, which takes the slow path anyway). On a large worktree add -u is a full lstat sweep, and this runs per prompt redraw.

Inline: two spots where the new pathspec plumbing adds a positive . it doesn't need.

Comment thread src/git/repository/working_tree.rs Outdated
Comment thread src/git/repository/working_tree.rs

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Both earlier concerns are addressed — the hard-fail is now a fallback into Git metadata with an integration test pinning it, and the staged-only fast path is back as write_index_tree. I re-verified the two pathspec fixes on git 2.55: with only exclude pathspecs and no -- ., git status stays repo-wide and git ls-files --others stays cwd-scoped, identically to before, so the removal changed no scoping. Resolving both threads.

One new thing, and the coverage arithmetic.

The FAQ file inventory no longer covers where these files land. ed74cab added tempdir_in(&self.git_common_dir) and tempfile_in(&git_dir), so on a broken TMPDIR the probe store and the temp index are created inside Git's metadata rather than under $TMPDIR — but the FAQ's "Temporary files (automatic)" section still names only the $TMPDIR/... forms. The PR body documents the fallback; the user-facing inventory that CLAUDE.md points at ("Full inventory: FAQ What files does Worktrunk create? … Review new code that changes this surface against those sections") doesn't. Inline suggestion on the primary; cargo test --test integration test_docs_are_in_sync rewrites the two mirrors.

Related, and only an observation: under $TMPDIR an abnormal exit leaves a worktrunk-list-objects-* directory for the OS reaper to collect, and nothing in the tree sweeps stale ones (no TEMP_INDEX_PREFIX or worktrunk-list-objects- scan outside creation and pathspec exclusion). Inside .git there is no reaper, and the condition that triggers the fallback — a TMPDIR pointing somewhere that doesn't exist — is persistent, while wt list statusline runs per prompt redraw. "The temporary store has no persistent cache, reuse, pruning, or lifecycle policy" reads differently once the store can live in the repo. Worth a sentence in the FAQ at most; I don't think it justifies adding a sweeper.

codecov/patch is red and I'm not approving over it — CLAUDE.md → Coverage requires explicit approval to merge with it failing, so handing over the arithmetic. 197 patch lines, 191 hits, 6 misses = 96.95% against an auto target of 98.13%.

Which six lines, and which are real

Measured from the Codecov compare API for ff77480...ed74cab:

File Line Code Assessment
src/git/repository/mod.rs 834–837 format!(…) / })? in the tempdir_in context closure Needs both the system temp dir and the Git common dir to be unwritable. tempdir_in itself is covered by the new test_list_survives_unavailable_system_temp_directory; only the double-failure message isn't.
src/git/repository/working_tree.rs 858–861 same closure for tempfile_in Same double failure.
src/git/repository/working_tree.rs 797 return Err(CommandError::from_failed_output("git", &args, &output).into()); Relocated-line false positive, and it came from my own suggestion. The base has the identical error path at lines 769–774, uncovered there too — so this rewrite takes six uncovered lines down to one, and codecov charges the one to the patch.
src/git/repository/mod.rs 862 path — the is_absolute() arm of object_database_path Genuinely closable. test_list_preserves_inherited_object_directory passes a relative GIT_OBJECT_DIRECTORY (.git/external-objects), which exercises the current_dir().join(path) arm only; an absolute-path case would cover this one.

Closing 862 moves the patch to 192/197 = 97.46%, still under the 98.13% target — the remaining five are the two double-failure context closures and the relocated line. So the easy fix doesn't turn the check green, and I don't see a deterministic trigger for the rest that's worth the test machinery. That's your call rather than mine; flagging it because the check gates the merge despite being marked not required.

Comment thread docs/src/content/docs/faq.md Outdated

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The FAQ inventory now covers the fallback locations, and it goes further than the suggestion did — naming wt step commit --dry-run (which does reach temp_index(), via src/commands/step/commit.rs), correcting wt statusline to wt list statusline, and folding in the interrupted-exit caveat. All three mirrors match. Resolving that thread.

One thing on the same commit's other half. Dropping {temp_error} from the two fallback messages is the coverage fix — the with_context(|| …) closure body was a separate llvm-cov region that only ran when both locations failed, which is why codecov charged mod.rs 834–837 and working_tree.rs 858–861 to the patch. But that region is the closure, not the format!: the Err(…) arm itself is already exercised by test_list_survives_unavailable_system_temp_directory, so an eager .context(format!(…)) is covered by that test and keeps the detail. As it stands the message names the system temp directory as one of the two locations it tried while the cause chain reports only the Git-directory failure — on the case that actually triggers this (a TMPDIR pointing somewhere that doesn't exist), the first failure is the one the user needs to see. Inline suggestions on both sites; cargo fmt will settle the wrapping either way.

Comment thread src/git/repository/mod.rs Outdated
Comment thread src/git/repository/working_tree.rs Outdated

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Both outstanding concerns are closed: the fallback messages keep {temp_error} via eager .context(format!(…)), and codecov/patch is now green at 98.44% against the 98.13% target — the absolute-GIT_OBJECT_DIRECTORY case in test_list_preserves_inherited_object_directory closed the last real gap.

test (windows) was still running when my poll budget ran out, so it's unverified rather than green.

@max-sixty
max-sixty merged commit 7e5d5de into main Aug 25, 2026
41 checks passed
@max-sixty
max-sixty deleted the codex/list-conflict-probe branch August 25, 2026 15:58
social4hyq pushed a commit to social4hyq/homebrew-core that referenced this pull request Sep 20, 2026
worktrunk 0.75.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

- **The `wt switch` picker opens on one unified diff**: local rows combine committed, staged, unstaged, and untracked changes. Tab skips empty subsidiary views, while `Alt-1` through `Alt-8` retain direct access. [Docs](https://worktrunk.dev/switch/#interactive-picker) ([#3865](max-sixty/worktrunk#3865))

- **Git 2.43 is now the minimum supported version**: older Git exits with an upgrade message before Git-dependent commands run; `wt config shell` remains available so shell startup continues. (Breaking.) ([#3895](max-sixty/worktrunk#3895))

### Fixed

- **`wt list` no longer grows `.git/objects` during advisory conflict checks**: untracked content stays visible but is excluded from synthetic trees, and all probe-only objects use temporary storage. ([#3906](max-sixty/worktrunk#3906), fixes [#3883](max-sixty/worktrunk#3883), thanks @srobroek for reporting and the original fix)

- **`HEAD±` counts untracked files without inflating moves**: `wt list`, the picker, and statusline include untracked lines. Tracked deletions paired with untracked destinations count as renames, so pure moves are line-neutral and edited moves show only their edits. `HEAD±` now always detects renames, regardless of `diff.renames`. ([#3925](max-sixty/worktrunk#3925))

- **Timed child processes no longer abort `wt` in restricted sandboxes**: `wt switch --create`, picker pagers, and other bounded commands survive denied signal-handler wakes. TERM→KILL cleanup also returns promptly once the process group is gone. ([#3857](max-sixty/worktrunk#3857), [#3887](max-sixty/worktrunk#3887), fixes [#3856](max-sixty/worktrunk#3856), thanks @tomascamargo for reporting)

- **JSON list output ignores display-column gates**: `[list] columns` no longer makes `wt list --format json` contact a forge or generate summaries. CI requires `--full`; summaries also require `[list] summary = true` and a configured generator. (Breaking: schema 1 loses config-driven `ci` and `summary` fields.) ([#3812](max-sixty/worktrunk#3812), thanks @emeren for the request)

- **Long Windows paths compare consistently**: paths beyond 260 characters could retain a `\\?\` prefix and appear to be on another drive. `copy-ignored` then refused them, while switch, remove, merge, and relocate landed at the worktree root instead of the original subdirectory. ([#3899](max-sixty/worktrunk#3899), fixes [#3898](max-sixty/worktrunk#3898), thanks @Persedes for reporting and verifying the fix)

- **Shell configuration rechecks before it writes**: overlapping installs lock and reread rc files; Fish completion installs preserve files created after preview; uninstall applies only previewed rc removals and rejects changed Worktrunk-owned files. ([#3853](max-sixty/worktrunk#3853), [#3924](max-sixty/worktrunk#3924))

- **`-vv` profiles exclude their own collector commands**: command counts and cache summaries no longer include duplicate-looking work performed only to assemble the diagnostic report; raw traces still retain it. ([#3900](max-sixty/worktrunk#3900))

- **Fenced HTML comments survive picker Markdown rendering**: PR descriptions and comments now preserve fenced `<!-- … -->` lines; fenced `<!-- wt list … -->` markers also no longer affect the following block. ([#3908](max-sixty/worktrunk#3908))

### Documentation

- **Agent CLIs without a plugin can publish activity markers**: the integration guide now specifies the session-start, turn-end, and session-end calls, working-directory requirement, error guard, and cleanup contract. [Docs](https://worktrunk.dev/claude-code/#agent-clis-without-a-plugin) ([#3848](max-sixty/worktrunk#3848), thanks @AsafMah for requesting generic-agent guidance and @ortonomy for the related Pi use case)

- **Agent guidance explains worktree selection**: commands that name a branch already select its worktree; `-C` changes repository context and is needed only for commands without a worktree selector or callers outside the repository. ([#3890](max-sixty/worktrunk#3890))

- **The `wt up` recipe safely updates dirty worktrees**: it fetches all remotes, fast-forwards dirty branches without autostash, rebases clean branches, and continues past an ordinary refusal or a failed remote. [Docs](https://worktrunk.dev/extending/#recipe-rebase-every-worktree-onto-its-upstream) ([#3882](max-sixty/worktrunk#3882))

- **The docs site has a new responsive design**: rebuilt on Astro and Starlight while preserving public routes, anchors, and crawler URLs; generated reference pages remain synchronized. ([#3866](max-sixty/worktrunk#3866))

### Internal

- **Library API rework** (Breaking library API): `BranchDiffSpec` gained `working_base`, while remote-URL, shell-path, branch-push, approval, temporary-index, and repository helpers were removed. ([#3833](max-sixty/worktrunk#3833), [#3853](max-sixty/worktrunk#3853), [#3865](max-sixty/worktrunk#3865), [#3866](max-sixty/worktrunk#3866), [#3875](max-sixty/worktrunk#3875))

- **Codex loads repository maintainer skills**: `.agents/skills` now exposes the canonical `.claude/skills` tree; checkouts without symlink support keep the existing limitation. ([#3903](max-sixty/worktrunk#3903))

## Install worktrunk 0.75.0

### Install prebuilt binaries via shell script

```sh
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/max-sixty/worktrunk/releases/download/v0.75.0/worktrunk-installer.sh | sh && wt config shell install
```

### Install prebuilt binaries via powershell script

```sh
powershell -ExecutionPolicy Bypass -c "irm https://github.com/max-sixty/worktrunk/releases/download/v0.75.0/worktrunk-installer.ps1 | iex"; git-wt config shell install
```

### Install prebuilt binaries via Homebrew

```sh
brew install worktrunk && wt config shell install
```

## Download worktrunk 0.75.0

|  File  | Platform | Checksum |
|--------|----------|----------|
| [worktrunk-aarch64-apple-darwin.tar.xz](https://github.com/max-sixty/worktrunk/releases/download/v0.75.0/worktrunk-aarch64-apple-darwin.tar.xz) | Apple Silicon macOS | [checksum](https://github.com/max-sixty/worktrunk/releases/download/v0.75.0/worktrunk-aarch64-apple-darwin.tar.xz.sha256) |
| [worktrunk-x86_64-apple-darwin.tar.xz](https://github.com/max-sixty/worktrunk/releases/download/v0.75.0/worktrunk-x86_64-apple-darwin.tar.xz) | Intel macOS | [checksum](https://github.com/max-sixty/worktrunk/releases/download/v0.75.0/worktrunk-x86_64-apple-darwin.tar.xz.sha256) |
| [worktrunk-x86_64-pc-windows-msvc.zip](https://github.com/max-sixty/worktrunk/releases/download/v0.75.0/worktrunk-x86_64-pc-windows-msvc.zip) | x64 Windows | [checksum](https://github.com/max-sixty/worktrunk/releases/download/v0.75.0/worktrunk-x86_64-pc-windows-msvc.zip.sha256) |
| [worktrunk-aarch64-unknown-linux-musl.tar.xz](https://github.com/max-sixty/worktrunk/releases/download/v0.75.0/worktrunk-aarch64-unknown-linux-musl.tar.xz) | ARM64 MUSL Linux | [checksum](https://github.com/max-sixty/worktrunk/releases/download/v0.75.0/worktrunk-aarch64-unknown-linux-musl.tar.xz.sha256) |
| [worktrunk-x86_64-unknown-linux-musl.tar.xz](https://github.com/max-sixty/worktrunk/releases/download/v0.75.0/worktrunk-x86_64-unknown-linux-musl.tar.xz) | x64 MUSL Linux | [checksum](https://github.com/max-sixty/worktrunk/releases/download/v0.75.0/worktrunk-x86_64-unknown-linux-musl.tar.xz.sha256) |



### Install via Cargo

```sh
cargo install worktrunk && wt config shell install
```

### Install via Winget (Windows)

```sh
winget install max-sixty.worktrunk && git-wt config shell install
```

### Install via AUR (Arch Linux)

```sh
paru worktrunk-bin && wt config shell install
```
</pre>
  <p>View the full release notes at <a href="https://github.com/max-sixty/worktrunk/releases/tag/v0.75.0">https://github.com/max-sixty/worktrunk/releases/tag/v0.75.0</a>.</p>
</details>
<hr>

See merge request: Harmonybrew/homebrew-core!17938
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

wt list's conflict probe leaves unreachable objects in the primary object database on every invocation

2 participants