use anyhow::{Context, Result};
use std::collections::{HashMap, HashSet};
use std::process::Command;
use crate::diff::DiffHunk;
use crate::hunk_id::assign_ids;
use crate::patch::{
ApplyMode, apply_patch, build_patch, slice_hunk, slice_hunk_multi, slice_hunk_with_state,
};
const MAX_PREVIEW_LINES: usize = 4;
pub fn list_hunks(
staged: bool,
file: Option<&str>,
commit: Option<&str>,
full: bool,
blame: bool,
) -> Result<()> {
let diff_output = match commit {
Some(c) => crate::diff::run_git_diff_commit(c, file)?,
None => crate::diff::run_git_diff(staged, file)?,
};
let hunks = crate::diff::parse_diff(&diff_output);
let identified = assign_ids(&hunks);
if identified.is_empty() {
return Ok(());
}
for (id, hunk) in &identified {
let additions = hunk.lines.iter().filter(|l| l.starts_with('+')).count();
let deletions = hunk.lines.iter().filter(|l| l.starts_with('-')).count();
let func_ctx = hunk
.header
.find("@@ ")
.and_then(|start| {
let rest = &hunk.header[start + 3..];
rest.find("@@ ").map(|end| rest[end + 3..].trim())
})
.unwrap_or("");
let func_part = if func_ctx.is_empty() {
String::new()
} else {
format!(" {}", func_ctx)
};
println!(
"{} {}{} (+{} -{})",
id, hunk.file, func_part, additions, deletions
);
if blame {
print_blamed_lines(hunk, commit)?;
} else if full {
let width = hunk.lines.len().to_string().len();
for (i, line) in hunk.lines.iter().enumerate() {
println!("{:>w$}:{}", i + 1, line, w = width);
}
} else {
let changed: Vec<&String> = hunk
.lines
.iter()
.filter(|l| l.starts_with('+') || l.starts_with('-'))
.collect();
let show = changed.len().min(MAX_PREVIEW_LINES);
for line in &changed[..show] {
println!(" {}", line);
}
if changed.len() > MAX_PREVIEW_LINES {
println!(" ... (+{} more lines)", changed.len() - MAX_PREVIEW_LINES);
}
}
println!();
}
Ok(())
}
fn print_blamed_lines(hunk: &crate::diff::DiffHunk, commit: Option<&str>) -> Result<()> {
use crate::blame::{get_blame, parse_hunk_header};
let (old_from, old_count, new_from, new_count) =
parse_hunk_header(&hunk.header).unwrap_or((1, 0, 1, 0));
let (old_rev_str, new_rev): (String, Option<&str>) = match commit {
Some(c) => (format!("{}^", c), Some(c)),
None => ("HEAD".to_string(), None),
};
let old_blame = if hunk.old_file != "dev/null" && old_count > 0 {
get_blame(&hunk.old_file, old_from, old_count, Some(&old_rev_str)).unwrap_or_default()
} else {
Vec::new()
};
let new_blame = if hunk.new_file != "dev/null" && new_count > 0 {
get_blame(&hunk.new_file, new_from, new_count, new_rev).unwrap_or_default()
} else {
Vec::new()
};
let mut old_idx = 0usize;
let mut new_idx = 0usize;
for line in &hunk.lines {
let hash = if line.starts_with(' ') {
let h = new_blame
.get(new_idx)
.map(|s| s.as_str())
.unwrap_or("0000000");
old_idx += 1;
new_idx += 1;
h.to_string()
} else if line.starts_with('-') {
let h = old_blame
.get(old_idx)
.map(|s| s.as_str())
.unwrap_or("0000000");
old_idx += 1;
h.to_string()
} else if line.starts_with('+') {
let h = new_blame
.get(new_idx)
.map(|s| s.as_str())
.unwrap_or("0000000");
new_idx += 1;
h.to_string()
} else {
println!(" {}", line);
continue;
};
println!(" {} {}", hash, line);
}
Ok(())
}
pub fn show_hunk(id: &str, commit: Option<&str>) -> Result<()> {
let hunk = match commit {
Some(c) => find_hunk_in_commit(id, c)?,
None => find_hunk_by_id(id, false).or_else(|_| find_hunk_by_id(id, true))?,
};
println!("{}", hunk.header);
let width = hunk.lines.len().to_string().len();
for (i, line) in hunk.lines.iter().enumerate() {
println!("{:>w$}:{}", i + 1, line, w = width);
}
Ok(())
}
fn find_hunk_in_commit(id: &str, commit: &str) -> Result<DiffHunk> {
let diff_output = crate::diff::run_git_diff_commit(commit, None)?;
let hunks = crate::diff::parse_diff(&diff_output);
let identified = assign_ids(&hunks);
identified
.into_iter()
.find(|(hunk_id, _)| hunk_id == id)
.map(|(_, hunk)| hunk.clone())
.ok_or_else(|| anyhow::anyhow!("hunk {} not found in commit {}", id, commit))
}
fn find_hunk_by_id(id: &str, staged: bool) -> Result<DiffHunk> {
let diff_output = crate::diff::run_git_diff(staged, None)?;
let hunks = crate::diff::parse_diff(&diff_output);
let identified = assign_ids(&hunks);
identified
.into_iter()
.find(|(hunk_id, _)| hunk_id == id)
.map(|(_, hunk)| hunk.clone())
.ok_or_else(|| anyhow::anyhow!("hunk {} not found (re-run 'hunks')", id))
}
pub fn apply_hunks(ids: &[String], mode: ApplyMode, lines: Option<(usize, usize)>) -> Result<()> {
if lines.is_some() && ids.len() != 1 {
anyhow::bail!("--lines requires exactly one hunk ID");
}
let staged = matches!(mode, ApplyMode::Unstage);
let diff_output = crate::diff::run_git_diff(staged, None)?;
let hunks = crate::diff::parse_diff(&diff_output);
let identified = assign_ids(&hunks);
let mut combined_patch = String::new();
for id in ids {
let (_, hunk) = identified
.iter()
.find(|(hunk_id, _)| hunk_id == id)
.ok_or_else(|| anyhow::anyhow!("hunk {} not found (re-run 'hunks')", id))?;
crate::diff::check_supported(hunk, id)?;
let reverse = matches!(mode, ApplyMode::Unstage | ApplyMode::Discard);
let patched_hunk = if let Some((start, end)) = lines {
slice_hunk(hunk, start, end, reverse)?
} else {
(*hunk).clone()
};
combined_patch.push_str(&build_patch(&patched_hunk));
eprintln!("{}", id);
}
apply_patch(&combined_patch, &mode)?;
Ok(())
}
fn parse_id_range(raw: &str) -> Result<(&str, Vec<(usize, usize)>)> {
if let Some((id, range_str)) = raw.split_once(':') {
let mut ranges = Vec::new();
for part in range_str.split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}
let (start, end) = if let Some((a, b)) = part.split_once('-') {
let start: usize = a
.parse()
.map_err(|_| anyhow::anyhow!("invalid start number in '{}'", raw))?;
let end: usize = b
.parse()
.map_err(|_| anyhow::anyhow!("invalid end number in '{}'", raw))?;
(start, end)
} else {
let n: usize = part
.parse()
.map_err(|_| anyhow::anyhow!("invalid line number in '{}'", raw))?;
(n, n)
};
if start == 0 || end == 0 || start > end {
anyhow::bail!("range must be 1-based and start <= end in '{}'", raw);
}
ranges.push((start, end));
}
Ok((id, ranges))
} else {
Ok((raw, Vec::new()))
}
}
fn require_clean_index() -> Result<()> {
let status = Command::new("git")
.args(["diff", "--cached", "--quiet"])
.status()
.context("failed to check staged changes")?;
if !status.success() {
anyhow::bail!("index already contains staged changes; commit or unstage them first");
}
Ok(())
}
type HunkEntry<'a> = (String, Vec<(usize, usize)>, &'a DiffHunk);
struct ResolvedHunks<'a> {
entries: Vec<HunkEntry<'a>>,
}
fn resolve_hunks<'a>(
ids: &[String],
identified: &'a [(String, &'a DiffHunk)],
) -> Result<ResolvedHunks<'a>> {
let mut entries: Vec<HunkEntry> = Vec::new();
for raw_id in ids {
let (id, ranges) = parse_id_range(raw_id)?;
if let Some(entry) = entries.iter_mut().find(|(eid, _, _)| eid == id) {
entry.1.extend(ranges);
} else {
let (_, hunk) = identified
.iter()
.find(|(hunk_id, _)| hunk_id == id)
.ok_or_else(|| anyhow::anyhow!("hunk {} not found (re-run 'hunks')", id))?;
crate::diff::check_supported(hunk, id)?;
entries.push((id.to_string(), ranges, hunk));
}
}
Ok(ResolvedHunks { entries })
}
fn build_combined_patch(resolved: &ResolvedHunks, reverse: bool) -> Result<String> {
let mut combined_patch = String::new();
for (id, ranges, hunk) in &resolved.entries {
let patched_hunk = if ranges.is_empty() {
(*hunk).clone()
} else {
slice_hunk_multi(hunk, ranges, reverse)?
};
combined_patch.push_str(&build_patch(&patched_hunk));
if !reverse {
eprintln!("{}", id);
}
}
Ok(combined_patch)
}
fn build_patch_from_ids(ids: &[String]) -> Result<String> {
let diff_output = crate::diff::run_git_diff(false, None)?;
let hunks = crate::diff::parse_diff(&diff_output);
let identified = assign_ids(&hunks);
let resolved = resolve_hunks(ids, &identified)?;
build_combined_patch(&resolved, false)
}
pub fn commit_hunks(ids: &[String], message: &str) -> Result<()> {
require_clean_index()?;
let combined_patch = build_patch_from_ids(ids)?;
apply_patch(&combined_patch, &ApplyMode::Stage)?;
let output = Command::new("git")
.args(["commit", "-m", message])
.output()
.context("failed to run git commit")?;
if !output.status.success() {
let _ = apply_patch(&combined_patch, &ApplyMode::Unstage);
anyhow::bail!(
"git commit failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
pub fn commit_to_hunks(branch: &str, ids: &[String], message: &str) -> Result<()> {
require_clean_index()?;
let target_ref = format!("refs/heads/{}", branch);
let target_sha =
crate::diff::run_git_cmd(Command::new("git").args(["rev-parse", "--verify", &target_ref]))
.with_context(|| format!("branch '{}' not found", branch))?;
let target_sha = target_sha.trim().to_string();
let current_ref = Command::new("git")
.args(["symbolic-ref", "--quiet", "HEAD"])
.output()
.context("failed to check current branch")?;
if current_ref.status.success() {
let current = String::from_utf8_lossy(¤t_ref.stdout)
.trim()
.to_string();
if current == target_ref {
anyhow::bail!(
"target branch '{}' is currently checked out; use 'commit' instead",
branch
);
}
}
let diff_output = crate::diff::run_git_diff(false, None)?;
let hunks = crate::diff::parse_diff(&diff_output);
let identified = assign_ids(&hunks);
let resolved = resolve_hunks(ids, &identified)?;
let stage_patch = build_combined_patch(&resolved, false)?;
if stage_patch.is_empty() {
anyhow::bail!("no hunks selected");
}
let discard_patch = build_combined_patch(&resolved, true)?;
let git_dir = crate::diff::run_git_cmd(Command::new("git").args(["rev-parse", "--git-dir"]))?;
let git_dir = git_dir.trim();
let tmp_index =
std::path::PathBuf::from(git_dir).join(format!("surgeon-tmp-index-{}", std::process::id()));
let result = commit_to_with_index(
branch,
&target_ref,
&target_sha,
&stage_patch,
message,
&tmp_index,
);
let _ = std::fs::remove_file(&tmp_index);
match result {
Ok(()) => {
apply_patch(&discard_patch, &ApplyMode::Discard).with_context(|| {
format!(
"committed to {} but failed to discard local hunks; \
the changes are still in your working tree",
branch
)
})?;
Ok(())
}
Err(e) => Err(e),
}
}
fn commit_to_with_index(
branch: &str,
target_ref: &str,
target_sha: &str,
patch: &str,
message: &str,
tmp_index: &std::path::Path,
) -> Result<()> {
use std::io::Write;
use std::process::Stdio;
let status = Command::new("git")
.env("GIT_INDEX_FILE", tmp_index)
.args(["read-tree", target_sha])
.status()
.context("failed to read target branch tree")?;
if !status.success() {
anyhow::bail!("git read-tree failed for {}", branch);
}
crate::patch::apply_patch_to_index(patch, &ApplyMode::Stage, tmp_index).with_context(|| {
format!(
"failed to apply patch to branch '{}'; changes may be incompatible with that branch",
branch
)
})?;
let tree_sha = crate::diff::run_git_cmd(
Command::new("git")
.env("GIT_INDEX_FILE", tmp_index)
.args(["write-tree"]),
)?;
let tree_sha = tree_sha.trim();
let mut cmd = Command::new("git");
cmd.args(["commit-tree", tree_sha, "-p", target_sha, "-F", "-"]);
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().context("failed to run git commit-tree")?;
child
.stdin
.as_mut()
.unwrap()
.write_all(message.as_bytes())?;
let output = child.wait_with_output()?;
if !output.status.success() {
anyhow::bail!(
"git commit-tree failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let commit_sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
let output = Command::new("git")
.args(["update-ref", target_ref, &commit_sha, target_sha])
.output()
.context("failed to update branch ref")?;
if !output.status.success() {
anyhow::bail!(
"git update-ref failed (branch may have moved): {}",
String::from_utf8_lossy(&output.stderr)
);
}
eprintln!(
"committed to {}: {}",
branch,
&commit_sha[..7.min(commit_sha.len())]
);
Ok(())
}
pub fn undo_hunks(ids: &[String], commit: &str, lines: Option<(usize, usize)>) -> Result<()> {
if lines.is_some() && ids.len() != 1 {
anyhow::bail!("--lines requires exactly one hunk ID");
}
let diff_output = crate::diff::run_git_diff_commit(commit, None)?;
let hunks = crate::diff::parse_diff(&diff_output);
let identified = assign_ids(&hunks);
let mut combined_patch = String::new();
for id in ids {
let (_, hunk) = identified
.iter()
.find(|(hunk_id, _)| hunk_id == id)
.ok_or_else(|| anyhow::anyhow!("hunk {} not found in commit {}", id, commit))?;
crate::diff::check_supported(hunk, id)?;
let patched_hunk = if let Some((start, end)) = lines {
slice_hunk(hunk, start, end, true)?
} else {
(*hunk).clone()
};
combined_patch.push_str(&build_patch(&patched_hunk));
eprintln!("{}", id);
}
apply_patch(&combined_patch, &ApplyMode::Discard)?;
Ok(())
}
pub fn undo_files(files: &[String], commit: &str) -> Result<()> {
let diff_output = crate::diff::run_git_diff_commit(commit, None)?;
let hunks = crate::diff::parse_diff(&diff_output);
let mut combined_patch = String::new();
let mut matched_files = HashSet::new();
for hunk in &hunks {
if files
.iter()
.any(|f| f == &hunk.file || f == &hunk.old_file || f == &hunk.new_file)
{
crate::diff::check_supported(hunk, &hunk.file)?;
combined_patch.push_str(&build_patch(hunk));
matched_files.extend(
files
.iter()
.filter(|f| *f == &hunk.file || *f == &hunk.old_file || *f == &hunk.new_file),
);
}
}
for file in files {
if !matched_files.contains(&file) {
anyhow::bail!("file {} not found in commit {}", file, commit);
}
eprintln!("{}", file);
}
apply_patch(&combined_patch, &ApplyMode::Discard)?;
Ok(())
}
fn has_staged_changes() -> Result<bool> {
let output = Command::new("git")
.args(["diff", "--cached", "--quiet"])
.output()
.context("failed to run git diff")?;
match output.status.code() {
Some(0) => Ok(false),
Some(1) => Ok(true),
_ => anyhow::bail!(
"git diff --cached failed: {}",
String::from_utf8_lossy(&output.stderr)
),
}
}
pub fn amend(commit: &str) -> Result<()> {
if !has_staged_changes()? {
anyhow::bail!(
"no staged changes to amend; to fold an existing commit, use `git-surgeon fold {commit}`"
);
}
check_no_rebase_in_progress()?;
let target_sha = crate::diff::run_git_cmd(Command::new("git").args(["rev-parse", commit]))?;
let target_sha = target_sha.trim();
let head_sha = crate::diff::run_git_cmd(Command::new("git").args(["rev-parse", "HEAD"]))?;
let head_sha = head_sha.trim();
if target_sha == head_sha {
let output = Command::new("git")
.args(["commit", "--amend", "--no-edit"])
.output()
.context("failed to amend HEAD")?;
if !output.status.success() {
anyhow::bail!(
"git commit --amend failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
} else {
let subject = crate::diff::run_git_cmd(Command::new("git").args([
"log",
"-1",
"--format=%s",
target_sha,
]))?;
let subject = subject.trim();
let output = Command::new("git")
.args(["commit", "-m", &format!("fixup! {}", subject)])
.output()
.context("failed to create fixup commit")?;
if !output.status.success() {
anyhow::bail!(
"git commit failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let is_root = Command::new("git")
.args(["rev-parse", "--verify", &format!("{}^", target_sha)])
.output()
.map(|o| !o.status.success())
.unwrap_or(false);
let mut rebase_cmd = Command::new("git");
rebase_cmd.args(["rebase", "-i", "--autosquash", "--autostash"]);
if is_root {
rebase_cmd.arg("--root");
} else {
rebase_cmd.arg(format!("{}~1", target_sha));
}
rebase_cmd.env("GIT_SEQUENCE_EDITOR", "true");
let output = rebase_cmd.output().context("failed to run rebase")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!(
"error: rebase conflict while fixing up {}",
&target_sha[..7.min(target_sha.len())]
);
eprintln!("resolve conflicts and run: git rebase --continue");
eprintln!("or abort with: git rebase --abort");
anyhow::bail!("rebase failed: {}", stderr);
}
}
let info = crate::diff::run_git_cmd(Command::new("git").args([
"log",
"-1",
"--format=%h %s",
target_sha,
]));
if let Ok(info) = info {
eprintln!("amended {}", info.trim());
}
Ok(())
}
pub fn edit_todo(file: &str, sources: &[String], target: &str, mode: &str) -> Result<()> {
let content = std::fs::read_to_string(file).context("failed to read todo file")?;
let mut lines: Vec<String> = content.lines().map(String::from).collect();
let target_short = &target[..7.min(target.len())];
match mode {
"fixup" => {
let source_shorts: Vec<&str> = sources.iter().map(|s| &s[..7.min(s.len())]).collect();
let mut extracted = Vec::new();
let mut i = 0;
while i < lines.len() {
let trimmed = lines[i].trim().to_string();
if !trimmed.starts_with('#')
&& let Some(sha) = trimmed.split_whitespace().nth(1)
{
let is_source = source_shorts.iter().any(|short| sha.starts_with(short))
|| sources.iter().any(|full| full.starts_with(sha));
if is_source {
let mut line = lines.remove(i);
if let Some(rest) = line.strip_prefix("pick ") {
line = format!("fixup {}", rest);
}
extracted.push(line);
continue;
}
}
i += 1;
}
if extracted.len() != sources.len() {
let found = extracted.len();
anyhow::bail!(
"expected {} source commit(s) in todo but found {}",
sources.len(),
found
);
}
let target_idx = lines.iter().position(|l| {
let l = l.trim();
!l.starts_with('#')
&& l.split_whitespace()
.nth(1)
.is_some_and(|sha| sha.starts_with(target_short) || target.starts_with(sha))
});
let target_idx = target_idx.ok_or_else(|| {
anyhow::anyhow!("target commit {} not found in todo", target_short)
})?;
for (j, line) in extracted.into_iter().enumerate() {
lines.insert(target_idx + 1 + j, line);
}
}
"move" | "move-before" => {
if sources.len() != 1 {
anyhow::bail!("move mode requires exactly one source commit");
}
let source = &sources[0];
let source_short = &source[..7.min(source.len())];
let source_idx = lines.iter().position(|l| {
let l = l.trim();
!l.starts_with('#')
&& l.split_whitespace()
.nth(1)
.is_some_and(|sha| sha.starts_with(source_short) || source.starts_with(sha))
});
let source_idx = source_idx.ok_or_else(|| {
anyhow::anyhow!("source commit {} not found in todo", source_short)
})?;
let source_line = lines.remove(source_idx);
let target_idx = lines.iter().position(|l| {
let l = l.trim();
!l.starts_with('#')
&& l.split_whitespace()
.nth(1)
.is_some_and(|sha| sha.starts_with(target_short) || target.starts_with(sha))
});
let target_idx = target_idx.ok_or_else(|| {
anyhow::anyhow!("target commit {} not found in todo", target_short)
})?;
if mode == "move-before" {
lines.insert(target_idx, source_line);
} else {
lines.insert(target_idx + 1, source_line);
}
}
_ => anyhow::bail!("unknown edit-todo mode: {}", mode),
}
std::fs::write(file, lines.join("\n") + "\n").context("failed to write todo file")?;
Ok(())
}
pub fn move_commit(
commit: &str,
after: Option<&str>,
before: Option<&str>,
to_end: bool,
) -> Result<()> {
check_no_rebase_in_progress()?;
let source_sha = crate::diff::run_git_cmd(Command::new("git").args(["rev-parse", commit]))?;
let source_sha = source_sha.trim().to_string();
let head_sha = crate::diff::run_git_cmd(Command::new("git").args(["rev-parse", "HEAD"]))?;
let head_sha = head_sha.trim().to_string();
if source_sha == head_sha && to_end {
eprintln!("commit is already at HEAD");
return Ok(());
}
let (target_sha, insert_after) = if to_end {
(head_sha.clone(), true)
} else if let Some(after_ref) = after {
let sha = crate::diff::run_git_cmd(Command::new("git").args(["rev-parse", after_ref]))?;
(sha.trim().to_string(), true)
} else if let Some(before_ref) = before {
let sha = crate::diff::run_git_cmd(Command::new("git").args(["rev-parse", before_ref]))?;
(sha.trim().to_string(), false)
} else {
anyhow::bail!("one of --after, --before, or --to-end is required");
};
if source_sha == target_sha {
anyhow::bail!("source and target are the same commit");
}
for (label, sha) in [("source", &source_sha), ("target", &target_sha)] {
let is_ancestor = Command::new("git")
.args(["merge-base", "--is-ancestor", sha, &head_sha])
.status()
.context("failed to check ancestry")?;
if !is_ancestor.success() {
anyhow::bail!(
"{} commit {} is not in the current branch",
label,
&sha[..7.min(sha.len())]
);
}
}
let oldest_sha = {
let is_source_ancestor = Command::new("git")
.args(["merge-base", "--is-ancestor", &source_sha, &target_sha])
.status()
.context("failed to check ancestry")?;
if is_source_ancestor.success() {
source_sha.clone()
} else {
target_sha.clone()
}
};
let is_root = Command::new("git")
.args(["rev-parse", "--verify", &format!("{}^", oldest_sha)])
.output()
.map(|o| !o.status.success())
.unwrap_or(false);
let merges_range = if is_root {
head_sha.clone()
} else {
format!("{}~1..{}", oldest_sha, head_sha)
};
let merges = Command::new("git")
.args(["rev-list", "--merges", &merges_range])
.output()
.context("failed to check for merge commits")?;
if !merges.status.success() {
anyhow::bail!(
"failed to check for merge commits: {}",
String::from_utf8_lossy(&merges.stderr)
);
}
if !String::from_utf8_lossy(&merges.stdout).trim().is_empty() {
anyhow::bail!("range contains merge commits; move cannot proceed");
}
let (editor_target, editor_mode) = if insert_after {
(target_sha.clone(), "move")
} else {
(target_sha.clone(), "move-before")
};
let exe = std::env::current_exe().context("failed to get current executable path")?;
let editor = format!(
"{} _edit-todo --source {} --target {} --mode {}",
exe.display(),
source_sha,
editor_target,
editor_mode
);
let mut rebase_cmd = Command::new("git");
rebase_cmd.args(["rebase", "-i", "--autostash"]);
if is_root {
rebase_cmd.arg("--root");
} else {
rebase_cmd.arg(format!("{}~1", oldest_sha));
}
rebase_cmd.env("GIT_SEQUENCE_EDITOR", &editor);
let output = rebase_cmd.output().context("failed to run rebase")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!(
"error: rebase conflict while moving {}",
&source_sha[..7.min(source_sha.len())]
);
eprintln!("resolve conflicts and run: git rebase --continue");
eprintln!("or abort with: git rebase --abort");
anyhow::bail!("rebase failed: {}", stderr);
}
let mut log_cmd = Command::new("git");
log_cmd.args(["log", "--oneline", "--reverse"]);
if is_root {
log_cmd.arg("HEAD");
} else {
log_cmd.arg(format!("{}~1..HEAD", oldest_sha));
}
if let Ok(log) = crate::diff::run_git_cmd(&mut log_cmd) {
eprintln!("moved commit, new order:");
for line in log.trim().lines() {
eprintln!(" {}", line);
}
}
Ok(())
}
pub fn fold(target: &str, sources: &[String]) -> Result<()> {
check_no_rebase_in_progress()?;
if has_staged_changes()? {
anyhow::bail!(
"index has staged changes; `fold` folds existing commits, not staged changes. Use `git-surgeon amend {target}` to fold staged changes."
);
}
let target_sha = crate::diff::run_git_cmd(Command::new("git").args(["rev-parse", target]))?;
let target_sha = target_sha.trim().to_string();
let head_sha = crate::diff::run_git_cmd(Command::new("git").args(["rev-parse", "HEAD"]))?;
let head_sha = head_sha.trim().to_string();
let source_shas: Vec<String> = if sources.is_empty() {
vec![head_sha.clone()]
} else {
let mut shas = Vec::new();
for s in sources {
let sha = crate::diff::run_git_cmd(Command::new("git").args(["rev-parse", s]))?;
let sha = sha.trim().to_string();
if !shas.contains(&sha) {
shas.push(sha);
}
}
shas
};
for sha in &source_shas {
if *sha == target_sha {
anyhow::bail!("target and source are the same commit");
}
}
for sha in &source_shas {
let is_ancestor = Command::new("git")
.args(["merge-base", "--is-ancestor", &target_sha, sha])
.status()
.context("failed to check ancestry")?;
if !is_ancestor.success() {
anyhow::bail!(
"commit {} is not an ancestor of {}",
&target_sha[..7.min(target_sha.len())],
&sha[..7.min(sha.len())]
);
}
}
for sha in &source_shas {
if *sha != head_sha {
let is_ancestor = Command::new("git")
.args(["merge-base", "--is-ancestor", sha, &head_sha])
.status()
.context("failed to check ancestry")?;
if !is_ancestor.success() {
anyhow::bail!(
"source commit {} is not an ancestor of HEAD",
&sha[..7.min(sha.len())]
);
}
}
}
let merges = Command::new("git")
.args(["rev-list", "--merges", &format!("{}..HEAD", target_sha)])
.output()
.context("failed to check for merge commits")?;
if !String::from_utf8_lossy(&merges.stdout).trim().is_empty() {
anyhow::bail!("range contains merge commits; fold cannot proceed");
}
if source_shas.len() == 1 && source_shas[0] == head_sha {
let output = Command::new("git")
.args(["reset", "--soft", "HEAD~1"])
.output()
.context("failed to reset HEAD")?;
if !output.status.success() {
anyhow::bail!(
"git reset failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
amend(&target_sha)?;
} else {
let exe = std::env::current_exe().context("failed to get current executable path")?;
let is_root = Command::new("git")
.args(["rev-parse", "--verify", &format!("{}^", target_sha)])
.output()
.map(|o| !o.status.success())
.unwrap_or(false);
let source_args: String = source_shas
.iter()
.map(|s| format!(" --source {}", s))
.collect();
let editor = format!(
"{} _edit-todo{} --target {}",
exe.display(),
source_args,
target_sha
);
let mut rebase_cmd = Command::new("git");
rebase_cmd.args(["rebase", "-i", "--autostash"]);
if is_root {
rebase_cmd.arg("--root");
} else {
rebase_cmd.arg(format!("{}~1", target_sha));
}
rebase_cmd.env("GIT_SEQUENCE_EDITOR", &editor);
let output = rebase_cmd.output().context("failed to run rebase")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!(
"error: rebase conflict while folding into {}",
&target_sha[..7.min(target_sha.len())]
);
eprintln!("resolve conflicts and run: git rebase --continue");
eprintln!("or abort with: git rebase --abort");
anyhow::bail!("rebase failed: {}", stderr);
}
let distance = crate::diff::run_git_cmd(Command::new("git").args([
"rev-list",
"--count",
&format!("{}..HEAD", target_sha),
]));
if let Ok(d) = distance {
let d: usize = d.trim().parse().unwrap_or(0);
let ref_spec = if d == 0 {
"HEAD".to_string()
} else {
format!("HEAD~{}", d)
};
let info = crate::diff::run_git_cmd(Command::new("git").args([
"log",
"-1",
"--format=%h %s",
&ref_spec,
]));
if let Ok(info) = info {
eprintln!("folded {}", info.trim());
}
}
}
Ok(())
}
pub fn reword(commit: &str, message: &str) -> Result<()> {
check_no_rebase_in_progress()?;
let target_sha = crate::diff::run_git_cmd(Command::new("git").args(["rev-parse", commit]))?;
let target_sha = target_sha.trim();
let head_sha = crate::diff::run_git_cmd(Command::new("git").args(["rev-parse", "HEAD"]))?;
let head_sha = head_sha.trim();
let distance = crate::diff::run_git_cmd(Command::new("git").args([
"rev-list",
"--count",
&format!("{}..HEAD", target_sha),
]))?;
let distance: usize = distance.trim().parse().unwrap_or(0);
if target_sha == head_sha {
let output = Command::new("git")
.args(["commit", "--amend", "-m", message])
.output()
.context("failed to amend HEAD")?;
if !output.status.success() {
anyhow::bail!(
"git commit --amend failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
} else {
let subject = crate::diff::run_git_cmd(Command::new("git").args([
"log",
"-1",
"--format=%s",
target_sha,
]))?;
let subject = subject.trim();
let output = Command::new("git")
.args([
"commit",
"--allow-empty",
"-m",
&format!("amend! {}\n\n{}", subject, message),
])
.output()
.context("failed to create reword commit")?;
if !output.status.success() {
anyhow::bail!(
"git commit failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let is_root = Command::new("git")
.args(["rev-parse", "--verify", &format!("{}^", target_sha)])
.output()
.map(|o| !o.status.success())
.unwrap_or(false);
let mut rebase_cmd = Command::new("git");
rebase_cmd.args(["rebase", "-i", "--autosquash", "--autostash"]);
if is_root {
rebase_cmd.arg("--root");
} else {
rebase_cmd.arg(format!("{}~1", target_sha));
}
rebase_cmd.env("GIT_SEQUENCE_EDITOR", "true");
let output = rebase_cmd.output().context("failed to run rebase")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!(
"error: rebase conflict while rewording {}",
&target_sha[..7.min(target_sha.len())]
);
eprintln!("resolve conflicts and run: git rebase --continue");
eprintln!("or abort with: git rebase --abort");
anyhow::bail!("rebase failed: {}", stderr);
}
}
let ref_spec = if distance == 0 {
"HEAD".to_string()
} else {
format!("HEAD~{}", distance)
};
let info = crate::diff::run_git_cmd(Command::new("git").args([
"log",
"-1",
"--format=%h %s",
&ref_spec,
]));
if let Ok(info) = info {
eprintln!("reworded {}", info.trim());
}
Ok(())
}
pub fn split(
commit: &str,
pick_groups: &[crate::PickGroup],
rest_message: Option<&[String]>,
) -> Result<()> {
let status = Command::new("git")
.args(["status", "--porcelain"])
.output()
.context("failed to check git status")?;
if !String::from_utf8_lossy(&status.stdout).trim().is_empty() {
anyhow::bail!("working tree is dirty; commit or stash changes before splitting");
}
check_no_rebase_in_progress()?;
let target_sha = crate::diff::run_git_cmd(Command::new("git").args(["rev-parse", commit]))?;
let target_sha = target_sha.trim().to_string();
let head_sha = crate::diff::run_git_cmd(Command::new("git").args(["rev-parse", "HEAD"]))?;
let head_sha = head_sha.trim().to_string();
let is_head = target_sha == head_sha;
let diff_output = crate::diff::run_git_diff_commit(&target_sha, None)?;
let hunks = crate::diff::parse_diff(&diff_output);
let identified = assign_ids(&hunks);
for group in pick_groups {
for (id, _) in &group.ids {
let (_, hunk) = identified
.iter()
.find(|(hid, _)| hid == id)
.ok_or_else(|| {
anyhow::anyhow!(
"hunk {} not found in commit {}",
id,
&target_sha[..7.min(target_sha.len())]
)
})?;
crate::diff::check_supported(hunk, id)?;
}
}
let original_message = crate::diff::run_git_cmd(Command::new("git").args([
"log",
"-1",
"--format=%B",
&target_sha,
]))?;
let original_message = original_message.trim();
let rest_msg_joined;
let rest_msg = match rest_message {
Some(parts) => {
rest_msg_joined = parts.join("\n\n");
rest_msg_joined.as_str()
}
None => original_message,
};
struct HunkState {
hunk: DiffHunk,
picked: Vec<bool>, }
let mut hunk_states: HashMap<String, HunkState> = identified
.iter()
.map(|(id, hunk)| {
(
id.clone(),
HunkState {
hunk: (*hunk).clone(),
picked: vec![false; hunk.lines.len()],
},
)
})
.collect();
for group in pick_groups {
let mut hunk_ranges: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
for (id, lines_range) in &group.ids {
if let Some(range) = lines_range {
hunk_ranges.entry(id.clone()).or_default().push(*range);
}
}
for (id, ranges) in &hunk_ranges {
let state = hunk_states
.get(id)
.ok_or_else(|| anyhow::anyhow!("hunk {} not found", id))?;
for (start, end) in ranges {
if *start == 0 || *end == 0 {
anyhow::bail!("line ranges are 1-based, got {}:{}-{}", id, start, end);
}
if *end > state.hunk.lines.len() {
anyhow::bail!(
"line range {}:{}-{} exceeds hunk length ({})",
id,
start,
end,
state.hunk.lines.len()
);
}
}
}
}
if !is_head {
start_rebase_at_commit(&target_sha)?;
} else {
let output = Command::new("git")
.args(["reset", "HEAD~"])
.output()
.context("failed to reset HEAD")?;
if !output.status.success() {
anyhow::bail!(
"git reset failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
}
for group in pick_groups {
let mut combined_patch = String::new();
let mut hunk_ranges: Vec<(String, Vec<(usize, usize)>)> = Vec::new();
for (id, lines_range) in &group.ids {
if let Some(entry) = hunk_ranges.iter_mut().find(|(eid, _)| eid == id) {
if let Some(range) = lines_range {
entry.1.push(*range);
}
} else {
let ranges = match lines_range {
Some(range) => vec![*range],
None => vec![],
};
hunk_ranges.push((id.clone(), ranges));
}
}
for (id, ranges) in &hunk_ranges {
let state = hunk_states
.get_mut(id)
.ok_or_else(|| anyhow::anyhow!("hunk {} not found", id))?;
let mut selected = vec![false; state.hunk.lines.len()];
if ranges.is_empty() {
for (i, line) in state.hunk.lines.iter().enumerate() {
if (line.starts_with('+') || line.starts_with('-')) && !state.picked[i] {
selected[i] = true;
}
}
} else {
for (start, end) in ranges {
#[allow(clippy::needless_range_loop)]
for i in (*start - 1)..*end {
if i < state.hunk.lines.len() {
let line = &state.hunk.lines[i];
if line.starts_with('+') || line.starts_with('-') {
if state.picked[i] {
anyhow::bail!(
"line {} in hunk {} was already picked in a previous group",
i + 1,
id
);
}
selected[i] = true;
}
}
}
}
}
let has_changes = selected.iter().any(|&s| s);
if !has_changes {
continue;
}
let patched_hunk = slice_hunk_with_state(&state.hunk, &state.picked, &selected)?;
combined_patch.push_str(&build_patch(&patched_hunk));
for (i, sel) in selected.iter().enumerate() {
if *sel {
state.picked[i] = true;
}
}
}
if combined_patch.is_empty() {
anyhow::bail!("no changes selected for commit");
}
apply_patch(&combined_patch, &ApplyMode::Stage)?;
let message = group.message_parts.join("\n\n");
let output = Command::new("git")
.args(["commit", "-m", &message])
.output()
.context("failed to commit")?;
if !output.status.success() {
anyhow::bail!(
"git commit failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let subject = message.lines().next().unwrap_or(&message);
eprintln!("committed: {}", subject);
}
let mut has_remaining = false;
let mut combined_patch = String::new();
for (id, state) in &hunk_states {
let mut remaining_selected = vec![false; state.hunk.lines.len()];
for (i, line) in state.hunk.lines.iter().enumerate() {
if (line.starts_with('+') || line.starts_with('-')) && !state.picked[i] {
remaining_selected[i] = true;
has_remaining = true;
}
}
if remaining_selected.iter().any(|&s| s) {
let patched_hunk =
slice_hunk_with_state(&state.hunk, &state.picked, &remaining_selected)?;
combined_patch.push_str(&build_patch(&patched_hunk));
for (i, sel) in remaining_selected.iter().enumerate() {
if *sel {
let _ = (id, sel, i); }
}
}
}
if has_remaining {
apply_patch(&combined_patch, &ApplyMode::Stage)?;
let output = Command::new("git")
.args(["commit", "-m", rest_msg])
.output()
.context("failed to commit remaining")?;
if !output.status.success() {
anyhow::bail!(
"git commit failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let subject = rest_msg.lines().next().unwrap_or(rest_msg);
eprintln!("committed: {}", subject);
}
if !is_head {
let output = Command::new("git")
.args(["rebase", "--continue"])
.output()
.context("failed to continue rebase")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!("error: rebase continue failed");
eprintln!("resolve conflicts and run: git rebase --continue");
eprintln!("or abort with: git rebase --abort");
anyhow::bail!("rebase continue failed: {}", stderr);
}
}
Ok(())
}
fn check_no_rebase_in_progress() -> Result<()> {
for dir_name in ["rebase-merge", "rebase-apply"] {
let check = Command::new("git")
.args(["rev-parse", "--git-path", dir_name])
.output()
.context("failed to check rebase state")?;
let dir = String::from_utf8_lossy(&check.stdout).trim().to_string();
if std::path::Path::new(&dir).exists() {
anyhow::bail!("rebase already in progress");
}
}
Ok(())
}
pub fn squash(commit: &str, message: &str, force: bool, preserve_author: bool) -> Result<()> {
check_no_rebase_in_progress()?;
let status = Command::new("git")
.args(["status", "--porcelain", "--untracked-files=no"])
.output()
.context("failed to check git status")?;
let needs_stash = !String::from_utf8_lossy(&status.stdout).trim().is_empty();
if needs_stash {
let output = Command::new("git")
.args(["stash", "push", "-m", "git-surgeon squash autostash"])
.output()
.context("failed to stash changes")?;
if !output.status.success() {
anyhow::bail!(
"git stash failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
}
let target_sha = crate::diff::run_git_cmd(Command::new("git").args(["rev-parse", commit]))
.with_context(|| format!("could not resolve commit '{}'", commit))?;
let target_sha = target_sha.trim();
let head_sha = crate::diff::run_git_cmd(Command::new("git").args(["rev-parse", "HEAD"]))?;
let head_sha = head_sha.trim();
if target_sha == head_sha {
anyhow::bail!("nothing to squash: target commit is HEAD");
}
let (author, author_date) = if preserve_author {
let ident = crate::diff::run_git_cmd(Command::new("git").args([
"log",
"-1",
"--format=%an <%ae>",
target_sha,
]))?
.trim()
.to_string();
let date = crate::diff::run_git_cmd(Command::new("git").args([
"log",
"-1",
"--format=%aI", target_sha,
]))?
.trim()
.to_string();
(Some(ident), Some(date))
} else {
(None, None)
};
let is_ancestor = Command::new("git")
.args(["merge-base", "--is-ancestor", target_sha, "HEAD"])
.status()
.context("failed to check ancestry")?;
if !is_ancestor.success() {
anyhow::bail!(
"commit {} is not an ancestor of HEAD",
&target_sha[..7.min(target_sha.len())]
);
}
if !force {
let merges = Command::new("git")
.args(["rev-list", "--merges", &format!("{}..HEAD", target_sha)])
.output()
.context("failed to check for merge commits")?;
if !String::from_utf8_lossy(&merges.stdout).trim().is_empty() {
anyhow::bail!(
"range contains merge commits which will be flattened; use --force to proceed"
);
}
}
let is_root = Command::new("git")
.args(["rev-parse", "--verify", &format!("{}^", target_sha)])
.output()
.map(|o| !o.status.success())
.unwrap_or(false);
if is_root {
let output = Command::new("git")
.args(["update-ref", "-d", "HEAD"])
.output()
.context("failed to delete HEAD ref")?;
if !output.status.success() {
anyhow::bail!(
"git update-ref failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let mut commit_cmd = Command::new("git");
commit_cmd.args(["commit", "-m", message]);
if let Some(ref auth) = author {
commit_cmd.args(["--author", auth]);
}
if let Some(ref date) = author_date {
commit_cmd.args(["--date", date]);
}
let output = commit_cmd.output().context("failed to commit")?;
if !output.status.success() {
anyhow::bail!(
"git commit failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
} else {
let output = Command::new("git")
.args(["reset", "--soft", &format!("{}^", target_sha)])
.output()
.context("failed to reset")?;
if !output.status.success() {
anyhow::bail!(
"git reset failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let mut commit_cmd = Command::new("git");
commit_cmd.args(["commit", "-m", message]);
if let Some(ref auth) = author {
commit_cmd.args(["--author", auth]);
}
if let Some(ref date) = author_date {
commit_cmd.args(["--date", date]);
}
let output = commit_cmd.output().context("failed to commit")?;
if !output.status.success() {
anyhow::bail!(
"git commit failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
}
let count = crate::diff::run_git_cmd(Command::new("git").args([
"rev-list",
"--count",
&format!("{}..{}", target_sha, head_sha),
]))?;
let count: i32 = count.trim().parse().unwrap_or(0);
eprintln!("squashed {} commits", count + 1);
if needs_stash {
let output = Command::new("git")
.args(["stash", "pop"])
.output()
.context("failed to pop stash")?;
if !output.status.success() {
eprintln!(
"warning: stash pop failed (conflicts?), run 'git stash pop' manually: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
}
Ok(())
}
fn start_rebase_at_commit(target_sha: &str) -> Result<()> {
let is_root = Command::new("git")
.args(["rev-parse", "--verify", &format!("{}^", target_sha)])
.output()
.map(|o| !o.status.success())
.unwrap_or(false);
let short_sha = &target_sha[..7.min(target_sha.len())];
let sed_script = format!("s/^pick {} /edit {} /", short_sha, short_sha);
let mut rebase_cmd = Command::new("git");
rebase_cmd.args(["rebase", "-i", "--autostash"]);
if is_root {
rebase_cmd.arg("--root");
} else {
rebase_cmd.arg(format!("{}~1", target_sha));
}
rebase_cmd.env(
"GIT_SEQUENCE_EDITOR",
format!("sed -i.bak '{}'", sed_script),
);
let output = rebase_cmd.output().context("failed to start rebase")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("rebase failed: {}", stderr);
}
let output = Command::new("git")
.args(["reset", "HEAD~"])
.output()
.context("failed to reset commit")?;
if !output.status.success() {
anyhow::bail!(
"git reset failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}