Skip to content

Commit e3b647b

Browse files
authored
Merge pull request #5411 from gitbutlerapp/performance
use gitoxide for merging trees
2 parents 2ba4cb2 + e4079e5 commit e3b647b

File tree

9 files changed

+511
-440
lines changed

9 files changed

+511
-440
lines changed

Cargo.lock

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

Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,13 @@ resolver = "2"
4242
[workspace.dependencies]
4343
bstr = "1.10.0"
4444
# Add the `tracing` or `tracing-detail` features to see more of gitoxide in the logs. Useful to see which programs it invokes.
45-
gix = { git = "https://github.com/Byron/gitoxide", rev = "b36d7efb9743766338ac7bb7fb2399a06fae5e60", default-features = false, features = [
45+
gix = { git = "https://github.com/Byron/gitoxide", rev = "3fb989be21c739bbfeac93953c1685e7c6cd2106", default-features = false, features = [
4646
] }
4747
git2 = { version = "0.19.0", features = [
4848
"vendored-openssl",
4949
"vendored-libgit2",
5050
] }
51-
uuid = { version = "1.11.0", features = ["serde"] }
51+
uuid = { version = "1.11.0", features = ["v4", "serde"] }
5252
serde = { version = "1.0", features = ["derive"] }
5353
thiserror = "1.0.66"
5454
tokio = { version = "1.41.0", default-features = false }

crates/gitbutler-branch-actions/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ publish = false
99
tracing.workspace = true
1010
anyhow = "1.0.92"
1111
git2.workspace = true
12-
gix = { workspace = true, features = ["blob-diff", "revision"] }
12+
gix = { workspace = true, features = ["blob-diff", "revision", "blob-merge"] }
1313
tokio.workspace = true
1414
gitbutler-oplog.workspace = true
1515
gitbutler-repo.workspace = true

crates/gitbutler-branch-actions/src/stack.rs

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use gitbutler_oplog::entry::{OperationKind, SnapshotDetails};
77
use gitbutler_oplog::{OplogExt, SnapshotExt};
88
use gitbutler_project::Project;
99
use gitbutler_reference::normalize_branch_name;
10+
use gitbutler_repo::GixRepositoryExt;
1011
use gitbutler_repo_actions::RepoActionsExt;
1112
use gitbutler_stack::{
1213
CommitOrChangeId, ForgeIdentifier, PatchReference, PatchReferenceUpdate, Series,
@@ -191,14 +192,20 @@ pub fn push_stack(project: &Project, branch_id: StackId, with_force: bool) -> Re
191192

192193
// First fetch, because we dont want to push integrated series
193194
ctx.fetch(&default_target.push_remote_name(), None)?;
194-
let check_commit = IsCommitIntegrated::new(ctx, &default_target)?;
195+
let gix_repo = ctx
196+
.gix_repository()?
197+
.for_tree_diffing()?
198+
.with_object_memory();
199+
let cache = gix_repo.commit_graph_if_enabled()?;
200+
let mut graph = gix_repo.revision_graph(cache.as_ref());
201+
let mut check_commit = IsCommitIntegrated::new(ctx, &default_target, &gix_repo, &mut graph)?;
195202
let stack_series = stack.list_series(ctx)?;
196203
for series in stack_series {
197204
if series.head.target == merge_base {
198205
// Nothing to push for this one
199206
continue;
200207
}
201-
if series_integrated(&check_commit, &series)? {
208+
if series_integrated(&mut check_commit, &series)? {
202209
// Already integrated, nothing to push
203210
continue;
204211
}
@@ -214,7 +221,7 @@ pub fn push_stack(project: &Project, branch_id: StackId, with_force: bool) -> Re
214221
Ok(())
215222
}
216223

217-
fn series_integrated(check_commit: &IsCommitIntegrated, series: &Series) -> Result<bool> {
224+
fn series_integrated(check_commit: &mut IsCommitIntegrated, series: &Series) -> Result<bool> {
218225
let mut is_integrated = false;
219226
for commit in series.clone().local_commits.iter().rev() {
220227
if !is_integrated {
@@ -226,12 +233,14 @@ fn series_integrated(check_commit: &IsCommitIntegrated, series: &Series) -> Resu
226233

227234
/// Returns the stack series for the API.
228235
/// Newest first, oldest last in the list
236+
/// `commits` is used to accelerate the is-integrated check.
229237
pub(crate) fn stack_series(
230238
ctx: &CommandContext,
231239
branch: &mut Stack,
232240
default_target: &Target,
233-
check_commit: &IsCommitIntegrated,
241+
check_commit: &mut IsCommitIntegrated,
234242
remote_commit_data: HashMap<CommitData, git2::Oid>,
243+
commits: &[VirtualBranchCommit],
235244
) -> Result<(Vec<PatchSeries>, bool)> {
236245
let mut requires_force = false;
237246
let mut api_series: Vec<PatchSeries> = vec![];
@@ -248,7 +257,10 @@ pub(crate) fn stack_series(
248257
// Reverse first instead of later, so that we catch the first integrated commit
249258
for commit in series.clone().local_commits.iter().rev() {
250259
if !is_integrated {
251-
is_integrated = check_commit.is_integrated(commit)?;
260+
is_integrated = commits
261+
.iter()
262+
.find_map(|c| (c.id == commit.id()).then_some(Ok(c.is_integrated)))
263+
.unwrap_or_else(|| check_commit.is_integrated(commit))?;
252264
}
253265
let copied_from_remote_id = CommitData::try_from(commit)
254266
.ok()

crates/gitbutler-branch-actions/src/status.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,7 @@ pub fn get_applied_status_cached(
131131
.filter_map(|claimed_hunk| {
132132
// if any of the current hunks intersects with the owned hunk, we want to keep it
133133
for (i, git_diff_hunk) in git_diff_hunks.iter().enumerate() {
134-
if claimed_hunk == &Hunk::from(git_diff_hunk)
135-
|| claimed_hunk.intersects(git_diff_hunk)
136-
{
134+
if claimed_hunk.intersects(git_diff_hunk) {
137135
let hash = Hunk::hash_diff(&git_diff_hunk.diff_lines);
138136
if locks.contains_key(&hash) {
139137
return None; // Defer allocation to unclaimed hunks processing

crates/gitbutler-branch-actions/src/virtual.rs

Lines changed: 89 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,20 @@ use gitbutler_commit::{commit_ext::CommitExt, commit_headers::HasCommitHeaders};
2020
use gitbutler_diff::{trees, GitHunk, Hunk};
2121
use gitbutler_error::error::Code;
2222
use gitbutler_operating_modes::assure_open_workspace_mode;
23-
use gitbutler_oxidize::git2_signature_to_gix_signature;
23+
use gitbutler_oxidize::{git2_signature_to_gix_signature, git2_to_gix_object_id, gix_to_git2_oid};
2424
use gitbutler_project::access::WorktreeWritePermission;
2525
use gitbutler_reference::{normalize_branch_name, Refname, RemoteRefname};
2626
use gitbutler_repo::{
2727
rebase::{cherry_rebase, cherry_rebase_group},
28-
LogUntil, RepositoryExt,
28+
GixRepositoryExt, LogUntil, RepositoryExt,
2929
};
3030
use gitbutler_repo_actions::RepoActionsExt;
3131
use gitbutler_stack::{
3232
reconcile_claims, BranchOwnershipClaims, ForgeIdentifier, Stack, StackId, Target,
3333
VirtualBranchesHandle,
3434
};
3535
use gitbutler_time::time::now_since_unix_epoch_ms;
36+
use gix::objs::Write;
3637
use serde::Serialize;
3738
use std::collections::HashSet;
3839
use std::{collections::HashMap, path::PathBuf, vec};
@@ -300,8 +301,15 @@ pub fn list_virtual_branches_cached(
300301

301302
let branches_span =
302303
tracing::debug_span!("handle branches", num_branches = status.branches.len()).entered();
304+
let repo = ctx.repository();
305+
let gix_repo = ctx
306+
.gix_repository()?
307+
.for_tree_diffing()?
308+
.with_object_memory();
309+
// We will perform virtual merges, no need to write them to the ODB.
310+
let cache = gix_repo.commit_graph_if_enabled()?;
311+
let mut graph = gix_repo.revision_graph(cache.as_ref());
303312
for (mut branch, mut files) in status.branches {
304-
let repo = ctx.repository();
305313
update_conflict_markers(ctx, files.clone())?;
306314

307315
let upstream_branch = match branch.clone().upstream {
@@ -323,13 +331,18 @@ pub fn list_virtual_branches_cached(
323331
.as_ref()
324332
.map(
325333
|upstream| -> Result<(HashSet<git2::Oid>, HashMap<CommitData, git2::Oid>)> {
326-
let merge_base =
327-
repo.merge_base(upstream.id(), default_target.sha)
328-
.context(format!(
329-
"failed to find merge base between {} and {}",
330-
upstream.id(),
331-
default_target.sha
332-
))?;
334+
let merge_base = gix_repo
335+
.merge_base_with_graph(
336+
git2_to_gix_object_id(upstream.id()),
337+
git2_to_gix_object_id(default_target.sha),
338+
&mut graph,
339+
)
340+
.context(format!(
341+
"failed to find merge base between {} and {}",
342+
upstream.id(),
343+
default_target.sha
344+
))?;
345+
let merge_base = gitbutler_oxidize::gix_to_git2_oid(merge_base);
333346
let remote_commit_ids = HashSet::from_iter(repo.l(
334347
upstream.id(),
335348
LogUntil::Commit(merge_base),
@@ -356,7 +369,8 @@ pub fn list_virtual_branches_cached(
356369

357370
// find all commits on head that are not on target.sha
358371
let commits = repo.log(branch.head(), LogUntil::Commit(default_target.sha), false)?;
359-
let check_commit = IsCommitIntegrated::new(ctx, &default_target)?;
372+
let mut check_commit =
373+
IsCommitIntegrated::new(ctx, &default_target, &gix_repo, &mut graph)?;
360374
let vbranch_commits = {
361375
let _span = tracing::debug_span!(
362376
"is-commit-integrated",
@@ -397,9 +411,14 @@ pub fn list_virtual_branches_cached(
397411
.collect::<Result<Vec<_>>>()?
398412
};
399413

400-
let merge_base = repo
401-
.merge_base(default_target.sha, branch.head())
414+
let merge_base = gix_repo
415+
.merge_base_with_graph(
416+
git2_to_gix_object_id(default_target.sha),
417+
git2_to_gix_object_id(branch.head()),
418+
check_commit.graph,
419+
)
402420
.context("failed to find merge base")?;
421+
let merge_base = gix_to_git2_oid(merge_base);
403422
let base_current = true;
404423

405424
let upstream = upstream_branch.and_then(|upstream_branch| {
@@ -436,8 +455,9 @@ pub fn list_virtual_branches_cached(
436455
ctx,
437456
&mut branch,
438457
&default_target,
439-
&check_commit,
458+
&mut check_commit,
440459
remote_commit_data,
460+
&vbranch_commits,
441461
) {
442462
Ok((series, force)) => {
443463
if series.iter().any(|s| s.upstream_reference.is_some()) {
@@ -943,40 +963,50 @@ pub(crate) fn push(
943963
})
944964
}
945965

946-
pub(crate) struct IsCommitIntegrated<'repo> {
947-
repo: &'repo git2::Repository,
948-
target_commit_id: git2::Oid,
949-
remote_head_id: git2::Oid,
966+
type MergeBaseCommitGraph<'repo, 'cache> = gix::revwalk::Graph<
967+
'repo,
968+
'cache,
969+
gix::revision::plumbing::graph::Commit<gix::revision::plumbing::merge_base::Flags>,
970+
>;
971+
972+
pub(crate) struct IsCommitIntegrated<'repo, 'cache, 'graph> {
973+
gix_repo: &'repo gix::Repository,
974+
graph: &'graph mut MergeBaseCommitGraph<'repo, 'cache>,
975+
target_commit_id: gix::ObjectId,
976+
upstream_tree_id: gix::ObjectId,
950977
upstream_commits: Vec<git2::Oid>,
951-
/// A repository opened at the same path as `repo`, but with an in-memory ODB attached
952-
/// to avoid writing intermediate objects.
953-
inmemory_repo: git2::Repository,
954978
}
955979

956-
impl<'repo> IsCommitIntegrated<'repo> {
957-
pub(crate) fn new(ctx: &'repo CommandContext, target: &Target) -> anyhow::Result<Self> {
980+
impl<'repo, 'cache, 'graph> IsCommitIntegrated<'repo, 'cache, 'graph> {
981+
pub(crate) fn new(
982+
ctx: &'repo CommandContext,
983+
target: &Target,
984+
gix_repo: &'repo gix::Repository,
985+
graph: &'graph mut MergeBaseCommitGraph<'repo, 'cache>,
986+
) -> anyhow::Result<Self> {
958987
let remote_branch = ctx
959988
.repository()
960989
.maybe_find_branch_by_refname(&target.branch.clone().into())?
961990
.ok_or(anyhow!("failed to get branch"))?;
962991
let remote_head = remote_branch.get().peel_to_commit()?;
963-
let upstream_commits =
992+
let mut upstream_commits =
964993
ctx.repository()
965994
.l(remote_head.id(), LogUntil::Commit(target.sha), false)?;
966-
let inmemory_repo = ctx.repository().in_memory_repo()?;
995+
upstream_commits.sort();
996+
let upstream_tree_id = ctx.repository().find_commit(remote_head.id())?.tree_id();
967997
Ok(Self {
968-
repo: ctx.repository(),
969-
target_commit_id: target.sha,
970-
remote_head_id: remote_head.id(),
998+
gix_repo,
999+
graph,
1000+
target_commit_id: git2_to_gix_object_id(target.sha),
1001+
upstream_tree_id: git2_to_gix_object_id(upstream_tree_id),
9711002
upstream_commits,
972-
inmemory_repo,
9731003
})
9741004
}
9751005
}
9761006

977-
impl IsCommitIntegrated<'_> {
978-
pub(crate) fn is_integrated(&self, commit: &git2::Commit) -> Result<bool> {
979-
if self.target_commit_id == commit.id() {
1007+
impl IsCommitIntegrated<'_, '_, '_> {
1008+
pub(crate) fn is_integrated(&mut self, commit: &git2::Commit) -> Result<bool> {
1009+
if self.target_commit_id == git2_to_gix_object_id(commit.id()) {
9801010
// could not be integrated if heads are the same.
9811011
return Ok(false);
9821012
}
@@ -986,44 +1016,54 @@ impl IsCommitIntegrated<'_> {
9861016
return Ok(false);
9871017
}
9881018

989-
if self.upstream_commits.contains(&commit.id()) {
1019+
if self.upstream_commits.binary_search(&commit.id()).is_ok() {
9901020
return Ok(true);
9911021
}
9921022

993-
let merge_base_id = self.repo.merge_base(self.target_commit_id, commit.id())?;
994-
if merge_base_id.eq(&commit.id()) {
1023+
let merge_base_id = self.gix_repo.merge_base_with_graph(
1024+
self.target_commit_id,
1025+
git2_to_gix_object_id(commit.id()),
1026+
self.graph,
1027+
)?;
1028+
if gix_to_git2_oid(merge_base_id).eq(&commit.id()) {
9951029
// if merge branch is the same as branch head and there are upstream commits
9961030
// then it's integrated
9971031
return Ok(true);
9981032
}
9991033

1000-
let merge_base = self.repo.find_commit(merge_base_id)?;
1001-
let merge_base_tree = merge_base.tree()?;
1002-
let upstream = self.repo.find_commit(self.remote_head_id)?;
1003-
let upstream_tree = upstream.tree()?;
1004-
1005-
if merge_base_tree.id() == upstream_tree.id() {
1034+
let merge_base_tree_id = self.gix_repo.find_commit(merge_base_id)?.tree_id()?;
1035+
if merge_base_tree_id == self.upstream_tree_id {
10061036
// if merge base is the same as upstream tree, then it's integrated
10071037
return Ok(true);
10081038
}
10091039

10101040
// try to merge our tree into the upstream tree
1011-
let mut merge_index = self
1012-
.repo
1013-
.merge_trees(&merge_base_tree, &commit.tree()?, &upstream_tree, None)
1041+
let mut merge_options = self.gix_repo.tree_merge_options()?;
1042+
let conflict_kind = gix::merge::tree::UnresolvedConflict::Renames;
1043+
merge_options.fail_on_conflict = Some(conflict_kind);
1044+
let mut merge_output = self
1045+
.gix_repo
1046+
.merge_trees(
1047+
merge_base_tree_id,
1048+
git2_to_gix_object_id(commit.tree_id()),
1049+
self.upstream_tree_id,
1050+
Default::default(),
1051+
merge_options,
1052+
)
10141053
.context("failed to merge trees")?;
10151054

1016-
if merge_index.has_conflicts() {
1055+
if merge_output.has_unresolved_conflicts(conflict_kind) {
10171056
return Ok(false);
10181057
}
10191058

1020-
let merge_tree_oid = merge_index
1021-
.write_tree_to(&self.inmemory_repo)
1022-
.context("failed to write tree")?;
1059+
let merge_tree_id = merge_output
1060+
.tree
1061+
.write(|tree| self.gix_repo.write(tree))
1062+
.map_err(|err| anyhow!("failed to write tree: {err}"))?;
10231063

10241064
// if the merge_tree is the same as the new_target_tree and there are no files (uncommitted changes)
10251065
// then the vbranch is fully merged
1026-
Ok(merge_tree_oid == upstream_tree.id())
1066+
Ok(merge_tree_id == self.upstream_tree_id)
10271067
}
10281068
}
10291069

crates/gitbutler-diff/src/diff.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,12 +174,9 @@ pub fn trees(
174174
false => 0,
175175
};
176176
diff_opts
177-
.recurse_untracked_dirs(true)
178-
.include_untracked(true)
179177
.show_binary(true)
180178
.ignore_submodules(true)
181-
.context_lines(context_lines)
182-
.show_untracked_content(true);
179+
.context_lines(context_lines);
183180

184181
let diff = repo.diff_tree_to_tree(Some(old_tree), Some(new_tree), Some(&mut diff_opts))?;
185182
hunks_by_filepath(None, &diff)

0 commit comments

Comments
 (0)