Skip to content

Avoid clobbering concurrently created rc files - #3929

Merged
max-sixty merged 6 commits into
mainfrom
codex/release-0.75.0-drift
Aug 27, 2026
Merged

max-sixty merged 6 commits into
mainfrom
codex/release-0.75.0-drift

Conversation

@max-sixty

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

Copy link
Copy Markdown
Owner

Shell installation checked that a user rc file was absent before persisting an atomic temp file with overwrite semantics. A file created in that gap could therefore be replaced.

This adds write_new_atomically, backed by tempfile::persist_noclobber, and uses it only for missing user rc files. A concurrent creator now wins and Worktrunk fails safely for a rerun. A dangling rc symlink is also preserved and gets an actionable error explaining how to restore its target or remove the link. Existing rc files retain the small advisory append lock; the project guidance records why broader locks and extra prechecks do not close races with editors or dotfile managers, and identifies the remaining last-writer-wins merge into another tool's settings.json.

The change also replaces the custom one-syscall append helper with standard Write::write_all. The helper could report a partial append but could not prevent one, so removing it cuts 99 lines of special handling without weakening the no-truncation recovery boundary.

Validated with the full pre-merge hook: formatting, clippy, documentation, doctests, and 4,712 tests passed (1 skipped). After the review follow-ups, the focused rc-creation, concurrent-creator, dangling- and live-symlink, and no-clobber helper tests pass locally.

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

@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.

One verified behavior change, and a scope note on the new guidance.

write_new_atomically treats a dangling symlink as an existing file, where write_atomically replaced it. configure_shell_file gates the create branch on path.exists(), which follows symlinks and reports false for a link pointing nowhere. dunce::canonicalize then has nothing to resolve, so target stays the link path, and persist_noclobber (renameat2(RENAME_NOREPLACE), falling back to link(2)) sees the link itself and returns EEXIST. Checked on this branch by calling both functions against a dangling link in a tempdir:

noclobber on dangling symlink: Err(Os { code: 17, kind: AlreadyExists, message: "File exists" })
overwrite  on dangling symlink: Ok(())

So a user whose ~/.zshrc is a stow/chezmoi link into a store that isn't materialized now gets Failed to write to ~/.zshrc: File exists (os error 17) from shell install, against a path that reads as absent, with no way forward short of removing the link by hand. It fails safe, so nothing is lost — but it's the same dotfile-manager setup the Symlinks bullet on write_atomically is about, and that bullet's closing sentence ("A dangling link has nothing to resolve to, so it is replaced by a regular file rather than followed to the missing path it names") now describes only the overwrite = true half of the function it sits above. Suggestion inline records it on write_new_atomically. If the failure is the behavior you want, the message is the other half of the fix: File exists on a path the user can't see reads as a bug rather than a race they can rerun through.

The new boundary bullet leaves out Claude Code's settings.json. It closes with "Worktrunk-owned wrappers and completions use last-writer-wins semantics", which accounts for wrappers, completions, and rc files — but not the one remaining file that is neither Worktrunk-owned nor an rc file. configure_claude_statusline in src/commands/config/plugins.rs reads that file (or starts from an empty map when settings_path.exists() is false), merges statusLine, and hands the result to write_atomically — the same observed-absent-then-overwrite shape this PR fixes for rc files, with a read-modify-write window on top. Either answer seems defensible; since the bullet's job is to record where the boundary sits, a reader following it today would conclude every non-Worktrunk-owned file is covered.

Not approving on my own judgment rather than a written rule: the dangling-link case is a real change on the shell-install path, and whether to keep failing there is your call. CI is green on 4a551ea apart from affected tests (macos, advisory), still running.

Comment thread src/utils.rs Outdated
Comment thread CLAUDE.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.

Both prior findings are addressed: the dangling-symlink divergence now fails with an actionable message and a Unix regression test that asserts the link survives, and the boundary bullet names the settings.json merge. Two smaller things, both inline.

The remaining gap is the sibling branch of the same AlreadyExists. The dangling link gets a tailored message; the concurrent-creator case — the one this PR exists to create — still surfaces as Failed to write to ~/.zshrc: File exists (os error 17). That's the PR's own user-facing output for its headline behavior, and the user is told nothing about the rerun the design depends on.

