Skip to main content

differential_engine/plan/
staging.rs

1//! What a file contributes to a tree — decided without touching git.
2//!
3//! Both tree builders used to interleave this decision with the writes it
4//! implies, which is why neither could be tested without a repository. The
5//! decisions are here; the writing stays with whoever owns an index.
6
7use crate::EngineError;
8use crate::model::{Disposition, FileChange};
9
10/// How one file should be staged.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Staged<'a> {
13    /// Drop the path from the tree.
14    Remove,
15    /// Stage an oid recorded in the diff, verbatim.
16    ///
17    /// The documented tautology, and the only place one is allowed: gitlinks
18    /// and binary files carry no hunks, so a recorded oid is the sole
19    /// available content. The invariant report says so out loud.
20    Recorded { mode: &'a str, oid: &'a str },
21    /// Stage content computed **by applying hunks**.
22    ///
23    /// Never by copying the head blob: with the copy shortcut the tree
24    /// assertion holds by construction and proves nothing (invariant 3).
25    Apply { mode: &'a str },
26}
27
28/// The core tree builder's rule: the file's final state, every hunk applied.
29pub fn final_state(f: &FileChange) -> Result<Staged<'_>, EngineError> {
30    if f.disposition == Disposition::Deleted {
31        return Ok(Staged::Remove);
32    }
33
34    let mode = f.new_mode.as_deref().ok_or_else(|| {
35        EngineError::Invariant(format!(
36            "no new mode recorded for {}",
37            String::from_utf8_lossy(&f.path)
38        ))
39    })?;
40
41    if f.submodule.is_some() {
42        // The commit id from the pseudo-hunk, cross-checked against the raw
43        // record's oid when both exist.
44        let oid = f
45            .submodule
46            .as_ref()
47            .and_then(|(_, new)| new.as_deref())
48            .or(f.new_oid.as_deref())
49            .ok_or_else(|| {
50                EngineError::Invariant(format!(
51                    "submodule {} has no new commit id",
52                    String::from_utf8_lossy(&f.path)
53                ))
54            })?;
55        return Ok(Staged::Recorded { mode, oid });
56    }
57
58    if f.binary {
59        let oid = f.new_oid.as_deref().ok_or_else(|| {
60            EngineError::Invariant(format!(
61                "binary file {} has no recorded oid",
62                String::from_utf8_lossy(&f.path)
63            ))
64        })?;
65        return Ok(Staged::Recorded { mode, oid });
66    }
67
68    Ok(Staged::Apply { mode })
69}
70
71/// The stack renderer's rule: the file's state after `applied` of its hunks.
72///
73/// Deliberately a second function rather than a flag on `final_state` — the
74/// rules genuinely differ, and each difference is load-bearing:
75///
76/// - a deletion is a removal only once **every** hunk has been applied, or the
77///   stack would drop a file mid-series and lose the hunks still to come;
78/// - the mode falls back to `old_mode`, because a partially-built file may not
79///   have reached the commit that sets its new one;
80/// - submodules are decided **before** the mode, since a gitlink's mode is a
81///   constant rather than something the diff has to have recorded.
82pub fn cumulative_state(f: &FileChange, applied: usize) -> Result<Staged<'_>, EngineError> {
83    let complete = applied == f.hunks.len();
84
85    if f.disposition == Disposition::Deleted && complete {
86        return Ok(Staged::Remove);
87    }
88
89    if let Some((_, new)) = &f.submodule {
90        let oid = new.as_deref().or(f.new_oid.as_deref()).ok_or_else(|| {
91            EngineError::Invariant(format!(
92                "submodule {} has no new commit id",
93                String::from_utf8_lossy(&f.path)
94            ))
95        })?;
96        return Ok(Staged::Recorded {
97            mode: "160000",
98            oid,
99        });
100    }
101
102    let mode = f
103        .new_mode
104        .as_deref()
105        .or(f.old_mode.as_deref())
106        .ok_or_else(|| missing_mode(f))?;
107
108    Ok(Staged::Apply { mode })
109}
110
111/// A file with NO hunks: what it contributes to a tree.
112///
113/// Binary content, a submodule bump and a mode-only change all arrive with
114/// zero hunks, so there is nothing to apply and the recorded oid is the only
115/// content there is. **Never returns `Staged::Apply`.**
116///
117/// Deliberately a third rule rather than a flag on `final_state`, for the same
118/// reason `cumulative_state` is: the rules genuinely differ. `final_state`
119/// answers `Apply` for a mode-only text change, and applying zero hunks would
120/// rewrite a blob whose bytes are already correct — work, and a second oid for
121/// content git already has.
122pub fn zero_hunk_state(f: &FileChange) -> Result<Staged<'_>, EngineError> {
123    if f.disposition == Disposition::Deleted {
124        return Ok(Staged::Remove);
125    }
126
127    let mode = f.new_mode.as_deref().ok_or_else(|| missing_mode(f))?;
128    let oid = f.new_oid.as_deref().ok_or_else(|| {
129        EngineError::Invariant(format!(
130            "zero-hunk file {} has no recorded oid",
131            String::from_utf8_lossy(&f.path)
132        ))
133    })?;
134    Ok(Staged::Recorded { mode, oid })
135}
136
137/// The one wording for "this file records no mode", shared by every rule above
138/// and by the renderers. It was copied character for character into
139/// `crates/stack`, where a reader could not tell it was the same failure.
140pub fn missing_mode(f: &FileChange) -> EngineError {
141    EngineError::Invariant(format!(
142        "no mode recorded for {}",
143        String::from_utf8_lossy(&f.path)
144    ))
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    fn file(disposition: Disposition) -> FileChange {
152        FileChange {
153            path: b"src/a.rs".to_vec(),
154            disposition,
155            new_mode: Some("100644".into()),
156            old_mode: None,
157            binary: false,
158            submodule: None,
159            old_oid: None,
160            new_oid: None,
161            hunks: vec![0, 1],
162            rename_similarity: None,
163            rename_from: None,
164            rename_to: None,
165            generated: None,
166        }
167    }
168
169    #[test]
170    fn text_files_are_always_rebuilt_from_hunks() {
171        // The whole tree assertion rests on this never becoming a copy.
172        let f = file(Disposition::Modified);
173        assert_eq!(final_state(&f).unwrap(), Staged::Apply { mode: "100644" });
174        assert_eq!(
175            cumulative_state(&f, 1).unwrap(),
176            Staged::Apply { mode: "100644" }
177        );
178    }
179
180    #[test]
181    fn binary_files_stage_their_recorded_oid() {
182        let mut f = file(Disposition::Modified);
183        f.binary = true;
184        f.hunks.clear();
185        f.new_oid = Some("deadbeef".into());
186        assert_eq!(
187            final_state(&f).unwrap(),
188            Staged::Recorded {
189                mode: "100644",
190                oid: "deadbeef"
191            }
192        );
193    }
194
195    #[test]
196    fn a_binary_file_without_an_oid_is_an_invariant_failure_not_an_empty_blob() {
197        let mut f = file(Disposition::Modified);
198        f.binary = true;
199        let err = final_state(&f).unwrap_err().to_string();
200        assert!(err.contains("binary file"), "{err}");
201        assert!(err.contains("src/a.rs"), "{err}");
202    }
203
204    #[test]
205    fn submodules_stage_the_gitlink_commit_id() {
206        let mut f = file(Disposition::Modified);
207        f.submodule = Some((Some("old".into()), Some("new".into())));
208        f.new_mode = Some("160000".into());
209        assert_eq!(
210            final_state(&f).unwrap(),
211            Staged::Recorded {
212                mode: "160000",
213                oid: "new"
214            }
215        );
216        // The stack does not need the mode recorded: a gitlink's is a constant.
217        f.new_mode = None;
218        assert_eq!(
219            cumulative_state(&f, 0).unwrap(),
220            Staged::Recorded {
221                mode: "160000",
222                oid: "new"
223            }
224        );
225    }
226
227    /// A zero-hunk file stages what the diff recorded, and never rebuilds.
228    ///
229    /// This is the rule the stack's meta-commit loop used to state in its own
230    /// words. `final_state` is NOT the same answer, which is why it is a third
231    /// rule: it says `Apply` here, and applying no hunks would write a second
232    /// oid for bytes git already has.
233    #[test]
234    fn a_zero_hunk_file_stages_its_recorded_oid_and_never_applies() {
235        let mut f = file(Disposition::Modified);
236        f.hunks.clear();
237        f.new_oid = Some("cafe".into());
238
239        assert_eq!(
240            zero_hunk_state(&f).unwrap(),
241            Staged::Recorded {
242                mode: "100644",
243                oid: "cafe"
244            }
245        );
246        // The rule the renderer must not reach for: same file, different answer.
247        assert_eq!(final_state(&f).unwrap(), Staged::Apply { mode: "100644" });
248
249        f.disposition = Disposition::Deleted;
250        assert_eq!(zero_hunk_state(&f).unwrap(), Staged::Remove);
251    }
252
253    #[test]
254    fn a_zero_hunk_file_without_an_oid_or_a_mode_names_which_is_missing() {
255        let mut f = file(Disposition::Modified);
256        f.hunks.clear();
257        let err = zero_hunk_state(&f).unwrap_err().to_string();
258        assert!(err.contains("zero-hunk file"), "{err}");
259        assert!(err.contains("src/a.rs"), "{err}");
260
261        f.new_mode = None;
262        let err = zero_hunk_state(&f).unwrap_err().to_string();
263        assert!(err.contains("no mode recorded"), "{err}");
264    }
265
266    /// The difference between the two rules that actually matters: a deletion
267    /// mid-series still carries hunks, so removing it early would lose them.
268    #[test]
269    fn a_deletion_is_removed_only_once_every_hunk_has_been_applied() {
270        let f = file(Disposition::Deleted);
271        assert_eq!(final_state(&f).unwrap(), Staged::Remove);
272
273        assert_eq!(
274            cumulative_state(&f, 1).unwrap(),
275            Staged::Apply { mode: "100644" },
276            "one of two hunks applied: the file must still exist"
277        );
278        assert_eq!(cumulative_state(&f, 2).unwrap(), Staged::Remove);
279    }
280
281    #[test]
282    fn the_stack_falls_back_to_the_old_mode_the_core_does_not() {
283        let mut f = file(Disposition::Modified);
284        f.new_mode = None;
285        f.old_mode = Some("100755".into());
286
287        assert_eq!(
288            cumulative_state(&f, 0).unwrap(),
289            Staged::Apply { mode: "100755" },
290            "a partially built file may not have reached its mode change yet"
291        );
292        assert!(
293            final_state(&f)
294                .unwrap_err()
295                .to_string()
296                .contains("new mode"),
297            "the final state has no excuse for a missing new mode"
298        );
299    }
300}