use std::collections::HashMap;
use std::ffi::OsStr;
use differential_schema as schema;
use crate::EngineError;
use crate::apply::apply_hunks;
use crate::gitio::Repo;
use crate::invariants::dumb_hunk_count;
use crate::model::{DiffView, Disposition};
use crate::tree::{index_entry, removal_entry};
#[derive(Default)]
pub struct StackOptions<'a> {
pub ref_name: Option<&'a str>,
}
#[derive(Debug, Clone)]
pub struct StackCommit {
pub sha: String,
pub subject: String,
pub hunks: usize,
}
#[derive(Debug, Clone)]
pub struct StackResult {
pub ref_name: String,
pub tip: String,
pub commits: Vec<StackCommit>,
pub hunks_carried: usize,
pub recount: usize,
}
struct PlannedCommit {
subject: String,
body: String,
hunks: Vec<usize>,
meta_files: Vec<usize>,
}
pub fn build_stack(
repo: &Repo,
doc: &schema::PlanDocument,
view: &DiffView,
opts: &StackOptions,
) -> Result<StackResult, EngineError> {
let base = &doc.source.base;
let head = &doc.source.head;
let mut plan = commit_plan(doc)?;
let meta_files: Vec<usize> = (0..view.files.len())
.filter(|&i| view.files[i].hunks.is_empty())
.collect();
if !meta_files.is_empty() {
plan.push(PlannedCommit {
subject: format!(
"[meta] {} binary, mode or empty-file changes",
meta_files.len()
),
body: "Changes that carry no text hunks: binary content, mode-only flips and \
empty files. Staged from recorded object ids."
.to_string(),
hunks: Vec::new(),
meta_files,
});
}
let mut seen = vec![false; view.hunks.len()];
for c in &plan {
for &h in &c.hunks {
if seen[h] {
return Err(EngineError::Invariant(format!(
"hunk h{h} carried by two commits"
)));
}
seen[h] = true;
}
}
let hunks_carried = seen.iter().filter(|s| **s).count();
if hunks_carried != view.hunks.len() {
return Err(EngineError::Invariant(format!(
"stack plan carries {hunks_carried} hunks, {} exist",
view.hunks.len()
)));
}
let (commits, tip) = emit(repo, base, head, view, &plan)?;
let tip_tree = repo.rev_parse_raw(&format!("{tip}^{{tree}}"))?;
let head_tree = repo.rev_parse_raw(&format!("{head}^{{tree}}"))?;
if tip_tree != head_tree {
return Err(EngineError::Invariant(format!(
"stack tip tree {tip_tree} != head tree {head_tree} — a hunk was not carried"
)));
}
let mut recount = 0usize;
let mut parent = base.clone();
for c in &commits {
let patch = repo.run(
["diff-tree", "-r", "-U0", "--no-renames", &parent, &c.sha],
None,
)?;
recount += dumb_hunk_count(&patch);
parent = c.sha.clone();
}
if recount != view.hunks.len() {
return Err(EngineError::Invariant(format!(
"stack recount {recount} != canonical {}",
view.hunks.len()
)));
}
let ref_name = opts.ref_name.map(str::to_string).unwrap_or_else(|| {
format!(
"refs/review/{}-{}/stack",
&base[..7.min(base.len())],
&head[..7.min(head.len())]
)
});
repo.run(["update-ref", &ref_name, &tip], None)?;
Ok(StackResult {
ref_name,
tip,
commits,
hunks_carried,
recount,
})
}
fn commit_plan(doc: &schema::PlanDocument) -> Result<Vec<PlannedCommit>, EngineError> {
let Some(groups) = &doc.groups else {
return Err(EngineError::Invariant(
"stack rendering needs a grouped document (groups is null)".into(),
));
};
let class_by_id: HashMap<&str, &schema::ClassEntry> =
doc.classes.iter().map(|c| (c.id.as_str(), c)).collect();
let hunk_idx = |hid: &str| -> usize { hid[1..].parse().expect("hunk ids are h<N>") };
let backfilled = doc.audit.classes_missing.unwrap_or(0) > 0;
let mut plan = Vec::new();
for (gi, g) in groups.iter().enumerate() {
let classes: Vec<&schema::ClassEntry> = g
.class_ids
.iter()
.map(|c| class_by_id[c.as_str()])
.collect();
let all: Vec<usize> = classes
.iter()
.flat_map(|c| c.hunk_ids.iter().map(|h| hunk_idx(h)))
.collect();
let body = format!("{}\n\n{}", g.description, g.reason);
let is_backfill = backfilled && gi == groups.len() - 1;
match g.effort {
schema::Effort::Close if is_backfill => plan.push(PlannedCommit {
subject: format!("[unclassified] {} hunks carried by no group", all.len()),
body,
hunks: all,
meta_files: Vec::new(),
}),
schema::Effort::Close => plan.push(PlannedCommit {
subject: format!("[close] {}", g.label),
body,
hunks: all,
meta_files: Vec::new(),
}),
schema::Effort::Noise => plan.push(PlannedCommit {
subject: format!("[noise] {} — folded, {} hunks", g.label, all.len()),
body,
hunks: all,
meta_files: Vec::new(),
}),
schema::Effort::Skim => {
let exemplars: Vec<usize> = classes.iter().map(|c| hunk_idx(&c.exemplar)).collect();
let rest: Vec<usize> = classes
.iter()
.flat_map(|c| {
c.hunk_ids
.iter()
.filter(|h| **h != c.exemplar)
.map(|h| hunk_idx(h))
})
.collect();
if rest.is_empty() {
plan.push(PlannedCommit {
subject: format!("[skim] {} — {} exemplars", g.label, exemplars.len()),
body: format!("{body}\n\nEvery shape class in this group is a singleton."),
hunks: exemplars,
meta_files: Vec::new(),
});
} else {
plan.push(PlannedCommit {
subject: format!("[skim 1/2] {} — {} exemplars", g.label, exemplars.len()),
body: format!(
"{body}\n\nOne hunk per shape class. {} further hunks follow in \
[skim 2/2].",
rest.len()
),
hunks: exemplars,
meta_files: Vec::new(),
});
plan.push(PlannedCommit {
subject: format!(
"[skim 2/2] {} — {} further hunks, same shapes",
g.label,
rest.len()
),
body: "Remaining members of the shapes verified in [skim 1/2]. \
Skippable on this subject line."
.to_string(),
hunks: rest,
meta_files: Vec::new(),
});
}
}
}
}
Ok(plan)
}
fn emit(
repo: &Repo,
base: &str,
head: &str,
view: &DiffView,
plan: &[PlannedCommit],
) -> Result<(Vec<StackCommit>, String), EngineError> {
let idx = tempfile::NamedTempFile::new().map_err(|e| EngineError::GitSpawn { source: e })?;
let env: [(&str, &OsStr); 5] = [
("GIT_INDEX_FILE", idx.path().as_os_str()),
("GIT_AUTHOR_NAME", OsStr::new("differential")),
("GIT_AUTHOR_EMAIL", OsStr::new("differential@localhost")),
("GIT_COMMITTER_NAME", OsStr::new("differential")),
("GIT_COMMITTER_EMAIL", OsStr::new("differential@localhost")),
];
repo.run_env(["read-tree", base], None, &env)?;
let mut applied: HashMap<usize, Vec<usize>> = HashMap::new();
let mut base_blobs: HashMap<usize, Option<Vec<u8>>> = HashMap::new();
let mut parent = base.to_string();
let mut commits = Vec::with_capacity(plan.len());
let trailer = format!(
"Review-Synthetic: {}..{}",
&base[..12.min(base.len())],
&head[..12.min(head.len())]
);
for c in plan {
let mut touched: Vec<usize> = c.hunks.iter().map(|&h| view.hunks[h].file).collect();
touched.sort_unstable();
touched.dedup();
for &h in &c.hunks {
applied.entry(view.hunks[h].file).or_default().push(h);
}
let mut feed: Vec<u8> = Vec::new();
for &fi in &touched {
feed.extend_from_slice(&stage_file(
repo,
base,
view,
fi,
&applied,
&mut base_blobs,
)?);
feed.push(0);
}
for &fi in &c.meta_files {
let f = &view.files[fi];
let entry = if f.disposition == Disposition::Deleted {
removal_entry(&f.path)
} else {
let mode = f.new_mode.as_deref().ok_or_else(|| missing_mode(f))?;
let oid = f.new_oid.as_deref().ok_or_else(|| {
EngineError::Invariant(format!(
"zero-hunk file {} has no recorded oid",
String::from_utf8_lossy(&f.path)
))
})?;
index_entry(mode, oid, &f.path)
};
feed.extend_from_slice(&entry);
feed.push(0);
}
if !feed.is_empty() {
repo.run_env(["update-index", "-z", "--index-info"], Some(&feed), &env)?;
}
let tree = String::from_utf8_lossy(&repo.run_env(["write-tree"], None, &env)?)
.trim()
.to_string();
let msg = format!("{}\n\n{}\n\n{}\n", c.subject, c.body, trailer);
let sha = String::from_utf8_lossy(&repo.run_env(
["commit-tree", &tree, "-p", &parent],
Some(msg.as_bytes()),
&env,
)?)
.trim()
.to_string();
commits.push(StackCommit {
sha: sha.clone(),
subject: c.subject.clone(),
hunks: c.hunks.len(),
});
parent = sha;
}
Ok((commits, parent))
}
fn stage_file(
repo: &Repo,
base: &str,
view: &DiffView,
fi: usize,
applied: &HashMap<usize, Vec<usize>>,
base_blobs: &mut HashMap<usize, Option<Vec<u8>>>,
) -> Result<Vec<u8>, EngineError> {
let f = &view.files[fi];
let done = applied.get(&fi).map_or(0, Vec::len) == f.hunks.len();
if f.disposition == Disposition::Deleted && done {
return Ok(removal_entry(&f.path));
}
if let Some((_, new)) = &f.submodule {
let oid = new.as_deref().or(f.new_oid.as_deref()).ok_or_else(|| {
EngineError::Invariant(format!(
"submodule {} has no new commit id",
String::from_utf8_lossy(&f.path)
))
})?;
return Ok(index_entry("160000", oid, &f.path));
}
let mode = f
.new_mode
.as_deref()
.or(f.old_mode.as_deref())
.ok_or_else(|| missing_mode(f))?;
if let std::collections::hash_map::Entry::Vacant(e) = base_blobs.entry(fi) {
e.insert(repo.blob(base, &f.path)?);
}
let hunks: Vec<&crate::model::Hunk> = applied
.get(&fi)
.map(|v| v.iter().map(|&h| &view.hunks[h]).collect())
.unwrap_or_default();
let content = apply_hunks(base_blobs[&fi].as_deref(), &hunks);
let out = repo.run(["hash-object", "-w", "--stdin"], Some(&content))?;
let oid = String::from_utf8_lossy(&out).trim().to_string();
Ok(index_entry(mode, &oid, &f.path))
}
fn missing_mode(f: &crate::model::FileChange) -> EngineError {
EngineError::Invariant(format!(
"no mode recorded for {}",
String::from_utf8_lossy(&f.path)
))
}