Skip to main content

differential_stack/
lib.rs

1//! The shadow-branch renderer: the diff rewritten as a synthetic commit stack
2//! on a `refs/review/…/stack` ref, one commit per group in reading order —
3//! `git log --oneline` alone shows the shape of the change, and skim
4//! remainders are skippable on their subject line.
5//!
6//! Plumbing only (ADR 0011): temp index, hash-object, update-index,
7//! write-tree, commit-tree, update-ref. No checkout, no branch switch, no
8//! contact with the user's worktree.
9//!
10//! Every commit's content is computed BY APPLYING HUNKS cumulatively — the
11//! final tip tree equalling the head tree is therefore a real assertion that
12//! every hunk was carried (invariant 3), backed by an independent per-commit
13//! `@@` recount (invariant 4). The exceptions are the same documented ones as
14//! the core tree builder: zero-hunk files (binary, mode-only, empty) are
15//! staged from recorded oids in a trailing `[meta]` commit.
16
17use 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
31/// Ref-name component width (`spec/stack.md`).
32///
33/// Narrower than `plan::short_oid` on purpose: this one ends up in a ref a
34/// human types back, so it is a documented part of the CLI contract rather
35/// than a display convenience.
36const REF_ABBREV: usize = 7;
37
38/// Who the synthetic commits belong to. Fixed, so a re-run of the same range
39/// produces the same shas.
40const IDENTITY: CommitIdentity<'static> = CommitIdentity {
41    name: "differential",
42    email: "differential@localhost",
43};
44
45#[derive(Default)]
46pub struct StackOptions<'a> {
47    /// Ref to land the stack on. Default: `refs/review/<base7>-<head7>/stack`.
48    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    /// Independent per-commit `@@` recount, summed (invariant 4).
65    pub recount: usize,
66}
67
68struct PlannedCommit {
69    subject: String,
70    body: String,
71    /// Canonical hunks carried by this commit.
72    hunks: Vec<HunkId>,
73    /// Zero-hunk file indices carried by this commit (the `[meta]` commit).
74    meta_files: Vec<usize>,
75}
76
77/// Build and land the stack. Errors (and leaves the ref untouched) if any
78/// stack invariant fails.
79pub 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    // Zero-hunk files (binary, mode-only, empty add/delete) belong to no class
99    // and therefore no group; without this commit the tree assertion cannot
100    // hold. Staged from recorded oids — the documented tautology.
101    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    // Invariant 2 over the plan: every canonical hunk in exactly one commit.
119    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    // Invariant 3: the tip tree, built from applied hunks, equals head's tree.
141    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    // Invariant 4: independent recount over the built commits.
150    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
182/// One commit per group in rank order; skim groups split into exemplars (one
183/// hunk per shape class) and a remainder skippable on its subject line.
184fn 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    // The audit's back-fill group is assembled last and the ordering stage
193    // keeps it trailing, so its position identifies it.
194    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        // Always folded: the stack's way of unfolding a skim group is the
199        // [skim 2/2] commit that follows it.
200        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                // A folded group still carries every hunk: what a reviewer is
234                // asked to read never decides what the commit contains.
235                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
267/// Cumulative emission over a temporary index.
268fn 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        // The synthetic identity is the renderer's policy, expressed as data
335        // rather than as an environment the whole session inherits.
336        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
347/// Stage one file's cumulative state: full-application deletions become
348/// removals; everything else is content computed by applying the hunks seen so
349/// far (submodules become gitlinks from the pseudo-hunk's commit id).
350fn 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
398/// Output of the full stack pipeline.
399pub struct StackOutput {
400    pub pipeline: differential_engine::PipelineOutput,
401    /// `None` iff invariants failed upstream (no document, nothing rendered).
402    pub stack: Option<StackResult>,
403}
404
405/// Full production path for the shadow-branch renderer: grouped pipeline
406/// (core -> group -> order, in the engine) -> commit stack.
407pub 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}