Skip to content

Commit 85de82a

Browse files
committed
Use gix-merge to merge trees when cherry-picking and rebasing.
This change affects the entire edit mode, and every rebase.
1 parent 5082de3 commit 85de82a

File tree

9 files changed

+288
-60
lines changed

9 files changed

+288
-60
lines changed

Cargo.lock

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

crates/gitbutler-cherry-pick/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,6 @@ publish = false
88
[dependencies]
99
gitbutler-commit.workspace = true
1010
git2.workspace = true
11+
gitbutler-oxidize.workspace = true
12+
gix.workspace = true
1113
anyhow.workspace = true

crates/gitbutler-cherry-pick/src/lib.rs

Lines changed: 101 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,9 @@
1-
// tree_writer.insert(".conflict-side-0", side0.id(), 0o040000)?;
2-
// tree_writer.insert(".conflict-side-1", side1.id(), 0o040000)?;
3-
// tree_writer.insert(".conflict-base-0", base_tree.id(), 0o040000)?;
4-
// tree_writer.insert(".auto-resolution", resolved_tree_id, 0o040000)?;
5-
// tree_writer.insert(".conflict-files", conflicted_files_blob, 0o100644)?;
6-
71
use std::ops::Deref;
82

9-
use anyhow::Context;
3+
use anyhow::{Context, Result};
104
use git2::MergeOptions;
115
use gitbutler_commit::commit_ext::CommitExt;
6+
use gitbutler_oxidize::git2_to_gix_object_id;
127

138
#[derive(Default)]
149
pub enum ConflictedTreeKey {
@@ -40,30 +35,55 @@ impl Deref for ConflictedTreeKey {
4035
}
4136

4237
pub trait RepositoryExt {
38+
/// Cherry-pick, but understands GitButler conflicted states.
39+
///
40+
/// This method *should* always be used in favour of native functions.
4341
fn cherry_pick_gitbutler(
4442
&self,
4543
head: &git2::Commit,
4644
to_rebase: &git2::Commit,
4745
merge_options: Option<&MergeOptions>,
48-
) -> Result<git2::Index, anyhow::Error>;
49-
fn find_real_tree(
50-
&self,
51-
commit: &git2::Commit,
46+
) -> Result<git2::Index>;
47+
48+
/// Find the real tree of a commit, which is the tree of the commit if it's not in a conflicted state
49+
/// or the tree according to `side` if it is conflicted.
50+
///
51+
/// Unless you want to find a particular side, you likely want to pass Default::default()
52+
/// as the [`side`](ConflictedTreeKey) which will give the automatically resolved resolution
53+
fn find_real_tree(&self, commit: &git2::Commit, side: ConflictedTreeKey) -> Result<git2::Tree>;
54+
}
55+
56+
pub trait GixRepositoryExt {
57+
/// Cherry-pick, but understands GitButler conflicted states.
58+
/// Note that it will automatically resolve conflicts in *our* favor, so any tree produced
59+
/// here can be used.
60+
///
61+
/// This method *should* always be used in favour of native functions.
62+
fn cherry_pick_gitbutler<'repo>(
63+
&'repo self,
64+
head: &git2::Commit,
65+
to_rebase: &git2::Commit,
66+
) -> Result<gix::merge::tree::Outcome<'repo>>;
67+
68+
/// Find the real tree of a commit, which is the tree of the commit if it's not in a conflicted state
69+
/// or the tree according to `side` if it is conflicted.
70+
///
71+
/// Unless you want to find a particular side, you likely want to pass Default::default()
72+
/// as the [`side`](ConflictedTreeKey) which will give the automatically resolved resolution
73+
fn find_real_tree<'repo>(
74+
&'repo self,
75+
commit_id: &gix::oid,
5276
side: ConflictedTreeKey,
53-
) -> Result<git2::Tree, anyhow::Error>;
77+
) -> Result<gix::Id<'repo>>;
5478
}
5579