The CLAUDE.md bullet dropped Removal still rewrites those rc files whole (uninstall_previewed_lines), which is why the write_atomically spec still names them, and rc files also left the parenthetical inventory of what goes through write_atomically. But uninstall_previewed_lines still calls write_atomically(path, &new_content) on the rc file, and the spec's first paragraph still opens "rc files and shell wrappers the user's shell sources at startup". So the guidance and the spec now disagree about rc files, and the sentence that existed to keep them aligned is the one that went. The new concurrency bullet mentions "rc-file uninstall" but never says it is a write_atomically caller.

Holding approval per this repo's .claude/skills/running-tend/references/review-pr.md → Data-Loss Surface: Hold for Human Review: the diff edits src/commands/configure_shell.rs, which holds three fs::remove_file call sites (legacy-wrapper and stranded-Nushell cleanup), and the surface the change governs is rc files — named in that section as work a user can't get back. That section also says to request review from @max-sixty, which is a no-op here since you authored it. Nothing in the diff widens what can be deleted; the hold is on the file and surface, not on a defect.

CI on 436b9430e: fast-checks, lint, feature-check, affected tests (linux, advisory) and pre-commit.ci green; the three test legs and code-coverage still running.

Comment thread src/commands/configure_shell.rs Outdated
Comment thread CLAUDE.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.

Both prior findings are addressed at 8e45b8848: the concurrent-creator branch now has its own message, and the uninstall_previewed_lines pointer is back in the Data Safety bullet (still accurate — uninstall_previewed_lines calls write_atomically(path, &new_content)). One new finding on the extracted helper, inline.

format_new_rc_error uses path.is_symlink() to mean dangling, but that predicate is also true for a live link. The only way to reach the arm at all is the race this PR exists to handle, and a dotfile manager is a realistic concurrent creator: chezmoi apply or stow landing ~/.zshrc as a symlink between the path.exists() check and persist_noclobber produces AlreadyExists on a link that resolves fine, and the user is told to "restore its target or remove the link". Following that advice deletes a working dotfile link. !path.exists() pins the arm to dangling; a live link then falls through to "another process created it first; rerun to append", which is both true and actionable, since a rerun takes the append branch.

Verified on this branch

Path::exists follows the link and Path::is_symlink does not, so the two cases are only separable together:

"live":     exists=true  is_symlink=true
"dangling": exists=false is_symlink=true
hard-link onto live symlink: Err(AlreadyExists)

The last line is the link(2) fallback persist_noclobber takes when renameat2(RENAME_NOREPLACE) is unavailable — a live symlink reaches the same AlreadyExists a dangling one does. test_configure_shell_reports_dangling_symlink still passes under the added condition, since its link is dangling.

Nothing else: dropping write_append_buffer for write_all is a net gain (write_all finishes a short write instead of turning it into an error), the removed test went with the helper it exercised, and all three arms of the new helper are covered.

Holding approval per this repo's .claude/skills/running-tend/references/review-pr.md → Data-Loss Surface: Hold for Human Review: the diff edits src/commands/configure_shell.rs, which holds three production fs::remove_file sites, and the surface it governs is rc files. Nothing in the diff widens what can be deleted — the hold is on the file and surface. That section also says to request review from @max-sixty, a no-op here since you authored it.

CI on 8e45b8848: fast-checks, lint, feature-check, code-coverage, affected tests (linux, advisory) and pre-commit.ci green; the three test legs, the macOS/Windows advisory legs, and codecov/patch not yet reported.

Comment thread src/commands/configure_shell.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.

The format_new_rc_error finding is resolved at ef573338e: path.is_symlink() && !path.exists() pins the dangling arm correctly (is_symlink reads symlink_metadata, exists follows the link, so a live link now falls through to "another process created it first; rerun to append" — which is the accurate advice for it), and test_new_rc_error_reports_live_symlink_as_concurrent_creator covers the newly-separated branch while test_configure_shell_reports_dangling_symlink still exercises the other. Nothing further from me on the diff.

Not approving, same reason as the prior two rounds and not a defect: this repo's .claude/skills/running-tend/references/review-pr.md → Data-Loss Surface: Hold for Human Review holds on a source change in a file carrying fs::remove_file sites, which src/commands/configure_shell.rs does (legacy-wrapper and stranded-Nushell cleanup). Nothing in the diff widens what can be deleted.

CI on ef573338e: fast-checks, lint, feature-check, gate, affected tests (linux, advisory) and pre-commit.ci green; the three test legs, code-coverage, the macOS/Windows advisory legs, and codecov/patch not yet reported.

