1use std::collections::HashMap;
18use std::ffi::OsStr;
19
20use differential_schema as schema;
21
22use crate::EngineError;
23use crate::apply::apply_hunks;
24use crate::gitio::Repo;
25use crate::invariants::dumb_hunk_count;
26use crate::model::{DiffView, Disposition};
27use crate::tree::{index_entry, removal_entry};
28
29#[derive(Default)]
30pub struct StackOptions<'a> {
31 pub ref_name: Option<&'a str>,
33}
34
35#[derive(Debug, Clone)]
36pub struct StackCommit {
37 pub sha: String,
38 pub subject: String,
39 pub hunks: usize,
40}
41
42#[derive(Debug, Clone)]
43pub struct StackResult {
44 pub ref_name: String,
45 pub tip: String,
46 pub commits: Vec<StackCommit>,
47 pub hunks_carried: usize,
48 pub recount: usize,
50}
51
52struct PlannedCommit {
53 subject: String,
54 body: String,
55 hunks: Vec<usize>,
57 meta_files: Vec<usize>,
59}
60
61pub fn build_stack(
64 repo: &Repo,
65 doc: &schema::PlanDocument,
66 view: &DiffView,
67 opts: &StackOptions,
68) -> Result<StackResult, EngineError> {
69 let base = &doc.source.base;
70 let head = &doc.source.head;
71 let mut plan = commit_plan(doc)?;
72
73 let meta_files: Vec<usize> = (0..view.files.len())
77 .filter(|&i| view.files[i].hunks.is_empty())
78 .collect();
79 if !meta_files.is_empty() {
80 plan.push(PlannedCommit {
81 subject: format!(
82 "[meta] {} binary, mode or empty-file changes",
83 meta_files.len()
84 ),
85 body: "Changes that carry no text hunks: binary content, mode-only flips and \
86 empty files. Staged from recorded object ids."
87 .to_string(),
88 hunks: Vec::new(),
89 meta_files,
90 });
91 }
92
93 let mut seen = vec![false; view.hunks.len()];
95 for c in &plan {
96 for &h in &c.hunks {
97 if seen[h] {
98 return Err(EngineError::Invariant(format!(
99 "hunk h{h} carried by two commits"
100 )));
101 }
102 seen[h] = true;
103 }
104 }
105 let hunks_carried = seen.iter().filter(|s| **s).count();
106 if hunks_carried != view.hunks.len() {
107 return Err(EngineError::Invariant(format!(
108 "stack plan carries {hunks_carried} hunks, {} exist",
109 view.hunks.len()
110 )));
111 }
112
113 let (commits, tip) = emit(repo, base, head, view, &plan)?;
114
115 let tip_tree = repo.rev_parse_raw(&format!("{tip}^{{tree}}"))?;
117 let head_tree = repo.rev_parse_raw(&format!("{head}^{{tree}}"))?;
118 if tip_tree != head_tree {
119 return Err(EngineError::Invariant(format!(
120 "stack tip tree {tip_tree} != head tree {head_tree} — a hunk was not carried"
121 )));
122 }
123
124 let mut recount = 0usize;
126 let mut parent = base.clone();
127 for c in &commits {
128 let patch = repo.run(
129 ["diff-tree", "-r", "-U0", "--no-renames", &parent, &c.sha],
130 None,
131 )?;
132 recount += dumb_hunk_count(&patch);
133 parent = c.sha.clone();
134 }
135 if recount != view.hunks.len() {
136 return Err(EngineError::Invariant(format!(
137 "stack recount {recount} != canonical {}",
138 view.hunks.len()
139 )));
140 }
141
142 let ref_name = opts.ref_name.map(str::to_string).unwrap_or_else(|| {
143 format!(
144 "refs/review/{}-{}/stack",
145 &base[..7.min(base.len())],
146 &head[..7.min(head.len())]
147 )
148 });
149 repo.run(["update-ref", &ref_name, &tip], None)?;
150
151 Ok(StackResult {
152 ref_name,
153 tip,
154 commits,
155 hunks_carried,
156 recount,
157 })
158}
159
160fn commit_plan(doc: &schema::PlanDocument) -> Result<Vec<PlannedCommit>, EngineError> {
163 let Some(groups) = &doc.groups else {
164 return Err(EngineError::Invariant(
165 "stack rendering needs a grouped document (groups is null)".into(),
166 ));
167 };
168 let class_by_id: HashMap<&str, &schema::ClassEntry> =
169 doc.classes.iter().map(|c| (c.id.as_str(), c)).collect();
170 let hunk_idx = |hid: &str| -> usize { hid[1..].parse().expect("hunk ids are h<N>") };
171
172 let backfilled = doc.audit.classes_missing.unwrap_or(0) > 0;
173 let mut plan = Vec::new();
174
175 for (gi, g) in groups.iter().enumerate() {
176 let classes: Vec<&schema::ClassEntry> = g
177 .class_ids
178 .iter()
179 .map(|c| class_by_id[c.as_str()])
180 .collect();
181 let all: Vec<usize> = classes
182 .iter()
183 .flat_map(|c| c.hunk_ids.iter().map(|h| hunk_idx(h)))
184 .collect();
185 let body = format!("{}\n\n{}", g.description, g.reason);
186 let is_backfill = backfilled && gi == groups.len() - 1;
187
188 match g.effort {
189 schema::Effort::Close if is_backfill => plan.push(PlannedCommit {
190 subject: format!("[unclassified] {} hunks carried by no group", all.len()),
191 body,
192 hunks: all,
193 meta_files: Vec::new(),
194 }),
195 schema::Effort::Close => plan.push(PlannedCommit {
196 subject: format!("[close] {}", g.label),
197 body,
198 hunks: all,
199 meta_files: Vec::new(),
200 }),
201 schema::Effort::Noise => plan.push(PlannedCommit {
202 subject: format!("[noise] {} — folded, {} hunks", g.label, all.len()),
203 body,
204 hunks: all,
205 meta_files: Vec::new(),
206 }),
207 schema::Effort::Skim => {
208 let exemplars: Vec<usize> = classes.iter().map(|c| hunk_idx(&c.exemplar)).collect();
209 let rest: Vec<usize> = classes
210 .iter()
211 .flat_map(|c| {
212 c.hunk_ids
213 .iter()
214 .filter(|h| **h != c.exemplar)
215 .map(|h| hunk_idx(h))
216 })
217 .collect();
218 if rest.is_empty() {
219 plan.push(PlannedCommit {
220 subject: format!("[skim] {} — {} exemplars", g.label, exemplars.len()),
221 body: format!("{body}\n\nEvery shape class in this group is a singleton."),
222 hunks: exemplars,
223 meta_files: Vec::new(),
224 });
225 } else {
226 plan.push(PlannedCommit {
227 subject: format!("[skim 1/2] {} — {} exemplars", g.label, exemplars.len()),
228 body: format!(
229 "{body}\n\nOne hunk per shape class. {} further hunks follow in \
230 [skim 2/2].",
231 rest.len()
232 ),
233 hunks: exemplars,
234 meta_files: Vec::new(),
235 });
236 plan.push(PlannedCommit {
237 subject: format!(
238 "[skim 2/2] {} — {} further hunks, same shapes",
239 g.label,
240 rest.len()
241 ),
242 body: "Remaining members of the shapes verified in [skim 1/2]. \
243 Skippable on this subject line."
244 .to_string(),
245 hunks: rest,
246 meta_files: Vec::new(),
247 });
248 }
249 }
250 }
251 }
252 Ok(plan)
253}
254
255fn emit(
257 repo: &Repo,
258 base: &str,
259 head: &str,
260 view: &DiffView,
261 plan: &[PlannedCommit],
262) -> Result<(Vec<StackCommit>, String), EngineError> {
263 let idx = tempfile::NamedTempFile::new().map_err(|e| EngineError::GitSpawn { source: e })?;
264 let env: [(&str, &OsStr); 5] = [
265 ("GIT_INDEX_FILE", idx.path().as_os_str()),
266 ("GIT_AUTHOR_NAME", OsStr::new("differential")),
267 ("GIT_AUTHOR_EMAIL", OsStr::new("differential@localhost")),
268 ("GIT_COMMITTER_NAME", OsStr::new("differential")),
269 ("GIT_COMMITTER_EMAIL", OsStr::new("differential@localhost")),
270 ];
271 repo.run_env(["read-tree", base], None, &env)?;
272
273 let mut applied: HashMap<usize, Vec<usize>> = HashMap::new();
274 let mut base_blobs: HashMap<usize, Option<Vec<u8>>> = HashMap::new();
275 let mut parent = base.to_string();
276 let mut commits = Vec::with_capacity(plan.len());
277 let trailer = format!(
278 "Review-Synthetic: {}..{}",
279 &base[..12.min(base.len())],
280 &head[..12.min(head.len())]
281 );
282
283 for c in plan {
284 let mut touched: Vec<usize> = c.hunks.iter().map(|&h| view.hunks[h].file).collect();
285 touched.sort_unstable();
286 touched.dedup();
287 for &h in &c.hunks {
288 applied.entry(view.hunks[h].file).or_default().push(h);
289 }
290
291 let mut feed: Vec<u8> = Vec::new();
292 for &fi in &touched {
293 feed.extend_from_slice(&stage_file(
294 repo,
295 base,
296 view,
297 fi,
298 &applied,
299 &mut base_blobs,
300 )?);
301 feed.push(0);
302 }
303 for &fi in &c.meta_files {
304 let f = &view.files[fi];
305 let entry = if f.disposition == Disposition::Deleted {
306 removal_entry(&f.path)
307 } else {
308 let mode = f.new_mode.as_deref().ok_or_else(|| missing_mode(f))?;
309 let oid = f.new_oid.as_deref().ok_or_else(|| {
310 EngineError::Invariant(format!(
311 "zero-hunk file {} has no recorded oid",
312 String::from_utf8_lossy(&f.path)
313 ))
314 })?;
315 index_entry(mode, oid, &f.path)
316 };
317 feed.extend_from_slice(&entry);
318 feed.push(0);
319 }
320 if !feed.is_empty() {
321 repo.run_env(["update-index", "-z", "--index-info"], Some(&feed), &env)?;
322 }
323
324 let tree = String::from_utf8_lossy(&repo.run_env(["write-tree"], None, &env)?)
325 .trim()
326 .to_string();
327 let msg = format!("{}\n\n{}\n\n{}\n", c.subject, c.body, trailer);
328 let sha = String::from_utf8_lossy(&repo.run_env(
329 ["commit-tree", &tree, "-p", &parent],
330 Some(msg.as_bytes()),
331 &env,
332 )?)
333 .trim()
334 .to_string();
335 commits.push(StackCommit {
336 sha: sha.clone(),
337 subject: c.subject.clone(),
338 hunks: c.hunks.len(),
339 });
340 parent = sha;
341 }
342 Ok((commits, parent))
343}
344
345fn stage_file(
349 repo: &Repo,
350 base: &str,
351 view: &DiffView,
352 fi: usize,
353 applied: &HashMap<usize, Vec<usize>>,
354 base_blobs: &mut HashMap<usize, Option<Vec<u8>>>,
355) -> Result<Vec<u8>, EngineError> {
356 let f = &view.files[fi];
357 let done = applied.get(&fi).map_or(0, Vec::len) == f.hunks.len();
358
359 if f.disposition == Disposition::Deleted && done {
360 return Ok(removal_entry(&f.path));
361 }
362 if let Some((_, new)) = &f.submodule {
363 let oid = new.as_deref().or(f.new_oid.as_deref()).ok_or_else(|| {
364 EngineError::Invariant(format!(
365 "submodule {} has no new commit id",
366 String::from_utf8_lossy(&f.path)
367 ))
368 })?;
369 return Ok(index_entry("160000", oid, &f.path));
370 }
371
372 let mode = f
373 .new_mode
374 .as_deref()
375 .or(f.old_mode.as_deref())
376 .ok_or_else(|| missing_mode(f))?;
377 if let std::collections::hash_map::Entry::Vacant(e) = base_blobs.entry(fi) {
378 e.insert(repo.blob(base, &f.path)?);
379 }
380 let hunks: Vec<&crate::model::Hunk> = applied
381 .get(&fi)
382 .map(|v| v.iter().map(|&h| &view.hunks[h]).collect())
383 .unwrap_or_default();
384 let content = apply_hunks(base_blobs[&fi].as_deref(), &hunks);
385 let out = repo.run(["hash-object", "-w", "--stdin"], Some(&content))?;
386 let oid = String::from_utf8_lossy(&out).trim().to_string();
387 Ok(index_entry(mode, &oid, &f.path))
388}
389
390fn missing_mode(f: &crate::model::FileChange) -> EngineError {
391 EngineError::Invariant(format!(
392 "no mode recorded for {}",
393 String::from_utf8_lossy(&f.path)
394 ))
395}