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;
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
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    if doc.groups.is_none() {
186        return Err(EngineError::Invariant(
187            "stack rendering needs a grouped document (groups is null)".into(),
188        ));
189    }
190    // Which group is the audit's back-fill, and what token names a tier, are
191    // both domain answers. They used to be re-derived here — the back-fill
192    // test character for character, the token by composing `effort_name`
193    // itself — which is exactly the drift `plan::ReviewView` exists to stop.
194    //
195    // The projection is now the ONLY handle: it carries the prose too, so the
196    // raw `schema::Group` and the `PlanIndex` beside it are both gone.
197    let review = plan::ReviewView::project(doc)?;
198    let mut plan = Vec::new();
199
200    for g in &review.groups {
201        // Always folded: the stack's way of unfolding a skim group is the
202        // [skim 2/2] commit that follows it.
203        let split = reading_split(&review, g, Fold::Folded);
204        let body = format!("{}\n\n{}", g.description, g.reason);
205        // `unclassified` for the back-fill, the tier's own name otherwise.
206        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                // A folded group still carries every hunk: what a reviewer is
235                // asked to read never decides what the commit contains.
236                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
268/// Cumulative emission over a temporary index.
269fn 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            // What a zero-hunk file contributes is a domain rule, and this
312            // loop used to state it again in its own words — deletion, then
313            // the recorded mode and oid, then the same two error strings.
314            let f = &view.files[fi];
315            entries.push(IndexEntry::from_staged(
316                plan::zero_hunk_state(f)?,
317                f.path.clone(),
318                // The rule never answers `Apply` for a zero-hunk file, and an
319                // error says so where a panic would only assert it.
320                || {
321                    Err(EngineError::Invariant(format!(
322                        "zero-hunk file {} was asked to apply hunks it has none of",
323                        String::from_utf8_lossy(&f.path)
324                    )))
325                },
326            )?);
327        }
328        session.stage(&entries)?;
329
330        let tree = session.write_tree()?;
331        let msg = format!("{}\n\n{}\n\n{}\n", c.subject, c.body, trailer);
332        // The synthetic identity is the renderer's policy, expressed as data
333        // rather than as an environment the whole session inherits.
334        let sha = git.commit_tree(&tree, &parent, msg.as_bytes(), IDENTITY)?;
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
345/// Stage one file's cumulative state: full-application deletions become
346/// removals; everything else is content computed by applying the hunks seen so
347/// far (submodules become gitlinks from the pseudo-hunk's commit id).
348fn stage_file<G>(
349    git: &G,
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<IndexEntry, EngineError>
356where
357    G: ObjectReader + ObjectWriter,
358{
359    let f = &view.files[fi];
360    let applied_here = applied.get(&fi).map_or(0, Vec::len);
361
362    IndexEntry::from_staged(
363        plan::cumulative_state(f, applied_here)?,
364        f.path.clone(),
365        || {
366            if let std::collections::hash_map::Entry::Vacant(e) = base_blobs.entry(fi) {
367                e.insert(git.blob(base, &f.path)?);
368            }
369            let hunks: Vec<&differential_engine::model::Hunk> = applied
370                .get(&fi)
371                .map(|v| v.iter().map(|&h| &view.hunks[h]).collect())
372                .unwrap_or_default();
373            let content = apply_hunks(base_blobs[&fi].as_deref(), &hunks);
374            git.write_blob(&content)
375        },
376    )
377}
378
379/// Output of the full stack pipeline.
380pub struct StackOutput {
381    pub pipeline: differential_engine::PipelineOutput,
382    /// `None` iff invariants failed upstream (no document, nothing rendered).
383    pub stack: Option<StackResult>,
384}
385
386/// Full production path for the shadow-branch renderer: grouped pipeline
387/// (core -> group -> order, in the engine) -> commit stack.
388pub fn run_stack_pipeline<G, C, A>(
389    git: &G,
390    source: &plan::ReviewSource,
391    config: &differential_engine::config::Config,
392    langs: &differential_engine::lang::LanguageRegistry,
393    symbols: &differential_engine::artefact::symbols::SymbolReaders,
394    grouping: &differential_engine::grouping::GroupingOptions<C, A>,
395    stack: &StackOptions,
396) -> Result<StackOutput, EngineError>
397where
398    G: ObjectReader
399        + ObjectWriter
400        + TreeBuilder
401        + CommitWriter
402        + TreeResolver
403        + RecountSource
404        + RefWriter
405        + RangeResolver
406        + DiffSource
407        + AttributeSource,
408    C: differential_engine::ports::GroupingCache,
409    A: differential_engine::ports::ArtefactStore,
410{
411    let mut out =
412        differential_engine::run_grouped_pipeline(git, source, config, langs, symbols, grouping)?;
413    // Invariants 3 and 4. This renderer is the reason they exist: its commits
414    // are trees built from these hunks, so the tree assertion is about exactly
415    // the path taken below. The engine's pipeline no longer runs them, because
416    // a consumer that never builds a tree is not protected by them.
417    differential_engine::verify(git, &mut out)?;
418    if !out.report.all_ok() {
419        return Ok(StackOutput {
420            pipeline: out,
421            stack: None,
422        });
423    }
424    let Some(doc) = &out.document else {
425        return Ok(StackOutput {
426            pipeline: out,
427            stack: None,
428        });
429    };
430    let result = build_stack(git, doc, &out.view, stack)?;
431    Ok(StackOutput {
432        pipeline: out,
433        stack: Some(result),
434    })
435}