Comment thread src/commands/configure_shell.rs
@max-sixty
max-sixty merged commit 0c17b26 into main Aug 27, 2026
44 checks passed
@max-sixty
max-sixty deleted the codex/release-0.75.0-drift branch August 27, 2026 07:22
social4hyq pushed a commit to social4hyq/homebrew-core that referenced this pull request Sep 20, 2026
worktrunk 0.76.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

- **`wt switch --execute` takes a program, not a shell string**: `-x` names one program, with everything after `--` passed as literal argv. Worktrunk spawns it as a child rather than running it in your interactive shell, so shell functions and its `cd` no longer reach you. `-x sh -- -c '…'` recovers shell syntax, not functions. (Breaking: existing `-x` strings, plus `WORKTRUNK_DIRECTIVE_EXEC_FILE` and `WORKTRUNK_SHELL`.) ([#3977](max-sixty/worktrunk#3977), closes [#2860](max-sixty/worktrunk#2860), fixes [#3944](max-sixty/worktrunk#3944), thanks @omgreenfield for testing the migration path)

- **Worktrunk decides tracking for the branches it creates**: a new branch gets an upstream only when its name matches the remote branch it starts from, whatever `branch.autoSetupMerge` says: `--create release --base origin/release` tracks, `--create feature --base origin/release` does not. Under `autoSetupMerge = false` this previously exited 128 after creating the worktree. ([#3913](max-sixty/worktrunk#3913), [#3950](max-sixty/worktrunk#3950), fixes [#3937](max-sixty/worktrunk#3937), thanks @mjakl for reporting and diagnosing)

- **Retired config keys are no longer read**: `commit.generation.template-file` and `squash-template-file` are gone — put the file's contents in `template` or `squash-template`. There is no migration: the key stays, warns as unknown on every load, and the prompt reverts to the built-in default. Setting both `template` and `template-file` used to fail the load; it now loads `template`. `switch.picker.timeout-ms` likewise warns rather than being stripped. (Breaking.) ([#3949](max-sixty/worktrunk#3949))

- **Project aliases and hooks can use `wt switch --execute`**: their bodies previously refused it. Worktrunk now starts the program itself instead of handing shell text to your shell, and the command-approval gate remains the control on project-defined commands. ([#3977](max-sixty/worktrunk#3977))

- **Established automation and LLM customization interfaces are now stable**: `wt step eval`, `wt step for-each`, `wt step prune`, LLM branch summaries, `wt config state vars`, and `commit.generation.template-append` are no longer marked experimental. ([#3949](max-sixty/worktrunk#3949))

- **`wt step prune` stages removals concurrently again**: only the `git worktree remove` teardowns serialize, so dirty checks, fsmonitor shutdown, and the trash rename overlap. The `prune_e2e/live` benchmark median went from 377 ms to 222 ms. ([#3954](max-sixty/worktrunk#3954))

- **`wt config approvals add` reads as a review rather than a warning**: it opens with a cyan `Review 1 command for repo:` instead of a yellow `▲ … needs approval to execute`. The execution-time gate is unchanged. ([#3953](max-sixty/worktrunk#3953))

### Fixed

- **The `wt switch` picker survives a preview longer than 65,535 lines**: skim keeps the pane's line count in a `u16`, so a larger diff aborted `wt` with exit 101 seconds after it painted — on screen the picker looked like it closed by itself. Panes now cap at 60,000 lines and say so. ([#3959](max-sixty/worktrunk#3959), fixes [#3958](max-sixty/worktrunk#3958), thanks @sandertammesoo for reporting and diagnosing)

- **The nushell wrapper propagates exit codes without a POSIX shell**: it reported a failing `wt` by spawning `sh`, so on Windows every failing command printed a ``Command `sh` not found`` trace and exit 1 whatever the real code was. Nushell's wrapper is a static file, so rerun `wt config shell install` to pick this up. ([#3945](max-sixty/worktrunk#3945))

- **The picker no longer shows one detached worktree's diff for another**: previews were keyed by branch name, so every detached row shared the key `(detached)`. Rows now key by canonical identity, which also lets `wt remove feature ~/repo.feature` plan that worktree once instead of failing on the second. ([#3926](max-sixty/worktrunk#3926))

- **`wt switch pr:<n>` gives `pre-switch` hooks the real branch**: `branch` and `target` held the literal `pr:3933`, and `target_worktree_path` was unset even when that branch had a worktree. `pr_number` and `pr_url` are now set for same-repo PRs, not only forks. ([#3941](max-sixty/worktrunk#3941), fixes [#3934](max-sixty/worktrunk#3934), thanks @robsonpeixoto for reporting)

- **`wt config shell install` no longer replaces an rc file created while it was checking**: the missing-file path refuses to clobber and fails with a rerun hint. A dangling rc symlink is preserved and rejected rather than overwritten. ([#3929](max-sixty/worktrunk#3929))

- **`wt switch --create X --base pr:<n>` works when the PR branch has no local branch**: the fetch writes only the remote-tracking ref, and git won't expand a bare name to one, so the command failed with `No branch, tag, or commit named …`. The base is now named under its remote. ([#3951](max-sixty/worktrunk#3951))

- **`wt switch --create` streams `git worktree add` instead of stalling**: where a sandbox denied the timed wait's pipe or `sigaction`, the command waited out git's entire run and then reported `Failed to wait for command` for work that had already succeeded. ([#3881](max-sixty/worktrunk#3881))

- **Claude Code activity markers stay on the session's launch worktree**: the plugin's marker hooks pass `-C "$CLAUDE_PROJECT_DIR"`, so a `cd` mid-session no longer marks another repository. A session launched outside a repository gets no marker. ([#3956](max-sixty/worktrunk#3956))

### Documentation

- **`--execute` documents how to get a variable into a shell body**: each position is substituted verbatim, so a variable spliced into `sh -c` text is re-parsed by that shell and splits on spaces. The help now shows passing it as a separate argument and referencing it positionally. ([#3977](max-sixty/worktrunk#3977))

- **The clone-local default-branch override is documented**: `wt config state default-branch set` writes `worktrunk.default-branch` to the clone's local git config — machine-local, never committed, and shared by every linked worktree. `set` warns when the branch isn't checked out locally, and `wt list` skips its comparisons until it is. ([#3947](max-sixty/worktrunk#3947), [#3948](max-sixty/worktrunk#3948), fixes [#3946](max-sixty/worktrunk#3946), thanks @danielo515 for the request)

- **The docs site renders code with the CLI's own colors**: terminal examples reproduce each snapshot's exact ANSI roles, while command references and the homepage command comparison get clap and shell syntax color from paired light/dark themes. File excerpts gain a path tab, and code wraps instead of scrolling on mobile. ([#3936](max-sixty/worktrunk#3936), [#3938](max-sixty/worktrunk#3938), [#3939](max-sixty/worktrunk#3939), [#3942](max-sixty/worktrunk#3942))

- **The `pre-start` upstream example uses `--base`**: it showed `wt switch --create feature origin/feature`, which `wt switch` rejects. ([#3955](max-sixty/worktrunk#3955))

### Internal

- **Library API rework** (Breaking library API): `cargo-semver-checks` fails ten lints — `RemoteRefProvider` and its provider structs give way to `ForgeKind` dispatch, the `--execute` rework retires the fish and PowerShell `ShellEscapeMode` escapes and `WORKTRUNK_SHELL_ENV_VAR`, `BranchRef` swaps `full_ref`/`worktree_path` for `id`, and accessors including `Branch::unset_upstream` and `ProjectConfig::ci_platform` are gone. ([#3913](max-sixty/worktrunk#3913), [#3926](max-sixty/worktrunk#3926), [#3949](max-sixty/worktrunk#3949), [#3963](max-sixty/worktrunk#3963), [#3977](max-sixty/worktrunk#3977))

- **Hook pipelines and `wt list` collection lost two indirection layers**: the background pipeline carries `PreparedStep` directly, and 15 marker task types collapse into one exhaustive dispatch. The synthesized-`--force` submodule path keeps its dirty re-check and destructive command under one lock, so prune's narrower locking doesn't widen that window. ([#3964](max-sixty/worktrunk#3964), [#3954](max-sixty/worktrunk#3954))

## Install worktrunk 0.76.0

### Install prebuilt binaries via shell script

```sh
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/max-sixty/worktrunk/releases/download/v0.76.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.76.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.76.0

|  File  | Platform | Checksum |
|--------|----------|----------|
| [worktrunk-aarch64-apple-darwin.tar.xz](https://github.com/max-sixty/worktrunk/releases/download/v0.76.0/worktrunk-aarch64-apple-darwin.tar.xz) | Apple Silicon macOS | [checksum](https://github.com/max-sixty/worktrunk/releases/download/v0.76.0/worktrunk-aarch64-apple-darwin.tar.xz.sha256) |
| [worktrunk-x86_64-ap

See merge request: Harmonybrew/homebrew-core!18482
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.

2 participants