From ea453e255a521095539b94ac15ecacc198121c10 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Sun, 26 Jul 2026 05:28:04 -0700 Subject: [PATCH 1/2] docs(push): record that destination-worktree safety matches git MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `prepare_target_worktree` works from `git status --porcelain`, which omits ignored files, so a push overwrites an ignored file in the destination worktree whose path the incoming commits track. That has always been the behavior; what was missing is that it is intended. git draws the line in the same place — a `git merge` run in that worktree refuses an untracked collision and overwrites an ignored one — so a probe that refused here would make `wt` stricter than the tool it wraps, over data git treats as expendable. The push module spec now says so at the point where such a probe would be added, `prepare_target_worktree` points there from the implementation site, and Data Safety in CLAUDE.md bounds "prefer failure over silent loss" at git's edge, since that rule read unqualified is what invites the change. --- CLAUDE.md | 2 ++ src/commands/repository_ext.rs | 5 +++++ src/commands/worktree/push.rs | 12 ++++++++++++ 3 files changed, 19 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 2af8f3a2fb..ce0358becd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,6 +60,8 @@ Never risk data loss without explicit user consent. A failed command that preser - **Time-of-check vs time-of-use** — be conservative when there's a gap between the safety check and the operation. `wt merge` verifies clean before rebasing, but files could appear before cleanup — don't force-remove during cleanup. - **Replace files, never truncate them** — `fs::write` truncates before it writes, so a crash mid-write leaves the file empty. Every write to a file worktrunk can't put back (rc files, shell wrappers, `config.toml`, `approvals.toml`, another tool's `settings.json`) goes through `utils::write_atomically`, which renames a sibling temp file over the target; the spec on that function covers symlinks, mode, and what a rename costs. Regenerable content (the cache, the `-vv` diagnostic report) keeps the plain write. +These stop where git's own protections stop: `wt merge` and `wt step push` overwrite an ignored file in the destination worktree whose path the incoming commits track, exactly as a `git merge` run there would. Matching git is deliberate — the spec in `src/commands/worktree/push.rs` says why. + Full inventory: FAQ [What files does Worktrunk create?](docs/content/faq.md#what-files-does-worktrunk-create) and [What can Worktrunk delete?](docs/content/faq.md#what-can-worktrunk-delete). Review new code that changes this surface against those sections. ## Command Execution Principles diff --git a/src/commands/repository_ext.rs b/src/commands/repository_ext.rs index 7625cc7961..ace8377c77 100644 --- a/src/commands/repository_ext.rs +++ b/src/commands/repository_ext.rs @@ -62,6 +62,11 @@ pub trait RepositoryCliExt { /// The caller has already established that `target_worktree` exists on disk /// (`MergeContext::prepare` refuses a registered-but-missing worktree), so /// the status read here is free to fail if the directory is gone. + /// + /// Ignored files are deliberately out of scope — they're absent from the + /// `git status --porcelain` read this works from, and matching git there is + /// the decision, not an oversight. The module spec in + /// `commands/worktree/push.rs` says why. fn prepare_target_worktree( &self, target_worktree: Option<&PathBuf>, diff --git a/src/commands/worktree/push.rs b/src/commands/worktree/push.rs index fa37054af0..6bcaf05854 100644 --- a/src/commands/worktree/push.rs +++ b/src/commands/worktree/push.rs @@ -3,6 +3,18 @@ //! Push changes to target branch with safety checks. Both fast-forward push and //! `--no-ff` merge share common scaffolding (target resolution, fast-forward check, //! stash guard, progress/success output) extracted into [`MergeContext`]. +//! +//! # Destination-worktree safety follows git +//! +//! The checks cover what `git status --porcelain` reports: a modified or +//! untracked file at a path the push range changes is refused, and the rest of +//! a dirty tree is autostashed and restored. Ignored files fall outside that +//! report, so a push overwrites one whose path the incoming commits track. +//! +//! That is git's own line, not an oversight — a `git merge` run in that +//! worktree refuses the untracked collision and overwrites the ignored one. +//! Matching git is the decision; don't add an ignored-file probe to make `wt` +//! stricter than the tool it wraps. use std::path::PathBuf; From 23b7a4ae2db229bfd38fe3437271eb883139e4f8 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Sun, 26 Jul 2026 06:47:41 -0700 Subject: [PATCH 2/2] docs(push): ground the git comparison at the real sync mechanisms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec justified matching git by what a `git merge` run in the destination worktree would do, which is an analogy — neither push path runs `git merge`. Name the mechanisms instead: the fast-forward hands the checkout to `receive.denyCurrentBranch=updateInstead`, `--no-ff` syncs with `read-tree -m -u`, and both go through git's unpack-trees checks. Verified against a scratch repo — `read-tree -m -u ` with a local file at a path the new tree tracks: ignored → silently overwritten (content: TRACKED-FROM-BRANCH) untracked → "error: Untracked working tree file 'db.sqlite' would be overwritten by merge", exit 128, file intact Same split `git merge` produces, so the justification transfers to the code path rather than resting on a hypothetical. --- src/commands/worktree/push.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/commands/worktree/push.rs b/src/commands/worktree/push.rs index 6bcaf05854..962d8877c3 100644 --- a/src/commands/worktree/push.rs +++ b/src/commands/worktree/push.rs @@ -11,10 +11,14 @@ //! a dirty tree is autostashed and restored. Ignored files fall outside that //! report, so a push overwrites one whose path the incoming commits track. //! -//! That is git's own line, not an oversight — a `git merge` run in that -//! worktree refuses the untracked collision and overwrites the ignored one. -//! Matching git is the decision; don't add an ignored-file probe to make `wt` -//! stricter than the tool it wraps. +//! That is git's own line, not an oversight, and it holds at the mechanisms +//! actually used: the fast-forward hands the checkout to +//! `receive.denyCurrentBranch=updateInstead`, `--no-ff` syncs with +//! `read-tree -m -u`, and both run git's unpack-trees checks, which refuse to +//! clobber an untracked file and silently overwrite an ignored one — the same +//! split a `git merge` there would produce. Matching git is the decision; +//! don't add an ignored-file probe to make `wt` stricter than the tool it +//! wraps. use std::path::PathBuf;