5680
impl RepositoryExt for git2::Repository {
57-
/// cherry-pick, but understands GitButler conflicted states
58-
///
59-
/// cherry_pick_gitbutler should always be used in favour of libgit2 or gitoxide
60-
/// cherry pick functions
6181
fn cherry_pick_gitbutler(
6282
&self,
6383
head: &git2::Commit,
6484
to_rebase: &git2::Commit,
6585
merge_options: Option<&MergeOptions>,
66-
) -> Result<git2::Index, anyhow::Error> {
86+
) -> Result<git2::Index> {
6787
// we need to do a manual 3-way patch merge
6888
// find the base, which is the parent of to_rebase
6989
let base = if to_rebase.is_conflicted() {
@@ -77,22 +97,13 @@ impl RepositoryExt for git2::Repository {
7797
// Get the auto-resolution
7898
let ours = self.find_real_tree(head, Default::default())?;
7999
// Get the original theirs
80-
let thiers = self.find_real_tree(to_rebase, ConflictedTreeKey::Theirs)?;
100+
let theirs = self.find_real_tree(to_rebase, ConflictedTreeKey::Theirs)?;
81101

82-
self.merge_trees(&base, &ours, &thiers, merge_options)
102+
self.merge_trees(&base, &ours, &theirs, merge_options)
83103
.context("failed to merge trees for cherry pick")
84104
}
85105

86-
/// Find the real tree of a commit, which is the tree of the commit if it's not in a conflicted state
87-
/// or the parent parent tree if it is in a conflicted state
88-
///
89-
/// Unless you want to find a particular side, you likly want to pass Default::default()
90-
/// as the ConfclitedTreeKey which will give the automatically resolved resolution
91-
fn find_real_tree(
92-
&self,
93-
commit: &git2::Commit,
94-
side: ConflictedTreeKey,
95-
) -> Result<git2::Tree, anyhow::Error> {
106+
fn find_real_tree(&self, commit: &git2::Commit, side: ConflictedTreeKey) -> Result<git2::Tree> {
96107
let tree = commit.tree()?;
97108
if commit.is_conflicted() {
98109
let conflicted_side = tree
@@ -105,3 +116,64 @@ impl RepositoryExt for git2::Repository {
105116
}
106117
}
107118
}
119+
120+
impl GixRepositoryExt for gix::Repository {
121+
fn cherry_pick_gitbutler<'repo>(
122+
&'repo self,
123+
head: &git2::Commit,
124+
to_rebase: &git2::Commit,
125+
) -> Result<gix::merge::tree::Outcome<'repo>> {
126+
// we need to do a manual 3-way patch merge
127+
// find the base, which is the parent of to_rebase
128+
let base = if to_rebase.is_conflicted() {
129+
// Use to_rebase's recorded base
130+
self.find_real_tree(
131+
&git2_to_gix_object_id(to_rebase.id()),
132+
ConflictedTreeKey::Base,
133+
)?
134+
} else {
135+
let base_commit = to_rebase.parent(0)?;
136+
// Use the parent's auto-resolution
137+
self.find_real_tree(&git2_to_gix_object_id(base_commit.id()), Default::default())?
138+
};
139+
// Get the auto-resolution
140+
let ours = self.find_real_tree(&git2_to_gix_object_id(head.id()), Default::default())?;
141+
// Get the original theirs
142+
let theirs = self.find_real_tree(
143+
&git2_to_gix_object_id(to_rebase.id()),
144+
ConflictedTreeKey::Theirs,
145+
)?;
146+
147+
self.merge_trees(
148+
base,
149+
ours,
150+
theirs,
151+
gix::merge::blob::builtin_driver::text::Labels {
152+
ancestor: Some("base".into()),
153+
current: Some("ours".into()),
154+
other: Some("theirs".into()),
155+
},
156+
self.tree_merge_options()?
157+
.with_tree_favor(Some(gix::merge::tree::TreeFavor::Ours))
158+
.with_file_favor(Some(gix::merge::tree::FileFavor::Ours)),
159+
)
160+
.context("failed to merge trees for cherry pick")
161+
}
162+
163+
fn find_real_tree<'repo>(
164+
&'repo self,
165+
commit_id: &gix::oid,
166+
side: ConflictedTreeKey,
167+
) -> Result<gix::Id<'repo>> {
168+
let commit = self.find_commit(commit_id)?;
169+
Ok(if commit.is_conflicted() {
170+
let tree = commit.tree()?;
171+
let conflicted_side = tree
172+
.find_entry(&*side)
173+
.context("Failed to get conflicted side of commit")?;
174+
conflicted_side.id()
175+
} else {
176+
commit.tree_id()?
177+
})
178+
}
179+
}

