use crate::engine::Stack;
use crate::git::GitRepo;
use anyhow::{bail, Result};
use colored::Colorize;
use std::collections::HashMap;
use std::io::Write;
use std::path::Path;
use std::process::Command;
struct AbsorbPlan {
groups: Vec<(String, Vec<String>)>,
unattributed: Vec<String>,
}
pub fn run(dry_run: bool, all: bool) -> Result<()> {
let repo = GitRepo::open()?;
let stack = Stack::load(&repo)?;
let current = repo.current_branch()?;
if current == stack.trunk {
bail!("Cannot absorb on trunk. Checkout a stacked branch first.");
}
let workdir = repo.workdir()?;
if all {
let status = Command::new("git")
.args(["add", "-A"])
.current_dir(workdir)
.status()?;
if !status.success() {
bail!("Failed to stage changes");
}
}
let staged_output = Command::new("git")
.args(["diff", "--cached", "--name-only"])
.current_dir(workdir)
.output()?;
if !staged_output.status.success() {
bail!("Failed to list staged files");
}
let staged_files: Vec<String> = String::from_utf8_lossy(&staged_output.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(|s| s.to_string())
.collect();
if staged_files.is_empty() {
println!(
"{}",
"No staged changes to absorb. Stage files or use `st absorb -a`.".yellow()
);
return Ok(());
}
let ancestors = stack.ancestors(¤t);
let mut stack_branches: Vec<String> = ancestors.into_iter().rev().collect();
stack_branches.push(current.clone());
stack_branches.retain(|b| *b != stack.trunk);
if stack_branches.is_empty() {
bail!("No stack branches found above trunk.");
}
let branch_boundaries: Vec<(String, String)> = stack_branches
.iter()
.map(|branch| {
let parent = stack
.branches
.get(branch)
.and_then(|b| b.parent.clone())
.unwrap_or_else(|| stack.trunk.clone());
(branch.clone(), parent)
})
.collect();
let plan = attribute_files(workdir, &staged_files, &branch_boundaries)?;
if plan.groups.is_empty() && plan.unattributed.is_empty() {
println!("{}", "No changes to absorb.".yellow());
return Ok(());
}
println!("{}", "Absorb plan:".bold());
for (branch, files) in &plan.groups {
let marker = if *branch == current {
" (current)".dimmed().to_string()
} else {
String::new()
};
println!(" {} {}{}", "→".green(), branch.cyan(), marker);
for file in files {
println!(" {}", file);
}
}
if !plan.unattributed.is_empty() {
println!(
" {} {}",
"?".yellow(),
"unattributed (staying staged)".dimmed()
);
for file in &plan.unattributed {
println!(" {}", file);
}
}
println!();
if dry_run {
println!("{}", "Dry run — no changes made.".dimmed());
return Ok(());
}
let has_other_targets = plan.groups.iter().any(|(b, _)| *b != current);
if !has_other_targets {
println!(
"{}",
"All changes already target the current branch. Nothing to absorb.".dimmed()
);
return Ok(());
}
let mut patches: Vec<(String, Vec<u8>, Vec<String>)> = Vec::new();
for (branch, files) in &plan.groups {
if *branch == current {
continue;
}
let mut diff_args = vec!["diff".to_string(), "--cached".to_string(), "--".to_string()];
diff_args.extend(files.iter().cloned());
let diff_output = Command::new("git")
.args(&diff_args)
.current_dir(workdir)
.output()?;
if diff_output.status.success() && !diff_output.stdout.is_empty() {
patches.push((branch.clone(), diff_output.stdout, files.clone()));
}
}
if patches.is_empty() {
println!("{}", "No changes to move to other branches.".dimmed());
return Ok(());
}
let stash_output = Command::new("git")
.args(["stash", "push", "-u", "-m", "stax-absorb"])
.current_dir(workdir)
.output()?;
let stash_msg = String::from_utf8_lossy(&stash_output.stdout);
let stashed = stash_output.status.success() && !stash_msg.contains("No local changes to save");
if !stashed {
bail!("Failed to stash changes before absorbing");
}
let mut absorbed_files: Vec<String> = Vec::new();
let mut errors: Vec<String> = Vec::new();
for (branch, patch_bytes, files) in &patches {
let co = Command::new("git")
.args(["checkout", branch])
.current_dir(workdir)
.status()?;
if !co.success() {
errors.push(format!("Failed to checkout '{}'", branch));
break; }
let mut apply_cmd = Command::new("git")
.args(["apply", "--cached"])
.current_dir(workdir)
.stdin(std::process::Stdio::piped())
.spawn()?;
if let Some(mut stdin) = apply_cmd.stdin.take() {
stdin.write_all(patch_bytes)?;
drop(stdin);
}
let apply_status = apply_cmd.wait()?;
if !apply_status.success() {
errors.push(format!(
"Failed to apply patch to '{}' (files may have diverged)",
branch
));
let _ = Command::new("git")
.args(["reset"])
.current_dir(workdir)
.status();
} else {
let tip_msg = get_branch_tip_message(workdir, branch);
let commit_msg = format!("fixup! {}", tip_msg.unwrap_or_else(|| branch.clone()));
let commit_status = Command::new("git")
.args(["commit", "-m", &commit_msg])
.current_dir(workdir)
.status()?;
if !commit_status.success() {
errors.push(format!("Failed to commit fixup on '{}'", branch));
let _ = Command::new("git")
.args(["reset"])
.current_dir(workdir)
.status();
} else {
let reset_status = Command::new("git")
.args(["reset", "--hard", "HEAD"])
.current_dir(workdir)
.status()?;
if !reset_status.success() {
errors.push(format!(
"Failed to clean worktree after committing absorbed changes on '{}'",
branch
));
break;
}
absorbed_files.extend(files.iter().cloned());
println!(
" {} {} file(s) → {}",
"✓".green(),
files.len(),
branch.cyan()
);
}
}
let co_back = Command::new("git")
.args(["checkout", ¤t])
.current_dir(workdir)
.status()?;
if !co_back.success() {
errors.push(format!(
"Failed to return to '{}'. Repository may be on wrong branch.",
current
));
break;
}
}
if repo.current_branch()? != current {
let co_back = Command::new("git")
.args(["checkout", ¤t])
.current_dir(workdir)
.status()?;
if !co_back.success() {
errors.push(format!(
"Failed to return to '{}'. Repository may be on wrong branch.",
current
));
}
}
let pop = Command::new("git")
.args(["stash", "pop"])
.current_dir(workdir)
.status()?;
if !pop.success() {
println!(
"{}",
"Warning: failed to pop stash. Run `git stash pop` manually.".yellow()
);
}
for file in &absorbed_files {
let _ = Command::new("git")
.args(["reset", "HEAD", "--", file])
.current_dir(workdir)
.status();
let checkout = Command::new("git")
.args(["checkout", "HEAD", "--", file])
.current_dir(workdir)
.status();
if checkout.map(|s| !s.success()).unwrap_or(true) {
let _ = std::fs::remove_file(workdir.join(file));
}
}
if !errors.is_empty() {
println!();
println!("{}", "Some files could not be absorbed:".yellow());
for e in &errors {
println!(" {}", e);
}
}
println!();
println!("{}", "Absorb complete.".green());
Ok(())
}
fn attribute_files(
workdir: &Path,
files: &[String],
branch_boundaries: &[(String, String)],
) -> Result<AbsorbPlan> {
let mut branch_files: HashMap<String, Vec<String>> = HashMap::new();
let mut unattributed: Vec<String> = Vec::new();
for file in files {
let mut attributed = false;
for (branch, parent) in branch_boundaries.iter().rev() {
let output = Command::new("git")
.args([
"log",
"--oneline",
"-1",
&format!("{}..{}", parent, branch),
"--",
file,
])
.current_dir(workdir)
.output()?;
if output.status.success() && !output.stdout.is_empty() {
branch_files
.entry(branch.clone())
.or_default()
.push(file.clone());
attributed = true;
break;
}
}
if !attributed {
unattributed.push(file.clone());
}
}
let groups: Vec<(String, Vec<String>)> = branch_boundaries
.iter()
.filter_map(|(branch, _)| {
branch_files
.get(branch)
.map(|files| (branch.clone(), files.clone()))
})
.collect();
Ok(AbsorbPlan {
groups,
unattributed,
})
}
fn get_branch_tip_message(workdir: &Path, branch: &str) -> Option<String> {
Command::new("git")
.args(["log", "-1", "--format=%s", branch])
.current_dir(workdir)
.output()
.ok()
.and_then(|o| {
if o.status.success() {
let msg = String::from_utf8_lossy(&o.stdout).trim().to_string();
if msg.is_empty() {
None
} else {
Some(msg)
}
} else {
None
}
})
}