1use std::collections::HashMap;
18
19use differential_engine::schema;
20
21use differential_engine::EngineError;
22use differential_engine::apply::apply_hunks;
23use differential_engine::invariants::dumb_hunk_count;
24use differential_engine::model::{DiffView, Disposition};
25use differential_engine::plan::{self, Deferral, Fold, HunkId, PlanIndex, reading_split};
26use differential_engine::ports::{
27 AttributeSource, CommitIdentity, CommitWriter, DiffSource, IndexEntry, IndexSession,
28 ObjectReader, ObjectWriter, RangeResolver, RecountSource, RefWriter, TreeBuilder, TreeResolver,
29};
30
31const REF_ABBREV: usize = 7;
37
38const IDENTITY: CommitIdentity<'static> = CommitIdentity {
41 name: "differential",
42 email: "differential@localhost",
43};
44
45#[derive(Default)]
46pub struct StackOptions<'a> {
47 pub ref_name: Option<&'a str>,
49}
50
51#[derive(Debug, Clone)]
52pub struct StackCommit {
53 pub sha: String,
54 pub subject: String,
55 pub hunks: usize,
56}
57
58#[derive(Debug, Clone)]
59pub struct StackResult {
60 pub ref_name: String,
61 pub tip: String,
62 pub commits: Vec<StackCommit>,
63 pub hunks_carried: usize,
64 pub recount: usize,
66}
67
68struct PlannedCommit {
69 subject: String,
70 body: String,
71 hunks: Vec<HunkId>,
73 meta_files: Vec<usize>,
75}
76
77pub fn build_stack<G>(
80 git: &G,
81 doc: &schema::PlanDocument,
82 view: &DiffView,
83 opts: &StackOptions,
84) -> Result<StackResult, EngineError>
85where
86 G: ObjectReader
87 + ObjectWriter
88 + TreeBuilder
89 + CommitWriter
90 + TreeResolver
91 + RecountSource
92 + RefWriter,
93{
94 let base = &doc.source.base;
95 let head = &doc.source.head;
96 let mut plan = commit_plan(doc)?;
97
98 let meta_files: Vec<usize> = (0..view.files.len())
102 .filter(|&i| view.files[i].hunks.is_empty())
103 .collect();
104 if !meta_files.is_empty() {
105 plan.push(PlannedCommit {
106 subject: format!(
107 "[meta] {} binary, mode or empty-file changes",
108 meta_files.len()
109 ),
110 body: "Changes that carry no text hunks: binary content, mode-only flips and \
111 empty files. Staged from recorded object ids."
112 .to_string(),
113 hunks: Vec::new(),
114 meta_files,
115 });
116 }
117
118 let mut seen = vec![false; view.hunks.len()];
120 for c in &plan {
121 for &h in &c.hunks {
122 if seen[h.index()] {
123 return Err(EngineError::Invariant(format!(
124 "hunk {h} carried by two commits"
125 )));
126 }
127 seen[h.index()] = true;
128 }
129 }
130 let hunks_carried = seen.iter().filter(|s| **s).count();
131 if hunks_carried != view.hunks.len() {
132 return Err(EngineError::Invariant(format!(
133 "stack plan carries {hunks_carried} hunks, {} exist",
134 view.hunks.len()
135 )));
136 }
137
138 let (commits, tip) = emit(git, base, head, view, &plan)?;
139
140 let tip_tree = git.tree_of(&tip)?;
142 let head_tree = git.tree_of(head)?;
143 if tip_tree != head_tree {
144 return Err(EngineError::Invariant(format!(
145 "stack tip tree {tip_tree} != head tree {head_tree} — a hunk was not carried"
146 )));
147 }
148
149 let mut recount = 0usize;
151 let mut parent = base.clone();
152 for c in &commits {
153 let patch = git.recount_patch(&parent, &c.sha)?;
154 recount += dumb_hunk_count(&patch);
155 parent = c.sha.clone();
156 }
157 if recount != view.hunks.len() {
158 return Err(EngineError::Invariant(format!(
159 "stack recount {recount} != canonical {}",
160 view.hunks.len()
161 )));
162 }
163
164 let ref_name = opts.ref_name.map(str::to_string).unwrap_or_else(|| {
165 format!(
166 "refs/review/{}-{}/stack",
167 &base[..REF_ABBREV.min(base.len())],
168 &head[..REF_ABBREV.min(head.len())]
169 )
170 });
171 git.update_ref(&ref_name, &tip)?;
172
173 Ok(StackResult {
174 ref_name,
175 tip,
176 commits,
177 hunks_carried,
178 recount,
179 })
180}
181
182fn commit_plan(doc: &schema::PlanDocument) -> Result<Vec<PlannedCommit>, EngineError> {
185 let Some(groups) = &doc.groups else {
186 return Err(EngineError::Invariant(
187 "stack rendering needs a grouped document (groups is null)".into(),
188 ));
189 };
190 let index = PlanIndex::build(doc)?;
191
192 let backfilled = doc.audit.classes_missing.unwrap_or(0) > 0;
195 let mut plan = Vec::new();
196
197 for (gi, g) in groups.iter().enumerate() {
198 let split = reading_split(&index, g, Fold::Folded);
201 let body = format!("{}\n\n{}", g.description, g.reason);
202 let is_backfill = backfilled && gi == groups.len() - 1;
203
204 match split.deferral {
205 Deferral::None if is_backfill => plan.push(PlannedCommit {
206 subject: format!(
207 "[unclassified] {} hunks carried by no group",
208 split.shown.len()
209 ),
210 body,
211 hunks: split.shown,
212 meta_files: Vec::new(),
213 }),
214 Deferral::None if g.effort == schema::Effort::Skim => plan.push(PlannedCommit {
215 subject: format!("[skim] {} — {} exemplars", g.label, split.shown.len()),
216 body: format!("{body}\n\nEvery shape class in this group is a singleton."),
217 hunks: split.shown,
218 meta_files: Vec::new(),
219 }),
220 Deferral::None => plan.push(PlannedCommit {
221 subject: format!("[{}] {}", plan::effort_name(g.effort), g.label),
222 body,
223 hunks: split.shown,
224 meta_files: Vec::new(),
225 }),
226 Deferral::FoldedNoise => plan.push(PlannedCommit {
227 subject: format!(
228 "[noise] {} — folded, {} hunks",
229 g.label,
230 split.deferred.len()
231 ),
232 body,
233 hunks: split.all(),
236 meta_files: Vec::new(),
237 }),
238 Deferral::SkimRemainder => {
239 plan.push(PlannedCommit {
240 subject: format!("[skim 1/2] {} — {} exemplars", g.label, split.shown.len()),
241 body: format!(
242 "{body}\n\nOne hunk per shape class. {} further hunks follow in \
243 [skim 2/2].",
244 split.deferred.len()
245 ),
246 hunks: split.shown,
247 meta_files: Vec::new(),
248 });
249 plan.push(PlannedCommit {
250 subject: format!(
251 "[skim 2/2] {} — {} further hunks, same shapes",
252 g.label,
253 split.deferred.len()
254 ),
255 body: "Remaining members of the shapes verified in [skim 1/2]. \
256 Skippable on this subject line."
257 .to_string(),
258 hunks: split.deferred,
259 meta_files: Vec::new(),
260 });
261 }
262 }
263 }
264 Ok(plan)
265}
266
267fn emit<G>(
269 git: &G,
270 base: &str,
271 head: &str,
272 view: &DiffView,
273 plan: &[PlannedCommit],
274) -> Result<(Vec<StackCommit>, String), EngineError>
275where
276 G: ObjectReader + ObjectWriter + TreeBuilder + CommitWriter,
277{
278 let mut session = git.begin_from_tree(base)?;
279
280 let mut applied: HashMap<usize, Vec<usize>> = HashMap::new();
281 let mut base_blobs: HashMap<usize, Option<Vec<u8>>> = HashMap::new();
282 let mut parent = base.to_string();
283 let mut commits = Vec::with_capacity(plan.len());
284 let trailer = format!(
285 "Review-Synthetic: {}..{}",
286 plan::short_oid(base),
287 plan::short_oid(head)
288 );
289
290 for c in plan {
291 let mut touched: Vec<usize> = c
292 .hunks
293 .iter()
294 .map(|&h| view.hunks[h.index()].file)
295 .collect();
296 touched.sort_unstable();
297 touched.dedup();
298 for &h in &c.hunks {
299 applied
300 .entry(view.hunks[h.index()].file)
301 .or_default()
302 .push(h.index());
303 }
304
305 let mut entries: Vec<IndexEntry> = Vec::new();
306 for &fi in &touched {
307 entries.push(stage_file(git, base, view, fi, &applied, &mut base_blobs)?);
308 }
309 for &fi in &c.meta_files {
310 let f = &view.files[fi];
311 entries.push(if f.disposition == Disposition::Deleted {
312 IndexEntry::Remove {
313 path: f.path.clone(),
314 }
315 } else {
316 let mode = f.new_mode.as_deref().ok_or_else(|| missing_mode(f))?;
317 let oid = f.new_oid.as_deref().ok_or_else(|| {
318 EngineError::Invariant(format!(
319 "zero-hunk file {} has no recorded oid",
320 String::from_utf8_lossy(&f.path)
321 ))
322 })?;
323 IndexEntry::Set {
324 mode: mode.to_string(),
325 oid: oid.to_string(),
326 path: f.path.clone(),
327 }
328 });
329 }
330 session.stage(&entries)?;
331
332 let tree = session.write_tree()?;
333 let msg = format!("{}\n\n{}\n\n{}\n", c.subject, c.body, trailer);
334 let sha = git.commit_tree(&tree, &parent, msg.as_bytes(), IDENTITY)?;
337 commits.push(StackCommit {
338 sha: sha.clone(),
339 subject: c.subject.clone(),
340 hunks: c.hunks.len(),
341 });
342 parent = sha;
343 }
344 Ok((commits, parent))
345}
346
347fn stage_file<G>(
351 git: &G,
352 base: &str,
353 view: &DiffView,
354 fi: usize,
355 applied: &HashMap<usize, Vec<usize>>,
356 base_blobs: &mut HashMap<usize, Option<Vec<u8>>>,
357) -> Result<IndexEntry, EngineError>
358where
359 G: ObjectReader + ObjectWriter,
360{
361 let f = &view.files[fi];
362 let applied_here = applied.get(&fi).map_or(0, Vec::len);
363
364 match plan::cumulative_state(f, applied_here)? {
365 plan::Staged::Remove => Ok(IndexEntry::Remove {
366 path: f.path.clone(),
367 }),
368 plan::Staged::Recorded { mode, oid } => Ok(IndexEntry::Set {
369 mode: mode.to_string(),
370 oid: oid.to_string(),
371 path: f.path.clone(),
372 }),
373 plan::Staged::Apply { mode } => {
374 if let std::collections::hash_map::Entry::Vacant(e) = base_blobs.entry(fi) {
375 e.insert(git.blob(base, &f.path)?);
376 }
377 let hunks: Vec<&differential_engine::model::Hunk> = applied
378 .get(&fi)
379 .map(|v| v.iter().map(|&h| &view.hunks[h]).collect())
380 .unwrap_or_default();
381 let content = apply_hunks(base_blobs[&fi].as_deref(), &hunks);
382 Ok(IndexEntry::Set {
383 mode: mode.to_string(),
384 oid: git.write_blob(&content)?,
385 path: f.path.clone(),
386 })
387 }
388 }
389}
390
391fn missing_mode(f: &differential_engine::model::FileChange) -> EngineError {
392 EngineError::Invariant(format!(
393 "no mode recorded for {}",
394 String::from_utf8_lossy(&f.path)
395 ))
396}
397
398pub struct StackOutput {
400 pub pipeline: differential_engine::PipelineOutput,
401 pub stack: Option<StackResult>,
403}
404
405pub fn run_stack_pipeline<G, C>(
408 git: &G,
409 source: &plan::ReviewSource,
410 config: &differential_engine::config::Config,
411 langs: &differential_engine::lang::LanguageRegistry,
412 grouping: &differential_engine::grouping::GroupingOptions<C>,
413 stack: &StackOptions,
414) -> Result<StackOutput, EngineError>
415where
416 G: ObjectReader
417 + ObjectWriter
418 + TreeBuilder
419 + CommitWriter
420 + TreeResolver
421 + RecountSource
422 + RefWriter
423 + RangeResolver
424 + DiffSource
425 + AttributeSource,
426 C: differential_engine::ports::GroupingCache,
427{
428 let out = differential_engine::run_grouped_pipeline(
429 git,
430 &source.base,
431 &source.head,
432 source.kind,
433 config,
434 langs,
435 grouping,
436 )?;
437 let Some(doc) = &out.document else {
438 return Ok(StackOutput {
439 pipeline: out,
440 stack: None,
441 });
442 };
443 let result = build_stack(git, doc, &out.view, stack)?;
444 Ok(StackOutput {
445 pipeline: out,
446 stack: Some(result),
447 })
448}