crates/gitbutler-commit/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@ publish = false
77

88
[dependencies]
99
git2.workspace = true
10+
gix.workspace = true
1011
bstr.workspace = true
1112
uuid.workspace = true

crates/gitbutler-commit/src/commit_ext.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,29 @@ impl CommitExt for git2::Commit<'_> {
3232
}
3333
}
3434

35+
impl CommitExt for gix::Commit<'_> {
36+
fn message_bstr(&self) -> &BStr {
37+
self.message_raw()
38+
.expect("valid commit that can be parsed: TODO - allow it to return errors?")
39+
}
40+
41+
fn change_id(&self) -> Option<String> {
42+
self.gitbutler_headers().map(|headers| headers.change_id)
43+
}
44+
45+
fn is_signed(&self) -> bool {
46+
self.decode().map_or(false, |decoded| {
47+
decoded.extra_headers().pgp_signature().is_some()
48+
})
49+
}
50+
51+
fn is_conflicted(&self) -> bool {
52+
self.gitbutler_headers()
53+
.and_then(|headers| headers.conflicted.map(|conflicted| conflicted > 0))
54+
.unwrap_or(false)
55+
}
56+
}
57+
3558
fn contains<'a, I>(iter: I, item: &git2::Commit<'a>) -> bool
3659
where
3760
I: IntoIterator<Item = git2::Commit<'a>>,

crates/gitbutler-commit/src/commit_headers.rs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use bstr::{BStr, BString};
1+
use bstr::{BStr, BString, ByteSlice};
22
use uuid::Uuid;
33

44
/// Header used to determine which version of the headers is in use. This should never be changed
@@ -113,6 +113,41 @@ impl HasCommitHeaders for git2::Commit<'_> {
113113
}
114114
}
115115

116+
impl HasCommitHeaders for gix::Commit<'_> {
117+
fn gitbutler_headers(&self) -> Option<CommitHeadersV2> {
118+
let decoded = self.decode().ok()?;
119+
if let Some(header) = decoded.extra_headers().find(HEADERS_VERSION_HEADER) {
120+
let version_number = header.to_owned();
121+
122+
// Parse v2 headers
123+
if version_number == V2_HEADERS_VERSION {
124+
let change_id = decoded.extra_headers().find(V2_CHANGE_ID_HEADER)?;
125+
// We can safely assume that the change id should be UTF8
126+
let change_id = change_id.to_str().ok()?.to_string();
127+
128+
let conflicted = decoded
129+
.extra_headers()
130+
.find(V2_CONFLICTED_HEADER)
131+
.and_then(|value| value.to_str().ok()?.parse::<u64>().ok());
132+
133+
Some(CommitHeadersV2 {
134+
change_id,
135+
conflicted,
136+
})
137+
} else {
138+
// Must be for a version we don't recognise
139+
None
140+
}
141+
} else {
142+
// Parse v1 headers
143+
let change_id = decoded.extra_headers().find(V1_CHANGE_ID_HEADER)?;
144+
let change_id = change_id.to_str().ok()?.to_string();
145+
let headers = CommitHeadersV1 { change_id };
146+
Some(headers.into())
147+
}
148+
}
149+
}
150+
116151
/// Lifecycle
117152
impl CommitHeadersV2 {
118153
/// Used to create a CommitHeadersV2. This does not allow a change_id to be

0 commit comments

Comments
 (0)