Skip to content

Commit 4b604f6

Browse files
Caleb-T-Owensclaude
andcommitted
Fix data-loss and consistency findings from the final review
- amending into an immutable commit fails fast instead of silently no-opping - previously the worktree discard fallback then DELETED the changes it believed were committed (data loss) - the whole-file discard fallback content-verifies each file against its pre-amend hash and leaves mid-operation edits in place (warn + skip), making the never-loss invariant hold for whole-file specs - amends whose rebase would leave a worktree tip conflict-encoded are refused before materializing (plain git worktrees have no conflict UI) - materialize_without_checkout now runs worktree checkouts too - its contract only exempts the editor's own worktree - but worktree amend resolves worktree-only commits via rev-parse - worktree chips never collide with change-id chips and the no-window fallback is a deterministic name-seeded probe (no more migration when the uncommitted-file set changes) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent fcd35bf commit 4b604f6

10 files changed

Lines changed: 632 additions & 76 deletions

File tree

crates/but-api/src/worktrees.rs

Lines changed: 138 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use but_core::{DiffSpec, DryRun};
1010
use but_rebase::graph_rebase::{Editor, GraphEditorOptions, LookupStep as _};
1111
use but_workspace::worktrees::{WorktreeListing, WorktreeSource};
1212
use gix::bstr::BStr;
13+
use gix::prelude::ObjectIdExt as _;
1314
use tracing::instrument;
1415

1516
use 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+
}

crates/but-rebase/src/graph_rebase/materialize.rs

Lines changed: 100 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ use gix::{
1313
};
1414

1515
use crate::graph_rebase::{
16-
Checkout, MaterializeOutcome, Pick, Step, SuccessfulRebase, util::collect_ordered_parents,
16+
Checkout, MaterializeOutcome, Pick, Selector, Step, SuccessfulRebase,
17+
util::collect_ordered_parents,
1718
};
1819

1920
/// Check out `new_tip` in the linked worktree named `worktree_name`, doing nothing
@@ -56,61 +57,105 @@ fn checkout_worktree(
5657
}
5758

