Skip to content

fix(relocate): atomic no-overwrite rename for --clobber backup#2865

Merged
max-sixty merged 3 commits into
mainfrom
relocate-clobber-renamore
May 22, 2026
Merged

fix(relocate): atomic no-overwrite rename for --clobber backup#2865
max-sixty merged 3 commits into
mainfrom
relocate-clobber-renamore

Conversation

@max-sixty

@max-sixty max-sixty commented May 21, 2026

Copy link
Copy Markdown
Owner

wt switch --clobber and wt step relocate --clobber each had their own logic for backing up a path blocking the target, and the two had diverged. relocate used an exists() check followed by std::fs::rename — a time-of-check/time-of-use race, since std::fs::rename silently replaces an existing file or empty directory on Unix and could destroy a just-created backup. switch (#2849) instead used renamore::rename_exclusive, an atomic no-overwrite rename, and counted up through suffixes (…-2, …-3, …) on a name collision rather than failing.

This PR replaces both with one shared helper in commands::backup. back_up_clobbered_path_now computes the timestamped name and delegates to the suffix-iterating back_up_clobbered_path, which moves the blocker with renamore::rename_exclusive — atomic and no-overwrite, so an existing backup is never clobbered; a name collision just lands on the next free -N name. relocate gains the suffix fallback (previously it failed closed on AlreadyExists), the timestamp computation is no longer duplicated across the two call sites, and renamore::rename_exclusive now has a single caller.

renamore is already a direct dependency and keeps the platform FFI and its unsafe inside that crate, so worktrunk stays unsafe_code = "forbid".

relocate's backup name changes from .bak-<timestamp> to the extension-aware .bak.<timestamp> form used by switch (e.g. repo.featurerepo.feature.bak.<timestamp>). The test_relocate_clobber_error_backup_exists regression test asserted the old fail-closed behavior and is rewritten as test_relocate_clobber_falls_back_when_backup_taken, mirroring the switch test. CLI help and the generated doc mirrors are updated for the new naming and fallback.

The shared helper carries the suffix-iteration, collision, and missing-source unit tests (moved from resolve.rs); the relocate --clobber path keeps its integration coverage.

This was written by Claude Code on behalf of Maximilian Roos

`wt step relocate --clobber` backed up a blocking destination by checking
`backup_path.exists()` and then calling `std::fs::rename`. The backup name
is only second-resolution (`.bak-<YYYYmmdd-HHMMSS>`), so a process could
create that path between the check and the rename — and `std::fs::rename`
silently replaces an existing file or empty directory on Unix, destroying
the just-created backup. A time-of-check/time-of-use race that defeats the
intended fail-closed behavior.

The backup now moves with `renamore::rename_exclusive` — an atomic
no-overwrite rename (`renameat2(RENAME_NOREPLACE)` on Linux,
`renamex_np(RENAME_EXCL)` on macOS, `MoveFileExW` on Windows). An
`AlreadyExists` error maps to the existing fail-closed "Backup path already
exists" error, so the command still refuses rather than overwriting. The
atomic rename — not a pre-check — is what actually closes the race;
`renamore` keeps the platform FFI and its `unsafe` inside the crate, so
worktrunk stays `unsafe_code = "forbid"`. `renamore` is already a direct
dependency, used the same way on the `wt switch --clobber` path (#2849).

Adds a unit test asserting the rename refuses to replace an existing
directory and leaves both source and destination intact.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.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.

The swap to renamore::rename_exclusive is the right call — mirrors the wt switch --clobber path in src/commands/worktree/resolve.rs and closes the same TOCTOU window. One coverage gap on the non-AlreadyExists error arm; suggested test mirrors test_back_up_clobbered_path_errors_when_source_missing in worktree/resolve.rs.

Comment thread src/commands/relocate.rs Outdated
`backup_blocking_path` had a test for the `AlreadyExists` fail-closed
path but none for the generic `Err(err)` arm, leaving that line as a
codecov/patch miss. Add a test that calls the function with a
non-existent source: `renamore::rename_exclusive` returns a `NotFound`
error, which flows through the generic arm and surfaces the
"Failed to backup" context.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`wt switch --clobber` and `wt step relocate --clobber` each had their own
backup-the-blocker logic. They diverged: switch's `back_up_clobbered_path`
iterates suffixes (`…-2`, `…-3`, …) on a name collision so it never fails,
while relocate's `backup_blocking_path` failed closed on `AlreadyExists`.
A relocate run could abort on a same-second clobber that switch handles
gracefully.

Both now go through `commands::backup` — `back_up_clobbered_path_now`
computes the timestamp suffix and delegates to the suffix-iterating
`back_up_clobbered_path`. relocate gains the suffix fallback, the
timestamp computation is no longer duplicated across the two call sites,
and `renamore::rename_exclusive` has a single caller.

relocate's backup name changes from `.bak-<timestamp>` to the
extension-aware `.bak.<timestamp>` form (e.g. `repo.feature` →
`repo.feature.bak.<timestamp>`), matching switch. The
`test_relocate_clobber_error_backup_exists` regression test asserted the
old fail-closed behavior; it is rewritten as
`test_relocate_clobber_falls_back_when_backup_taken` to assert the
corrected `-N` fallback, mirroring the switch test. CLI help and the
generated doc mirrors are updated for the new naming and fallback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@max-sixty max-sixty closed this May 21, 2026
@max-sixty max-sixty deleted the relocate-clobber-renamore branch May 21, 2026 22:44
@max-sixty max-sixty restored the relocate-clobber-renamore branch May 21, 2026 22:45
@max-sixty max-sixty reopened this May 21, 2026
@max-sixty max-sixty merged commit 9e8c028 into main May 22, 2026
67 checks passed
@max-sixty max-sixty deleted the relocate-clobber-renamore branch May 22, 2026 01:43
max-sixty added a commit that referenced this pull request May 23, 2026
`wt switch --clobber` and `wt step relocate --clobber` each had their
own logic for backing up a path blocking the target, and the two had
diverged. relocate used an `exists()` check followed by
`std::fs::rename` — a time-of-check/time-of-use race, since
`std::fs::rename` silently replaces an existing file or empty directory
on Unix and could destroy a just-created backup. switch (#2849) instead
used `renamore::rename_exclusive`, an atomic no-overwrite rename, and
counted up through suffixes (`…-2`, `…-3`, …) on a name collision rather
than failing.

This PR replaces both with one shared helper in `commands::backup`.
`back_up_clobbered_path_now` computes the timestamped name and delegates
to the suffix-iterating `back_up_clobbered_path`, which moves the
blocker with `renamore::rename_exclusive` — atomic and no-overwrite, so
an existing backup is never clobbered; a name collision just lands on
the next free `-N` name. relocate gains the suffix fallback (previously
it failed closed on `AlreadyExists`), the timestamp computation is no
longer duplicated across the two call sites, and
`renamore::rename_exclusive` now has a single caller.

`renamore` is already a direct dependency and keeps the platform FFI and
its `unsafe` inside that crate, so worktrunk stays `unsafe_code =
"forbid"`.

relocate's backup name changes from `.bak-<timestamp>` to the
extension-aware `.bak.<timestamp>` form used by switch (e.g.
`repo.feature` → `repo.feature.bak.<timestamp>`). The
`test_relocate_clobber_error_backup_exists` regression test asserted the
old fail-closed behavior and is rewritten as
`test_relocate_clobber_falls_back_when_backup_taken`, mirroring the
switch test. CLI help and the generated doc mirrors are updated for the
new naming and fallback.

The shared helper carries the suffix-iteration, collision, and
missing-source unit tests (moved from `resolve.rs`); the relocate
`--clobber` path keeps its integration coverage.

> _This was written by Claude Code on behalf of Maximilian Roos_

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
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