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..962d8877c3 100644 --- a/src/commands/worktree/push.rs +++ b/src/commands/worktree/push.rs @@ -3,6 +3,22 @@ //! 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, 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;