Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ self_cell = "1.2.2"
unicode-segmentation = "1.12.0"
urlencoding = "2.1"
inventory = "0.3"
bimap = "=0.6.3"

[workspace.lints.clippy]
all = { level = "deny", priority = -1 }
Expand Down
3 changes: 2 additions & 1 deletion crates/but/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ regex.workspace = true
anyhow.workspace = true
rmcp.workspace = true
command-group = { version = "5.0.1", features = ["with-tokio"] }
gix = { workspace = true, features = ["tracing", "tracing-detail"] }
gix = { workspace = true, features = ["tracing", "tracing-detail", "revision"] }
colored = "3.0.0"
serde_json.workspace = true
boolean-enums.workspace = true
Expand Down Expand Up @@ -153,6 +153,7 @@ syntect = { version = "5.3.0", default-features = false, features = [
"html",
] }
tracing-appender = "0.2.4"
bimap.workspace = true

[dev-dependencies]
but-graph.workspace = true
Expand Down
6 changes: 6 additions & 0 deletions crates/but/src/args/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -901,6 +901,12 @@ pub enum Subcommands {
Merge {
/// Branch ID or name to merge
branch: String,
#[clap(long = "remote")]
remote: Option<String>,
#[clap(long = "local")]
local: Option<String>,
#[clap(long = "graph")]
graph: Option<String>,
},

/// Move a commit or branch to a different location.
Expand Down
293 changes: 291 additions & 2 deletions crates/but/src/command/legacy/merge.rs
Original file line number Diff line number Diff line change
@@ -1,22 +1,311 @@
use std::cell::RefCell;
use std::collections::{BTreeSet, HashMap, HashSet};
use std::fmt::Write;
use std::rc::Rc;

use anyhow::bail;
use anyhow::{Context as _, bail};
use bimap::BiMap;
use bstr::BString;
use but_ctx::Context;
use colored::Colorize;
use itertools::Itertools as _;

use crate::{
CliId, IdMap,
utils::{OutputChannel, shorten_object_id},
};

fn do_evo(
ctx: &mut Context,
_guard: &but_core::sync::RepoShared,
remote: String,
local: String,
graph: String,
) -> anyhow::Result<gix::ObjectId> {
let repo = &*ctx.repo.get()?;

type RevCommit =
gix::revision::plumbing::graph::Commit<gix::revision::plumbing::merge_base::Flags>;
let mut gix_graph: gix::revision::plumbing::Graph<'_, '_, RevCommit> =
gix::revision::plumbing::Graph::new(&repo.objects, None);
let remote_commit_id = repo.rev_parse_single(&*remote)?.detach();
let local_commit_id = repo.rev_parse_single(&*local)?.detach();
let merge_base =
gix::revision::plumbing::merge_base(remote_commit_id, &[local_commit_id], &mut gix_graph)?
.context("missing merge base")?
.first()
.to_owned();

/// Push the ancestors of `commit_id` up to, but not including, `merge_base`
/// in reverse topological order.
fn push_parents_then_self(
gix_graph: &gix::revision::plumbing::Graph<'_, '_, RevCommit>,
commit_id: &gix::ObjectId,
merge_base: &gix::ObjectId,
reverse_topology: &mut Vec<gix::ObjectId>,
) -> anyhow::Result<()> {
if commit_id == merge_base {
return Ok(());
}
if reverse_topology.contains(commit_id) {
return Ok(());
}
let commit = gix_graph.get(commit_id).context("missing")?;
for parent_id in &commit.parents {
push_parents_then_self(gix_graph, parent_id, merge_base, reverse_topology)?;
}
reverse_topology.push(commit_id.to_owned());
Ok(())
}
let mut remote_reverse_topology: Vec<gix::ObjectId> = Vec::new();
push_parents_then_self(
&gix_graph,
&remote_commit_id,
&merge_base,
&mut remote_reverse_topology,
)?;
let mut local_reverse_topology: Vec<gix::ObjectId> = Vec::new();
push_parents_then_self(
&gix_graph,
&local_commit_id,
&merge_base,
&mut local_reverse_topology,
)?;

// Assumes that family is family, no matter how distantly related (thus, this union-find structure is sufficient).
// We'll need to switch to something that can distinguish close family from distant family.
// TODO link to a doc describing this
#[derive(Debug, Default)]
struct Family<'repo> {
chars: BTreeSet<u8>,
/// In reverse topological order.
remote_commits: Vec<gix::Commit<'repo>>,
/// In reverse topological order.
local_commits: Vec<gix::Commit<'repo>>,
}
type FamilyCell<'repo> = Rc<RefCell<Family<'repo>>>;
let mut char_to_family = HashMap::<u8, FamilyCell>::new();
for chars in graph.as_bytes().chunks(2) {
let [char1, char2] = chars else {
anyhow::bail!("graph must have even chars");
};
match (char_to_family.get(char1), char_to_family.get(char2)) {
(None, None) => {
let mut ref_cell = RefCell::<Family>::default();
ref_cell.get_mut().chars.insert(*char1);
ref_cell.get_mut().chars.insert(*char2);
let family_cell = Rc::new(ref_cell);
char_to_family.insert(*char1, family_cell.clone());
char_to_family.insert(*char2, family_cell);
}
(None, Some(family_cell)) => {
family_cell.borrow_mut().chars.insert(*char1);
char_to_family.insert(*char1, family_cell.clone());
}
(Some(family_cell), None) => {
family_cell.borrow_mut().chars.insert(*char2);
char_to_family.insert(*char2, family_cell.clone());
}
(Some(family_cell1), Some(family_cell2)) => {
family_cell1
.borrow_mut()
.chars
.extend(family_cell2.borrow_mut().chars.iter());
char_to_family.insert(*char2, family_cell1.clone());
}
}
}
let mut remote_commit_id_to_family = HashMap::<gix::ObjectId, FamilyCell>::new();
for commit_id in remote_reverse_topology.iter() {
let commit = repo.find_commit(*commit_id)?;
let message = commit.message_raw()?;
if message.get(0) == message.get(1)
&& let Some(char) = message.get(0)
&& let Some(family) = char_to_family.get(char)
{
family.borrow_mut().remote_commits.push(commit);
remote_commit_id_to_family.insert(*commit_id, family.clone());
}
}
for commit_id in local_reverse_topology.iter() {
let commit = repo.find_commit(*commit_id)?;
let message = commit.message_raw()?;
if message.get(0) == message.get(1)
&& let Some(char) = message.get(0)
&& let Some(family) = char_to_family.get(char)
{
family.borrow_mut().local_commits.push(commit);
}
}

fn write_parents_then_self(
repo: &gix::Repository,
gix_graph: &gix::revision::plumbing::Graph<'_, '_, RevCommit>,
remote_commit_id_to_family: &HashMap<gix::ObjectId, FamilyCell>,
remote_commit_id: &gix::ObjectId,
merge_base: &gix::ObjectId,
remote_to_final_commit_id: &mut BiMap<gix::ObjectId, gix::ObjectId>,
) -> anyhow::Result<gix::ObjectId> {
if remote_commit_id == merge_base {
return Ok(*remote_commit_id);
}
if let Some(final_commit_id) = remote_to_final_commit_id.get_by_left(remote_commit_id) {
return Ok(*final_commit_id);
}
let remote_commit = repo.find_commit(*remote_commit_id)?;
let mut new_parent_ids = Vec::<gix::ObjectId>::new();
for parent_id in remote_commit.parent_ids() {
new_parent_ids.push(write_parents_then_self(
repo,
gix_graph,
remote_commit_id_to_family,
&parent_id.detach(),
merge_base,
remote_to_final_commit_id,
)?);
}
let message = if let Some(family) = remote_commit_id_to_family.get(remote_commit_id) {
let local_summary = family
.borrow()
.local_commits
.iter()
.map(|commit| commit.message().expect("message should be present").title)
.join(",");
BString::from(format!(
"merge remote {} + local {}",
remote_commit.message()?.title,
local_summary
))
} else {
BString::from(remote_commit.message_raw()?)
};
let new_commit = gix::objs::Commit {
tree: repo.empty_tree().id,
parents: new_parent_ids.into(),
author: remote_commit.author()?.to_owned()?,
committer: remote_commit.committer()?.to_owned()?,
encoding: None,
message,
extra_headers: Vec::new(),
};
let final_commit_id = repo.write_object(new_commit)?.detach();
remote_to_final_commit_id.insert(*remote_commit_id, final_commit_id);
Ok(final_commit_id)
}

let mut remote_to_final_commit_id = BiMap::<gix::ObjectId, gix::ObjectId>::new();
remote_to_final_commit_id.insert(merge_base, merge_base);
let mut local_to_final_commit_id = HashMap::<gix::ObjectId, gix::ObjectId>::new();
local_to_final_commit_id.insert(merge_base, merge_base);

for commit_id in local_reverse_topology.iter() {
let local_commit = repo.find_commit(*commit_id)?;
let message = local_commit.message_raw()?;
// Compare the first two bytes. Clippy doesn't like get(0), hence the first().
if message.first() == message.get(1)
&& let Some(char) = message.first()
&& let Some(family) = char_to_family.get(char)
{
let mut borrowed_family = family.borrow_mut();
let remote_commits = std::mem::take(&mut borrowed_family.remote_commits);
std::mem::drop(borrowed_family);

let mut final_commit_id: Option<gix::ObjectId> = None;

for remote_commit in remote_commits {
final_commit_id = Some(write_parents_then_self(
repo,
&gix_graph,
&remote_commit_id_to_family,
&remote_commit.id,
&merge_base,
&mut remote_to_final_commit_id,
)?);
}

if let Some(final_commit_id) = final_commit_id {
local_to_final_commit_id.insert(*commit_id, final_commit_id);
} else {
// This commit's family already had its remote commits written
// when another local commit (from the same family) was
// encountered. Reuse the information from this commit's first
// parent.
let parent_id = local_commit
.parent_ids()
.next()
.context("BUG: this descends from merge base; it should not be an orphan")?
.detach();
local_to_final_commit_id.insert(
*commit_id,
*local_to_final_commit_id
.get(&parent_id)
.context("BUG: parent is either merge base or should have been iterated")?,
);
}
} else {
let mut new_parent_ids = Vec::<gix::ObjectId>::new();
let mut seen_new_parent_ids = HashSet::new();
for parent_id in local_commit.parent_ids() {
let new_parent_id = *local_to_final_commit_id
.get(&parent_id.detach())
.context("BUG: parent is either merge base or should have been iterated")?;
if seen_new_parent_ids.insert(new_parent_id) {
new_parent_ids.push(new_parent_id);
}
}
let new_commit = gix::objs::Commit {
tree: repo.empty_tree().id,
parents: new_parent_ids.into(),
author: local_commit.author()?.to_owned()?,
committer: local_commit.committer()?.to_owned()?,
encoding: None,
message: local_commit.message_raw()?.to_owned(),
extra_headers: Vec::new(),
};
let final_commit_id = repo.write_object(new_commit)?.detach();
local_to_final_commit_id.insert(*commit_id, final_commit_id);

// Any remote commits that would be children of any commit in `new_parent_ids`
// should now be children of `final_commit_id` instead.
for new_parent_id in seen_new_parent_ids {
if let Some(remote_commit_id) =
remote_to_final_commit_id.get_by_right(&new_parent_id)
{
remote_to_final_commit_id.insert(*remote_commit_id, final_commit_id);
}
}
}
}

Ok(write_parents_then_self(
repo,
&gix_graph,
&remote_commit_id_to_family,
&remote_commit_id,
&merge_base,
&mut remote_to_final_commit_id,
)?)
}

