Skip to content

Commit b40b347

Browse files
committed
Use gix-merge to merge trees when cherry-picking.
This change affects the entire edit mode, and every rebase.
1 parent d303e39 commit b40b347

File tree

9 files changed

+290
-74
lines changed

9 files changed

+290
-74
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 & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66

77
use std::ops::Deref;
88

9-
use anyhow::Context;
9+
use anyhow::{Context, Result};
1010
use git2::MergeOptions;
1111
use gitbutler_commit::commit_ext::CommitExt;
12+
use gitbutler_oxidize::git2_to_gix_object_id;
1213

1314
#[derive(Default)]
1415
pub enum ConflictedTreeKey {
@@ -40,30 +41,55 @@ impl Deref for ConflictedTreeKey {
4041
}
4142

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

5686
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
6187
fn cherry_pick_gitbutler(
6288
&self,
6389
head: &git2::Commit,
6490
to_rebase: &git2::Commit,
6591
merge_options: Option<&MergeOptions>,
66-
) -> Result<git2::Index, anyhow::Error> {
92+
) -> Result<git2::Index> {
6793
// we need to do a manual 3-way patch merge
6894
// find the base, which is the parent of to_rebase
6995
let base = if to_rebase.is_conflicted() {
@@ -77,22 +103,13 @@ impl RepositoryExt for git2::Repository {
77103
// Get the auto-resolution
78104
let ours = self.find_real_tree(head, Default::default())?;
79105
// Get the original theirs
80-
let thiers = self.find_real_tree(to_rebase, ConflictedTreeKey::Theirs)?;
106+
let theirs = self.find_real_tree(to_rebase, ConflictedTreeKey::Theirs)?;
81107

82-
self.merge_trees(&base, &ours, &thiers, merge_options)
108+
self.merge_trees(&base, &ours, &theirs, merge_options)
83109
.context("failed to merge trees for cherry pick")
84110
}
85111

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> {
112+
fn find_real_tree(&self, commit: &git2::Commit, side: ConflictedTreeKey) -> Result<git2::Tree> {
96113
let tree = commit.tree()?;
97114
if commit.is_conflicted() {
98115
let conflicted_side = tree
@@ -105,3 +122,64 @@ impl RepositoryExt for git2::Repository {
105122
}
106123
}
107124
}
125+
126+
impl GixRepositoryExt for gix::Repository {
127+
fn cherry_pick_gitbutler<'repo>(
128+
&'repo self,
129+
head: &git2::Commit,
130+
to_rebase: &git2::Commit,
131+
) -> Result<gix::merge::tree::Outcome<'repo>> {
132+
// we need to do a manual 3-way patch merge
133+
// find the base, which is the parent of to_rebase
134+
let base = if to_rebase.is_conflicted() {
135+
// Use to_rebase's recorded base
136+
self.find_real_tree(
137+
&git2_to_gix_object_id(to_rebase.id()),
138+
ConflictedTreeKey::Base,
139+
)?
140+
} else {
141+
let base_commit = to_rebase.parent(0)?;
142+
// Use the parent's auto-resolution
143+
self.find_real_tree(&git2_to_gix_object_id(base_commit.id()), Default::default())?
144+
};
145+
// Get the auto-resolution
146+
let ours = self.find_real_tree(&git2_to_gix_object_id(head.id()), Default::default())?;
147+
// Get the original theirs
148+
let theirs = self.find_real_tree(
149+
&git2_to_gix_object_id(to_rebase.id()),
150+
ConflictedTreeKey::Theirs,
151+
)?;
152+
153+
self.merge_trees(
154+
base,
155+
ours,
156+
theirs,
157+
gix::merge::blob::builtin_driver::text::Labels {
158+
ancestor: Some("base".into()),
159+
current: Some("ours".into()),
160+
other: Some("theirs".into()),
161+
},
162+
self.tree_merge_options()?
163+
.with_tree_favor(Some(gix::merge::tree::TreeFavor::Ours))
164+
.with_file_favor(Some(gix::merge::tree::FileFavor::Ours)),
165+
)
166+
.context("failed to merge trees for cherry pick")
167+
}
168+
169+
fn find_real_tree<'repo>(
170+
&'repo self,
171+
commit_id: &gix::oid,
172+
side: ConflictedTreeKey,
173+
) -> Result<gix::Id<'repo>> {
174+
let commit = self.find_commit(commit_id)?;
175+
Ok(if commit.is_conflicted() {
176+
let tree = commit.tree()?;
177+
let conflicted_side = tree
178+
.find_entry(&*side)
179+
.context("Failed to get conflicted side of commit")?;
180+
conflicted_side.id()
181+
} else {
182+
commit.tree_id()?
183+
})
184+
}
185+
}

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)