@@ -10,6 +10,7 @@ use but_core::{DiffSpec, DryRun};
1010use but_rebase:: graph_rebase:: { Editor , GraphEditorOptions , LookupStep as _} ;
1111use but_workspace:: worktrees:: { WorktreeListing , WorktreeSource } ;
1212use gix:: bstr:: BStr ;
13+ use gix:: prelude:: ObjectIdExt as _;
1314use tracing:: instrument;
1415
1516use crate :: { WorkspaceState , commit:: types:: CommitCreateResult } ;
@@ -92,12 +93,20 @@ pub fn linked_worktree_changes(
9293/// the branch of any active worktree (including `name`'s own).
9394///
9495/// The worktree's branch is rebased if the target is in its history, and its
95- /// checkout follows with the consumed changes cancelled out. When the worktree's
96- /// tip doesn't move (the target lives elsewhere), the consumed changes are
97- /// discarded from the worktree after the commit and all ref edits are durable -
98- /// so every failure window leaves a harmless duplicate of the changes, never a
99- /// loss. Consumed changes that no longer match the worktree's live state at that
100- /// point are left in place with a warning.
96+ /// checkout follows with the consumed changes cancelled out. When the rewrite
97+ /// would leave any linked worktree's branch on a conflict-encoded commit (the
98+ /// amend conflicts with later commits on that branch), this fails before
99+ /// anything is materialized - zero mutation.
100+ ///
101+ /// When the worktree's tip doesn't move (the target lives elsewhere), the
102+ /// consumed changes are discarded from the worktree after the commit and all
103+ /// ref edits are durable, and each file is only discarded after re-verifying
104+ /// that its content still matches what was amended - so every failure window
105+ /// leaves a harmless duplicate of the changes, never a loss. Consumed changes
106+ /// whose file content changed in the meantime, or that no longer match the
107+ /// worktree's live state, are left in place with a `tracing` warning only;
108+ /// [`CommitCreateResult`] does not (yet) report which ones were left behind,
109+ /// so callers can merely tell users that this may happen.
101110///
102111/// Note that unlike [`commit_amend`](crate::commit::amend::commit_amend), no
103112/// oplog snapshot is recorded yet - oplog coverage of linked worktrees is
@@ -124,6 +133,19 @@ pub fn worktree_commit_amend(
124133 // Captured before any mutation: an unchanged tip afterwards means the
125134 // worktree's checkout didn't participate in the rewrite.
126135 let old_worktree_head = wt_repo. head_id ( ) ?. detach ( ) ;
136+ // Content identity of every requested file, captured before the amend so
137+ // the discard fallback below can verify nothing wrote to them in between
138+ // (editor autosave, formatters, watchers - the amend takes long enough for
139+ // that race to be real).
140+ let content_snapshots: std:: collections:: BTreeMap < _ , _ > = changes
141+ . iter ( )
142+ . map ( |spec| {
143+ (
144+ spec. path . clone ( ) ,
145+ snapshot_worktree_file ( & wt_repo, spec. path . as_ref ( ) ) ,
146+ )
147+ } )
148+ . collect ( ) ;
127149
128150 // Worktree branches are seeded into the graph as extra tips but aren't
129151 // reachable from `HEAD`, which leaves them immutable by default - without
@@ -164,6 +186,30 @@ pub fn worktree_commit_amend(
164186 let new_commit = commit_selector
165187 . map ( |commit_selector| rebase. lookup_pick ( commit_selector) )
166188 . transpose ( ) ?;
189+
190+ // Refuse to leave any linked worktree's branch on a conflict-encoded
191+ // commit: its checkout would be skipped (conflicted trees are never
192+ // written into plain worktrees) while the ref still moves, silently
193+ // stranding a stale checkout on a GitButler-internal commit. Nothing has
194+ // been materialized yet, so bailing here mutates nothing.
195+ if new_commit. is_some ( ) {
196+ for ( wt_name, tip) in rebase. worktree_checkout_tips ( ) ? {
197+ if but_core:: Commit :: from_id ( tip. attach ( rebase. repo ( ) ) ) ?. is_conflicted ( ) {
198+ bail ! (
199+ "amending into {commit_id} would conflict with later commits on the branch \
200+ checked out in worktree {wt_name} - aborting without any changes"
201+ ) ;
202+ }
203+ }
204+ }
205+ if let Some ( new_commit) = new_commit
206+ && but_core:: Commit :: from_id ( new_commit. attach ( rebase. repo ( ) ) ) ?. is_conflicted ( )
207+ {
208+ bail ! (
209+ "amending into {commit_id} would produce a conflicted commit - aborting without any changes"
210+ ) ;
211+ }
212+
167213 let is_dry_run: bool = dry_run. into ( ) ;
168214 let workspace = WorkspaceState :: from_successful_rebase ( rebase, & repo, dry_run) ?;
169215
@@ -173,15 +219,37 @@ pub fn worktree_commit_amend(
173219 if !is_dry_run && new_commit. is_some ( ) && !consumed_specs. is_empty ( ) {
174220 let wt_repo = but_workspace:: worktrees:: open_worktree_repo ( & repo, name) ?;
175221 if wt_repo. head_id ( ) ?. detach ( ) == old_worktree_head {
176- let dropped =
177- but_workspace:: discard_workspace_changes ( & wt_repo, consumed_specs, context_lines) ?;
178- if !dropped. is_empty ( ) {
222+ // Only discard files whose content still matches the pre-amend
223+ // snapshot - anything written in between is in neither the amended
224+ // commit nor the snapshot, so discarding it would destroy it.
225+ let ( verified_specs, changed_specs) : ( Vec < _ > , Vec < _ > ) =
226+ consumed_specs. into_iter ( ) . partition ( |spec| {
227+ content_snapshots. get ( & spec. path ) . is_some_and ( |before| {
228+ before. is_verifiable ( )
229+ && * before == snapshot_worktree_file ( & wt_repo, spec. path . as_ref ( ) )
230+ } )
231+ } ) ;
232+ for spec in & changed_specs {
179233 tracing:: warn!(
180234 worktree = %name,
181- ?dropped ,
182- "some committed changes no longer matched the worktree state - leaving them in place "
235+ path = %spec . path ,
236+ "file content changed while amending - leaving it in the worktree "
183237 ) ;
184238 }
239+ if !verified_specs. is_empty ( ) {
240+ let dropped = but_workspace:: discard_workspace_changes (
241+ & wt_repo,
242+ verified_specs,
243+ context_lines,
244+ ) ?;
245+ if !dropped. is_empty ( ) {
246+ tracing:: warn!(
247+ worktree = %name,
248+ ?dropped,
249+ "some committed changes no longer matched the worktree state - leaving them in place"
250+ ) ;
251+ }
252+ }
185253 }
186254 }
187255
@@ -191,3 +259,62 @@ pub fn worktree_commit_amend(
191259 workspace,
192260 } )
193261}
262+
263+ /// The content identity of a worktree file at one point in time, used to
264+ /// verify it didn't change between two reads.
265+ #[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
266+ enum FileSnapshot {
267+ /// Nothing exists at the path.
268+ Missing ,
269+ /// A regular file or symlink whose content hashes to this blob id
270+ /// (symlinks hash their target path, like Git does).
271+ Blob ( gix:: ObjectId ) ,
272+ /// The path exists but could not be read, or isn't a file/symlink.
273+ /// Never treated as matching anything, not even another unreadable state.
274+ Unreadable ,
275+ }
276+
277+ impl FileSnapshot {
278+ /// Whether this snapshot pins down actual content that a later read can be
279+ /// compared against.
280+ fn is_verifiable ( & self ) -> bool {
281+ !matches ! ( self , FileSnapshot :: Unreadable )
282+ }
283+ }
284+
285+ /// Hash the file at worktree-relative `rela_path` in `wt_repo`'s working
286+ /// directory as a git blob, without writing any object.
287+ ///
288+ /// The raw on-disk bytes are hashed (no filters applied) - the result is only
289+ /// meant to be compared against another snapshot taken the same way.
290+ fn snapshot_worktree_file ( wt_repo : & gix:: Repository , rela_path : & BStr ) -> FileSnapshot {
291+ let Some ( workdir) = wt_repo. workdir ( ) else {
292+ return FileSnapshot :: Unreadable ;
293+ } ;
294+ let path = workdir. join ( gix:: path:: from_bstr ( rela_path) ) ;
295+ let md = match std:: fs:: symlink_metadata ( & path) {
296+ Ok ( md) => md,
297+ Err ( err) if err. kind ( ) == std:: io:: ErrorKind :: NotFound => return FileSnapshot :: Missing ,
298+ Err ( _) => return FileSnapshot :: Unreadable ,
299+ } ;
300+ let bytes = if md. is_symlink ( ) {
301+ match std:: fs:: read_link ( & path)
302+ . map_err ( anyhow:: Error :: from)
303+ . and_then ( |target| Ok ( gix:: path:: os_string_into_bstring ( target. into ( ) ) ?) )
304+ {
305+ Ok ( target) => Vec :: from ( target) ,
306+ Err ( _) => return FileSnapshot :: Unreadable ,
307+ }
308+ } else if md. is_file ( ) {
309+ match std:: fs:: read ( & path) {
310+ Ok ( bytes) => bytes,
311+ Err ( _) => return FileSnapshot :: Unreadable ,
312+ }
313+ } else {
314+ return FileSnapshot :: Unreadable ;
315+ } ;
316+ match gix:: objs:: compute_hash ( wt_repo. object_hash ( ) , gix:: object:: Kind :: Blob , & bytes) {
317+ Ok ( id) => FileSnapshot :: Blob ( id) ,
318+ Err ( _) => FileSnapshot :: Unreadable ,
319+ }
320+ }
0 commit comments