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;
25use differential_engine::plan::{self, Deferral, Fold, HunkId, 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 if doc.groups.is_none() {
186 return Err(EngineError::Invariant(
187 "stack rendering needs a grouped document (groups is null)".into(),
188 ));
189 }
190 let review = plan::ReviewView::project(doc)?;
198 let mut plan = Vec::new();
199
200 for g in &review.groups {
201 let split = reading_split(&review, g, Fold::Folded);
204 let body = format!("{}\n\n{}", g.description, g.reason);
205 let tier = review.tier_name(g);
207
208 match split.deferral {
209 Deferral::None if g.unclassified => plan.push(PlannedCommit {
210 subject: format!("[{tier}] {} hunks carried by no group", split.shown.len()),
211 body,
212 hunks: split.shown,
213 meta_files: Vec::new(),
214 }),
215 Deferral::None if g.effort == schema::Effort::Skim => plan.push(PlannedCommit {
216 subject: format!("[{tier}] {} — {} exemplars", g.label, split.shown.len()),
217 body: format!("{body}\n\nEvery shape class in this group is a singleton."),
218 hunks: split.shown,
219 meta_files: Vec::new(),
220 }),
221 Deferral::None => plan.push(PlannedCommit {
222 subject: format!("[{tier}] {}", g.label),
223 body,
224 hunks: split.shown,
225 meta_files: Vec::new(),
226 }),
227 Deferral::FoldedNoise => plan.push(PlannedCommit {
228 subject: format!(
229 "[noise] {} — folded, {} hunks",
230 g.label,
231 split.deferred.len()
232 ),
233 body,
234 hunks: split.all(),
237 meta_files: Vec::new(),
238 }),
239 Deferral::SkimRemainder => {
240 plan.push(PlannedCommit {
241 subject: format!("[skim 1/2] {} — {} exemplars", g.label, split.shown.len()),
242 body: format!(
243 "{body}\n\nOne hunk per shape class. {} further hunks follow in \
244 [skim 2/2].",
245 split.deferred.len()
246 ),
247 hunks: split.shown,
248 meta_files: Vec::new(),
249 });
250 plan.push(PlannedCommit {
251 subject: format!(
252 "[skim 2/2] {} — {} further hunks, same shapes",
253 g.label,
254 split.deferred.len()
255 ),
256 body: "Remaining members of the shapes verified in [skim 1/2]. \
257 Skippable on this subject line."
258 .to_string(),
259 hunks: split.deferred,
260 meta_files: Vec::new(),
261 });
262 }
263 }
264 }
265 Ok(plan)
266}
267
268fn emit<G>(
270 git: &G,
271 base: &str,
272 head: &str,
273 view: &DiffView,
274 plan: &[PlannedCommit],
275) -> Result<(Vec<StackCommit>, String), EngineError>
276where
277 G: ObjectReader + ObjectWriter + TreeBuilder + CommitWriter,
278{
279 let mut session = git.begin_from_tree(base)?;
280
281 let mut applied: HashMap<usize, Vec<usize>> = HashMap::new();
282 let mut base_blobs: HashMap<usize, Option<Vec<u8>>> = HashMap::new();
283 let mut parent = base.to_string();
284 let mut commits = Vec::with_capacity(plan.len());
285 let trailer = format!(
286 "Review-Synthetic: {}..{}",
287 plan::short_oid(base),
288 plan::short_oid(head)
289 );
290
291 for c in plan {
292 let mut touched: Vec<usize> = c
293 .hunks
294 .iter()
295 .map(|&h| view.hunks[h.index()].file)
296 .collect();
297 touched.sort_unstable();
298 touched.dedup();
299 for &h in &c.hunks {
300 applied
301 .entry(view.hunks[h.index()].file)
302 .or_default()
303 .push(h.index());
304 }
305
306 let mut entries: Vec<IndexEntry> = Vec::new();
307 for &fi in &touched {
308 entries.push(stage_file(git, base, view, fi, &applied, &mut base_blobs)?);
309 }
310 for &fi in &c.meta_files {
311 let f = &view.files[fi];
315 entries.push(match plan::zero_hunk_state(f)? {
316 plan::Staged::Remove => IndexEntry::Remove {
317 path: f.path.clone(),
318 },
319 plan::Staged::Recorded { mode, oid } => IndexEntry::Set {
320 mode: mode.to_string(),
321 oid: oid.to_string(),
322 path: f.path.clone(),
323 },
324 plan::Staged::Apply { .. } => {
327 return Err(EngineError::Invariant(format!(
328 "zero-hunk file {} was asked to apply hunks it has none of",
329 String::from_utf8_lossy(&f.path)
330 )));
331 }
332 });
333 }
334 session.stage(&entries)?;
335
336 let tree = session.write_tree()?;
337 let msg = format!("{}\n\n{}\n\n{}\n", c.subject, c.body, trailer);
338 let sha = git.commit_tree(&tree, &parent, msg.as_bytes(), IDENTITY)?;
341 commits.push(StackCommit {
342 sha: sha.clone(),
343 subject: c.subject.clone(),
344 hunks: c.hunks.len(),
345 });
346 parent = sha;
347 }
348 Ok((commits, parent))
349}
350
351fn stage_file<G>(
355 git: &G,
356 base: &str,
357 view: &DiffView,
358 fi: usize,
359 applied: &HashMap<usize, Vec<usize>>,
360 base_blobs: &mut HashMap<usize, Option<Vec<u8>>>,
361) -> Result<IndexEntry, EngineError>
362where
363 G: ObjectReader + ObjectWriter,
364{
365 let f = &view.files[fi];
366 let applied_here = applied.get(&fi).map_or(0, Vec::len);
367
368 match plan::cumulative_state(f, applied_here)? {
369 plan::Staged::Remove => Ok(IndexEntry::Remove {
370 path: f.path.clone(),
371 }),
372 plan::Staged::Recorded { mode, oid } => Ok(IndexEntry::Set {
373 mode: mode.to_string(),
374 oid: oid.to_string(),
375 path: f.path.clone(),
376 }),
377 plan::Staged::Apply { mode } => {
378 if let std::collections::hash_map::Entry::Vacant(e) = base_blobs.entry(fi) {
379 e.insert(git.blob(base, &f.path)?);
380 }
381 let hunks: Vec<&differential_engine::model::Hunk> = applied
382 .get(&fi)
383 .map(|v| v.iter().map(|&h| &view.hunks[h]).collect())
384 .unwrap_or_default();
385 let content = apply_hunks(base_blobs[&fi].as_deref(), &hunks);
386 Ok(IndexEntry::Set {
387 mode: mode.to_string(),
388 oid: git.write_blob(&content)?,
389 path: f.path.clone(),
390 })
391 }
392 }
393}
394
395pub struct StackOutput {
397 pub pipeline: differential_engine::PipelineOutput,
398 pub stack: Option<StackResult>,
400}
401
402pub fn run_stack_pipeline<G, C, A>(
405 git: &G,
406 source: &plan::ReviewSource,
407 config: &differential_engine::config::Config,
408 langs: &differential_engine::lang::LanguageRegistry,
409 symbols: &differential_engine::artefact::symbols::SymbolReaders,
410 grouping: &differential_engine::grouping::GroupingOptions<C, A>,
411 stack: &StackOptions,
412) -> Result<StackOutput, EngineError>
413where
414 G: ObjectReader
415 + ObjectWriter
416 + TreeBuilder
417 + CommitWriter
418 + TreeResolver
419 + RecountSource
420 + RefWriter
421 + RangeResolver
422 + DiffSource
423 + AttributeSource,
424 C: differential_engine::ports::GroupingCache,
425 A: differential_engine::ports::ArtefactStore,
426{
427 let mut out = differential_engine::run_grouped_pipeline(
428 git,
429 &source.base,
430 &source.head,
431 source.kind,
432 config,
433 langs,
434 symbols,
435 grouping,
436 )?;
437 differential_engine::verify(git, &mut out)?;
442 if !out.report.all_ok() {
443 return Ok(StackOutput {
444 pipeline: out,
445 stack: None,
446 });
447 }
448 let Some(doc) = &out.document else {
449 return Ok(StackOutput {
450 pipeline: out,
451 stack: None,
452 });
453 };
454 let result = build_stack(git, doc, &out.view, stack)?;
455 Ok(StackOutput {
456 pipeline: out,
457 stack: Some(result),
458 })
459}