use anyhow::{Context, Result, bail};
use git2::{Repository, StatusOptions};
use serde::{Deserialize, Serialize};
use std::path::Path;
use crate::core::diff;
use crate::core::graph;
use crate::core::hunk_select::{self, HunkArgs, Picker};
use crate::core::msg;
use crate::core::repo::{self, Target, TargetKind};
use crate::core::staging;
use crate::core::transaction::{self, LoomState, Rollback};
use crate::core::weave::{self, EmptiedRefs, Position, RebaseOutcome, Weave};
use crate::git;
use crate::tui::hunk_selector::FileEntry;
#[derive(Serialize, Deserialize)]
#[serde(tag = "op")]
enum FoldVariant {
FilesIntoCommit {
original_commit_hash: String,
files_count: usize,
},
CommitIntoCommit {
source_hash: String,
target_hash: String,
},
CommitToBranch {
commit_hash: String,
branch_name: String,
#[serde(default)]
parked: Vec<String>,
},
CommitToUnstaged {
commit_hash: String,
diff: String,
#[serde(default)]
emptied: Vec<String>,
},
CommitRelative {
commit_hash: String,
target_hash: String,
above: bool,
#[serde(default)]
parked: Vec<String>,
},
}
const TRACK_BRANCH: &str = "_loom-track";
const COMMAND: &str = "fold";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Anchor {
Above(String),
Below(String),
}
pub fn run(
create: bool,
patch: bool,
anchor: Option<Anchor>,
hunks: HunkArgs,
args: Vec<String>,
git_args: Vec<String>,
theme: &graph::Theme,
) -> Result<()> {
if args.is_empty() {
bail!(
"At least one argument required\n\
Usage: git-loom fold [<source>...] <target>"
);
}
let git_opts: Vec<&str> = git_args.iter().map(String::as_str).collect();
let repo = repo::open_repo()?;
if let Some(anchor) = anchor {
no_git_args(&git_opts, "moving commits next to another")?;
return run_relative(&repo, &args, anchor);
}
if create {
no_git_args(&git_opts, "moving commits to a new branch")?;
return run_create(&repo, &args);
}
if patch {
return run_patch_fold(&repo, &args, &hunks, &git_opts, theme);
}
if args.len() == 1 {
return run_staged(&repo, &args[0], &git_opts);
}
let (source_args, target_arg) = args.split_at(args.len() - 1);
let target_arg = &target_arg[0];
let source_args = if source_args.iter().any(|s| s == "zz") {
let files = collect_changed_files(&repo)?;
if files.is_empty() {
bail!("No changes to fold — working tree is clean");
}
files
} else {
source_args.to_vec()
};
let resolved_sources: Vec<Target> = source_args
.iter()
.map(|s| {
repo::resolve_arg(
&repo,
s,
&[
TargetKind::Commit,
TargetKind::CommitFile,
TargetKind::File,
TargetKind::Unstaged,
],
)
})
.collect::<Result<Vec<_>, _>>()?;
let resolved_target = repo::resolve_arg(
&repo,
target_arg,
&[
TargetKind::Branch,
TargetKind::Commit,
TargetKind::CommitFile,
TargetKind::File,
TargetKind::Unstaged,
],
)?;
match classify(&resolved_sources, &resolved_target)? {
FoldOp::FilesIntoCommit { files, commit } => {
fold_files_into_commit(&repo, &files, &commit, false, &git_opts, None)
}
FoldOp::CommitIntoCommit { source, target } => {
no_git_args(&git_opts, "folding a commit into another")?;
fold_commit_into_commit(&repo, &source, &target)
}
FoldOp::CommitsToBranch { commits, branch } => {
no_git_args(&git_opts, "moving commits to a branch")?;
let info = repo::gather_commit_graph(&repo)?;
let commits = commits_to_move(&repo, commits, weave::base_oid(&repo, &info)?)?;
if commits.len() == 1 {
fold_commit_to_branch(&repo, &commits[0], &branch)
} else {
let workdir = repo::require_workdir(&repo, COMMAND)?;
move_commits_and_report(workdir, &repo, &commits, &branch, None)
}
}
FoldOp::CommitToUnstaged { commit } => {
no_git_args(&git_opts, "uncommitting a commit")?;
fold_commit_to_unstaged(&repo, &commit)
}
FoldOp::CommitFileToUnstaged { commit, path } => {
fold_commit_file_to_unstaged(&repo, &commit, &path, &git_opts)
}
FoldOp::CommitFileToCommit {
source_commit,
path,
target_commit,
} => fold_commit_file_to_commit(&repo, &source_commit, &path, &target_commit, &git_opts),
}
}
fn no_git_args(git_opts: &[&str], what: &str) -> Result<()> {
if git_opts.is_empty() {
return Ok(());
}
bail!("{what} runs no `git commit`, so it takes no arguments after `--`");
}
fn run_create(repo: &Repository, args: &[String]) -> Result<()> {
if args.len() < 2 {
bail!(
"fold --create requires at least one commit and one new branch name\n\
Usage: loom fold -c <commit>... <new-branch>"
);
}
let (source_args, branch_name) = args.split_at(args.len() - 1);
let branch_name = &branch_name[0];
let workdir = repo::require_workdir(repo, COMMAND)?;
let mut commit_hashes = Vec::new();
for source_arg in source_args {
let source = repo::resolve_arg(repo, source_arg, &[TargetKind::Commit])?;
match source {
Target::Commit(hash) => commit_hashes.push(hash),
_ => unreachable!(),
}
}
git::branch_validate_name(workdir, branch_name)?;
if repo
.find_branch(branch_name, git2::BranchType::Local)
.is_ok()
{
bail!(
"Branch `{}` already exists\n\
Use `loom fold <commit>... {}` to move commits onto it",
branch_name,
branch_name
);
}
let info = repo::gather_commit_graph(repo)?;
let base_oid = weave::base_oid(repo, &info)?;
let commit_hashes = commits_to_move(repo, commit_hashes, base_oid)?;
move_commits_and_report(
workdir,
repo,
&commit_hashes,
branch_name,
Some(&base_oid.to_string()),
)
}
fn commits_to_move(repo: &Repository, hashes: Vec<String>, base: git2::Oid) -> Result<Vec<String>> {
let mut oids: Vec<git2::Oid> = Vec::new();
for h in &hashes {
let oid = git2::Oid::from_str(h)?;
if !oids.contains(&oid) {
oids.push(oid);
}
}
let ordered = ordered_topologically(repo, &oids, base)?;
if let Some(missing) = oids.iter().find(|o| !ordered.contains(o)) {
bail!(
"Commit `{}` is not in the integration scope\n\
Only commits above the integration base can be moved",
git::short_hash(&missing.to_string())
);
}
Ok(ordered.iter().map(|o| o.to_string()).collect())
}
fn ordered_topologically(
repo: &Repository,
oids: &[git2::Oid],
boundary: git2::Oid,
) -> Result<Vec<git2::Oid>> {
let mut by_time: Vec<(i64, git2::Oid)> = oids
.iter()
.map(|oid| Ok((repo.find_commit(*oid)?.time().seconds(), *oid)))
.collect::<Result<Vec<_>>>()?;
by_time.sort_unstable();
let mut walk = repo.revwalk()?;
walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
for (_, oid) in &by_time {
walk.push(*oid)?;
}
walk.hide(boundary)?;
let mut wanted: Vec<git2::Oid> = oids.to_vec();
let mut newest_first = Vec::with_capacity(oids.len());
for step in walk {
let oid = step?;
if let Some(i) = wanted.iter().position(|w| *w == oid) {
wanted.swap_remove(i);
newest_first.push(oid);
if wanted.is_empty() {
break;
}
}
}
newest_first.reverse();
Ok(newest_first)
}
fn move_commits_and_report(
workdir: &Path,
repo: &Repository,
commit_hashes: &[String],
branch_name: &str,
base_hash: Option<&str>,
) -> Result<()> {
let created = base_hash.is_some();
if let Some(base) = base_hash {
git::branch_create(workdir, branch_name, base)?;
}
let saved_staged = git::diff_cached(workdir)?;
let parked = match move_commits_to_branch(repo, commit_hashes, branch_name) {
Ok((RebaseOutcome::Completed, parked)) => {
git::restore_staged_after_rebase(workdir, &saved_staged);
parked
}
Ok((RebaseOutcome::Stopped | RebaseOutcome::Paused, _)) => {
let err = abort_and_restage(workdir, &saved_staged);
if created && !git::rebase_is_in_progress(repo.path()) {
let _ = git::branch_delete(workdir, branch_name);
}
return Err(err);
}
Err(e) => {
git::restore_or_park_after_abort(workdir, &saved_staged, &e);
if created && !git::rebase_is_in_progress(repo.path()) {
let _ = git::branch_delete(workdir, branch_name);
}
return Err(e);
}
};
let new_hash = git::rev_parse(workdir, branch_name)?;
let mut message = if created {
format!(
"Created branch `{}` and moved {} commit(s) to it (now {})",
branch_name,
commit_hashes.len(),
repo::describe_commit(workdir, &new_hash)
)
} else {
format!(
"Moved {} commit(s) to branch `{}` (now {})",
commit_hashes.len(),
branch_name,
repo::describe_commit(workdir, &new_hash)
)
};
if !parked.is_empty() {
message.push_str(&format!(
"\n{} now empty, at the base",
weave::describe_branches(&parked)
));
}
msg::success(&message);
Ok(())
}
fn abort_and_restage(workdir: &Path, saved_staged: &str) -> anyhow::Error {
let err = git::abort_after_failure(workdir);
git::restore_or_park_after_abort(workdir, saved_staged, &err);
err
}
fn run_relative(repo: &Repository, args: &[String], anchor: Anchor) -> Result<()> {
let (position, target_arg) = match &anchor {
Anchor::Above(target) => (Position::Above, target),
Anchor::Below(target) => (Position::Below, target),
};
let mut commit_hashes = Vec::new();
for arg in args {
match repo::resolve_arg(repo, arg, &[TargetKind::Commit])? {
Target::Commit(hash) => commit_hashes.push(hash),
_ => unreachable!(),
}
}
let target_hash = match repo::resolve_arg(repo, target_arg, &[TargetKind::Commit])? {
Target::Commit(hash) => hash,
_ => unreachable!(),
};
let info = repo::gather_commit_graph(repo)?;
let commit_hashes = commits_to_move(repo, commit_hashes, weave::base_oid(repo, &info)?)?;
if commit_hashes.len() == 1 {
fold_commit_relative(repo, &commit_hashes[0], &target_hash, position)
} else {
move_commits_relative_and_report(repo, &commit_hashes, &target_hash, position)
}
}
fn plan_relative(
repo: &Repository,
commit_hashes: &[String],
target_hash: &str,
position: Position,
) -> Result<(Weave, Vec<String>)> {
let mut graph = Weave::from_repo(repo)?;
let oids = commit_hashes
.iter()
.map(|h| git2::Oid::from_str(h))
.collect::<Result<Vec<_>, _>>()?;
let parked = graph.move_commits_relative(&oids, git2::Oid::from_str(target_hash)?, position)?;
Ok((graph, parked))
}
fn track_through_rebase(graph: &mut Weave, workdir: &Path, oid: git2::Oid) -> Result<()> {
git::branch_force_create(workdir, TRACK_BRANCH, &oid.to_string())?;
if !graph.track_commit(oid, TRACK_BRANCH) {
let _ = git::branch_delete(workdir, TRACK_BRANCH);
return Err(weave::not_in_the_weave(oid));
}
Ok(())
}
fn fold_commit_relative(
repo: &Repository,
commit_hash: &str,
target_hash: &str,
position: Position,
) -> Result<()> {
let workdir = repo::require_workdir(repo, COMMAND)?;
let git_dir = repo.path().to_path_buf();
let (mut graph, parked) = plan_relative(
repo,
std::slice::from_ref(&commit_hash.to_string()),
target_hash,
position,
)?;
track_through_rebase(&mut graph, workdir, git2::Oid::from_str(commit_hash)?)?;
let ctx = serde_json::to_value(FoldVariant::CommitRelative {
commit_hash: commit_hash.to_string(),
target_hash: target_hash.to_string(),
above: position == Position::Above,
parked: parked.clone(),
})?;
let state = LoomState {
command: COMMAND.to_string(),
rollback: Rollback {
delete_branches: vec![TRACK_BRANCH.to_string()],
saved_staged_patch: git::diff_cached(workdir)?,
..Default::default()
},
context: ctx,
protect: vec![commit_hash.to_string()],
targets: vec![target_hash.to_string()],
};
transaction::save(&git_dir, &state)?;
let todo = graph.to_todo();
let base = graph.base_oid.to_string();
let outcome = weave::run_rebase_protecting(workdir, Some(&base), &todo, state.protected())
.map_err(|e| transaction::roll_back_failed_rebase(workdir, &git_dir, &state, e))?;
match outcome {
RebaseOutcome::Completed => {
git::restore_staged_after_rebase(workdir, &state.rollback.saved_staged_patch);
transaction::delete(&git_dir)?;
let new_hash = git::rev_parse(workdir, TRACK_BRANCH)?;
let _ = git::branch_delete(workdir, TRACK_BRANCH);
report_moved_relative(
workdir,
commit_hash,
position,
target_hash,
&new_hash,
&parked,
);
}
RebaseOutcome::Paused => {
transaction::warn_paused_at_edit(Some(COMMAND));
}
RebaseOutcome::Stopped => {
transaction::warn_paused(workdir, COMMAND);
}
}
Ok(())
}
fn move_commits_relative_and_report(
repo: &Repository,
commit_hashes: &[String],
target_hash: &str,
position: Position,
) -> Result<()> {
let workdir = repo::require_workdir(repo, COMMAND)?;
let (graph, parked) = plan_relative(repo, commit_hashes, target_hash, position)?;
let saved_staged = git::diff_cached(workdir)?;
let todo = graph.to_todo();
let targets = [target_hash.to_string()];
let protected = git::Protected::named(commit_hashes).targeting(&targets);
let base = graph.base_oid.to_string();
let outcome = weave::run_rebase_protecting(workdir, Some(&base), &todo, protected)
.inspect_err(|e| git::restore_or_park_after_abort(workdir, &saved_staged, e))?;
match outcome {
RebaseOutcome::Completed => git::restore_staged_after_rebase(workdir, &saved_staged),
RebaseOutcome::Stopped | RebaseOutcome::Paused => {
return Err(abort_and_restage(workdir, &saved_staged));
}
}
let mut message = format!(
"Moved {} commit(s) {} `{}`",
commit_hashes.len(),
position.as_str(),
git::short_hash(target_hash)
);
if !parked.is_empty() {
message.push_str(&format!(
"\n{} now empty, at the base",
weave::describe_branches(&parked)
));
}
msg::success(&message);
Ok(())
}
fn report_moved_relative(
workdir: &Path,
commit_hash: &str,
position: Position,
target_hash: &str,
new_hash: &str,
parked: &[String],
) {
let mut message = format!(
"Moved `{}` {} `{}` (now {})",
git::short_hash(commit_hash),
position.as_str(),
git::short_hash(target_hash),
repo::describe_commit(workdir, new_hash)
);
if !parked.is_empty() {
message.push_str(&format!(
"\n{} now empty, at the base",
weave::describe_branches(parked)
));
}
msg::success(&message);
}
fn fold_picker(
hunks: &HunkArgs,
command: &str,
target_hash: Option<&str>,
git_opts: &[&str],
) -> Picker {
Picker {
hunks: hunks.clone(),
command: command.to_string(),
target_hash: target_hash.map(str::to_string),
git_args: hunk_select::git_args_suffix(git_opts),
}
}
fn run_patch_fold(
repo: &Repository,
args: &[String],
hunks: &HunkArgs,
git_opts: &[&str],
theme: &graph::Theme,
) -> Result<()> {
let workdir = repo::require_workdir(repo, COMMAND)?;
let (target_arg, source_args) = args.split_last().expect("args is non-empty");
if source_args.len() == 1 {
let source_arg = &source_args[0];
if let Ok(Target::Commit(source_hash)) =
repo::resolve_arg(repo, source_arg, &[TargetKind::Commit])
{
if target_arg == "zz" {
let picker = fold_picker(
hunks,
&format!("loom fold -p {} zz", hunk_select::quoted(source_arg)),
None,
git_opts,
);
return run_patch_fold_commit_to_unstaged(
repo,
workdir,
&source_hash,
&picker,
git_opts,
theme,
);
}
if let Ok(Target::Commit(target_hash)) =
repo::resolve_arg(repo, target_arg, &[TargetKind::Commit])
{
let picker = fold_picker(
hunks,
&format!(
"loom fold -p {} {}",
hunk_select::quoted(source_arg),
hunk_select::quoted(target_arg)
),
Some(&target_hash),
git_opts,
);
return run_patch_fold_commit_to_commit(
repo,
workdir,
&source_hash,
&target_hash,
&picker,
git_opts,
theme,
);
}
}
}
for arg in source_args {
if arg == "zz" {
continue;
}
match repo::resolve_arg(
repo,
arg,
&[TargetKind::File, TargetKind::Commit, TargetKind::Branch],
) {
Ok(Target::Commit(_)) | Ok(Target::Branch(_)) => bail!(
"fold -p does not support commit or branch sources\n\
Use file paths, short IDs, or 'zz' to filter the hunk picker"
),
Ok(_) => {}
Err(e) => return Err(e),
}
}
let resolved = repo::resolve_arg(repo, target_arg, &[TargetKind::Commit])?;
let commit_hash = match resolved {
Target::Commit(hash) => hash,
_ => unreachable!(),
};
let target_oid = git2::Oid::from_str(&commit_hash)?;
if repo::head_oid(repo)? != target_oid {
Weave::from_repo(repo)?.require_commit(target_oid)?;
}
let filter = staging::filter_paths(repo, source_args)?;
let picker = hunk_select::worktree_picker(
hunks.clone(),
format!(
"{} {}",
hunk_select::patch_command("loom fold", source_args),
hunk_select::quoted(target_arg)
),
Some(&commit_hash),
git_opts,
);
let picked = match staging::run_hunk_picker(
repo,
workdir,
filter.as_deref(),
&picker,
staging::LeftOut::KeptStaged,
theme,
)? {
Some(picked) => picked,
None => return Err(msg::cancelled()),
};
if picked.paths.is_empty() {
bail!("No hunks selected");
}
fold_files_into_commit(
repo,
&picked.paths,
&commit_hash,
true,
git_opts,
Some(picked.left_out),
)
}
pub fn run_picked(picked: &[FileEntry], stamp: &[Option<git2::Oid>], target: &str) -> Result<()> {
let repo = repo::open_repo()?;
let workdir = repo::require_workdir(&repo, COMMAND)?;
let Target::Commit(commit_hash) = repo::resolve_arg(&repo, target, &[TargetKind::Commit])?
else {
bail!("'{}' did not resolve to a commit", target);
};
if !staging::still_listed(&repo, workdir, picked, stamp)? {
bail!(
"The local changes moved since the hunks were picked\n\
Pick them again"
);
}
let picked = staging::stage_selection(workdir, picked, staging::LeftOut::KeptStaged)?;
if picked.paths.is_empty() {
bail!("No hunks selected");
}
fold_files_into_commit(
&repo,
&picked.paths,
&commit_hash,
true,
&[],
Some(picked.left_out),
)
}
fn selected_hunks(file: &FileEntry) -> impl Iterator<Item = &diff::DiffHunk> {
file.hunks
.iter()
.filter(|h| h.selected && h.hunk.is_text())
.map(|h| &h.hunk)
}
fn build_selected_patch(selections: &[FileEntry]) -> String {
let mut patch = String::new();
for file in selections {
let selected: Vec<_> = selected_hunks(file).collect();
patch.push_str(&diff::build_hunk_patch(&file.path, &selected));
}
patch
}
fn apply_and_amend_path(
workdir: &Path,
patch: &str,
path: &str,
gitlink: bool,
reverse: bool,
git_opts: &[&str],
) -> Result<()> {
match (gitlink, reverse) {
(true, true) => git::apply_cached_patch_reverse(workdir, patch)?,
(true, false) => git::apply_cached_patch(workdir, patch)?,
(false, true) => {
git::apply_patch_reverse(workdir, patch)?;
git::stage_path(workdir, path)?;
}
(false, false) => {
git::apply_patch(workdir, patch)?;
git::stage_path(workdir, path)?;
}
}
git::commit_amend_no_edit(workdir, git_opts)
}
enum WholeFileKind {
Gitlink { removed: bool },
File,
}
struct PickedWholeFile {
path: String,
diff: String,
kind: WholeFileKind,
}
fn picked_whole_files(
workdir: &Path,
commit: &str,
selections: &[FileEntry],
) -> Result<Vec<PickedWholeFile>> {
let gitlinks = git::commit_gitlinks(workdir, commit)?;
let mut picked = Vec::new();
for file in selections {
if !file.hunks.iter().any(|h| h.selected) {
continue;
}
let kind = if let Some(&removed) = gitlinks.get(&file.path) {
WholeFileKind::Gitlink { removed }
} else if file.index_status == 'D' || file.binary {
WholeFileKind::File
} else {
continue;
};
picked.push(PickedWholeFile {
path: file.path.clone(),
diff: git::diff_commit_file(workdir, commit, &file.path)?,
kind,
});
}
Ok(picked)
}
fn apply_and_amend(
workdir: &Path,
selections: &[FileEntry],
patch: &str,
whole_files: &[PickedWholeFile],
reverse: bool,
git_opts: &[&str],
) -> Result<()> {
if !patch.is_empty() {
if reverse {
git::apply_patch_reverse(workdir, patch)?;
} else {
git::apply_patch(workdir, patch)?;
}
}
for whole in whole_files {
match whole.kind {
WholeFileKind::Gitlink { .. } => {
if reverse {
git::apply_cached_patch_reverse(workdir, &whole.diff)?;
} else {
git::apply_cached_patch(workdir, &whole.diff)?;
}
}
WholeFileKind::File => {
if reverse {
git::apply_patch_with_index_reverse(workdir, &whole.diff)?;
} else {
git::apply_patch_with_index(workdir, &whole.diff)?;
}
}
}
}
for file in selections {
if selected_hunks(file).next().is_some() && !is_whole_file(whole_files, &file.path) {
git::stage_path(workdir, &file.path)?;
}
}
git::commit_amend_no_edit(workdir, git_opts)
}
fn is_whole_file(whole_files: &[PickedWholeFile], path: &str) -> bool {
whole_files.iter().any(|w| w.path == path)
}
fn restore_to_worktree(workdir: &Path, patch: &str, whole_files: &[PickedWholeFile]) -> Result<()> {
if !patch.is_empty() {
git::apply_patch_to_worktree(workdir, patch)?;
}
for whole in whole_files {
if matches!(whole.kind, WholeFileKind::File) {
git::apply_patch_to_worktree(workdir, &whole.diff)?;
}
}
Ok(())
}
fn run_patch_fold_commit_to_commit(
repo: &Repository,
workdir: &Path,
source_hash: &str,
target_hash: &str,
picker: &Picker,
git_opts: &[&str],
theme: &graph::Theme,
) -> Result<()> {
let source_oid = git2::Oid::from_str(source_hash)?;
let target_oid = git2::Oid::from_str(target_hash)?;
if source_oid == target_oid {
bail!("Source and target are the same commit");
}
if !repo.graph_descendant_of(source_oid, target_oid)? {
bail!("Source commit must be newer than target commit");
}
let selections = staging::run_commit_hunk_picker(workdir, source_hash, &[], picker, theme)?
.ok_or_else(msg::cancelled)?;
let (new_source_hash, new_target_hash) = fold_selected_hunks_to_commit(
repo,
workdir,
source_hash,
target_hash,
&selections,
git_opts,
)?;
msg::success(&format!(
"Moved hunk(s) from `{}` (now {}) into `{}` (now {})",
git::short_hash(source_hash),
repo::describe_commit(workdir, &new_source_hash),
git::short_hash(target_hash),
repo::describe_commit(workdir, &new_target_hash)
));
Ok(())
}
fn fold_selected_hunks_to_commit(
repo: &Repository,
workdir: &Path,
source_hash: &str,
target_hash: &str,
selections: &[FileEntry],
git_opts: &[&str],
) -> Result<(String, String)> {
let source_oid = git2::Oid::from_str(source_hash)?;
if !selections
.iter()
.any(|f| f.hunks.iter().any(|h| h.selected))
{
bail!("No hunks selected");
}
let whole_files = picked_whole_files(workdir, source_hash, selections)?;
refuse_whole_files_the_target_lacks(repo, source_oid, target_hash, &whole_files)?;
let selected_patch = build_selected_patch(selections);
let saved_head = repo::head_oid(repo)?.to_string();
let saved_refs = repo::snapshot_branch_refs(repo)?;
let saved_worktree = staging::WorktreeSnapshot::take(workdir)?;
let staged = staging::save_and_unstage_staged(repo, workdir)?;
let mut graph = Weave::from_repo(repo)?;
let _ = graph.edit_commit(source_oid);
let todo = graph.to_todo();
git::branch_force_create(workdir, TRACK_BRANCH, target_hash)?;
if let Err(e) = weave::run_rebase_expecting_edit(
workdir,
Some(&graph.base_oid.to_string()),
&todo,
source_oid,
&[target_hash],
) {
let _ = git::branch_delete(workdir, TRACK_BRANCH);
return Err(e);
}
if let Err(e) = apply_and_amend(
workdir,
selections,
&selected_patch,
&whole_files,
true,
git_opts,
) {
return Err(git::rebase_abort_then_cleanup(workdir, e, || {
let _ = git::branch_delete(workdir, TRACK_BRANCH);
}));
}
let phase1_source_hash = git::rev_parse(workdir, "HEAD").map_err(|e| {
git::rebase_abort_then_cleanup(workdir, e, || {
let _ = git::branch_delete(workdir, TRACK_BRANCH);
})
})?;
let protect = [phase1_source_hash.clone()];
if let Err(e) =
git::continue_rebase_expecting_edit(workdir, git::AfterStop::nothing().protecting(&protect))
{
let _ = git::branch_delete(workdir, TRACK_BRANCH);
return Err(e);
}
let rollback = || {
staged.handed_over();
let _ = git::branch_delete(workdir, TRACK_BRANCH);
staging::rollback_to_snapshot(workdir, &saved_head, Some(&saved_refs), &saved_worktree);
};
let plan_phase2 = || -> Result<(git2::Oid, Weave)> {
let phase2_target_hash = git::rev_parse(workdir, TRACK_BRANCH)?;
let _ = git::branch_delete(workdir, TRACK_BRANCH);
let phase2_target_oid = git2::Oid::from_str(&phase2_target_hash)?;
let repo2 = Repository::open(workdir)?;
let mut graph2 = Weave::from_repo(&repo2)?;
let _ = graph2.edit_commit(phase2_target_oid);
let phase1_source_oid = git2::Oid::from_str(&phase1_source_hash)?;
track_through_rebase(&mut graph2, workdir, phase1_source_oid)?;
Ok((phase2_target_oid, graph2))
};
let (phase2_target_oid, graph2) = plan_phase2().inspect_err(|_| rollback())?;
let todo2 = graph2.to_todo();
if let Err(e) = weave::run_rebase_expecting_edit(
workdir,
Some(&graph2.base_oid.to_string()),
&todo2,
phase2_target_oid,
&[&phase2_target_oid.to_string()],
) {
rollback();
return Err(e);
}
if let Err(e) = apply_and_amend(
workdir,
selections,
&selected_patch,
&whole_files,
false,
git_opts,
) {
return Err(git::rebase_abort_then_cleanup(workdir, e, rollback));
}
let new_target_hash = match git::rev_parse(workdir, "HEAD") {
Ok(hash) => hash,
Err(e) => return Err(git::rebase_abort_then_cleanup(workdir, e, rollback)),
};
if let Err(e) =
git::continue_rebase_expecting_edit(workdir, git::AfterStop::nothing().protecting(&protect))
{
rollback();
return Err(e);
}
let tracked = git::rev_parse(workdir, TRACK_BRANCH);
let _ = git::branch_delete(workdir, TRACK_BRANCH);
staged.restore();
let new_source_hash = tracked?;
Ok((new_source_hash, new_target_hash))
}
fn refuse_whole_files_the_target_lacks(
repo: &Repository,
source_oid: git2::Oid,
target_hash: &str,
whole_files: &[PickedWholeFile],
) -> Result<()> {
let target_oid = git2::Oid::from_str(target_hash)?;
let source_parent = repo.find_commit(source_oid)?.parent_id(0)?;
for whole in whole_files {
if matches!(whole.kind, WholeFileKind::File)
&& blob_at(repo, target_oid, &whole.path)? != blob_at(repo, source_parent, &whole.path)?
{
bail!(
"`{}` changed between `{}` and `{}`\n\
A binary or deleted file only moves onto the content it was changed from",
whole.path,
git::short_hash(target_hash),
git::short_hash(&source_oid.to_string())
);
}
}
Ok(())
}
fn blob_at(repo: &Repository, commit: git2::Oid, path: &str) -> Result<Option<git2::Oid>> {
let tree = repo.find_commit(commit)?.tree()?;
match tree.get_path(Path::new(path)) {
Ok(entry) => Ok(Some(entry.id())),
Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
fn run_patch_fold_commit_to_unstaged(
repo: &Repository,
workdir: &Path,
commit_hash: &str,
picker: &Picker,
git_opts: &[&str],
theme: &graph::Theme,
) -> Result<()> {
let selections = staging::run_commit_hunk_picker(workdir, commit_hash, &[], picker, theme)?
.ok_or_else(msg::cancelled)?;
if !selections
.iter()
.any(|f| f.hunks.iter().any(|h| h.selected))
{
bail!("No hunks selected");
}
let whole_files = picked_whole_files(workdir, commit_hash, &selections)?;
let selected_patch = build_selected_patch(&selections);
let head_oid = repo::head_oid(repo)?;
let target_oid = git2::Oid::from_str(commit_hash)?;
let is_head = head_oid == target_oid;
let saved_worktree = staging::WorktreeSnapshot::take(workdir)?;
let staged_aside = staging::save_and_unstage_staged(repo, workdir)?;
let new_hash;
if is_head {
let pre_amend_hash = head_oid.to_string();
if let Err(e) = apply_and_amend(
workdir,
&selections,
&selected_patch,
&whole_files,
true,
git_opts,
) {
staged_aside.handed_over();
staging::rollback_to_snapshot(workdir, &pre_amend_hash, None, &saved_worktree);
return Err(e).context("Failed to remove hunks from the commit, operation rolled back");
}
new_hash = git::rev_parse(workdir, "HEAD")?;
if let Err(e) = restore_to_worktree(workdir, &selected_patch, &whole_files) {
staged_aside.handed_over();
staging::rollback_to_snapshot(workdir, &pre_amend_hash, None, &saved_worktree);
return Err(e)
.context("Failed to restore hunks to working directory, operation rolled back");
}
} else {
let saved_head = head_oid.to_string();
let saved_refs = repo::snapshot_branch_refs(repo)?;
let mut graph = Weave::from_repo(repo)?;
let _ = graph.edit_commit(target_oid);
let todo = graph.to_todo();
weave::run_rebase_expecting_edit(
workdir,
Some(&graph.base_oid.to_string()),
&todo,
target_oid,
&[],
)?;
if let Err(e) = apply_and_amend(
workdir,
&selections,
&selected_patch,
&whole_files,
true,
git_opts,
) {
return Err(git::rebase_abort_then_cleanup(workdir, e, || {}));
}
new_hash = git::rev_parse(workdir, "HEAD").map_err(|e| {
git::rebase_abort_then_cleanup(workdir, e, || {})
})?;
if let Err(e) = git::continue_rebase_expecting_edit(workdir, git::AfterStop::nothing()) {
return Err(git::rebase_abort_then_cleanup(workdir, e, || {
staged_aside.handed_over();
staging::rollback_to_snapshot(
workdir,
&saved_head,
Some(&saved_refs),
&saved_worktree,
);
}));
}
if let Err(e) = restore_to_worktree(workdir, &selected_patch, &whole_files) {
staged_aside.handed_over();
staging::rollback_to_snapshot(workdir, &saved_head, Some(&saved_refs), &saved_worktree);
return Err(e)
.context("Failed to apply changes to working directory, operation rolled back");
}
}
staged_aside.restore();
let mut staged: Vec<String> = whole_files
.iter()
.filter(|w| matches!(w.kind, WholeFileKind::Gitlink { removed: true }))
.filter(|w| keep_submodule_removal(workdir, &w.path))
.map(|w| w.path.clone())
.collect();
staged.sort();
let mut message = format!(
"Uncommitted hunk(s) from `{}` (now {}) to working directory",
git::short_hash(commit_hash),
repo::describe_commit(workdir, &new_hash)
);
if !staged.is_empty() {
let names: Vec<String> = staged.iter().map(|p| format!("`{p}`")).collect();
message.push_str(&format!(
"\nThe removal of {} is staged — the checkout is still on disk",
names.join(", ")
));
}
msg::success(&message);
Ok(())
}
fn run_staged(repo: &Repository, target_arg: &str, git_opts: &[&str]) -> Result<()> {
let resolved = repo::resolve_arg(repo, target_arg, &[TargetKind::Commit])?;
let commit_hash = match resolved {
Target::Commit(hash) => hash,
_ => unreachable!(),
};
let staged = repo::get_staged_files(repo)?;
if staged.is_empty() {
bail!("Nothing to commit");
}
fold_files_into_commit(repo, &staged, &commit_hash, true, git_opts, None)
}
#[derive(Debug)]
enum FoldOp {
FilesIntoCommit {
files: Vec<String>,
commit: String,
},
CommitIntoCommit {
source: String,
target: String,
},
CommitsToBranch {
commits: Vec<String>,
branch: String,
},
CommitToUnstaged {
commit: String,
},
CommitFileToUnstaged {
commit: String,
path: String,
},
CommitFileToCommit {
source_commit: String,
path: String,
target_commit: String,
},
}
fn classify(sources: &[Target], target: &Target) -> Result<FoldOp> {
for source in sources {
if matches!(source, Target::Branch(_)) {
bail!("Cannot fold a branch\nUse `git loom branch` for branch operations");
}
}
if matches!(target, Target::CommitFile { .. }) {
bail!("Target must be a commit, branch, or unstaged (zz), not a commit file");
}
let has_files = sources.iter().any(|s| matches!(s, Target::File(_)));
let has_commits = sources.iter().any(|s| matches!(s, Target::Commit(_)));
let has_commit_files = sources
.iter()
.any(|s| matches!(s, Target::CommitFile { .. }));
if [has_files, has_commits, has_commit_files]
.iter()
.filter(|&&x| x)
.count()
> 1
{
bail!("Cannot mix different source types (files, commits, commit files)");
}
if has_commit_files {
if sources.len() > 1 {
bail!("Only one commit file source is allowed");
}
let (commit, path) = match &sources[0] {
Target::CommitFile { commit, path } => (commit.clone(), path.clone()),
_ => unreachable!(),
};
return match target {
Target::Unstaged => Ok(FoldOp::CommitFileToUnstaged { commit, path }),
Target::Commit(hash) => Ok(FoldOp::CommitFileToCommit {
source_commit: commit,
path,
target_commit: hash.clone(),
}),
Target::Branch(_) => {
bail!(
"Cannot fold a commit file into a branch\n\
Target a specific commit or use `zz` to uncommit"
)
}
Target::File(_) => bail!("Target must be a commit or unstaged (zz), not a file"),
Target::CommitFile { .. } => unreachable!(),
};
}
if matches!(target, Target::Unstaged) {
if has_files {
bail!("Cannot fold files into unstaged — files are already in the working directory");
}
if sources.len() > 1 {
bail!("Only one commit source is allowed");
}
let source_hash = match &sources[0] {
Target::Commit(hash) => hash.clone(),
_ => unreachable!(),
};
return Ok(FoldOp::CommitToUnstaged {
commit: source_hash,
});
}
if has_files {
let files: Vec<String> = sources
.iter()
.map(|s| match s {
Target::File(path) => path.clone(),
_ => unreachable!(),
})
.collect();
match target {
Target::Commit(hash) => Ok(FoldOp::FilesIntoCommit {
files,
commit: hash.clone(),
}),
Target::Branch(_) => {
bail!("Cannot fold files into a branch\nTarget a specific commit")
}
Target::File(_) => bail!("Target must be a commit or branch, not a file"),
_ => unreachable!(),
}
} else {
let hashes: Vec<String> = sources
.iter()
.map(|s| match s {
Target::Commit(hash) => hash.clone(),
_ => unreachable!(),
})
.collect();
match target {
Target::Commit(hash) => {
if hashes.len() > 1 {
bail!("Only one commit source is allowed");
}
Ok(FoldOp::CommitIntoCommit {
source: hashes[0].clone(),
target: hash.clone(),
})
}
Target::Branch(name) => Ok(FoldOp::CommitsToBranch {
commits: hashes,
branch: name.clone(),
}),
Target::File(_) => bail!("Target must be a commit or branch, not a file"),
_ => unreachable!(),
}
}
}
fn collect_changed_files(repo: &Repository) -> Result<Vec<String>> {
let mut opts = StatusOptions::new();
opts.include_untracked(true).recurse_untracked_dirs(true);
let statuses = repo.statuses(Some(&mut opts))?;
let mut paths = Vec::new();
for entry in statuses.iter() {
if let Ok(path) = entry.path() {
paths.push(path.to_string());
}
}
Ok(paths)
}
fn fold_files_into_commit(
repo: &Repository,
files: &[String],
commit_hash: &str,
skip_staging: bool,
git_opts: &[&str],
left_out: Option<staging::StagedAside<'_>>,
) -> Result<()> {
let workdir = repo::require_workdir(repo, COMMAND)?;
if !skip_staging {
for file in files {
if !repo::path_has_changes(repo, file)? {
bail!("File '{}' has no changes to fold", file);
}
}
}
let head_oid = repo::head_oid(repo)?;
let target_oid = git2::Oid::from_str(commit_hash)?;
let is_head = head_oid == target_oid;
if !is_head {
Weave::from_repo(repo)?.require_commit(target_oid)?;
}
let file_refs: Vec<&str> = files.iter().map(|s| s.as_str()).collect();
let staged_by_loom: &[&str] = if skip_staging { &[] } else { &file_refs };
let mut staged = staging::save_and_unstage_other_staged(repo, workdir, &file_refs)?;
if let Some(left_out) = left_out {
staged = staged.absorb(left_out);
}
let new_hash;
if is_head {
if !skip_staging {
git::stage_files(workdir, &file_refs)?;
}
if let Err(e) = git::commit_amend_no_edit(workdir, git_opts) {
undo_commit_attempt(workdir, head_oid, staged_by_loom, staged);
return Err(e);
}
staged.restore();
new_hash = git::rev_parse(workdir, "HEAD")?;
} else {
let target_commit = repo.find_commit(target_oid)?;
let subject = target_commit.summary().ok().flatten().unwrap_or("fixup");
let message = format!("fixup! {}", subject);
if !skip_staging {
git::stage_files(workdir, &file_refs)?;
}
if let Err(e) = git::commit_captured(workdir, &message, git_opts) {
undo_commit_attempt(workdir, head_oid, staged_by_loom, staged);
return Err(e);
}
if !committed_onto(workdir, head_oid) {
undo_commit_attempt(workdir, head_oid, staged_by_loom, staged);
let blame = if git_opts.is_empty() {
""
} else {
"\nAn argument after `--` stopped it from committing"
};
bail!("`git commit` left no new commit on HEAD, so nothing was folded{blame}");
}
if !git_opts.is_empty() && committed_the_same_tree(workdir, head_oid) {
undo_commit_attempt(workdir, head_oid, staged_by_loom, staged);
bail!(
"`git commit` made an empty `fixup!` commit, so nothing was folded\n\
An argument after `--` kept the staged changes out of it"
);
}
let git_dir = repo.path().to_path_buf();
let outcome = squash_fixup_into_commit(
&git_dir,
workdir,
target_oid,
head_oid,
files,
staged.patch(),
);
match outcome {
Ok(FixupOutcome::Rebased) => {
staged.restore();
transaction::delete(&git_dir)?;
new_hash = git::rev_parse(workdir, TRACK_BRANCH)?;
let _ = git::branch_delete(workdir, TRACK_BRANCH);
}
Ok(FixupOutcome::Paused) => {
staged.handed_over();
return Ok(());
}
Err(e) => {
return Err(git::rebase_abort_then_cleanup(workdir, e, || {
let _ = git::reset_soft(workdir, &head_oid.to_string());
if !skip_staging {
let _ = git::unstage_files(workdir, &file_refs);
}
let _ = git::branch_delete(workdir, TRACK_BRANCH);
let _ = transaction::delete(&git_dir);
}));
}
}
}
msg::success(&format!(
"Folded {} file(s) into `{}` (now {})",
files.len(),
git::short_hash(commit_hash),
repo::describe_commit(workdir, &new_hash)
));
Ok(())
}
fn committed_onto(workdir: &Path, parent: git2::Oid) -> bool {
git::rev_parse(workdir, "HEAD^").is_ok_and(|first| first == parent.to_string())
}
fn committed_the_same_tree(workdir: &Path, parent: git2::Oid) -> bool {
match (
git::rev_parse(workdir, "HEAD^{tree}"),
git::rev_parse(workdir, &format!("{parent}^{{tree}}")),
) {
(Ok(now), Ok(before)) => now == before,
_ => true,
}
}
fn undo_commit_attempt(
workdir: &Path,
head_oid: git2::Oid,
staged_by_loom: &[&str],
staged: staging::StagedAside<'_>,
) {
let head_moved = git::rev_parse(workdir, "HEAD").ok() != Some(head_oid.to_string());
if head_moved && let Err(e) = git::reset_soft(workdir, &head_oid.to_string()) {
msg::warn(&format!(
"could not put your commit back on HEAD: {e}\n\
`git reset --soft {head_oid}` restores it"
));
git::save_or_warn(
workdir,
"unrestored-staged",
&staged.release(),
git::Replay::Cached,
);
return;
}
if !staged_by_loom.is_empty() {
let _ = git::unstage_files(workdir, staged_by_loom);
}
staged.restore();
}
enum FixupOutcome {
Rebased,
Paused,
}
fn squash_fixup_into_commit(
git_dir: &Path,
workdir: &Path,
target_oid: git2::Oid,
head_oid: git2::Oid,
files: &[String],
saved_staged: &str,
) -> Result<FixupOutcome> {
let commit_hash = target_oid.to_string();
let fixup_hash = git::rev_parse(workdir, "HEAD")?;
let fixup_oid = git2::Oid::from_str(&fixup_hash)?;
let repo2 = Repository::open(workdir)?;
let mut graph = Weave::from_repo(&repo2)?;
graph.fixup_commit(fixup_oid, target_oid)?;
track_through_rebase(&mut graph, workdir, target_oid)?;
let fold_ctx = serde_json::to_value(FoldVariant::FilesIntoCommit {
original_commit_hash: commit_hash.clone(),
files_count: files.len(),
})?;
let loom_state = LoomState {
command: COMMAND.to_string(),
rollback: Rollback {
reset_mixed_to: head_oid.to_string(),
saved_staged_patch: saved_staged.to_string(),
delete_branches: vec![TRACK_BRANCH.to_string()],
..Default::default()
},
context: fold_ctx,
protect: Vec::new(),
targets: vec![commit_hash],
};
transaction::save(git_dir, &loom_state)?;
let todo = graph.to_todo();
let base = graph.base_oid.to_string();
match weave::run_rebase_protecting(workdir, Some(&base), &todo, loom_state.protected())? {
RebaseOutcome::Completed => Ok(FixupOutcome::Rebased),
RebaseOutcome::Paused => {
transaction::warn_paused_at_edit(Some(COMMAND));
Ok(FixupOutcome::Paused)
}
RebaseOutcome::Stopped => {
transaction::warn_paused(workdir, COMMAND);
Ok(FixupOutcome::Paused)
}
}
}
fn fold_commit_into_commit(repo: &Repository, source_hash: &str, target_hash: &str) -> Result<()> {
let workdir = repo::require_workdir(repo, COMMAND)?;
let source_oid = git2::Oid::from_str(source_hash)?;
let target_oid = git2::Oid::from_str(target_hash)?;
if source_oid == target_oid {
bail!("Source and target are the same commit");
}
if !repo.graph_descendant_of(source_oid, target_oid)? {
bail!("Source commit must be newer than target commit");
}
let mut graph = Weave::from_repo(repo)?;
graph.fixup_commit(source_oid, target_oid)?;
track_through_rebase(&mut graph, workdir, target_oid)?;
let git_dir = repo.path().to_path_buf();
let fold_ctx = serde_json::to_value(FoldVariant::CommitIntoCommit {
source_hash: source_hash.to_string(),
target_hash: target_hash.to_string(),
})?;
let loom_state = LoomState {
command: COMMAND.to_string(),
rollback: Rollback {
delete_branches: vec![TRACK_BRANCH.to_string()],
saved_staged_patch: git::diff_cached(workdir)?,
..Default::default()
},
context: fold_ctx,
protect: Vec::new(),
targets: vec![target_hash.to_string()],
};
transaction::save(&git_dir, &loom_state)?;
let todo = graph.to_todo();
let base = graph.base_oid.to_string();
let outcome = weave::run_rebase_protecting(workdir, Some(&base), &todo, loom_state.protected())
.map_err(|e| transaction::roll_back_failed_rebase(workdir, &git_dir, &loom_state, e))?;
match outcome {
RebaseOutcome::Completed => {
git::restore_staged_after_rebase(workdir, &loom_state.rollback.saved_staged_patch);
transaction::delete(&git_dir)?;
let new_hash = git::rev_parse(workdir, TRACK_BRANCH)?;
let _ = git::branch_delete(workdir, TRACK_BRANCH);
msg::success(&format!(
"Folded `{}` into `{}` (now {})",
git::short_hash(source_hash),
git::short_hash(target_hash),
repo::describe_commit(workdir, &new_hash)
));
}
RebaseOutcome::Paused => {
transaction::warn_paused_at_edit(Some(COMMAND));
}
RebaseOutcome::Stopped => {
transaction::warn_paused(workdir, COMMAND);
}
}
Ok(())
}
fn fold_commit_to_branch(repo: &Repository, commit_hash: &str, branch_name: &str) -> Result<()> {
let workdir = repo::require_workdir(repo, COMMAND)?;
let git_dir = repo.path().to_path_buf();
let (graph, parked) = plan_move(
repo,
std::slice::from_ref(&commit_hash.to_string()),
branch_name,
)?;
let ctx = serde_json::to_value(FoldVariant::CommitToBranch {
commit_hash: commit_hash.to_string(),
branch_name: branch_name.to_string(),
parked: parked.clone(),
})?;
let state = LoomState {
command: COMMAND.to_string(),
rollback: Rollback {
saved_staged_patch: git::diff_cached(workdir)?,
..Default::default()
},
context: ctx,
protect: vec![commit_hash.to_string()],
targets: Vec::new(),
};
transaction::save(&git_dir, &state)?;
let todo = graph.to_todo();
let base = graph.base_oid.to_string();
let outcome = weave::run_rebase_protecting(workdir, Some(&base), &todo, state.protected())
.map_err(|e| transaction::roll_back_failed_rebase(workdir, &git_dir, &state, e))?;
match outcome {
RebaseOutcome::Completed => {
git::restore_staged_after_rebase(workdir, &state.rollback.saved_staged_patch);
transaction::delete(&git_dir)?;
let new_hash = git::rev_parse(workdir, branch_name)?;
report_moved(workdir, commit_hash, branch_name, &new_hash, &parked);
}
RebaseOutcome::Paused => {
transaction::warn_paused_at_edit(Some(COMMAND));
}
RebaseOutcome::Stopped => {
transaction::warn_paused(workdir, COMMAND);
}
}
Ok(())
}
fn report_moved(
workdir: &Path,
commit_hash: &str,
branch_name: &str,
new_hash: &str,
parked: &[String],
) {
let mut message = format!(
"Moved `{}` to branch `{}` (now {})",
git::short_hash(commit_hash),
branch_name,
repo::describe_commit(workdir, new_hash)
);
if !parked.is_empty() {
message.push_str(&format!(
"\n{} now empty, at the base",
weave::describe_branches(parked)
));
}
msg::success(&message);
}
pub fn move_commits_to_branch(
repo: &Repository,
commit_hashes: &[String],
branch_name: &str,
) -> Result<(RebaseOutcome, Vec<String>)> {
let workdir = repo::require_workdir(repo, COMMAND)?;
let (graph, parked) = plan_move(repo, commit_hashes, branch_name)?;
let todo = graph.to_todo();
let base = graph.base_oid.to_string();
let outcome = weave::run_rebase_protecting(
workdir,
Some(&base),
&todo,
git::Protected::named(commit_hashes),
)?;
Ok((outcome, parked))
}
fn plan_move(
repo: &Repository,
commit_hashes: &[String],
branch_name: &str,
) -> Result<(Weave, Vec<String>)> {
let mut graph = Weave::from_repo(repo)?;
let is_woven = graph.has_branch_section(branch_name) || graph.is_inner_branch(branch_name);
if !is_woven {
if let Ok(branch) = repo.find_branch(branch_name, git2::BranchType::Local) {
let branch_oid = branch
.get()
.peel_to_commit()
.map(|c| c.id())
.unwrap_or(git2::Oid::ZERO_SHA1);
if branch_oid != graph.base_oid {
bail!(
"Branch '{}' exists but is not part of the current integration scope.\n\
Use `loom branch merge {}` to weave it first.",
branch_name,
branch_name
);
}
}
graph.add_branch_section(
branch_name.to_string(),
vec![branch_name.to_string()],
vec![],
"onto".to_string(),
);
graph.add_merge(branch_name.to_string(), None, None);
}
let mut parked = Vec::new();
for commit_hash in commit_hashes {
let commit_oid = git2::Oid::from_str(commit_hash)?;
parked.extend(graph.move_commit(commit_oid, branch_name)?);
}
parked.retain(|name| name != branch_name);
Ok((graph, parked))
}
fn keep_submodule_removal(workdir: &Path, path: &str) -> bool {
if !workdir.join(path).try_exists().unwrap_or(true) {
return false;
}
if let Err(e) = git::remove_from_index(workdir, path) {
msg::warn(&format!(
"could not stage the removal of submodule `{path}`: {e}\n\
Record it with `git rm --cached {path}`"
));
return false;
}
true
}
fn keep_submodule_removals(workdir: &Path, commit: &str) -> Vec<String> {
let mut staged = Vec::new();
match git::commit_gitlinks(workdir, commit) {
Ok(gitlinks) => {
for (path, removed) in gitlinks {
if removed && keep_submodule_removal(workdir, &path) {
staged.push(path);
}
}
}
Err(e) => msg::warn(&format!("could not read the submodules of `{commit}`: {e}")),
}
staged.sort();
staged
}
fn fold_commit_file_to_unstaged(
repo: &Repository,
commit_hash: &str,
path: &str,
git_opts: &[&str],
) -> Result<()> {
let workdir = repo::require_workdir(repo, COMMAND)?;
let head_oid = repo::head_oid(repo)?;
let target_oid = git2::Oid::from_str(commit_hash)?;
let is_head = head_oid == target_oid;
let file_diff = git::diff_commit_file(workdir, commit_hash, path)?;
if file_diff.is_empty() {
bail!(
"File '{}' has no changes in commit {}",
path,
git::short_hash(commit_hash)
);
}
let gitlinks = git::commit_gitlinks(workdir, commit_hash)?;
let gitlink = gitlinks.contains_key(path);
let saved_worktree = staging::WorktreeSnapshot::take(workdir)?;
let new_hash;
if is_head {
let saved_head = head_oid.to_string();
if let Err(e) = apply_and_amend_path(workdir, &file_diff, path, gitlink, true, git_opts) {
staging::rollback_to_snapshot(workdir, &saved_head, None, &saved_worktree);
return Err(e).context("Failed to uncommit file, operation rolled back");
}
new_hash = git::rev_parse(workdir, "HEAD")?;
if !gitlink && let Err(e) = git::apply_patch_to_worktree(workdir, &file_diff) {
staging::rollback_to_snapshot(workdir, &saved_head, None, &saved_worktree);
return Err(e).context("Failed to uncommit file, operation rolled back");
}
} else {
let saved_head = head_oid.to_string();
let saved_refs = repo::snapshot_branch_refs(repo)?;
let mut graph = Weave::from_repo(repo)?;
let _ = graph.edit_commit(target_oid);
let todo = graph.to_todo();
weave::run_rebase_expecting_edit(
workdir,
Some(&graph.base_oid.to_string()),
&todo,
target_oid,
&[],
)
.inspect_err(|e| git::restore_or_park_after_abort(workdir, &saved_worktree.staged, e))?;
if let Err(e) = apply_and_amend_path(workdir, &file_diff, path, gitlink, true, git_opts) {
let e = git::rebase_abort_then_cleanup(workdir, e, || {});
git::restore_or_park_after_abort(workdir, &saved_worktree.staged, &e);
return Err(e);
}
new_hash = git::rev_parse(workdir, "HEAD").map_err(|e| {
let e = git::rebase_abort_then_cleanup(workdir, e, || {});
git::restore_or_park_after_abort(workdir, &saved_worktree.staged, &e);
e
})?;
git::continue_rebase_expecting_edit(workdir, git::AfterStop::nothing()).inspect_err(
|e| git::restore_or_park_after_abort(workdir, &saved_worktree.staged, e),
)?;
if !gitlink && let Err(e) = git::apply_patch_to_worktree(workdir, &file_diff) {
staging::rollback_to_snapshot(workdir, &saved_head, Some(&saved_refs), &saved_worktree);
return Err(e).context("Failed to uncommit file, operation rolled back");
}
git::restore_staged_after_rebase(workdir, &saved_worktree.staged);
}
let mut staged_removal = false;
if gitlinks.get(path).copied().unwrap_or(false) {
staged_removal = keep_submodule_removal(workdir, path);
}
msg::success(&format!(
"Uncommitted `{}` from `{}` (now {}) {}",
path,
git::short_hash(commit_hash),
repo::describe_commit(workdir, &new_hash),
if staged_removal {
"as a staged deletion"
} else {
"to working directory"
}
));
Ok(())
}
fn fold_commit_file_to_commit(
repo: &Repository,
source_hash: &str,
path: &str,
target_hash: &str,
git_opts: &[&str],
) -> Result<()> {
let workdir = repo::require_workdir(repo, COMMAND)?;
let source_oid = git2::Oid::from_str(source_hash)?;
let target_oid = git2::Oid::from_str(target_hash)?;
if source_oid == target_oid {
bail!("Source and target are the same commit");
}
let file_diff = git::diff_commit_file(workdir, source_hash, path)?;
if file_diff.is_empty() {
bail!(
"File '{}' has no changes in commit {}",
path,
git::short_hash(source_hash)
);
}
let gitlink = git::commit_gitlinks(workdir, source_hash)?.contains_key(path);
let source_is_newer = repo.graph_descendant_of(source_oid, target_oid)?;
let saved_worktree = staging::WorktreeSnapshot::take(workdir)?;
let new_source_hash;
let new_target_hash;
if source_is_newer {
let saved_head = repo::head_oid(repo)?.to_string();
let saved_refs = repo::snapshot_branch_refs(repo)?;
let rollback = || {
let _ = git::branch_delete(workdir, TRACK_BRANCH);
staging::rollback_to_snapshot(workdir, &saved_head, Some(&saved_refs), &saved_worktree);
};
let mut graph = Weave::from_repo(repo)?;
let _ = graph.edit_commit(source_oid);
let todo = graph.to_todo();
git::branch_force_create(workdir, TRACK_BRANCH, target_hash)?;
if let Err(e) = weave::run_rebase_expecting_edit(
workdir,
Some(&graph.base_oid.to_string()),
&todo,
source_oid,
&[target_hash],
) {
let _ = git::branch_delete(workdir, TRACK_BRANCH);
git::restore_or_park_after_abort(workdir, &saved_worktree.staged, &e);
return Err(e);
}
if let Err(e) = apply_and_amend_path(workdir, &file_diff, path, gitlink, true, git_opts) {
let e = git::rebase_abort_then_cleanup(workdir, e, || {
let _ = git::branch_delete(workdir, TRACK_BRANCH);
});
git::restore_or_park_after_abort(workdir, &saved_worktree.staged, &e);
return Err(e);
}
let phase1_source_hash = git::rev_parse(workdir, "HEAD").map_err(|e| {
let e = git::rebase_abort_then_cleanup(workdir, e, || {
let _ = git::branch_delete(workdir, TRACK_BRANCH);
});
git::restore_or_park_after_abort(workdir, &saved_worktree.staged, &e);
e
})?;
let protect = [phase1_source_hash.clone()];
if let Err(e) = git::continue_rebase_expecting_edit(
workdir,
git::AfterStop::nothing().protecting(&protect),
) {
let _ = git::branch_delete(workdir, TRACK_BRANCH);
git::restore_or_park_after_abort(workdir, &saved_worktree.staged, &e);
return Err(e);
}
let restage = || git::restore_staged_after_rebase(workdir, &saved_worktree.staged);
let phase2_target_hash =
git::rev_parse(workdir, TRACK_BRANCH).inspect_err(|_| restage())?;
let _ = git::branch_delete(workdir, TRACK_BRANCH);
let phase2_target_oid =
git2::Oid::from_str(&phase2_target_hash).inspect_err(|_| restage())?;
let repo2 = Repository::open(workdir).inspect_err(|_| restage())?;
let mut graph2 = Weave::from_repo(&repo2).inspect_err(|_| restage())?;
let _ = graph2.edit_commit(phase2_target_oid);
let phase1_source_oid =
git2::Oid::from_str(&phase1_source_hash).inspect_err(|_| restage())?;
if let Err(e) = track_through_rebase(&mut graph2, workdir, phase1_source_oid) {
rollback();
return Err(e);
}
let todo2 = graph2.to_todo();
if let Err(e) = weave::run_rebase_expecting_edit(
workdir,
Some(&graph2.base_oid.to_string()),
&todo2,
phase2_target_oid,
&[&phase2_target_hash],
) {
rollback();
return Err(e);
}
if let Err(e) = apply_and_amend_path(workdir, &file_diff, path, gitlink, false, git_opts) {
return Err(git::rebase_abort_then_cleanup(workdir, e, rollback));
}
new_target_hash = git::rev_parse(workdir, "HEAD").map_err(|e| {
git::rebase_abort_then_cleanup(workdir, e, rollback)
})?;
if let Err(e) = git::continue_rebase_expecting_edit(
workdir,
git::AfterStop::nothing().protecting(&protect),
) {
rollback();
return Err(e);
}
new_source_hash = git::rev_parse(workdir, TRACK_BRANCH).inspect_err(|_| {
let _ = git::branch_delete(workdir, TRACK_BRANCH);
restage();
})?;
let _ = git::branch_delete(workdir, TRACK_BRANCH);
} else {
let saved_head = repo::head_oid(repo)?.to_string();
let saved_refs = repo::snapshot_branch_refs(repo)?;
let mut graph = Weave::from_repo(repo)?;
let _ = graph.edit_commit(source_oid);
if !graph.edit_commit(target_oid) {
return Err(weave::not_in_the_weave(target_oid));
}
let todo = graph.to_todo();
weave::run_rebase_expecting_edit(
workdir,
Some(&graph.base_oid.to_string()),
&todo,
source_oid,
&[],
)
.inspect_err(|e| git::restore_or_park_after_abort(workdir, &saved_worktree.staged, e))?;
if let Err(e) = apply_and_amend_path(workdir, &file_diff, path, gitlink, true, git_opts) {
let e = git::rebase_abort_then_cleanup(workdir, e, || {});
git::restore_or_park_after_abort(workdir, &saved_worktree.staged, &e);
return Err(e);
}
new_source_hash = git::rev_parse(workdir, "HEAD").map_err(|e| {
git::rebase_abort_then_cleanup(workdir, e, || {
staging::rollback_to_snapshot(
workdir,
&saved_head,
Some(&saved_refs),
&saved_worktree,
);
})
})?;
let targets = [target_oid.to_string()];
if let Err(e) = git::continue_rebase_expecting_edit(
workdir,
git::AfterStop::rewrite(&targets[0]).targeting(&targets),
) {
return Err(git::rebase_abort_then_cleanup(workdir, e, || {
staging::rollback_to_snapshot(
workdir,
&saved_head,
Some(&saved_refs),
&saved_worktree,
);
}));
}
if let Err(e) = apply_and_amend_path(workdir, &file_diff, path, gitlink, false, git_opts) {
return Err(git::rebase_abort_then_cleanup(workdir, e, || {
staging::rollback_to_snapshot(
workdir,
&saved_head,
Some(&saved_refs),
&saved_worktree,
);
}));
}
new_target_hash = git::rev_parse(workdir, "HEAD").map_err(|e| {
git::rebase_abort_then_cleanup(workdir, e, || {
staging::rollback_to_snapshot(
workdir,
&saved_head,
Some(&saved_refs),
&saved_worktree,
);
})
})?;
if let Err(e) = git::continue_rebase_expecting_edit(workdir, git::AfterStop::nothing()) {
return Err(git::rebase_abort_then_cleanup(workdir, e, || {
staging::rollback_to_snapshot(
workdir,
&saved_head,
Some(&saved_refs),
&saved_worktree,
);
}));
}
}
git::restore_staged_after_rebase(workdir, &saved_worktree.staged);
msg::success(&format!(
"Moved `{}` from `{}` (now {}) to `{}` (now {})",
path,
git::short_hash(source_hash),
repo::describe_commit(workdir, &new_source_hash),
git::short_hash(target_hash),
repo::describe_commit(workdir, &new_target_hash)
));
Ok(())
}
fn fold_commit_to_unstaged(repo: &Repository, commit_hash: &str) -> Result<()> {
let workdir = repo::require_workdir(repo, COMMAND)?;
let head_oid = repo::head_oid(repo)?;
let target_oid = git2::Oid::from_str(commit_hash)?;
let is_head = head_oid == target_oid;
if is_head {
git::reset_mixed(workdir, "HEAD~1")?;
let staged = keep_submodule_removals(workdir, commit_hash);
report_uncommitted(commit_hash, &[], &staged);
return Ok(());
} else {
let mut graph = Weave::from_repo(repo)?;
let Some(emptied) = graph.drop_commit(target_oid, EmptiedRefs::Park) else {
bail!(
"Commit `{}` is not in the local commits (upstream..HEAD)\n\
If history was rewritten, the SHA may be stale — run `loom` to see the current commits",
git::short_hash(commit_hash)
);
};
let diff = git::diff_commit(workdir, commit_hash)?;
let saved_head = head_oid.to_string();
let saved_refs = repo::snapshot_branch_refs(repo)?;
let saved_worktree = staging::WorktreeSnapshot::take(workdir)?;
let git_dir = repo.path().to_path_buf();
let fold_ctx = serde_json::to_value(FoldVariant::CommitToUnstaged {
commit_hash: commit_hash.to_string(),
diff: diff.clone(),
emptied: emptied.clone(),
})?;
let loom_state = LoomState {
command: COMMAND.to_string(),
rollback: Rollback {
saved_staged_patch: saved_worktree.staged.clone(),
..Default::default()
},
context: fold_ctx,
protect: Vec::new(),
targets: Vec::new(),
};
transaction::save(&git_dir, &loom_state)?;
let todo = graph.to_todo();
let outcome = weave::run_rebase(workdir, Some(&graph.base_oid.to_string()), &todo)
.map_err(|e| {
transaction::discard_state_after(
workdir,
&git_dir,
&loom_state.rollback.saved_staged_patch,
e,
)
})?;
let staged = match outcome {
RebaseOutcome::Completed => {
transaction::delete(&git_dir)?;
if !diff.is_empty()
&& let Err(e) = git::apply_patch_to_worktree(workdir, &diff)
{
staging::rollback_to_snapshot(
workdir,
&saved_head,
Some(&saved_refs),
&saved_worktree,
);
return Err(e).context(
"Failed to apply changes to working directory, operation rolled back",
);
}
git::restore_staged_after_rebase(workdir, &loom_state.rollback.saved_staged_patch);
keep_submodule_removals(workdir, commit_hash)
}
RebaseOutcome::Paused => {
transaction::warn_paused_at_edit(Some(COMMAND));
return Ok(());
}
RebaseOutcome::Stopped => {
transaction::warn_paused(workdir, COMMAND);
return Ok(());
}
};
report_uncommitted(commit_hash, &emptied, &staged);
}
Ok(())
}
fn report_uncommitted(commit_hash: &str, emptied: &[String], staged: &[String]) {
let mut message = format!(
"Uncommitted `{}` to working directory",
git::short_hash(commit_hash)
);
if !staged.is_empty() {
let names: Vec<String> = staged.iter().map(|p| format!("`{p}`")).collect();
message.push_str(&format!(
"\nThe removal of {} is staged — the checkout is still on disk",
names.join(", ")
));
}
if !emptied.is_empty() {
message.push_str(&format!(
"\n{} now empty, at the base",
weave::describe_branches(emptied)
));
}
msg::success(&message);
}
pub fn after_continue(
workdir: &Path,
rollback: &Rollback,
context: &serde_json::Value,
) -> Result<()> {
let variant: FoldVariant =
serde_json::from_value(context.clone()).context("Failed to parse fold resume context")?;
if !matches!(variant, FoldVariant::CommitToUnstaged { .. }) {
git::restore_staged_after_rebase(workdir, &rollback.saved_staged_patch);
}
match variant {
FoldVariant::FilesIntoCommit {
original_commit_hash,
files_count,
} => {
let new_hash = git::rev_parse(workdir, TRACK_BRANCH)?;
let _ = git::branch_delete(workdir, TRACK_BRANCH);
msg::success(&format!(
"Folded {} file(s) into `{}` (now {})",
files_count,
git::short_hash(&original_commit_hash),
repo::describe_commit(workdir, &new_hash)
));
}
FoldVariant::CommitIntoCommit {
source_hash,
target_hash,
} => {
let new_hash = git::rev_parse(workdir, TRACK_BRANCH)?;
let _ = git::branch_delete(workdir, TRACK_BRANCH);
msg::success(&format!(
"Folded `{}` into `{}` (now {})",
git::short_hash(&source_hash),
git::short_hash(&target_hash),
repo::describe_commit(workdir, &new_hash)
));
}
FoldVariant::CommitToBranch {
commit_hash,
branch_name,
parked,
} => {
let new_hash = git::rev_parse(workdir, &branch_name)?;
report_moved(workdir, &commit_hash, &branch_name, &new_hash, &parked);
}
FoldVariant::CommitToUnstaged {
commit_hash,
diff,
emptied,
} => {
if !diff.is_empty()
&& let Err(e) = git::apply_patch_to_worktree(workdir, &diff)
{
let mut warning = format!("Could not re-apply changes to working directory: {e}");
match git::save_patch_aside(workdir, "unapplied", &diff) {
Ok(path) => warning.push_str(&format!(
"\nThe diff has been saved — apply it with `git apply {}`",
path.display()
)),
Err(save) => warning.push_str(&format!(
"\nThe diff could not be saved either ({save}) — `git show {commit_hash}` \
still prints it, until that dangling commit is collected",
)),
}
msg::warn(&warning);
}
git::restore_staged_after_rebase(workdir, &rollback.saved_staged_patch);
let staged = keep_submodule_removals(workdir, &commit_hash);
report_uncommitted(&commit_hash, &emptied, &staged);
}
FoldVariant::CommitRelative {
commit_hash,
target_hash,
above,
parked,
} => {
let new_hash = git::rev_parse(workdir, TRACK_BRANCH)?;
let _ = git::branch_delete(workdir, TRACK_BRANCH);
let position = if above {
Position::Above
} else {
Position::Below
};
report_moved_relative(
workdir,
&commit_hash,
position,
&target_hash,
&new_hash,
&parked,
);
}
}
Ok(())
}
#[cfg(test)]
#[path = "fold_test.rs"]
mod tests;