Skip to content

Commit 7a0820d

Browse files
committed
feat: provide a way to record and apply index changes.
These changes will then be applicable to an index that is created from the written tree editor.
1 parent 39f46c5 commit 7a0820d

File tree

7 files changed

+227
-4
lines changed

7 files changed

+227
-4
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

gix-merge/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ gix-quote = { version = "^0.4.13", path = "../gix-quote" }
3232
gix-revision = { version = "^0.30.0", path = "../gix-revision", default-features = false, features = ["merge_base"] }
3333
gix-revwalk = { version = "^0.16.0", path = "../gix-revwalk" }
3434
gix-diff = { version = "^0.47.0", path = "../gix-diff", default-features = false, features = ["blob"] }
35+
gix-index = { version = "^0.36.0", path = "../gix-index" }
3536

3637
thiserror = "2.0.0"
3738
imara-diff = { version = "0.1.7" }

gix-merge/src/tree/mod.rs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,21 @@ impl Outcome<'_> {
8181
pub fn has_unresolved_conflicts(&self, how: TreatAsUnresolved) -> bool {
8282
self.conflicts.iter().any(|c| c.is_unresolved(how))
8383
}
84+
85+
/// Returns `true` if `index` changed as we applied conflicting stages to it, using `how` to determine if a
86+
/// conflict should be considered unresolved.
87+
/// It's important that `index` is at the state of [`Self::tree`].
88+
///
89+
/// Note that in practice, whenever there is a single [conflict], this function will return `true`.
90+
/// Also, the unconflicted stage of such entries will be removed merely by setting a flag, so the
91+
/// in-memory entry is still present.
92+
pub fn index_changed_after_applying_conflicts(
93+
&self,
94+
index: &mut gix_index::State,
95+
how: TreatAsUnresolved,
96+
) -> Result<bool, apply_index_entries::Error> {
97+
apply_index_entries(&self.conflicts, how, index)
98+
}
8499
}
85100

86101
/// A description of a conflict (i.e. merge issue without an auto-resolution) as seen during a [tree-merge](crate::tree()).
@@ -99,11 +114,30 @@ pub struct Conflict {
99114
pub ours: Change,
100115
/// The change representing *their* side.
101116
pub theirs: Change,
117+
/// An array to store an entry for each stage of the conflict.
118+
///
119+
/// * `entries[0]` => Base
120+
/// * `entries[1]` => Ours
121+
/// * `entries[2]` => Theirs
122+
///
123+
/// Note that ours and theirs might be swapped, so one should access it through [`Self::entries()`] to compensate for that.
124+
pub entries: [Option<ConflictIndexEntry>; 3],
102125
/// Determine how to interpret the `ours` and `theirs` fields. This is used to implement [`Self::changes_in_resolution()`]
103126
/// and [`Self::into_parts_by_resolution()`].
104127
map: ConflictMapping,
105128
}
106129

130+
/// A conflicting entry for insertion into the index.
131+
/// It will always be either on stage 1 (ancestor/base), 2 (ours) or 3 (theirs)
132+
#[derive(Debug, Clone, Copy)]
133+
pub struct ConflictIndexEntry {
134+
/// The kind of object at this stage.
135+
/// Note that it's possible that this is a directory, for instance if a directory was replaced with a file.
136+
pub mode: gix_object::tree::EntryMode,
137+
/// The id defining the state of the object.
138+
pub id: gix_hash::ObjectId,
139+
}
140+
107141
/// A utility to help define which side is what in the [`Conflict`] type.
108142
#[derive(Debug, Clone, Copy)]
109143
enum ConflictMapping {
@@ -178,6 +212,14 @@ impl Conflict {
178212
}
179213
}
180214

