use std::collections::HashSet;
use std::path::Path;
use anyhow::{Context, Result, bail};
use git2::{Oid, Repository};
use crate::core::msg;
use crate::core::repo;
use crate::git;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
Pick,
Edit,
Fixup,
}
impl Command {
fn as_str(&self) -> &str {
match self {
Command::Pick => "pick",
Command::Edit => "edit",
Command::Fixup => "fixup",
}
}
}
#[derive(Debug, Clone)]
pub struct CommitEntry {
pub oid: Oid,
pub short_hash: String,
pub message: String,
pub command: Command,
pub update_refs: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct BranchSection {
pub reset_target: String,
pub commits: Vec<CommitEntry>,
pub label: String,
pub branch_names: Vec<String>,
}
#[derive(Debug, Clone)]
pub enum IntegrationEntry {
Pick(CommitEntry),
Merge {
original_oid: Option<Oid>,
label: String,
},
}
#[derive(Debug, Clone)]
pub struct Weave {
pub base_oid: Oid,
pub branch_sections: Vec<BranchSection>,
pub integration_line: Vec<IntegrationEntry>,
pub base_refs: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Position {
Above,
Below,
}
impl Position {
pub fn as_str(&self) -> &'static str {
match self {
Position::Above => "above",
Position::Below => "below",
}
}
}
enum Slot {
Section(usize, usize),
Integration(usize),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EmptiedRefs {
Park,
Detach,
}
impl Weave {
pub fn to_todo(&self) -> String {
let mut out = String::new();
out.push_str("label onto\n");
for section in &self.branch_sections {
out.push('\n');
out.push_str(&format!("reset {}\n", section.reset_target));
let remaining = emit_commits_with_refs(&mut out, §ion.commits);
flush_refs(&mut out, &remaining);
out.push_str(&format!("label {}\n", section.label));
for branch_name in §ion.branch_names {
out.push_str(&format!("update-ref refs/heads/{}\n", branch_name));
}
}
out.push('\n');
out.push_str("reset onto\n");
flush_refs(&mut out, &self.base_refs);
let mut pending_refs: Vec<String> = Vec::new();
for entry in &self.integration_line {
match entry {
IntegrationEntry::Pick(commit) => {
if commit.command != Command::Fixup && !pending_refs.is_empty() {
flush_refs(&mut out, &pending_refs);
pending_refs.clear();
}
out.push_str(&format!(
"{} {} # {}\n",
commit.command.as_str(),
commit.short_hash,
commit.message
));
pending_refs.extend(commit.update_refs.iter().cloned());
}
IntegrationEntry::Merge {
original_oid,
label,
} => {
flush_refs(&mut out, &pending_refs);
pending_refs.clear();
if let Some(oid) = original_oid {
out.push_str(&format!(
"merge -C {} {} # Merge branch '{}'\n",
git::short_hash(&oid.to_string()),
label,
label
));
} else {
out.push_str(&format!("merge {} # Merge branch '{}'\n", label, label));
}
}
}
}
flush_refs(&mut out, &pending_refs);
out
}
pub fn from_repo(repo: &Repository) -> Result<Self> {
let info = repo::gather_repo_info(repo, false, 1)?;
Self::from_repo_with_info(repo, &info)
}
pub fn from_repo_with_info(repo: &Repository, info: &repo::RepoInfo) -> Result<Self> {
let head_oid = repo::head_oid(repo)?;
let merge_base_oid = base_oid(repo, info)?;
let first_parent_entries = walk_first_parent_line(repo, head_oid, merge_base_oid)?;
let branch_tips: std::collections::HashMap<Oid, Vec<String>> = {
let mut map: std::collections::HashMap<Oid, Vec<String>> =
std::collections::HashMap::new();
for branch in &info.branches {
map.entry(branch.tip_oid)
.or_default()
.push(branch.name.clone());
}
map
};
let mut branch_sections = Vec::new();
let mut integration_line = Vec::new();
let mut assigned_branches: std::collections::HashSet<String> =
std::collections::HashSet::new();
for entry in &first_parent_entries {
if entry.is_merge {
if let Some(merge_parent_oid) = entry.merge_parent {
let branch_names_at_tip = branch_tips
.get(&merge_parent_oid)
.cloned()
.unwrap_or_default();
let branch_commits =
walk_branch_commits(repo, merge_parent_oid, merge_base_oid)?;
if !branch_commits.is_empty() || !branch_names_at_tip.is_empty() {
let label = if !branch_names_at_tip.is_empty() {
branch_names_at_tip[0].clone()
} else {
format!("section-{}", git::short_hash(&merge_parent_oid.to_string()))
};
let todo_commits: Vec<CommitEntry> = branch_commits
.into_iter()
.rev()
.map(|c| {
let mut update_refs = Vec::new();
if let Some(names) = branch_tips.get(&c.oid) {
for name in names {
if !branch_names_at_tip.contains(name)
&& !assigned_branches.contains(name)
{
update_refs.push(name.clone());
}
}
}
CommitEntry {
oid: c.oid,
short_hash: c.short_hash,
message: c.message,
command: Command::Pick,
update_refs,
}
})
.collect();
let section = BranchSection {
reset_target: "onto".to_string(),
commits: todo_commits,
label: label.clone(),
branch_names: branch_names_at_tip.clone(),
};
for name in &branch_names_at_tip {
assigned_branches.insert(name.clone());
}
branch_sections.push(section);
integration_line.push(IntegrationEntry::Merge {
original_oid: Some(entry.oid),
label,
});
}
}
} else {
let mut update_refs = Vec::new();
if let Some(names) = branch_tips.get(&entry.oid) {
for name in names {
if !assigned_branches.contains(name) {
update_refs.push(name.clone());
assigned_branches.insert(name.clone());
}
}
}
integration_line.push(IntegrationEntry::Pick(CommitEntry {
oid: entry.oid,
short_hash: entry.short_hash.clone(),
message: entry.message.clone(),
command: Command::Pick,
update_refs,
}));
}
}
Ok(Weave {
base_oid: merge_base_oid,
branch_sections,
integration_line,
base_refs: Vec::new(),
})
}
pub fn filter_upstream_commits(
&mut self,
repo: &Repository,
workdir: &Path,
new_upstream_oid: Oid,
) -> Result<Vec<String>> {
let mut candidates: Vec<Oid> = Vec::new();
let mut to_drop = Vec::new();
for section in &self.branch_sections {
for commit in §ion.commits {
if repo::contains(repo, new_upstream_oid, commit.oid)? {
to_drop.push(commit.oid);
} else {
candidates.push(commit.oid);
}
}
}
if !candidates.is_empty() {
let candidate_set: HashSet<Oid> = candidates.into_iter().collect();
match cherry_pick_equivalents(workdir, &new_upstream_oid, &self.base_oid) {
Some(equivalent) => {
to_drop.extend(equivalent.intersection(&candidate_set).copied());
}
None => {
msg::warn(
"Could not run git cherry — \
cherry-picked commits may not be detected",
);
}
}
}
let mut emptied = Vec::new();
for oid in to_drop {
emptied.extend(
self.drop_commit(oid, EmptiedRefs::Detach)
.expect("the oid comes from the sections themselves"),
);
}
Ok(emptied)
}
#[must_use]
pub fn drop_commit(&mut self, oid: Oid, emptied: EmptiedRefs) -> Option<Vec<String>> {
let (_, mut names, section) = self.remove_commit(oid, emptied)?;
if let Some(i) = section
&& self.branch_sections[i].commits.is_empty()
{
names.extend(self.remove_empty_section(i, emptied));
}
Some(names)
}
fn remove_empty_section(&mut self, idx: usize, emptied: EmptiedRefs) -> Vec<String> {
let section = self.branch_sections.remove(idx);
for s in &mut self.branch_sections {
if s.reset_target == section.label {
s.reset_target = section.reset_target.clone();
}
}
self.integration_line.retain(
|e| !matches!(e, IntegrationEntry::Merge { label: l, .. } if *l == section.label),
);
if emptied == EmptiedRefs::Park {
self.park_refs(§ion.reset_target, section.branch_names.clone());
}
section.branch_names
}
fn remove_commit(
&mut self,
oid: Oid,
emptied: EmptiedRefs,
) -> Option<(CommitEntry, Vec<String>, Option<usize>)> {
for i in 0..self.branch_sections.len() {
if let Some(pos) = self.branch_sections[i]
.commits
.iter()
.position(|c| c.oid == oid)
{
let mut removed = self.branch_sections[i].commits.remove(pos);
let refs = std::mem::take(&mut removed.update_refs);
if pos > 0 {
self.branch_sections[i].commits[pos - 1]
.update_refs
.extend(refs);
return Some((removed, Vec::new(), Some(i)));
}
if emptied == EmptiedRefs::Park {
let target = self.branch_sections[i].reset_target.clone();
self.park_refs(&target, refs.clone());
}
return Some((removed, refs, Some(i)));
}
}
let pos = self
.integration_line
.iter()
.position(|e| matches!(e, IntegrationEntry::Pick(c) if c.oid == oid))?;
let IntegrationEntry::Pick(mut removed) = self.integration_line.remove(pos) else {
unreachable!("position matched a Pick");
};
let refs = std::mem::take(&mut removed.update_refs);
if refs.is_empty() {
return Some((removed, Vec::new(), None));
}
let target =
(0..pos).rfind(|&j| matches!(self.integration_line[j], IntegrationEntry::Pick(_)));
if let Some(j) = target
&& let IntegrationEntry::Pick(ref mut c) = self.integration_line[j]
{
c.update_refs.extend(refs);
return Some((removed, Vec::new(), None));
}
if emptied == EmptiedRefs::Park {
self.base_refs.extend(refs.clone());
}
Some((removed, refs, None))
}
fn park_refs(&mut self, reset_target: &str, names: Vec<String>) {
let mut target = reset_target.to_string();
let mut seen = HashSet::new();
loop {
if target == "onto" || !seen.insert(target.clone()) {
self.base_refs.extend(names);
return;
}
let Some(section) = self.branch_sections.iter_mut().find(|s| s.label == target) else {
self.base_refs.extend(names);
return;
};
if let Some(last) = section.commits.last_mut() {
last.update_refs.extend(names);
return;
}
target = section.reset_target.clone();
}
}
pub fn has_branch_section(&self, branch_name: &str) -> bool {
self.section_index(branch_name).is_some()
}
pub fn is_inner_branch(&self, branch_name: &str) -> bool {
self.inner_ref_position(branch_name).is_some()
}
pub fn inner_branch_keeper(&self, branch_name: &str) -> Option<&str> {
let (s, _) = self.inner_ref_position(branch_name)?;
self.branch_sections[s]
.branch_names
.first()
.map(String::as_str)
}
fn inner_ref_position(&self, branch_name: &str) -> Option<(usize, usize)> {
self.branch_sections
.iter()
.enumerate()
.find_map(|(s, section)| {
section
.commits
.iter()
.position(|c| c.update_refs.iter().any(|r| r == branch_name))
.map(|pos| (s, pos))
})
}
fn section_index(&self, branch_name: &str) -> Option<usize> {
self.branch_sections
.iter()
.position(|s| s.branch_names.iter().any(|n| n == branch_name) || s.label == branch_name)
}
fn inner_branch_boundary(&self, idx: usize) -> Option<usize> {
self.branch_sections[idx]
.commits
.iter()
.rposition(|c| !c.update_refs.is_empty())
}
pub fn branch_drop_size(&self, branch_name: &str) -> Option<usize> {
let idx = self.section_index(branch_name)?;
let kept = self
.inner_branch_boundary(idx)
.map_or(0, |boundary| boundary + 1);
let on_integration_line: HashSet<Oid> = self
.integration_line
.iter()
.filter_map(|e| match e {
IntegrationEntry::Pick(c) => Some(c.oid),
IntegrationEntry::Merge { .. } => None,
})
.collect();
Some(
self.branch_sections[idx].commits[kept..]
.iter()
.filter(|c| !on_integration_line.contains(&c.oid))
.count(),
)
}
#[must_use]
pub fn drop_branch(&mut self, branch_name: &str) -> bool {
let Some(idx) = self.section_index(branch_name) else {
return false;
};
let old_label = self.branch_sections[idx].label.clone();
let inner_branch_boundary = self.inner_branch_boundary(idx);
if let Some(boundary) = inner_branch_boundary {
let inner_ref = self.branch_sections[idx].commits[boundary]
.update_refs
.first()
.cloned()
.unwrap();
self.branch_sections[idx].commits.truncate(boundary + 1);
self.branch_sections[idx].label = inner_ref.clone();
self.branch_sections[idx].branch_names = vec![inner_ref.clone()];
let inner = inner_ref.clone();
self.branch_sections[idx].commits[boundary]
.update_refs
.retain(|r| *r != inner);
for entry in &mut self.integration_line {
if let IntegrationEntry::Merge {
label,
original_oid,
} = entry
&& *label == old_label
{
*label = inner_ref.clone();
*original_oid = None;
}
}
} else {
self.branch_sections.remove(idx);
self.integration_line.retain(
|e| !matches!(e, IntegrationEntry::Merge { label: l, .. } if *l == old_label),
);
}
true
}
pub fn move_commit(&mut self, oid: Oid, to_branch: &str) -> anyhow::Result<Vec<String>> {
let mut section_idx = self
.branch_sections
.iter()
.position(|s| s.label == to_branch || s.branch_names.contains(&to_branch.to_string()));
if section_idx.is_none() {
let Some((s, pos)) = self.inner_ref_position(to_branch) else {
anyhow::bail!(
"Cannot move commit: target branch '{}' not found in weave graph",
to_branch
);
};
if self.branch_sections[s].commits[pos].oid == oid {
return Ok(Vec::new());
}
}
let Some((mut commit, mut parked, source_idx)) = self.remove_commit(oid, EmptiedRefs::Park)
else {
anyhow::bail!(
"Cannot move commit: source commit {} not found in weave graph",
oid
);
};
commit.command = Command::Pick;
if let Some(i) = source_idx
&& Some(i) != section_idx
&& self.branch_sections[i].commits.is_empty()
{
parked.extend(self.remove_empty_section(i, EmptiedRefs::Park));
if let Some(idx) = &mut section_idx
&& i < *idx
{
*idx -= 1;
}
}
let Some(section_idx) = section_idx else {
let (s, pos) = self
.inner_ref_position(to_branch)
.expect("the moved commit is not the target's tip, so its ref stayed put");
self.branch_sections[s].commits[pos]
.update_refs
.retain(|r| r != to_branch);
commit.update_refs.push(to_branch.to_string());
self.branch_sections[s].commits.insert(pos + 1, commit);
return Ok(parked);
};
if self.branch_sections[section_idx].branch_names.len() > 1
&& self.branch_sections[section_idx]
.branch_names
.contains(&to_branch.to_string())
{
let old_label = self.branch_sections[section_idx].label.clone();
self.branch_sections[section_idx]
.branch_names
.retain(|n| n != to_branch);
if old_label == to_branch
&& let Some(first_remaining) =
self.branch_sections[section_idx].branch_names.first()
{
self.branch_sections[section_idx].label = first_remaining.clone();
}
let base_label = self.branch_sections[section_idx].label.clone();
let new_section = BranchSection {
reset_target: base_label,
commits: vec![commit],
label: to_branch.to_string(),
branch_names: vec![to_branch.to_string()],
};
self.branch_sections.insert(section_idx + 1, new_section);
for entry in &mut self.integration_line {
if let IntegrationEntry::Merge {
label,
original_oid,
} = entry
&& *label == old_label
{
*label = to_branch.to_string();
*original_oid = None;
}
}
if self.branch_sections[section_idx].commits.is_empty() {
parked.extend(self.remove_empty_section(section_idx, EmptiedRefs::Park));
}
} else {
self.branch_sections[section_idx].commits.push(commit);
}
Ok(parked)
}
pub fn move_commits_relative(
&mut self,
oids: &[Oid],
anchor: Oid,
position: Position,
) -> anyhow::Result<Vec<String>> {
if oids.contains(&anchor) {
anyhow::bail!("Source and target are the same commit");
}
for (i, oid) in oids.iter().enumerate() {
self.require_commit(*oid)?;
if oids[..i].contains(oid) {
anyhow::bail!(
"Cannot move commit: source commit {} is listed twice",
crate::git::short_hash(&oid.to_string())
);
}
}
self.require_commit(anchor)?;
if self.block_sits_at(oids, anchor, position) {
let anchor_hex = anchor.to_string();
let anchor_short = crate::git::short_hash(&anchor_hex);
match oids {
[only] => anyhow::bail!(
"Commit `{}` is already directly {} `{}`",
crate::git::short_hash(&only.to_string()),
position.as_str(),
anchor_short
),
_ => anyhow::bail!(
"Commits are already in place {} `{}`",
position.as_str(),
anchor_short
),
}
}
let anchor_refs = self
.find_commit(anchor)
.map(|c| c.update_refs.clone())
.unwrap_or_default();
let mut parked = Vec::new();
let mut block = Vec::with_capacity(oids.len());
for oid in oids {
let Some((mut commit, names, section)) = self.remove_commit(*oid, EmptiedRefs::Park)
else {
anyhow::bail!(
"Cannot move commit: source commit {} is not in the weave graph",
crate::git::short_hash(&oid.to_string())
);
};
commit.command = Command::Pick;
parked.extend(names);
if let Some(i) = section
&& self.branch_sections[i].commits.is_empty()
{
parked.extend(self.remove_empty_section(i, EmptiedRefs::Park));
}
block.push(commit);
}
if position == Position::Above
&& let Some(top) = block.last_mut()
{
top.update_refs.extend(anchor_refs.iter().cloned());
}
let Some(slot) = self.locate(anchor) else {
anyhow::bail!(
"Cannot move commit: target commit {} left the weave graph",
crate::git::short_hash(&anchor.to_string())
);
};
match slot {
Slot::Section(s, pos) => {
let commits = &mut self.branch_sections[s].commits;
let at = match position {
Position::Above => {
commits[pos]
.update_refs
.retain(|r| !anchor_refs.contains(r));
pos + 1
}
Position::Below => pos,
};
commits.splice(at..at, block);
}
Slot::Integration(i) => {
let at = match position {
Position::Above => {
if let IntegrationEntry::Pick(c) = &mut self.integration_line[i] {
c.update_refs.retain(|r| !anchor_refs.contains(r));
}
i + 1
}
Position::Below => i,
};
self.integration_line
.splice(at..at, block.into_iter().map(IntegrationEntry::Pick));
}
}
Ok(parked)
}
fn block_sits_at(&self, oids: &[Oid], anchor: Oid, position: Position) -> bool {
let Some(slot) = self.locate(anchor) else {
return false;
};
let neighbours: Vec<Option<Oid>> = match slot {
Slot::Section(s, pos) => {
let commits = &self.branch_sections[s].commits;
let range = match position {
Position::Above => pos + 1..(pos + 1 + oids.len()).min(commits.len()),
Position::Below => pos.saturating_sub(oids.len())..pos,
};
commits[range].iter().map(|c| Some(c.oid)).collect()
}
Slot::Integration(i) => {
let entries = &self.integration_line;
let range = match position {
Position::Above => i + 1..(i + 1 + oids.len()).min(entries.len()),
Position::Below => i.saturating_sub(oids.len())..i,
};
entries[range]
.iter()
.map(|e| match e {
IntegrationEntry::Pick(c) => Some(c.oid),
IntegrationEntry::Merge { .. } => None,
})
.collect()
}
};
neighbours == oids.iter().map(|o| Some(*o)).collect::<Vec<_>>()
}
fn locate(&self, oid: Oid) -> Option<Slot> {
for (s, section) in self.branch_sections.iter().enumerate() {
if let Some(pos) = section.commits.iter().position(|c| c.oid == oid) {
return Some(Slot::Section(s, pos));
}
}
self.integration_line
.iter()
.position(|e| matches!(e, IntegrationEntry::Pick(c) if c.oid == oid))
.map(Slot::Integration)
}
fn find_commit(&self, oid: Oid) -> Option<&CommitEntry> {
match self.locate(oid)? {
Slot::Section(s, pos) => Some(&self.branch_sections[s].commits[pos]),
Slot::Integration(i) => match &self.integration_line[i] {
IntegrationEntry::Pick(c) => Some(c),
IntegrationEntry::Merge { .. } => None,
},
}
}
pub fn contains_commit(&self, oid: Oid) -> bool {
self.branch_sections
.iter()
.any(|s| s.commits.iter().any(|c| c.oid == oid))
|| self
.integration_line
.iter()
.any(|entry| matches!(entry, IntegrationEntry::Pick(c) if c.oid == oid))
}
pub fn require_commit(&self, oid: Oid) -> anyhow::Result<()> {
if self.contains_commit(oid) {
return Ok(());
}
anyhow::bail!(
"Commit {} is not one loom can rewrite — it is not in the weave \
graph, so it sits on the upstream side of the integration base",
crate::git::short_hash(&oid.to_string())
)
}
pub fn fixup_commit(&mut self, source_oid: Oid, target_oid: Oid) -> anyhow::Result<()> {
self.require_commit(target_oid)?;
let Some(mut commit) = self.take_commit(source_oid) else {
anyhow::bail!(
"Cannot fixup commit: source commit {} not found in weave graph",
source_oid
);
};
commit.command = Command::Fixup;
for section in &mut self.branch_sections {
if let Some(pos) = section.commits.iter().position(|c| c.oid == target_oid) {
section.commits.insert(pos + 1, commit);
return Ok(());
}
}
for (i, entry) in self.integration_line.iter().enumerate() {
if let IntegrationEntry::Pick(c) = entry
&& c.oid == target_oid
{
self.integration_line
.insert(i + 1, IntegrationEntry::Pick(commit));
return Ok(());
}
}
anyhow::bail!(
"Cannot fixup commit: target commit {} disappeared during operation",
target_oid
)
}
#[must_use]
pub fn edit_commit(&mut self, oid: Oid) -> bool {
self.set_command(oid, Command::Edit)
}
pub fn add_branch_section(
&mut self,
label: String,
branch_names: Vec<String>,
commits: Vec<CommitEntry>,
reset_target: String,
) {
self.branch_sections.push(BranchSection {
reset_target,
commits,
label,
branch_names,
});
}
pub fn add_merge(&mut self, label: String, original_oid: Option<Oid>, position: Option<usize>) {
let entry = IntegrationEntry::Merge {
original_oid,
label,
};
let idx = position.unwrap_or_else(|| {
self.integration_line
.iter()
.position(|e| matches!(e, IntegrationEntry::Pick(_)))
.unwrap_or(self.integration_line.len())
});
self.integration_line.insert(idx, entry);
}
pub fn weave_branch(&mut self, branch_name: &str) {
let branch_idx = self.integration_line.iter().position(|e| {
matches!(e, IntegrationEntry::Pick(c) if c.update_refs.contains(&branch_name.to_string()))
});
let Some(branch_idx) = branch_idx else {
return;
};
let mut section_commits = Vec::new();
let mut indices_to_remove = Vec::new();
for i in 0..=branch_idx {
if let IntegrationEntry::Pick(commit) = &self.integration_line[i] {
let mut commit = commit.clone();
commit.update_refs.retain(|r| r != branch_name);
section_commits.push(commit);
indices_to_remove.push(i);
}
}
for &i in indices_to_remove.iter().rev() {
self.integration_line.remove(i);
}
self.branch_sections.push(BranchSection {
reset_target: "onto".to_string(),
commits: section_commits,
label: branch_name.to_string(),
branch_names: vec![branch_name.to_string()],
});
self.add_merge(branch_name.to_string(), None, None);
}
#[must_use]
pub fn reassign_branch(&mut self, drop_branch: &str, keep_branch: &str) -> bool {
let Some(section) = self
.branch_sections
.iter_mut()
.find(|s| s.label == drop_branch || s.branch_names.contains(&drop_branch.to_string()))
else {
return false;
};
let old_label = section.label.clone();
if section.label == drop_branch {
section.label = keep_branch.to_string();
}
section.branch_names.retain(|n| n != drop_branch);
if !section.branch_names.contains(&keep_branch.to_string()) {
section.branch_names.push(keep_branch.to_string());
}
let new_label = section.label.clone();
for entry in &mut self.integration_line {
if let IntegrationEntry::Merge {
label,
original_oid,
} = entry
&& *label == old_label
{
*label = new_label.clone();
*original_oid = None;
}
}
true
}
pub fn swap_commits(&mut self, oid_a: Oid, oid_b: Oid) -> Result<()> {
if oid_a == oid_b {
bail!("Cannot swap a commit with itself");
}
let mut sec_a: Option<(usize, usize)> = None;
let mut sec_b: Option<(usize, usize)> = None;
for (si, section) in self.branch_sections.iter().enumerate() {
for (pi, commit) in section.commits.iter().enumerate() {
if commit.oid == oid_a {
sec_a = Some((si, pi));
}
if commit.oid == oid_b {
sec_b = Some((si, pi));
}
}
}
let mut int_a: Option<usize> = None;
let mut int_b: Option<usize> = None;
for (i, entry) in self.integration_line.iter().enumerate() {
if let IntegrationEntry::Pick(c) = entry {
if c.oid == oid_a {
int_a = Some(i);
}
if c.oid == oid_b {
int_b = Some(i);
}
}
}
match (sec_a, sec_b, int_a, int_b) {
(Some((si_a, pi_a)), Some((si_b, pi_b)), _, _) if si_a == si_b => {
self.branch_sections[si_a].commits.swap(pi_a, pi_b);
Ok(())
}
(Some(_), Some(_), _, _) => {
bail!("Cannot swap commits from different branch sections")
}
(None, None, Some(i), Some(j)) => {
self.integration_line.swap(i, j);
Ok(())
}
_ => {
if sec_a.is_none() && int_a.is_none() {
bail!("Commit {} not found in weave graph", oid_a)
} else if sec_b.is_none() && int_b.is_none() {
bail!("Commit {} not found in weave graph", oid_b)
} else {
bail!(
"Cannot swap commits from different locations (branch section vs integration line)"
)
}
}
}
}
fn take_commit(&mut self, oid: Oid) -> Option<CommitEntry> {
for section in &mut self.branch_sections {
if let Some(pos) = section.commits.iter().position(|c| c.oid == oid) {
return Some(section.commits.remove(pos));
}
}
let i = self
.integration_line
.iter()
.position(|e| matches!(e, IntegrationEntry::Pick(c) if c.oid == oid))?;
match self.integration_line.remove(i) {
IntegrationEntry::Pick(commit) => Some(commit),
_ => unreachable!("position matched a Pick"),
}
}
#[must_use]
pub fn track_commit(&mut self, oid: Oid, ref_name: &str) -> bool {
for section in &mut self.branch_sections {
for commit in &mut section.commits {
if commit.oid == oid {
commit.update_refs.push(ref_name.to_string());
return true;
}
}
}
for entry in &mut self.integration_line {
if let IntegrationEntry::Pick(commit) = entry
&& commit.oid == oid
{
commit.update_refs.push(ref_name.to_string());
return true;
}
}
false
}
fn set_command(&mut self, oid: Oid, command: Command) -> bool {
for section in &mut self.branch_sections {
for commit in &mut section.commits {
if commit.oid == oid {
commit.command = command;
return true;
}
}
}
for entry in &mut self.integration_line {
if let IntegrationEntry::Pick(commit) = entry
&& commit.oid == oid
{
commit.command = command;
return true;
}
}
false
}
}
fn emit_commits_with_refs(out: &mut String, commits: &[CommitEntry]) -> Vec<String> {
let mut pending_refs: Vec<String> = Vec::new();
for commit in commits {
if commit.command != Command::Fixup && !pending_refs.is_empty() {
flush_refs(out, &pending_refs);
pending_refs.clear();
}
out.push_str(&format!(
"{} {} # {}\n",
commit.command.as_str(),
commit.short_hash,
commit.message
));
pending_refs.extend(commit.update_refs.iter().cloned());
}
pending_refs
}
fn flush_refs(out: &mut String, refs: &[String]) {
for r in refs {
out.push_str(&format!("update-ref refs/heads/{}\n", r));
}
}
pub fn describe_branches(names: &[String]) -> String {
let list = names
.iter()
.map(|b| format!("`{b}`"))
.collect::<Vec<_>>()
.join(", ");
if names.len() == 1 {
format!("branch {list}")
} else {
format!("branches {list}")
}
}
#[derive(Debug)]
struct FirstParentEntry {
oid: Oid,
short_hash: String,
message: String,
is_merge: bool,
merge_parent: Option<Oid>,
}
fn integration_base(repo: &Repository, head: Oid, upstream: Oid, merge_base: Oid) -> Result<Oid> {
let mut current = head;
loop {
if current == merge_base || repo::contains(repo, upstream, current).unwrap_or(false) {
return Ok(current);
}
let commit = repo.find_commit(current)?;
match commit.parent_id(0) {
Ok(parent) => current = parent,
Err(_) => return Ok(merge_base),
}
}
}
pub fn base_oid(repo: &Repository, info: &repo::RepoInfo) -> Result<Oid> {
integration_base(
repo,
repo::head_oid(repo)?,
info.upstream.tip_oid,
info.upstream.merge_base_oid,
)
}
fn walk_first_parent_line(
repo: &Repository,
head: Oid,
stop: Oid,
) -> Result<Vec<FirstParentEntry>> {
let mut entries = Vec::new();
let mut current = head;
let mut visited: HashSet<Oid> = HashSet::new();
while current != stop {
if !visited.insert(current) {
bail!("cycle detected in commit graph at {}", current);
}
let commit = repo.find_commit(current)?;
let short_hash = commit
.as_object()
.short_id()?
.as_str()
.context("short_id is not valid UTF-8")?
.to_string();
let message = repo::commit_subject(&commit);
let is_merge = commit.parent_count() > 1;
if is_merge {
let p0 = commit.parent_id(0)?;
let p1 = commit.parent_id(1)?;
let (continue_parent, branch_parent) =
if p0 == stop || repo.graph_descendant_of(p0, stop).unwrap_or(false) {
(p0, p1)
} else if p1 == stop || repo.graph_descendant_of(p1, stop).unwrap_or(false) {
(p1, p0)
} else {
bail!(
"Neither parent of merge {} leads to merge-base {}",
current,
stop
);
};
entries.push(FirstParentEntry {
oid: current,
short_hash,
message,
is_merge,
merge_parent: Some(branch_parent),
});
current = continue_parent;
} else {
entries.push(FirstParentEntry {
oid: current,
short_hash,
message,
is_merge,
merge_parent: None,
});
current = match commit.parent_id(0) {
Ok(oid) => oid,
Err(_) => {
bail!(
"First-parent walk from {} did not reach merge-base {}",
head,
stop
);
}
};
}
}
entries.reverse();
Ok(entries)
}
fn walk_branch_commits(repo: &Repository, tip: Oid, stop: Oid) -> Result<Vec<BranchCommitEntry>> {
let actual_stop = if tip == stop {
stop
} else {
repo.merge_base(tip, stop).unwrap_or(stop)
};
let mut entries = Vec::new();
let mut current = tip;
let mut visited: HashSet<Oid> = HashSet::new();
while current != actual_stop {
if !visited.insert(current) {
bail!("cycle detected in commit graph at {}", current);
}
let commit = repo.find_commit(current)?;
if commit.parent_count() <= 1 {
let short_hash = commit
.as_object()
.short_id()?
.as_str()
.context("short_id is not valid UTF-8")?
.to_string();
let message = repo::commit_subject(&commit);
entries.push(BranchCommitEntry {
oid: current,
short_hash,
message,
});
}
current = match commit.parent_id(0) {
Ok(oid) => oid,
Err(_) => break,
};
}
Ok(entries)
}
#[derive(Debug)]
struct BranchCommitEntry {
oid: Oid,
short_hash: String,
message: String,
}
pub fn start_edit_rebase(repo: &Repository, workdir: &Path, commit_oid: Oid) -> Result<()> {
if let Ok(mut graph) = Weave::from_repo(repo) {
if !graph.edit_commit(commit_oid) {
return Err(not_in_the_weave(commit_oid));
}
let todo = graph.to_todo();
return run_rebase_expecting_edit(
workdir,
Some(&graph.base_oid.to_string()),
&todo,
commit_oid,
&[],
);
}
build_and_run_linear_edit(repo, workdir, commit_oid)
}
fn build_and_run_linear_edit(repo: &Repository, workdir: &Path, commit_oid: Oid) -> Result<()> {
let head_oid = repo::head_oid(repo)?;
let commit = repo.find_commit(commit_oid)?;
let upstream: Option<String> = if commit.parent_count() > 0 {
Some(commit.parent_id(0)?.to_string())
} else {
None
};
let stop = upstream.as_ref().and_then(|s| Oid::from_str(s).ok());
let mut entries = Vec::new();
let mut current = head_oid;
let mut visited: HashSet<Oid> = HashSet::new();
loop {
if Some(current) == stop {
break;
}
if !visited.insert(current) {
bail!("cycle detected in commit graph at {}", current);
}
let c = repo.find_commit(current)?;
let short = c
.as_object()
.short_id()?
.as_str()
.context("Short ID is not valid UTF-8")?
.to_string();
let msg = repo::commit_subject(&c);
let cmd = if current == commit_oid {
"edit"
} else {
"pick"
};
entries.push(format!("{} {} # {}", cmd, short, msg));
if c.parent_count() == 0 {
break;
}
current = c.parent_id(0)?;
}
entries.reverse();
let mut todo = String::from("label onto\n\nreset onto\n");
for line in &entries {
todo.push_str(line);
todo.push('\n');
}
run_rebase_expecting_edit(workdir, upstream.as_deref(), &todo, commit_oid, &[])
}
pub use crate::git::RebaseOutcome;
pub fn run_rebase_or_abort(
workdir: &Path,
upstream: Option<&str>,
todo_content: &str,
) -> Result<()> {
let saved_staged = git::diff_cached(workdir)?;
match run_rebase(workdir, upstream, todo_content) {
Ok(RebaseOutcome::Completed) => {
git::restore_staged_after_rebase(workdir, &saved_staged);
Ok(())
}
Ok(RebaseOutcome::Stopped | RebaseOutcome::Paused) => {
let err = git::abort_after_failure(workdir);
git::restore_or_park_after_abort(workdir, &saved_staged, &err);
Err(err)
}
Err(e) if git::rebase_never_started(&e) => Err(e),
Err(e) => {
let err = git::rebase_abort_then_cleanup(workdir, e, || {});
git::restore_or_park_after_abort(workdir, &saved_staged, &err);
Err(err)
}
}
}
pub fn run_rebase_expecting_edit(
workdir: &Path,
upstream: Option<&str>,
todo_content: &str,
expect_stop: Oid,
also_target: &[&str],
) -> Result<()> {
let edited = edited_commits(todo_content);
ensure_todo_edits(&edited, expect_stop)?;
let expected = expect_stop.to_string();
let targets: Vec<String> = also_target.iter().map(|hash| hash.to_string()).collect();
let protected = git::Protected::named(&edited).targeting(&targets);
let outcome = halt_on_empty(workdir, upstream, todo_content, protected)?;
match outcome {
RebaseOutcome::Paused => git::verify_paused_at(workdir, &expected),
RebaseOutcome::Completed => Err(git::finished_without_stopping(&expected)),
RebaseOutcome::Stopped => Err(git::abort_after_failure(workdir)),
}
}
pub fn run_rebase_protecting(
workdir: &Path,
upstream: Option<&str>,
todo_content: &str,
protected: git::Protected<'_>,
) -> Result<RebaseOutcome> {
if let Some(bad) = protected
.named
.iter()
.chain(protected.targets)
.find(|hash| !matches!(hash.len(), 40 | 64) || !hash.bytes().all(|b| b.is_ascii_hexdigit()))
{
return git::before_rebase_starts(Err(anyhow::anyhow!(
"Internal error: `{bad}` is not a full object name"
)));
}
halt_on_empty(workdir, upstream, todo_content, protected)
}
fn halt_on_empty(
workdir: &Path,
upstream: Option<&str>,
todo_content: &str,
protected: git::Protected<'_>,
) -> Result<RebaseOutcome> {
let git_dir = git::absolute_git_dir(workdir)?;
git::carry_past_known_stops(
workdir,
&git_dir,
protected,
None,
run_rebase_with_empty(workdir, upstream, todo_content, git::empty_stop_value())?,
)
}
fn edited_commits(todo_content: &str) -> Vec<String> {
todo_content
.lines()
.filter_map(|line| line.strip_prefix("edit "))
.filter_map(|rest| rest.split_whitespace().next())
.map(str::to_string)
.collect()
}
fn ensure_todo_edits(edited: &[String], expect_stop: Oid) -> Result<()> {
let full = expect_stop.to_string();
let marked = edited
.iter()
.any(|hash| !hash.is_empty() && full.starts_with(hash.as_str()));
if marked {
return Ok(());
}
Err(not_in_the_weave(expect_stop))
}
pub fn not_in_the_weave(oid: Oid) -> anyhow::Error {
anyhow::anyhow!(
"Commit `{}` is not one of the commits loom rewrites — nothing was rewritten\n\
If history moved, the SHA may be stale — run `loom` to see the current commits",
git::short_hash(&oid.to_string())
)
}
pub fn run_rebase(
workdir: &Path,
upstream: Option<&str>,
todo_content: &str,
) -> Result<RebaseOutcome> {
let git_dir = git::before_rebase_starts(git::absolute_git_dir(workdir))?;
let outcome = run_rebase_with_empty(workdir, upstream, todo_content, "drop")?;
git::continue_rerere_stops(workdir, &git_dir, None, outcome)
}
fn run_rebase_with_empty(
workdir: &Path,
upstream: Option<&str>,
todo_content: &str,
empty: &str,
) -> Result<RebaseOutcome> {
use std::io::Write;
use std::process::Command;
use std::time::Instant;
use crate::trace as loom_trace;
let prepared = git::before_rebase_starts((|| {
git::ensure_not_checked_out_elsewhere(workdir, &rewritten_branches(workdir, todo_content))?;
let git_dir = git::absolute_git_dir(workdir)?;
let self_exe = git::loom_exe_path()?;
let mut temp_file = tempfile::NamedTempFile::new()?;
temp_file.write_all(todo_content.as_bytes())?;
temp_file.flush()?;
Ok((git_dir, self_exe, temp_file.into_temp_path()))
})())?;
let (git_dir, self_exe, temp_path) = prepared;
let exe_str = self_exe.display().to_string().replace('\\', "/");
let source_path = temp_path.display().to_string().replace('\\', "/");
let sequence_editor = format!(
"{} internal-write-todo --source {} ",
shell_escape::unix::escape(exe_str.into()),
shell_escape::unix::escape(source_path.into()),
);
let upstream_arg = upstream.unwrap_or("--root");
let empty_arg = format!("--empty={empty}");
let log_args = format!(
"rebase --interactive --autostash --keep-empty {empty_arg} --no-autosquash --rebase-merges --update-refs {upstream_arg}"
);
let mut cmd = Command::new("git");
cmd.current_dir(workdir)
.args(crate::git::FORCED_CONFIG)
.args([
"rebase",
"--interactive",
"--autostash",
"--keep-empty",
&empty_arg,
"--no-autosquash",
"--rebase-merges",
"--update-refs",
])
.env("GIT_SEQUENCE_EDITOR", sequence_editor)
.env("GIT_EDITOR", "true");
match upstream {
Some(oid) => {
cmd.arg(oid);
}
None => {
cmd.arg("--root");
}
}
let start = Instant::now();
let output = cmd.output()?;
let duration_ms = start.elapsed().as_millis();
let stderr = String::from_utf8_lossy(&output.stderr);
loom_trace::log_command(
"git",
&log_args,
duration_ms,
output.status.success(),
&stderr,
);
let sidecar = temp_path.with_extension("original");
if let Ok(original_todo) = std::fs::read_to_string(&sidecar) {
let filtered: String = original_todo
.lines()
.filter(|line| !line.starts_with('#'))
.collect::<Vec<_>>()
.join("\n");
loom_trace::annotate("original git todo", &filtered);
let _ = std::fs::remove_file(&sidecar);
}
loom_trace::annotate("generated todo", todo_content);
let _ = temp_path.close();
let result = if output.status.success() {
Ok(())
} else {
Err(anyhow::anyhow!("git rebase failed"))
};
git::rebase_outcome(&git_dir, result)
}
fn rewritten_branches(workdir: &Path, todo: &str) -> Vec<String> {
let mut branches: Vec<String> = todo
.lines()
.filter_map(|line| line.trim().strip_prefix("update-ref refs/heads/"))
.map(str::to_string)
.collect();
if let Ok(current) = git::current_branch(workdir) {
branches.push(current);
}
branches
}
fn cherry_pick_equivalents(workdir: &Path, upstream: &Oid, base: &Oid) -> Option<HashSet<Oid>> {
let stdout = git::run_git_stdout(
workdir,
&["cherry", &upstream.to_string(), "HEAD", &base.to_string()],
)
.ok()?;
Some(
stdout
.lines()
.filter_map(|line| {
line.strip_prefix("- ")
.and_then(|sha| Oid::from_str(sha.trim()).ok())
})
.collect(),
)
}
#[cfg(test)]
#[path = "weave_test.rs"]
mod tests;