pub async fn handle(
ctx: &mut Context,
out: &mut OutputChannel,
branch_id: &str,
remote: Option<String>,
local: Option<String>,
graph: Option<String>,
) -> anyhow::Result<()> {
let mut progress = out.progress_channel();
let guard = ctx.exclusive_worktree_access();

if let (Some(remote), Some(local), Some(graph)) = (remote, local, graph) {
println!(
"{}",
do_evo(ctx, guard.read_permission(), remote, local, graph)?.to_hex()
);
return Ok(());
}

let mut progress = out.progress_channel();
let id_map = IdMap::new_from_context(ctx, None, guard.read_permission())?;

// Resolve the branch ID
Expand Down
9 changes: 7 additions & 2 deletions crates/but/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1376,9 +1376,14 @@ async fn match_subcommand(
result.show_root_cause_error_then_exit_without_destructors(output)
}
#[cfg(feature = "legacy")]
Subcommands::Merge { branch } => {
Subcommands::Merge {
branch,
remote,
local,
graph,
} => {
let mut ctx = setup::init_ctx(&args, InitCtxOptions::default(), out)?;
command::legacy::merge::handle(&mut ctx, out, &branch)
command::legacy::merge::handle(&mut ctx, out, &branch, remote, local, graph)
.await
.context("Failed to merge branch.")
.emit_metrics(metrics_ctx)
Expand Down
Loading