Skip to main content

differential_engine/
stack.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;
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    /// Ref to land the stack on. Default: `refs/review/<base7>-<head7>/stack`.
32    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    /// Independent per-commit `@@` recount, summed (invariant 4).
49    pub recount: usize,
50}
51
52struct PlannedCommit {
53    subject: String,
54    body: String,
55    /// Canonical hunk indices carried by this commit.
56    hunks: Vec<usize>,
57    /// Zero-hunk file indices carried by this commit (the `[meta]` commit).
58    meta_files: Vec<usize>,
59}
60
61/// Build and land the stack. Errors (and leaves the ref untouched) if any
62/// stack invariant fails.
63pub 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    // Zero-hunk files (binary, mode-only, empty add/delete) belong to no class
74    // and therefore no group; without this commit the tree assertion cannot
75    // hold. Staged from recorded oids — the documented tautology.
76    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    // Invariant 2 over the plan: every canonical hunk in exactly one commit.
94    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    // Invariant 3: the tip tree, built from applied hunks, equals head's tree.
116    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    // Invariant 4: independent recount over the built commits.
125    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
160/// One commit per group in rank order; skim groups split into exemplars (one
161/// hunk per shape class) and a remainder skippable on its subject line.
162fn 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
255/// Cumulative emission over a temporary index.
256fn 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
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(
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}