differential_engine/plan/
staging.rs1use crate::EngineError;
8use crate::model::{Disposition, FileChange};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Staged<'a> {
13 Remove,
15 Recorded { mode: &'a str, oid: &'a str },
21 Apply { mode: &'a str },
26}
27
28pub 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 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
71pub 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
111pub 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
137pub 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 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 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 #[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 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 #[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}