5859
impl<'ws, 'graph, M: RefMetadata> SuccessfulRebase<'ws, 'graph, M> {
60+
/// Resolve the tip a worktree checkout `selector` points to after the rebase,
61+
/// or `None` when the branch step was removed from the graph.
62+
fn resolve_worktree_checkout_tip(&self, selector: Selector) -> Result<Option<gix::ObjectId>> {
63+
let selector = self.history.normalize_selector(selector)?;
64+
Ok(match &self.graph[selector.id] {
65+
Step::None => None,
66+
Step::Pick(Pick { id, .. }) => Some(*id),
67+
Step::Reference { .. } => {
68+
let parents = collect_ordered_parents(&self.graph, selector.id);
69+
let parent_step_id = parents.first().context("No first parent to reference")?;
70+
let Step::Pick(Pick { id, .. }) = self.graph[*parent_step_id] else {
71+
bail!("collect_ordered_parents should always return a commit pick");
72+
};
73+
Some(id)
74+
}
75+
})
76+
}
77+
78+
/// The tip each linked-worktree checkout will point to once this rebase is
79+
/// materialized, as `(worktree_name, commit_id)` pairs.
80+
///
81+
/// Worktrees whose checked-out branch was removed from the graph are skipped.
82+
/// Objects referenced by the returned ids may exist only in the in-memory
83+
/// repository, see [`Self::repo()`].
84+
pub fn worktree_checkout_tips(&self) -> Result<Vec<(gix::bstr::BString, gix::ObjectId)>> {
85+
let mut tips = Vec::new();
86+
for checkout in &self.checkouts {
87+
let Checkout::Worktree {
88+
selector,
89+
worktree_name,
90+
..
91+
} = checkout
92+
else {
93+
continue;
94+
};
95+
if let Some(tip) = self.resolve_worktree_checkout_tip(*selector)? {
96+
tips.push((worktree_name.clone(), tip));
97+
}
98+
}
99+
Ok(tips)
100+
}
101+
102+
/// Run the checkout of every linked worktree whose branch this rebase moves.
103+
///
104+
/// All objects must be persisted beforehand - the worktree repositories are
105+
/// opened from disk. A broken linked worktree degrades to today's
106+
/// stale-checkout behavior with a warning instead of failing the whole
107+
/// operation.
108+
fn checkout_worktrees(&self, repo: &gix::Repository) -> Result<()> {
109+
for checkout in &self.checkouts {
110+
let Checkout::Worktree {
111+
selector,
112+
worktree_name,
113+
merge_base_override,
114+
} = checkout
115+
else {
116+
continue;
117+
};
118+
let Some(new_tip) = self.resolve_worktree_checkout_tip(*selector)? else {
119+
tracing::warn!(
120+
worktree = %worktree_name,
121+
"the branch this worktree checks out was removed - leaving its checkout as is"
122+
);
123+
continue;
124+
};
125+
if let Err(err) = checkout_worktree(
126+
repo,
127+
worktree_name.as_ref(),
128+
new_tip,
129+
*merge_base_override,
130+
) {
131+
tracing::warn!(
132+
worktree = %worktree_name,
133+
err = %err,
134+
"failed to check out linked worktree - its branch still moves"
135+
);
136+
}
137+
}
138+
Ok(())
139+
}
140+
59141
/// Materializes a history rewrite
60142
pub fn materialize(mut self) -> Result<MaterializeOutcome<'ws, 'graph, M>> {
61143
let repo = self.repo.clone();
62144
if let Some(memory) = self.repo.objects.take_object_memory() {
63-
memory.persist(self.repo)?;
145+
memory.persist(&self.repo)?;
64146
}
65147

148+
self.checkout_worktrees(&repo)?;
149+
66150
let mut head_reference_update = None;
67-
for checkout in self.checkouts {
151+
for checkout in &self.checkouts {
68152
match checkout {
69-
Checkout::Worktree {
70-
selector,
71-
worktree_name,
72-
merge_base_override,
73-
} => {
74-
let selector = self.history.normalize_selector(selector)?;
75-
let new_tip = match &self.graph[selector.id] {
76-
Step::None => {
77-
tracing::warn!(
78-
worktree = %worktree_name,
79-
"the branch this worktree checks out was removed - leaving its checkout as is"
80-
);
81-
continue;
82-
}
83-
Step::Pick(Pick { id, .. }) => *id,
84-
Step::Reference { .. } => {
85-
let parents = collect_ordered_parents(&self.graph, selector.id);
86-
let parent_step_id =
87-
parents.first().context("No first parent to reference")?;
88-
let Step::Pick(Pick { id, .. }) = self.graph[*parent_step_id] else {
89-
bail!("collect_ordered_parents should always return a commit pick");
90-
};
91-
id
92-
}
93-
};
94-
// A broken linked worktree degrades to today's stale-checkout
95-
// behavior instead of failing the whole operation.
96-
if let Err(err) = checkout_worktree(
97-
&repo,
98-
worktree_name.as_ref(),
99-
new_tip,
100-
merge_base_override,
101-
) {
102-
tracing::warn!(
103-
worktree = %worktree_name,
104-
err = %err,
105-
"failed to check out linked worktree - its branch still moves"
106-
);
107-
}
108-
}
153+
Checkout::Worktree { .. } => {}
109154
Checkout::Head {
110155
selector,
111156
merge_base_override,
112157
} => {
113-
let selector = self.history.normalize_selector(selector)?;
158+
let selector = self.history.normalize_selector(*selector)?;
114159
let step = self.graph[selector.id].clone();
115160

116161
let (new_head, new_head_refname) = match step {
@@ -135,7 +180,7 @@ impl<'ws, 'graph, M: RefMetadata> SuccessfulRebase<'ws, 'graph, M> {
135180
&repo,
136181
Options {
137182
skip_head_update: true,
138-
merge_base_override,
183+
merge_base_override: *merge_base_override,
139184
allow_conflicted_commit_checkout: true,
140185
},
141186
)?;
@@ -180,7 +225,8 @@ impl<'ws, 'graph, M: RefMetadata> SuccessfulRebase<'ws, 'graph, M> {
180225
})
181226
}
182227

183-
/// Materializes a rebase without performing a checkout.
228+
/// Materializes a rebase without performing a checkout of the editor's own
229+
/// (`HEAD`) worktree.
184230
///
185231
/// For the vast majority of operations you want to use
186232
/// [`Self::materialize`]. This is intended to be used in niche cases like
@@ -195,12 +241,19 @@ impl<'ws, 'graph, M: RefMetadata> SuccessfulRebase<'ws, 'graph, M> {
195241
///
196242
/// If I instead called [`Self::materialize`], the changes would instead be
197243
/// gone from disk.
244+
///
245+
/// Note that linked worktrees whose branches the rebase moves are still
246+
/// checked out - "without checkout" is strictly about the editor's own
247+
/// worktree; skipping the linked ones would leave their checkouts stale
248+
/// behind their moved branches.
198249
pub fn materialize_without_checkout(mut self) -> Result<MaterializeOutcome<'ws, 'graph, M>> {
199250
let repo = self.repo.clone();
200251
if let Some(memory) = self.repo.objects.take_object_memory() {
201-
memory.persist(self.repo)?;
252+
memory.persist(&self.repo)?;
202253
}
203254

255+
self.checkout_worktrees(&repo)?;
256+
204257
repo.edit_references(self.ref_edits.clone())?;
205258

206259
let project_meta = self.workspace.graph.project_meta.clone();

0 commit comments

Comments
 (0)