Skip to content

Commit 9da8031

Browse files
committed
Make safe_checkout() fully functional
1 parent 8f311d4 commit 9da8031

File tree

4 files changed

+323
-48
lines changed

4 files changed

+323
-48
lines changed

crates/but-workspace/src/branch/checkout.rs

Lines changed: 251 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@ pub(crate) mod function {
4545
use gix::diff::rewrites::tracker::{Change as _, ChangeKind};
4646
use gix::diff::tree::visit;
4747
use gix::index::entry::Stage;
48-
use gix::object::tree::EntryKind;
48+
use gix::merge::tree::TreatAsUnresolved;
4949
use gix::objs::TreeRefIter;
50-
use gix::prelude::ObjectIdExt as _;
50+
use gix::prelude::{ObjectIdExt as _, Write};
5151
use gix::refs::Target;
5252
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
5353
use std::collections::{BTreeSet, VecDeque};
@@ -113,7 +113,7 @@ pub(crate) mod function {
113113
) -> anyhow::Result<Outcome> {
114114
let source_tree = current_head_id.attach(repo).object()?.peel_to_tree()?;
115115
let new_object = new_head_id.attach(repo).object()?;
116-
let destination_tree = new_object.clone().peel_to_tree()?;
116+
let mut destination_tree = new_object.clone().peel_to_tree()?;
117117

118118
let mut delegate = Delegate::default();
119119
gix::diff::tree(
@@ -125,7 +125,8 @@ pub(crate) mod function {
125125
)?;
126126

127127
let mut opts = git2::build::CheckoutBuilder::new();
128-
let snapshot_tree = if !delegate.changed_files.is_empty() {
128+
let mut snapshot_tree = None;
129+
if !delegate.changed_files.is_empty() {
129130
let changes = but_core::diff::worktree_changes_no_renames(repo)?;
130131
if !changes.changes.is_empty() {
131132
let actual_head_tree_id = repo.head_tree_id_or_empty()?;
@@ -138,7 +139,7 @@ pub(crate) mod function {
138139
// Figure out which added or modified files are actually touched. Deletions we ignore, and allow
139140
// these files to be recreated during checkout even if they were part in a rename
140141
// (we don't do rename tracking here)
141-
let mut change_lut = repo.empty_tree().edit()?;
142+
let mut change_lut = tree::Lut::default();
142143
for change in &changes.changes {
143144
match change.status {
144145
TreeStatus::Deletion { .. } => {
@@ -148,27 +149,25 @@ pub(crate) mod function {
148149
}
149150
TreeStatus::Addition { .. } | TreeStatus::Modification { .. } => {
150151
// It's not about the actual values, just to have a lookup for overlapping paths.
151-
change_lut.upsert(
152-
&change.path,
153-
EntryKind::Blob,
154-
repo.object_hash().empty_blob(),
155-
)?;
152+
change_lut.track_file(change.path.as_ref());
156153
}
157154
TreeStatus::Rename { .. } => {
158155
unreachable!("rename tracking was disabled")
159156
}
160157
}
161158
}
162159

163-
let selection_of_changes_checkout_would_affect = BTreeSet::new();
164-
// TODO: find uncommitted that would be overwritten.
165-
for _file_to_be_modified in &delegate.changed_files {
166-
// selection.extend(change_lut.extend_leafs(&file_to_be_modified))
160+
let mut selection_of_changes_checkout_would_affect = BTreeSet::new();
161+
for (_kind, file_to_be_modified) in &delegate.changed_files {
162+
change_lut.get_intersecting(
163+
file_to_be_modified.as_ref(),
164+
&mut selection_of_changes_checkout_would_affect,
165+
);
167166
}
168167

169168
if !selection_of_changes_checkout_would_affect.is_empty() {
170-
let repo_in_memory = repo.clone().with_object_memory();
171-
let _out = crate::snapshot::create_tree(
169+
let mut repo_in_memory = repo.clone().with_object_memory();
170+
let out = crate::snapshot::create_tree(
172171
source_tree.id.attach(&repo_in_memory),
173172
snapshot::create_tree::State {
174173
changes,
@@ -178,20 +177,45 @@ pub(crate) mod function {
178177
no_workspace_and_meta(),
179178
)?;
180179

181-
match uncommitted_changes {
182-
UncommitedWorktreeChanges::KeepAndAbortOnConflict => {}
183-
UncommitedWorktreeChanges::KeepConflictingInSnapshotAndOverwrite => {}
180+
if !out.is_empty() {
181+
let resolve = crate::snapshot::resolve_tree(
182+
out.snapshot_tree.attach(&repo_in_memory),
183+
destination_tree.id,
184+
snapshot::resolve_tree::Options {
185+
worktree_cherry_pick: None,
186+
},
187+
)?;
188+
if let Some(mut worktree_cherry_pick) = resolve.worktree_cherry_pick {
189+
// re-apply snapshot of just what we need and see if they apply cleanly.
190+
match uncommitted_changes {
191+
UncommitedWorktreeChanges::KeepAndAbortOnConflict => {
192+
let unresolved = TreatAsUnresolved::git();
193+
if worktree_cherry_pick.has_unresolved_conflicts(unresolved) {
194+
let paths = worktree_cherry_pick
195+
.conflicts
196+
.iter()
197+
.filter(|c| c.is_unresolved(unresolved)).map(|c| format!("{:?}", c.ours.location()))
198+
.collect::<Vec<_>>() ;
199+
bail!("Worktree changes would be overwritten by checkout: {}", paths.join(", "));
200+
}
201+
}
202+
UncommitedWorktreeChanges::KeepConflictingInSnapshotAndOverwrite => {}
203+
}
204+
destination_tree.id = worktree_cherry_pick.tree.write()?.detach();
205+
if let Some(memory) = repo_in_memory.objects.take_object_memory() {
206+
for (kind, data) in memory.values() {
207+
repo_in_memory
208+
.write_buf(*kind, data)
209+
.map_err(anyhow::Error::from_boxed)?;
210+
}
211+
}
212+
}
213+
snapshot_tree = Some(out.snapshot_tree);
214+
// TODO: deal with index, but to do that it needs to be merged with destination tree!
184215
}
185-
todo!("deal with snapshot")
186-
} else {
187-
None
188216
}
189-
} else {
190-
None
191217
}
192-
} else {
193-
None
194-
};
218+
}
195219

196220
// Finally, perform the actual checkout
197221
// TODO(gix): use unconditional `gix` checkout implementation as pre-cursor to the real deal (not needed here).
@@ -277,6 +301,206 @@ pub(crate) mod function {
277301
})
278302
}
279303

304+
mod tree {
305+
use bstr::{BStr, BString, ByteVec};
306+
use std::collections::{BTreeSet, HashMap};
307+
308+
/// A lookup table as a tree, with trees represented by their path component.
309+
pub struct Lut {
310+
nodes: Vec<TreeNode>,
311+
}
312+
313+
impl Default for Lut {
314+
fn default() -> Self {
315+
Lut {
316+
nodes: vec![TreeNode {
317+
children: Default::default(),
318+
}],
319+
}
320+
}
321+
}
322+
323+
#[allow(clippy::indexing_slicing)]
324+
impl Lut {
325+
/// Insert a node for each component in slash-separated `rela_path`.
326+
pub fn track_file(&mut self, rela_path: &BStr) {
327+
let mut next_index = self.nodes.len();
328+
let mut cursor = &mut self.nodes[0];
329+
for component in to_components(rela_path) {
330+
match cursor.children.get(component).copied() {
331+
None => {
332+
cursor.children.insert(component.to_owned(), next_index);
333+
self.nodes.push(TreeNode::default());
334+
cursor = &mut self.nodes[next_index];
335+
next_index += 1;
336+
}
337+
Some(existing_idx) => {
338+
cursor = &mut self.nodes[existing_idx];
339+
}
340+
}
341+
}
342+
}
343+
344+
/// Given `rela_path`, place all leaf paths into `out` which is partially or fully
345+
/// intersecting with `rela_path`.
346+
/// Note that all paths in `out` will be `/` separated relative paths.
347+
///
348+
/// For example, in a tree with paths `a`, `b/c` and `b/d`, `rela_path` of `a/b` will match `a`,
349+
/// while `b` would match `b/c` and `b/d`.
350+
pub fn get_intersecting(&self, rela_path: &BStr, out: &mut BTreeSet<BString>) {
351+
let mut cur_path = BString::default();
352+
let mut cursor = &self.nodes[0];
353+
354+
for component in to_components(rela_path) {
355+
match cursor.children.get(component).copied() {
356+
None => {
357+
if !cur_path.is_empty() {
358+
out.insert(cur_path.clone());
359+
}
360+
return;
361+
}
362+
Some(existing_idx) => {
363+
cursor = &self.nodes[existing_idx];
364+
push_component(&mut cur_path, component)
365+
}
366+
}
367+
}
368+
369+
if cursor.children.is_empty() {
370+
if !cur_path.is_empty() {
371+
out.insert(cur_path.clone());
372+
}
373+
return;
374+
}
375+
376+
let mut queue: Vec<_> = cursor
377+
.children
378+
.iter()
379+
.map(|(component, idx)| (cur_path.len(), (component, *idx)))
380+
.collect();
381+
382+
while let Some((cur_path_len, (component, idx))) = queue.pop() {
383+
cur_path.truncate(cur_path_len);
384+
push_component(&mut cur_path, component.as_ref());
385+
386+
let node = &self.nodes[idx];
387+
if node.children.is_empty() {
388+
if !cur_path.is_empty() {
389+
out.insert(cur_path.clone());
390+
}
391+
} else {
392+
queue.extend(
393+
node.children
394+
.iter()
395+
.map(|(component, idx)| (cur_path.len(), (component, *idx))),
396+
);
397+
}
398+
}
399+
}
400+
}
401+
402+
#[derive(Debug, Default, Clone)]
403+
struct TreeNode {
404+
/// A mapping of path component names (always without slash) to their entry in the [`Lut::nodes`].
405+
/// Note that the node is only a leaf if it has no `children` itself, which is when it can be assumed to be a file.
406+
children: HashMap<BString, usize>,
407+
}
408+
409+
pub fn to_components(rela_path: &BStr) -> impl Iterator<Item = &BStr> {
410+
rela_path.split(|b| *b == b'/').map(Into::into)
411+
}
412+
413+
pub fn push_component(path: &mut BString, component: &BStr) {
414+
if !path.is_empty() {
415+
path.push_byte(b'/');
416+
}
417+
path.extend_from_slice(component);
418+
}
419+
420+
#[cfg(test)]
421+
mod tests {
422+
use super::*;
423+
424+
#[test]
425+
fn journey() {
426+
let mut lut = Lut::default();
427+
for path in ["a", "b/c", "b/d"] {
428+
lut.track_file(path.into());
429+
}
430+
let mut out = BTreeSet::new();
431+
lut.get_intersecting("".into(), &mut out);
432+
assert_eq!(out.len(), 0, "empty means nothing, instead of everything");
433+
434+
lut.get_intersecting("d".into(), &mut out);
435+
436+
// This one could not be found.
437+
insta::assert_debug_snapshot!(out, @r"{}");
438+
439+
lut.get_intersecting("a".into(), &mut out);
440+
// Perfect match
441+
insta::assert_compact_debug_snapshot!(out, @r#"{"a"}"#);
442+
443+
out.clear();
444+
lut.get_intersecting("a/b".into(), &mut out);
445+
// indirect match, prefix
446+
insta::assert_compact_debug_snapshot!(out, @r#"{"a"}"#);
447+
448+
out.clear();
449+
lut.get_intersecting("b".into(), &mut out);
450+
// indirect match, suffix/leafs
451+
insta::assert_debug_snapshot!(out, @r#"
452+
{
453+
"b/c",
454+
"b/d",
455+
}
456+
"#);
457+
}
458+
459+
#[test]
460+
fn complex_journey() {
461+
let mut lut = Lut::default();
462+
for path in [
463+
"a/1/2/4/5",
464+
"a/1/2/3",
465+
"a/2/3",
466+
"a/3",
467+
"b/3/4/5",
468+
"b/3/2/1",
469+
"b/2/3",
470+
"b/1",
471+
] {
472+
lut.track_file(path.into());
473+
}
474+
let mut out = BTreeSet::new();
475+
476+
lut.get_intersecting("a".into(), &mut out);
477+
insta::assert_debug_snapshot!(out, @r#"
478+
{
479+
"a/1/2/3",
480+
"a/1/2/4/5",
481+
"a/2/3",
482+
"a/3",
483+
}
484+
"#);
485+
486+
// It's additive
487+
lut.get_intersecting("b".into(), &mut out);
488+
insta::assert_debug_snapshot!(out, @r#"
489+
{
490+
"a/1/2/3",
491+
"a/1/2/4/5",
492+
"a/2/3",
493+
"a/3",
494+
"b/1",
495+
"b/2/3",
496+
"b/3/2/1",
497+
"b/3/4/5",
498+
}
499+
"#);
500+
}
501+
}
502+
}
503+
280504
#[derive(Default)]
281505
struct Delegate {
282506
path_deque: VecDeque<BString>,

crates/but-workspace/src/snapshot/resolve_tree.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/// The information extracted from [`resolve_tree`](function::resolve_tree()).
2+
#[must_use]
23
pub struct Outcome<'repo> {
34
/// The cherry-pick result as merge between the target worktree and the snapshot, **possibly with conflicts**.
45
///
@@ -95,7 +96,7 @@ pub(super) mod function {
9596
let index = match (&head_tree, index, index_conflicts) {
9697
(_, Some(index_tree), Some(index_conflicts)) => {
9798
let (mut index, _path) = repo.index_from_tree(&index_tree.id())?.into_parts();
98-
resolve_conflicts(&mut index, index_conflicts.id())?;
99+
replace_entries_with_their_restored_conflicts(&mut index, index_conflicts.id())?;
99100
Some(index)
100101
}
101102
(_, Some(index_tree), None) => {
@@ -104,7 +105,7 @@ pub(super) mod function {
104105
}
105106
(Some(worktree_base), None, Some(index_conflicts)) => {
106107
let (mut index, _path) = repo.index_from_tree(&worktree_base.id())?.into_parts();
107-
resolve_conflicts(&mut index, index_conflicts.id())?;
108+
replace_entries_with_their_restored_conflicts(&mut index, index_conflicts.id())?;
108109
Some(index)
109110
}
110111
(None, None, Some(_index_conflicts)) => bail!(
@@ -123,7 +124,7 @@ pub(super) mod function {
123124
}
124125

125126
#[expect(clippy::indexing_slicing)]
126-
fn resolve_conflicts(
127+
fn replace_entries_with_their_restored_conflicts(
127128
index: &mut gix::index::State,
128129
conflict_tree: gix::Id,
129130
) -> anyhow::Result<()> {

0 commit comments

Comments
 (0)