use std::collections::HashMap;
use std::io::Write as _;
use std::path::Path;
use std::process::{Command, Stdio};
use super::patch;
struct Group {
target: String,
patch: String,
hunks: usize,
}
fn git(root: &Path, args: &[&str]) -> Result<String, String> {
let out =
Command::new("git").arg("-C").arg(root).args(args).output().map_err(|e| e.to_string())?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
fn git_stdin(root: &Path, args: &[&str], input: &str) -> Result<(), String> {
let mut child = Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.stdin(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| e.to_string())?;
child
.stdin
.take()
.ok_or("no input to the command")?
.write_all(input.as_bytes())
.map_err(|e| e.to_string())?;
let out = child.wait_with_output().map_err(|e| e.to_string())?;
if out.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
fn old_range(hunk: &str) -> Option<(u32, u32)> {
let head = hunk.lines().next()?;
let mid = head.strip_prefix("@@ ")?.split(" @@").next()?;
let old = mid.split_whitespace().next()?.strip_prefix('-')?;
let mut parts = old.split(',');
let start: u32 = parts.next()?.parse().ok()?;
let count: u32 = parts.next().unwrap_or("1").parse().unwrap_or(1);
Some((start, count))
}
fn changed_lines(hunk: &str) -> Vec<u32> {
let Some((start, _)) = old_range(hunk) else {
return Vec::new();
};
let mut line = start;
let mut out = Vec::new();
let mut first_add = None;
for text in hunk.lines().skip(1) {
match text.as_bytes().first() {
Some(b'-') => {
out.push(line);
line += 1;
}
Some(b'+') => {
if first_add.is_none() {
first_add = Some(line);
}
}
Some(b'\\') => {}
_ => line += 1,
}
}
if out.is_empty() {
out.push(first_add.unwrap_or(start).saturating_sub(1).max(1));
}
out
}
fn blame_target(root: &Path, file: &str, lines: &[u32]) -> Option<String> {
let (&lo, &hi) = (lines.iter().min()?, lines.iter().max()?);
let range = format!("{lo},{hi}");
let out = git(root, &["blame", "-L", &range, "--porcelain", "HEAD", "--", file]).ok()?;
let mut by_line: HashMap<u32, String> = HashMap::new();
for text in out.lines() {
let mut p = text.split(' ');
let (Some(id), Some(_), Some(final_line)) = (p.next(), p.next(), p.next()) else {
continue;
};
if id.len() == 40
&& id.chars().all(|c| c.is_ascii_hexdigit())
&& let Ok(n) = final_line.parse::<u32>()
{
by_line.insert(n, id.to_string());
}
}
let mut votes: HashMap<&String, usize> = HashMap::new();
for l in lines {
if let Some(id) = by_line.get(l) {
*votes.entry(id).or_default() += 1;
}
}
votes.into_iter().max_by_key(|(_, n)| *n).map(|(id, _)| id.clone())
}
fn plan(root: &Path, staged: &str, may_take: &dyn Fn(&str) -> bool) -> (Vec<Group>, usize) {
let mut groups: HashMap<String, Group> = HashMap::new();
let mut skipped = 0usize;
for block in split_files(staged) {
let Some(file) = file_of(&block) else {
continue;
};
let Some((header, hunks)) = patch::split_diff(&block) else {
continue;
};
for hunk in hunks {
let lines = changed_lines(&hunk);
if lines.is_empty() {
skipped += 1;
continue;
}
let target = blame_target(root, &file, &lines).filter(|id| may_take(id));
let Some(target) = target else {
skipped += 1;
continue;
};
let g = groups.entry(target.clone()).or_insert_with(|| Group {
target,
patch: String::new(),
hunks: 0,
});
g.patch.push_str(&patch::hunk_patch(&header, &hunk));
g.hunks += 1;
}
}
(groups.into_values().collect(), skipped)
}
fn split_files(diff: &str) -> Vec<String> {
let mut out = Vec::new();
let mut current = String::new();
for line in diff.lines() {
if line.starts_with("diff --git ") && !current.is_empty() {
out.push(std::mem::take(&mut current));
}
current.push_str(line);
current.push('\n');
}
if !current.is_empty() {
out.push(current);
}
out
}
fn file_of(block: &str) -> Option<String> {
block.lines().find_map(|l| l.strip_prefix("+++ b/").map(str::to_string))
}
pub fn run(root: &Path, may_take: &dyn Fn(&str) -> bool) -> Result<Vec<String>, String> {
let staged = git(root, &["diff", "--cached"])?;
if staged.trim().is_empty() {
return Err("stage the changes you want to send back first".into());
}
let (groups, skipped) = plan(root, &staged, may_take);
if groups.is_empty() {
return Err("no change belongs to a commit that is still yours to change".into());
}
git(root, &["reset", "-q"])?;
let mut made = 0usize;
let mut notes = Vec::new();
for g in &groups {
if let Err(e) = git_stdin(root, &["apply", "--cached", "--3way"], &g.patch) {
notes.push(format!("could not apply the changes for {}: {e}", &g.target[..7]));
continue;
}
match git(root, &["commit", "-q", &format!("--fixup={}", g.target)]) {
Ok(_) => made += 1,
Err(e) => notes.push(format!("could not commit for {}: {e}", &g.target[..7])),
}
}
if made == 0 {
return Err(notes.pop().unwrap_or_else(|| "nothing was absorbed".into()));
}
let mut oldest = (0u32, String::new());
for g in &groups {
let count = git(root, &["rev-list", "--count", &format!("{}..HEAD", g.target)])
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.unwrap_or(0);
if count >= oldest.0 {
oldest = (count, g.target.clone());
}
}
let base = format!("{}^", oldest.1);
let out = Command::new("git")
.arg("-C")
.arg(root)
.args(["rebase", "--autosquash", "--autostash", &base])
.env("GIT_SEQUENCE_EDITOR", "true")
.env("GIT_EDITOR", "true")
.output()
.map_err(|e| e.to_string())?;
if !out.status.success() {
notes.insert(0, "the fixup commits are there, but the rebase stopped".into());
notes.push(
String::from_utf8_lossy(&out.stderr).lines().take(3).collect::<Vec<_>>().join(" "),
);
return Err(notes.join("; "));
}
let total: usize = groups.iter().map(|g| g.hunks).sum();
let mut summary = vec![format!("{total} changes went back into {made} commits")];
if skipped > 0 {
summary.push(format!(
"{skipped} changes stayed, because no commit of yours holds those lines"
));
}
summary.extend(notes);
Ok(summary)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_the_old_range_of_a_hunk() {
assert_eq!(old_range("@@ -12,5 +12,7 @@\n ctx\n"), Some((12, 5)));
assert_eq!(old_range("@@ -3 +3,2 @@\n ctx\n"), Some((3, 1)));
assert_eq!(old_range("@@ -7,0 +8,3 @@\n+a\n"), Some((7, 0)));
}
#[test]
fn only_the_changed_lines_count() {
let hunk = "@@ -1,3 +1,3 @@\n ctx one\n-old two\n+new two\n ctx three\n";
assert_eq!(changed_lines(hunk), vec![2]);
}
#[test]
fn several_removed_lines_all_count() {
let hunk = "@@ -10,4 +10,3 @@\n ctx\n-a\n-b\n+c\n ctx\n";
assert_eq!(changed_lines(hunk), vec![11, 12]);
}
#[test]
fn a_hunk_that_only_adds_takes_the_line_above() {
let hunk = "@@ -5,2 +5,3 @@\n ctx five\n+added\n ctx six\n";
assert_eq!(changed_lines(hunk), vec![5]);
}
#[test]
fn splits_a_diff_by_file() {
let diff = "diff --git a/x b/x\n--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n\
diff --git a/y b/y\n--- a/y\n+++ b/y\n@@ -1 +1 @@\n-c\n+d\n";
let parts = split_files(diff);
assert_eq!(parts.len(), 2);
assert_eq!(file_of(&parts[0]).as_deref(), Some("x"));
assert_eq!(file_of(&parts[1]).as_deref(), Some("y"));
}
}