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::msg;
use crate::core::repo::{self, Target, TargetKind};
use crate::core::staging;
use crate::core::transaction::{self, LoomState, Rollback};
use crate::core::weave::{self, 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,
saved_staged: String,
},
CommitIntoCommit {
source_hash: String,
target_hash: String,
},
CommitToBranch {
commit_hash: String,
branch_name: String,
},
CommitToUnstaged {
commit_hash: String,
diff: String,
},
}
const TRACK_BRANCH: &str = "_loom-track";
const COMMAND: &str = "fold";
pub fn run(create: bool, patch: bool, 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 repo = repo::open_repo()?;
if create {
return run_create(&repo, &args);
}
if patch {
return run_patch_fold(&repo, &args, theme);
}
if args.len() == 1 {
return run_staged(&repo, &args[0]);
}
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)
}
FoldOp::CommitIntoCommit { source, target } => {
fold_commit_into_commit(&repo, &source, &target)
}
FoldOp::CommitToBranch { commit, branch } => fold_commit_to_branch(&repo, &commit, &branch),
FoldOp::CommitToUnstaged { commit } => fold_commit_to_unstaged(&repo, &commit),
FoldOp::CommitFileToUnstaged { commit, path } => {
fold_commit_file_to_unstaged(&repo, &commit, &path)
}
FoldOp::CommitFileToCommit {
source_commit,
path,
target_commit,
} => fold_commit_file_to_commit(&repo, &source_commit, &path, &target_commit),
}
}
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!(),
}
}
let commit_hashes = sort_commits_oldest_first(repo, commit_hashes)?;
git::branch_validate_name(workdir, branch_name)?;
let branch_exists = repo
.find_branch(branch_name, git2::BranchType::Local)
.is_ok();
if branch_exists {
msg::warn(&format!(
"Branch `{}` already exists — moving commit(s) to it",
branch_name
));
if commit_hashes.len() == 1 {
return fold_commit_to_branch(repo, &commit_hashes[0], branch_name);
}
return move_commits_and_report(workdir, repo, &commit_hashes, branch_name, None);
}
let info = repo::gather_repo_info(repo, false, 1).ok();
let base_hash = match &info {
Some(info) => info.upstream.merge_base_oid.to_string(),
None => bail!(
"Cannot create branch: no upstream tracking branch configured\n\
Use 'loom branch <name> -t <commit>' instead"
),
};
move_commits_and_report(workdir, repo, &commit_hashes, branch_name, Some(&base_hash))
}
fn sort_commits_oldest_first(repo: &Repository, hashes: Vec<String>) -> 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);
}
}
oids.sort_by(|a, b| {
if a == b {
return std::cmp::Ordering::Equal;
}
if repo.graph_descendant_of(*a, *b).unwrap_or(false) {
std::cmp::Ordering::Greater } else if repo.graph_descendant_of(*b, *a).unwrap_or(false) {
std::cmp::Ordering::Less } else {
let ta = repo
.find_commit(*a)
.map(|c| c.time().seconds())
.unwrap_or(0);
let tb = repo
.find_commit(*b)
.map(|c| c.time().seconds())
.unwrap_or(0);
ta.cmp(&tb)
}
});
Ok(oids.iter().map(|o| o.to_string()).collect())
}
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)?;
}
match move_commits_to_branch(repo, commit_hashes, branch_name) {
Ok(RebaseOutcome::Completed) => {}
Ok(RebaseOutcome::Conflicted) => {
let _ = git::rebase_abort(workdir);
if created {
let _ = git::branch_delete(workdir, branch_name);
}
bail!("Rebase failed with conflicts — aborted");
}
Err(e) => {
if created {
let _ = git::branch_delete(workdir, branch_name);
}
return Err(e);
}
}
let new_hash = git::rev_parse(workdir, branch_name)?;
if created {
msg::success(&format!(
"Created branch `{}` and moved {} commit(s) to it (now `{}`)",
branch_name,
commit_hashes.len(),
git::short_hash(&new_hash)
));
} else {
msg::success(&format!(
"Moved {} commit(s) to branch `{}` (now `{}`)",
commit_hashes.len(),
branch_name,
git::short_hash(&new_hash)
));
}
Ok(())
}
fn run_patch_fold(repo: &Repository, args: &[String], 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" {
return run_patch_fold_commit_to_unstaged(repo, workdir, &source_hash, theme);
}
if let Ok(Target::Commit(target_hash)) =
repo::resolve_arg(repo, target_arg, &[TargetKind::Commit])
{
return run_patch_fold_commit_to_commit(
repo,
workdir,
&source_hash,
&target_hash,
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 confirmed = staging::run_hunk_picker(repo, workdir, source_args, theme)?;
if !confirmed {
bail!("Cancelled");
}
let staged = repo::get_staged_files(repo)?;
if staged.is_empty() {
bail!("Nothing to commit");
}
fold_files_into_commit(repo, &staged, &commit_hash, true)
}
fn build_selected_patch(selections: &[FileEntry]) -> String {
let mut patch = String::new();
for file in selections {
if file.binary {
continue;
}
let selected: Vec<_> = file
.hunks
.iter()
.filter(|h| h.selected)
.map(|h| &h.hunk)
.collect();
if !selected.is_empty() {
patch.push_str(&diff::build_hunk_patch(&file.path, &selected));
}
}
patch
}
fn apply_and_amend_path(workdir: &Path, patch: &str, path: &str, reverse: bool) -> Result<()> {
if reverse {
git::apply_patch_reverse(workdir, patch)?;
} else {
git::apply_patch(workdir, patch)?;
}
git::stage_path(workdir, path)?;
git::commit_amend_no_edit(workdir)
}
fn apply_and_amend(
workdir: &Path,
selections: &[FileEntry],
patch: &str,
reverse: bool,
) -> Result<()> {
if reverse {
git::apply_patch_reverse(workdir, patch)?;
} else {
git::apply_patch(workdir, patch)?;
}
for file in selections {
if file.hunks.iter().any(|h| h.selected) {
git::stage_path(workdir, &file.path)?;
}
}
git::commit_amend_no_edit(workdir)
}
fn run_patch_fold_commit_to_commit(
repo: &Repository,
workdir: &Path,
source_hash: &str,
target_hash: &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, &[], theme)?
.ok_or_else(|| anyhow::anyhow!("Cancelled"))?;
if !selections
.iter()
.any(|f| f.hunks.iter().any(|h| h.selected))
{
bail!("No hunks selected");
}
let selected_patch = build_selected_patch(&selections);
if selected_patch.is_empty() {
bail!("No text hunks selected — binary and deleted files are not supported with -p");
}
let saved_head = repo::head_oid(repo)?.to_string();
let saved_refs = repo::snapshot_branch_refs(repo)?;
let saved_staged = staging::save_and_unstage_staged(repo, workdir)?;
let mut graph = Weave::from_repo(repo)?;
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_or_abort(workdir, Some(&graph.base_oid.to_string()), &todo) {
let _ = git::branch_delete(workdir, TRACK_BRANCH);
let _ = git::restore_staged_patch(workdir, &saved_staged);
return Err(e);
}
if let Err(e) = apply_and_amend(workdir, &selections, &selected_patch, true) {
let _ = git::rebase_abort(workdir);
let _ = git::branch_delete(workdir, TRACK_BRANCH);
let _ = git::restore_staged_patch(workdir, &saved_staged);
return Err(e);
}
let new_source_hash = git::rev_parse(workdir, "HEAD")?;
if let Err(e) = git::continue_rebase_or_abort(workdir) {
let _ = git::branch_delete(workdir, TRACK_BRANCH);
let _ = git::restore_staged_patch(workdir, &saved_staged);
return Err(e);
}
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)?;
graph2.edit_commit(phase2_target_oid);
let todo2 = graph2.to_todo();
let rollback = |saved_head: &str, saved_refs: &std::collections::HashMap<String, git2::Oid>| {
let _ = git::reset_hard(workdir, saved_head);
if let Err(re) = repo::restore_branch_refs(workdir, saved_refs) {
msg::warn(&format!("failed to restore branch refs: {re}"));
}
};
if let Err(e) = weave::run_rebase_or_abort(workdir, Some(&graph2.base_oid.to_string()), &todo2)
{
rollback(&saved_head, &saved_refs);
let _ = git::restore_staged_patch(workdir, &saved_staged);
return Err(e);
}
if let Err(e) = apply_and_amend(workdir, &selections, &selected_patch, false) {
let _ = git::rebase_abort(workdir);
rollback(&saved_head, &saved_refs);
let _ = git::restore_staged_patch(workdir, &saved_staged);
return Err(e);
}
let new_target_hash = git::rev_parse(workdir, "HEAD")?;
if let Err(e) = git::continue_rebase_or_abort(workdir) {
rollback(&saved_head, &saved_refs);
let _ = git::restore_staged_patch(workdir, &saved_staged);
return Err(e);
}
git::restore_staged_patch(workdir, &saved_staged)?;
msg::success(&format!(
"Moved hunk(s) from `{}` (now `{}`) into `{}` (now `{}`)",
git::short_hash(source_hash),
git::short_hash(&new_source_hash),
git::short_hash(target_hash),
git::short_hash(&new_target_hash)
));
Ok(())
}
fn run_patch_fold_commit_to_unstaged(
repo: &Repository,
workdir: &Path,
commit_hash: &str,
theme: &graph::Theme,
) -> Result<()> {
let selections = staging::run_commit_hunk_picker(workdir, commit_hash, &[], theme)?
.ok_or_else(|| anyhow::anyhow!("Cancelled"))?;
if !selections
.iter()
.any(|f| f.hunks.iter().any(|h| h.selected))
{
bail!("No hunks selected");
}
let selected_patch = build_selected_patch(&selections);
if selected_patch.is_empty() {
bail!("No text hunks selected — binary and deleted files are not supported with -p");
}
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_staged = staging::save_and_unstage_staged(repo, workdir)?;
let new_hash;
if is_head {
let pre_amend_hash = head_oid.to_string();
git::apply_patch_reverse(workdir, &selected_patch)?;
for file in &selections {
if file.hunks.iter().any(|h| h.selected) {
git::stage_path(workdir, &file.path)?;
}
}
git::commit_amend_no_edit(workdir)?;
new_hash = git::rev_parse(workdir, "HEAD")?;
if let Err(e) = git::apply_patch(workdir, &selected_patch) {
let _ = git::reset_hard(workdir, &pre_amend_hash);
let _ = git::restore_staged_patch(workdir, &saved_staged);
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)?;
graph.edit_commit(target_oid);
let todo = graph.to_todo();
if let Err(e) =
weave::run_rebase_or_abort(workdir, Some(&graph.base_oid.to_string()), &todo)
{
let _ = git::restore_staged_patch(workdir, &saved_staged);
return Err(e);
}
if let Err(e) = apply_and_amend(workdir, &selections, &selected_patch, true) {
let _ = git::rebase_abort(workdir);
let _ = git::restore_staged_patch(workdir, &saved_staged);
return Err(e);
}
new_hash = git::rev_parse(workdir, "HEAD")?;
git::continue_rebase_or_abort(workdir)?;
if let Err(e) = git::apply_patch(workdir, &selected_patch) {
let _ = git::reset_hard(workdir, &saved_head);
if let Err(re) = repo::restore_branch_refs(workdir, &saved_refs) {
msg::warn(&format!("failed to restore branch refs: {re}"));
}
let _ = git::restore_staged_patch(workdir, &saved_staged);
return Err(e)
.context("Failed to apply changes to working directory, operation rolled back");
}
}
git::restore_staged_patch(workdir, &saved_staged)?;
msg::success(&format!(
"Uncommitted hunk(s) from `{}` (now `{}`) to working directory",
git::short_hash(commit_hash),
git::short_hash(&new_hash)
));
Ok(())
}
fn run_staged(repo: &Repository, target_arg: &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)
}
#[derive(Debug)]
enum FoldOp {
FilesIntoCommit {
files: Vec<String>,
commit: String,
},
CommitIntoCommit {
source: String,
target: String,
},
CommitToBranch {
commit: 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 {
if sources.len() > 1 {
bail!("Only one commit source is allowed");
}
let source_hash = match &sources[0] {
Target::Commit(hash) => hash.clone(),
_ => unreachable!(),
};
match target {
Target::Commit(hash) => Ok(FoldOp::CommitIntoCommit {
source: source_hash,
target: hash.clone(),
}),
Target::Branch(name) => Ok(FoldOp::CommitToBranch {
commit: source_hash,
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 Some(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,
) -> 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;
let file_refs: Vec<&str> = files.iter().map(|s| s.as_str()).collect();
let saved_staged = staging::save_and_unstage_other_staged(repo, workdir, &file_refs)?;
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) {
if !skip_staging {
let _ = git::unstage_files(workdir, &file_refs);
}
let _ = git::restore_staged_patch(workdir, &saved_staged);
return Err(e);
}
git::restore_staged_patch(workdir, &saved_staged)?;
new_hash = git::rev_parse(workdir, "HEAD")?;
} else {
let target_commit = repo.find_commit(target_oid)?;
let subject = target_commit.summary().unwrap_or("fixup");
let message = format!("fixup! {}", subject);
if !skip_staging {
git::stage_files(workdir, &file_refs)?;
}
if let Err(e) = git::commit(workdir, &message) {
if !skip_staging {
let _ = git::unstage_files(workdir, &file_refs);
}
let _ = git::restore_staged_patch(workdir, &saved_staged);
return Err(e);
}
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)?;
git::branch_force_create(workdir, TRACK_BRANCH, commit_hash)?;
graph.track_commit(target_oid, TRACK_BRANCH);
let git_dir = repo.path().to_path_buf();
let fold_ctx = serde_json::to_value(FoldVariant::FilesIntoCommit {
original_commit_hash: commit_hash.to_string(),
files_count: files.len(),
saved_staged: saved_staged.clone(),
})?;
let loom_state = LoomState {
command: COMMAND.to_string(),
rollback: Rollback {
saved_staged_patch: saved_staged.clone(),
delete_branches: vec![TRACK_BRANCH.to_string()],
..Default::default()
},
context: fold_ctx,
};
transaction::save(&git_dir, &loom_state)?;
let todo = graph.to_todo();
match weave::run_rebase(workdir, Some(&graph.base_oid.to_string()), &todo)? {
RebaseOutcome::Completed => {
transaction::delete(&git_dir)?;
git::restore_staged_patch(workdir, &saved_staged)?;
new_hash = git::rev_parse(workdir, TRACK_BRANCH)?;
let _ = git::branch_delete(workdir, TRACK_BRANCH);
}
RebaseOutcome::Conflicted => {
transaction::warn_conflict_paused(COMMAND);
return Ok(());
}
}
}
msg::success(&format!(
"Folded {} file(s) into `{}` (now `{}`)",
files.len(),
git::short_hash(commit_hash),
git::short_hash(&new_hash)
));
Ok(())
}
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)?;
git::branch_force_create(workdir, TRACK_BRANCH, target_hash)?;
graph.track_commit(target_oid, TRACK_BRANCH);
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()],
..Default::default()
},
context: fold_ctx,
};
transaction::save(&git_dir, &loom_state)?;
let todo = graph.to_todo();
match weave::run_rebase(workdir, Some(&graph.base_oid.to_string()), &todo)? {
RebaseOutcome::Completed => {
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),
git::short_hash(&new_hash)
));
}
RebaseOutcome::Conflicted => {
transaction::warn_conflict_paused(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 ctx = serde_json::to_value(FoldVariant::CommitToBranch {
commit_hash: commit_hash.to_string(),
branch_name: branch_name.to_string(),
})?;
let state = LoomState {
command: COMMAND.to_string(),
rollback: Rollback::default(),
context: ctx,
};
transaction::save(&git_dir, &state)?;
match move_commit_to_branch(repo, commit_hash, branch_name)? {
RebaseOutcome::Completed => {
transaction::delete(&git_dir)?;
let new_hash = git::rev_parse(workdir, branch_name)?;
msg::success(&format!(
"Moved `{}` to branch `{}` (now `{}`)",
git::short_hash(commit_hash),
branch_name,
git::short_hash(&new_hash)
));
}
RebaseOutcome::Conflicted => {
transaction::warn_conflict_paused(COMMAND);
}
}
Ok(())
}
pub fn move_commit_to_branch(
repo: &Repository,
commit_hash: &str,
branch_name: &str,
) -> Result<RebaseOutcome> {
move_commits_to_branch(
repo,
std::slice::from_ref(&commit_hash.to_string()),
branch_name,
)
}
pub fn move_commits_to_branch(
repo: &Repository,
commit_hashes: &[String],
branch_name: &str,
) -> Result<RebaseOutcome> {
let workdir = repo::require_workdir(repo, COMMAND)?;
let mut graph = Weave::from_repo(repo)?;
let has_section = graph
.branch_sections
.iter()
.any(|s| s.label == branch_name || s.branch_names.contains(&branch_name.to_string()));
if !has_section {
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());
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);
}
for commit_hash in commit_hashes {
let commit_oid = git2::Oid::from_str(commit_hash)?;
graph.move_commit(commit_oid, branch_name)?;
}
let todo = graph.to_todo();
weave::run_rebase(workdir, Some(&graph.base_oid.to_string()), &todo)
}
fn fold_commit_file_to_unstaged(repo: &Repository, commit_hash: &str, path: &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 new_hash;
if is_head {
let saved_head = head_oid.to_string();
apply_and_amend_path(workdir, &file_diff, path, true)?;
new_hash = git::rev_parse(workdir, "HEAD")?;
if let Err(e) = git::apply_patch(workdir, &file_diff) {
let _ = git::reset_hard(workdir, &saved_head);
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)?;
graph.edit_commit(target_oid);
let todo = graph.to_todo();
weave::run_rebase_or_abort(workdir, Some(&graph.base_oid.to_string()), &todo)?;
if let Err(e) = apply_and_amend_path(workdir, &file_diff, path, true) {
let _ = git::rebase_abort(workdir);
return Err(e);
}
new_hash = git::rev_parse(workdir, "HEAD")?;
git::continue_rebase_or_abort(workdir)?;
if let Err(e) = git::apply_patch(workdir, &file_diff) {
let _ = git::reset_hard(workdir, &saved_head);
if let Err(re) = repo::restore_branch_refs(workdir, &saved_refs) {
msg::warn(&format!("failed to restore branch refs: {re}"));
}
return Err(e).context("Failed to uncommit file, operation rolled back");
}
}
msg::success(&format!(
"Uncommitted `{}` from `{}` (now `{}`) to working directory",
path,
git::short_hash(commit_hash),
git::short_hash(&new_hash)
));
Ok(())
}
fn fold_commit_file_to_commit(
repo: &Repository,
source_hash: &str,
path: &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");
}
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 source_is_newer = repo.graph_descendant_of(source_oid, target_oid)?;
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 =
|saved_head: &str, saved_refs: &std::collections::HashMap<String, git2::Oid>| {
let _ = git::branch_delete(workdir, TRACK_BRANCH);
let _ = git::reset_hard(workdir, saved_head);
if let Err(re) = repo::restore_branch_refs(workdir, saved_refs) {
msg::warn(&format!("failed to restore branch refs: {re}"));
}
};
let mut graph = Weave::from_repo(repo)?;
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_or_abort(workdir, Some(&graph.base_oid.to_string()), &todo)
{
let _ = git::branch_delete(workdir, TRACK_BRANCH);
return Err(e);
}
if let Err(e) = apply_and_amend_path(workdir, &file_diff, path, true) {
let _ = git::rebase_abort(workdir);
let _ = git::branch_delete(workdir, TRACK_BRANCH);
return Err(e);
}
let phase1_source_hash = git::rev_parse(workdir, "HEAD")?;
if let Err(e) = git::continue_rebase_or_abort(workdir) {
let _ = git::branch_delete(workdir, TRACK_BRANCH);
return Err(e);
}
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)?;
graph2.edit_commit(phase2_target_oid);
let phase1_source_oid = git2::Oid::from_str(&phase1_source_hash)?;
git::branch_force_create(workdir, TRACK_BRANCH, &phase1_source_hash)?;
graph2.track_commit(phase1_source_oid, TRACK_BRANCH);
let todo2 = graph2.to_todo();
if let Err(e) =
weave::run_rebase_or_abort(workdir, Some(&graph2.base_oid.to_string()), &todo2)
{
rollback(&saved_head, &saved_refs);
return Err(e);
}
if let Err(e) = apply_and_amend_path(workdir, &file_diff, path, false) {
let _ = git::rebase_abort(workdir);
rollback(&saved_head, &saved_refs);
return Err(e);
}
new_target_hash = git::rev_parse(workdir, "HEAD")?;
if let Err(e) = git::continue_rebase_or_abort(workdir) {
rollback(&saved_head, &saved_refs);
return Err(e);
}
new_source_hash = git::rev_parse(workdir, TRACK_BRANCH)?;
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)?;
graph.edit_commit(source_oid);
graph.edit_commit(target_oid);
let todo = graph.to_todo();
weave::run_rebase_or_abort(workdir, Some(&graph.base_oid.to_string()), &todo)?;
if let Err(e) = apply_and_amend_path(workdir, &file_diff, path, true) {
let _ = git::rebase_abort(workdir);
return Err(e);
}
new_source_hash = git::rev_parse(workdir, "HEAD")?;
git::continue_rebase_or_abort(workdir)?;
if let Err(e) = apply_and_amend_path(workdir, &file_diff, path, false) {
let _ = git::rebase_abort(workdir);
let _ = git::reset_hard(workdir, &saved_head);
if let Err(re) = repo::restore_branch_refs(workdir, &saved_refs) {
msg::warn(&format!("failed to restore branch refs: {re}"));
}
return Err(e);
}
new_target_hash = git::rev_parse(workdir, "HEAD")?;
git::continue_rebase_or_abort(workdir)?;
}
msg::success(&format!(
"Moved `{}` from `{}` (now `{}`) to `{}` (now `{}`)",
path,
git::short_hash(source_hash),
git::short_hash(&new_source_hash),
git::short_hash(target_hash),
git::short_hash(&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")?;
} else {
let diff = git::diff_commit(workdir, commit_hash)?;
let saved_head = head_oid.to_string();
let saved_refs = repo::snapshot_branch_refs(repo)?;
let mut graph = Weave::from_repo(repo)?;
if !graph.drop_commit(target_oid) {
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 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(),
})?;
let loom_state = LoomState {
command: COMMAND.to_string(),
rollback: Rollback::default(),
context: fold_ctx,
};
transaction::save(&git_dir, &loom_state)?;
let todo = graph.to_todo();
match weave::run_rebase(workdir, Some(&graph.base_oid.to_string()), &todo)? {
RebaseOutcome::Completed => {
transaction::delete(&git_dir)?;
if !diff.is_empty()
&& let Err(e) = git::apply_patch(workdir, &diff)
{
let _ = git::reset_hard(workdir, &saved_head);
if let Err(re) = repo::restore_branch_refs(workdir, &saved_refs) {
msg::warn(&format!("failed to restore branch refs: {re}"));
}
return Err(e).context(
"Failed to apply changes to working directory, operation rolled back",
);
}
}
RebaseOutcome::Conflicted => {
transaction::warn_conflict_paused(COMMAND);
return Ok(());
}
}
}
msg::success(&format!(
"Uncommitted `{}` to working directory",
git::short_hash(commit_hash)
));
Ok(())
}
pub fn after_continue(workdir: &Path, context: &serde_json::Value) -> Result<()> {
let variant: FoldVariant =
serde_json::from_value(context.clone()).context("Failed to parse fold resume context")?;
match variant {
FoldVariant::FilesIntoCommit {
original_commit_hash,
files_count,
saved_staged,
} => {
let new_hash = git::rev_parse(workdir, TRACK_BRANCH)?;
let _ = git::branch_delete(workdir, TRACK_BRANCH);
git::restore_staged_patch(workdir, &saved_staged)?;
msg::success(&format!(
"Folded {} file(s) into `{}` (now `{}`)",
files_count,
git::short_hash(&original_commit_hash),
git::short_hash(&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),
git::short_hash(&new_hash)
));
}
FoldVariant::CommitToBranch {
commit_hash,
branch_name,
} => {
let new_hash = git::rev_parse(workdir, &branch_name)?;
msg::success(&format!(
"Moved `{}` to branch `{}` (now `{}`)",
git::short_hash(&commit_hash),
branch_name,
git::short_hash(&new_hash)
));
}
FoldVariant::CommitToUnstaged { commit_hash, diff } => {
if !diff.is_empty()
&& let Err(e) = git::apply_patch(workdir, &diff)
{
let patch_path = workdir.join(".git/loom/unapplied.patch");
if let Some(parent) = patch_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&patch_path, &diff);
msg::warn(&format!(
"Could not re-apply changes to working directory: {}\n\
The diff has been saved to {}",
e,
patch_path.display()
));
}
msg::success(&format!(
"Uncommitted `{}` to working directory",
git::short_hash(&commit_hash)
));
}
}
Ok(())
}
#[cfg(test)]
#[path = "fold_test.rs"]
mod tests;