215+
/// Return the index entries for insertion into the index, to match with what's returned by [`Self::changes_in_resolution()`].
216+
pub fn entries(&self) -> [Option<ConflictIndexEntry>; 3] {
217+
match self.map {
218+
ConflictMapping::Original => self.entries,
219+
ConflictMapping::Swapped => [self.entries[0], self.entries[2], self.entries[1]],
220+
}
221+
}
222+
181223
/// Return information about the content merge if it was performed.
182224
pub fn content_merge(&self) -> Option<ContentMerge> {
183225
match &self.resolution {
@@ -308,3 +350,96 @@ pub struct Options {
308350

309351
pub(super) mod function;
310352
mod utils;
353+
pub mod apply_index_entries {
354+
355+
pub(super) mod function {
356+
use crate::tree::{Conflict, Resolution, ResolutionFailure, TreatAsUnresolved};
357+
use bstr::{BString, ByteSlice};
358+
359+
/// The error returned by [`apply_index_entries()`].
360+
#[derive(Debug, thiserror::Error)]
361+
#[allow(missing_docs)]
362+
pub enum Error {
363+
#[error(
364+
"Could not find '{path}' in the index, even though it should have the non-conflicting variant of it."
365+
)]
366+
MissingPathInIndex { path: BString },
367+
}
368+
369+
/// Returns `true` if `index` changed as we applied conflicting stages to it, using `how` to determine if a
370+
/// conflict should be considered unresolved.
371+
/// Once a stage of a path conflicts, the unconflicting stage is removed even though it might be the one
372+
/// that is currently checked out.
373+
/// This removal, however, is only done by flagging it with [gix_index::entry::Flags::REMOVE], which means
374+
/// these entries won't be written back to disk but will still be present in the index.
375+
/// It's important that `index` matches the tree that was produced as part of the merge that also
376+
/// brought about `conflicts`, or else this function will fail if it cannot find the path matching
377+
/// the conflicting entries.
378+
///
379+
/// Note that in practice, whenever there is a single [conflict], this function will return `true`.
380+
/// Errors can only occour if `index` isn't the one created from the merged tree that produced the `conflicts`.
381+
pub fn apply_index_entries(
382+
conflicts: &[Conflict],
383+
how: TreatAsUnresolved,
384+
index: &mut gix_index::State,
385+
) -> Result<bool, Error> {
386+
let len = index.entries().len();
387+
for conflict in conflicts.iter().filter(|c| c.is_unresolved(how)) {
388+
let path = match &conflict.resolution {
389+
Ok(success) => match success {
390+
Resolution::SourceLocationAffectedByRename { final_location } => Some(final_location),
391+
Resolution::OursModifiedTheirsRenamedAndChangedThenRename { final_location, .. } => {
392+
final_location.as_ref()
393+
}
394+
Resolution::OursModifiedTheirsModifiedThenBlobContentMerge { .. } => None,
395+
},
396+
Err(failure) => match failure {
397+
ResolutionFailure::OursRenamedTheirsRenamedDifferently { .. }
398+
| ResolutionFailure::OursModifiedTheirsRenamedTypeMismatch
399+
| ResolutionFailure::OursDeletedTheirsRenamed
400+
| ResolutionFailure::OursModifiedTheirsDeleted
401+
| ResolutionFailure::Unknown => None,
402+
ResolutionFailure::OursModifiedTheirsDirectoryThenOursRenamed {
403+
renamed_unique_path_to_modified_blob,
404+
} => Some(renamed_unique_path_to_modified_blob),
405+
ResolutionFailure::OursAddedTheirsAddedTypeMismatch { their_unique_location } => {
406+
Some(their_unique_location)
407+
}
408+
},
409+
};
410+
let path = path.map_or_else(|| conflict.ours.location(), |path| path.as_bstr());
411+
if conflict.entries.iter().any(|e| e.is_some()) {
412+
let existing_entry = {
413+
let pos = index
414+
.entry_index_by_path_and_stage_bounded(path, gix_index::entry::Stage::Unconflicted, len)
415+
.ok_or_else(|| Error::MissingPathInIndex { path: path.to_owned() })?;
416+
&mut index.entries_mut()[pos]
417+
};
418+
existing_entry.flags.insert(gix_index::entry::Flags::REMOVE);
419+
}
420+
421+
let entries_with_stage = conflict.entries().into_iter().enumerate().filter_map(|(idx, entry)| {
422+
entry.map(|e| {
423+
(
424+
match idx {
425+
0 => gix_index::entry::Stage::Base,
426+
1 => gix_index::entry::Stage::Ours,
427+
2 => gix_index::entry::Stage::Theirs,
428+
_ => unreachable!("fixed size arra with three items"),
429+
},
430+
e,
431+
)
432+
})
433+
});
434+
for (stage, entry) in entries_with_stage {
435+
index.dangerously_push_entry(Default::default(), entry.id, stage.into(), entry.mode.into(), path);
436+
}
437+
}
438+
439+
index.sort_entries();
440+
Ok(index.entries().len() != len)
441+
}
442+
}
443+
pub use function::Error;
444+
}
445+
pub use apply_index_entries::function::apply_index_entries;

gix-merge/src/tree/utils.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,7 @@ impl Conflict {
563563
resolution,
564564
ours: ours.clone(),
565565
theirs: theirs.clone(),
566+
entries: Default::default(),
566567
map: match outer_map {
567568
ConflictMapping::Original => map,
568569
ConflictMapping::Swapped => map.swapped(),
-106 KB
Binary file not shown.

gix-merge/tests/merge/tree/baseline.rs

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use gix_object::FindExt;
66
use std::path::{Path, PathBuf};
77

88
/// An entry in the conflict
9-
#[derive(Debug)]
9+
#[derive(Debug, Eq, PartialEq)]
1010
pub struct Entry {
1111
/// The relative path in the repository
1212
pub location: String,
@@ -17,7 +17,7 @@ pub struct Entry {
1717
}
1818

1919
/// Keep track of all the sides of a conflict. Some might not be set to indicate removal, including the ancestor.
20-
#[derive(Default, Debug)]
20+
#[derive(Default, Debug, Eq, PartialEq)]
2121
pub struct Conflict {
2222
pub ancestor: Option<Entry>,
2323
pub ours: Option<Entry>,
@@ -129,7 +129,7 @@ impl Iterator for Expectations<'_> {
129129
let mut tokens = line.split(' ');
130130
let (
131131
Some(subdir),
132-
Some(conflict_style),
132+
Some(conflict_style_name),
133133
Some(our_commit_id),
134134
Some(our_side_name),
135135
Some(their_commit_id),
@@ -160,7 +160,7 @@ impl Iterator for Expectations<'_> {
160160
});
161161

162162
let subdir_path = self.root.join(subdir);
163-
let conflict_style = match conflict_style {
163+
let conflict_style = match conflict_style_name {
164164
"merge" => ConflictStyle::Merge,
165165
"diff3" => ConflictStyle::Diff3,
166166
unknown => unreachable!("Unknown conflict style: '{unknown}'"),
@@ -221,6 +221,10 @@ fn parse_merge_info(content: String) -> MergeInfo {
221221
*field = Some(entry);
222222
}
223223

224+
if conflict.any_location().is_some() && conflicts.last() != Some(&conflict) {
225+
conflicts.push(conflict);
226+
}
227+
224228
while lines.peek().is_some() {
225229
out.information
226230
.push(parse_info(&mut lines).expect("if there are lines, it should be valid info"));
@@ -285,6 +289,30 @@ fn parse_info<'a>(mut lines: impl Iterator<Item = &'a str>) -> Option<ConflictIn
285289
Some(ConflictInfo { paths, kind, message })
286290
}
287291

292+
#[derive(Debug, PartialEq, Eq)]
293+
pub struct DebugIndexEntry<'a> {
294+
path: &'a BStr,
295+
id: gix_hash::ObjectId,
296+
mode: gix_index::entry::Mode,
297+
stage: gix_index::entry::Stage,
298+
}
299+
300+
pub fn clear_entries(state: &gix_index::State) -> Vec<DebugIndexEntry<'_>> {
301+
state
302+
.entries()
303+
.iter()
304+
.map(|entry| {
305+
let path = entry.path(state);
306+
DebugIndexEntry {
307+
path,
308+
id: entry.id,
309+
mode: entry.mode,
310+
stage: entry.stage(),
311+
}
312+
})
313+
.collect()
314+
}
315+
288316
pub fn visualize_tree(
289317
id: &gix_hash::oid,
290318
odb: &impl gix_object::Find,
@@ -342,3 +370,35 @@ pub fn show_diff_and_fail(
342370
expected.information
343371
);
344372
}
373+
374+
pub(crate) fn apply_git_index_entries(conflicts: Vec<Conflict>, state: &mut gix_index::State) {
375+
let len = state.entries().len();
376+
for Conflict { ours, theirs, ancestor } in conflicts {
377+
for (entry, stage) in [
378+
ancestor.map(|e| (e, gix_index::entry::Stage::Base)),
379+
ours.map(|e| (e, gix_index::entry::Stage::Ours)),
380+
theirs.map(|e| (e, gix_index::entry::Stage::Theirs)),
381+
]
382+
.into_iter()
383+
.filter_map(std::convert::identity)
384+
{
385+
if let Some(pos) = state.entry_index_by_path_and_stage_bounded(
386+
entry.location.as_str().into(),
387+
gix_index::entry::Stage::Unconflicted,
388+
len,
389+
) {
390+
state.entries_mut()[pos].flags.insert(gix_index::entry::Flags::REMOVE)
391+
}
392+
393+
state.dangerously_push_entry(
394+
Default::default(),
395+
entry.id,
396+
stage.into(),
397+
entry.mode.into(),
398+
entry.location.as_str().into(),
399+
);
400+
}
401+
}
402+
state.sort_entries();
403+
state.remove_entries(|_, _, e| e.flags.contains(gix_index::entry::Flags::REMOVE));
404+
}

gix-merge/tests/merge/tree/mod.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use crate::tree::baseline::Deviation;
22
use gix_diff::Rewrites;
33
use gix_merge::commit::Options;
4+
use gix_merge::tree::TreatAsUnresolved;
45
use gix_object::Write;
56
use gix_worktree::stack::state::attributes;
67
use std::path::Path;
@@ -98,6 +99,30 @@ fn run_baseline() -> crate::Result {
9899
);
99100
}
100101
}
102+
103+
let mut actual_index = gix_index::State::from_tree(&actual_id, &odb, Default::default())?;
104+
let expected_index = {
105+
dbg!(&actual.conflicts, &merge_info.conflicts);
106+
let mut index = actual_index.clone();
107+
if let Some(conflicts) = merge_info.conflicts {
108+
baseline::apply_git_index_entries(conflicts, &mut index)
109+
}
110+
index
111+
};
112+
let conflicts_like_in_git = TreatAsUnresolved::Renames;
113+
let did_change = actual.index_changed_after_applying_conflicts(&mut actual_index, conflicts_like_in_git)?;
114+
actual_index.remove_entries(|_, _, e| e.flags.contains(gix_index::entry::Flags::REMOVE));
115+
116+
pretty_assertions::assert_eq!(
117+
baseline::clear_entries(&actual_index),
118+
baseline::clear_entries(&expected_index),
119+
"{case_name}: index mismatch"
120+
);
121+
assert_eq!(
122+
did_change,
123+
actual.has_unresolved_conflicts(conflicts_like_in_git),
124+
"{case_name}: If there is any kind of conflict, the index should have been changed"
125+
);
101126
}
102127

103128
assert_eq!(

0 commit comments

Comments
 (0)