1use super::commit_index::copy_index_with_timestamps;
18use super::recovery_files::files_equal;
20use super::diagnostic_log::warn;
22use super::transaction_journal::TransactionMode;
24use super::transaction_git::{GitContext, GitFailure, GitOutput, GitRequest, run_git, run_git_checked};
26use std::collections::HashMap;
28use std::ffi::OsString;
30use std::path::Path;
31
32const TAG: &str = "computeLandingPostIndex";
34
35pub struct PostIndexInput<'input> {
45 pub mode: TransactionMode,
47 pub pre_landing_index: &'input Path,
49 pub captured_index: &'input Path,
51 pub landed_index: &'input Path,
53 pub post_index: &'input Path,
55 pub landed_tree: &'input str,
57 pub base_revision: &'input str,
59 pub committed_paths: &'input [String],
61 pub guarded_paths: &'input [String],
63 pub exact_private_index: bool,
65}
66
67fn literal_pathspecs() -> Vec<(OsString, OsString)> {
77 return vec![(OsString::from("GIT_LITERAL_PATHSPECS"), OsString::from("0"))];
78}
79
80fn decode(bytes: &[u8]) -> Result<String, GitFailure> {
90 return String::from_utf8(bytes.to_vec())
91 .map_err(|error| { return GitFailure(format!("Git output is not UTF-8: {error}")); });
92}
93
94fn records(bytes: &[u8]) -> Result<Vec<String>, GitFailure> {
104 return Ok(decode(bytes)?
105 .split('\0')
106 .filter(|record| { return !record.is_empty(); })
107 .map(str::to_string)
108 .collect());
109}
110
111fn list_stages(
121 context: &GitContext,
122 cwd: &Path,
123 index: &Path,
124 paths: &[String],
125) -> Result<HashMap<String, Vec<String>>, GitFailure> {
126 let mut grouped: HashMap<String, Vec<String>> = HashMap::new();
127 if paths.is_empty() {
128 return Ok(grouped);
129 }
130 let mut arguments: Vec<String> = vec!["ls-files".into(), "--stage".into(), "-z".into(), "--full-name".into(), "--".into()];
131 arguments.extend(paths.iter().map(|path| format!(":(top,literal){path}")));
132 let mut request = GitRequest::new(cwd, &arguments);
133 request.index = Some(index.to_path_buf());
134 request.environment = literal_pathspecs();
135 let output: GitOutput = run_git_checked(context, &request)?;
136 for record in records(&output.stdout)? {
137 let Some((_, path)) = record.split_once('\t') else {
138 return Err(GitFailure(format!("ls-files record has no path: {record}")));
139 };
140 grouped.entry(path.to_string()).or_default().push(record.clone());
141 }
142 return Ok(grouped);
143}
144
145fn changed_paths(context: &GitContext, cwd: &Path, from: &str, to: &str) -> Result<Vec<String>, GitFailure> {
155 let request = GitRequest::new(cwd, &["diff-tree", "-r", "--no-renames", "--name-only", "-z", from, to]);
156 let output: GitOutput = run_git_checked(context, &request)?;
157 return records(&output.stdout);
158}
159
160fn changed_since_captured(
170 context: &GitContext,
171 cwd: &Path,
172 captured: &Path,
173 landed_tree: &str,
174) -> Result<Vec<String>, GitFailure> {
175 let mut request = GitRequest::new(cwd, &["write-tree"]);
176 request.index = Some(captured.to_path_buf());
177 let output: GitOutput = run_git(context, &request)
178 .map_err(|error| { return GitFailure(format!("git write-tree could not start: {error}")); })?;
179 if !output.succeeded() {
180 return Ok(Vec::new());
181 }
182 let tree: String = decode(&output.stdout)?.trim().to_string();
183 return changed_paths(context, cwd, tree.as_str(), landed_tree);
184}
185
186fn merge_landed_entries(
197 context: &GitContext,
198 cwd: &Path,
199 artifacts: &Artifacts,
200 changed: &[String],
201) -> Result<(), GitFailure> {
202 let captured = list_stages(context, cwd, artifacts.captured, changed)?;
203 let current = list_stages(context, cwd, artifacts.post, changed)?;
204 let landed = list_stages(context, cwd, artifacts.landed, changed)?;
205 let untouched: Vec<&String> = changed
206 .iter()
207 .filter(|path| {
208 return captured.get(path.as_str()).map(Vec::as_slice).unwrap_or(&[])
209 == current.get(path.as_str()).map(Vec::as_slice).unwrap_or(&[]);
210 })
211 .collect();
212 if untouched.is_empty() {
213 return Ok(());
214 }
215 let zero: String = "0".repeat(artifacts.landed_tree.len());
216 let mut body: String = String::new();
217 for path in untouched {
218 match landed.get(path.as_str()) {
219 Some(entries) => {
220 for entry in entries {
221 body.push_str(entry);
222 body.push('\0');
223 }
224 }
225 None => {
226 body.push_str(&format!("0 {zero}\t{path}\0"));
227 }
228 }
229 }
230 let mut request = GitRequest::new(cwd, &["update-index", "-z", "--index-info"]);
231 request.index = Some(artifacts.post.to_path_buf());
232 request.input = Some(body.into_bytes());
233 run_git_checked(context, &request)?;
234 return Ok(());
235}
236
237struct Artifacts<'artifacts> {
247 captured: &'artifacts Path,
249 post: &'artifacts Path,
251 landed: &'artifacts Path,
253 landed_tree: &'artifacts str,
255}
256
257fn reset_guarded_paths(
267 context: &GitContext,
268 cwd: &Path,
269 artifacts: &Artifacts,
270 guarded: &[String],
271) -> Result<(), GitFailure> {
272 if guarded.is_empty() {
273 return Ok(());
274 }
275 let captured = list_stages(context, cwd, artifacts.captured, guarded)?;
276 let current = list_stages(context, cwd, artifacts.post, guarded)?;
277 let mut untouched: Vec<&String> = Vec::new();
278 for path in guarded {
279 let before: &[String] = captured.get(path.as_str()).map(Vec::as_slice).unwrap_or(&[]);
280 let now: &[String] = current.get(path.as_str()).map(Vec::as_slice).unwrap_or(&[]);
281 if before == now {
282 untouched.push(path);
283 } else {
284 warn(
285 TAG,
286 &format!(
287 "A commit hook changed {path}, but it was staged again while the commit ran; the committed bytes are in HEAD and your staged entry was kept. Compare them with git diff --cached -- {path}."
288 ),
289 );
290 }
291 }
292 if untouched.is_empty() {
293 return Ok(());
294 }
295 let mut arguments: Vec<String> = vec!["reset".into(), "--quiet".into(), artifacts.landed_tree.to_string(), "--".into()];
296 arguments.extend(untouched.iter().map(|path| format!(":(top,literal){path}")));
297 let mut request = GitRequest::new(cwd, &arguments);
298 request.index = Some(artifacts.post.to_path_buf());
299 request.environment = literal_pathspecs();
300 run_git_checked(context, &request)?;
301 return Ok(());
302}
303
304pub fn compute_landing_post_index(
314 context: &GitContext,
315 cwd: &Path,
316 input: &PostIndexInput,
317) -> Result<(), GitFailure> {
318 let artifacts = Artifacts {
319 captured: input.captured_index,
320 post: input.post_index,
321 landed: input.landed_index,
322 landed_tree: input.landed_tree,
323 };
324 if input.mode == TransactionMode::ExplicitPath {
325 copy(input.pre_landing_index, input.post_index)?;
326 if !input.committed_paths.is_empty() {
327 let mut arguments: Vec<String> = vec!["reset".into(), "--quiet".into(), input.landed_tree.to_string(), "--".into()];
328 arguments.extend(input.committed_paths.iter().map(|path| format!(":(top,literal){path}")));
329 let mut request = GitRequest::new(cwd, &arguments);
330 request.index = Some(input.post_index.to_path_buf());
331 request.environment = literal_pathspecs();
332 run_git_checked(context, &request)?;
333 }
334 let outside: Vec<String> = input
335 .guarded_paths
336 .iter()
337 .filter(|path| { return !input.committed_paths.contains(path); })
338 .cloned()
339 .collect();
340 return reset_guarded_paths(context, cwd, &artifacts, &outside);
341 }
342 if input.exact_private_index && files_equal(input.pre_landing_index, input.captured_index)
343 .map_err(|error| { return GitFailure(error.0); })?
344 {
345 copy(input.landed_index, input.post_index)?;
346 return Ok(());
347 }
348 warn(TAG, "real index changed since invocation; merging landed entries per path");
349 copy(input.pre_landing_index, input.post_index)?;
350 let mut changed: Vec<String> = changed_paths(context, cwd, input.base_revision, input.landed_tree)?;
351 for path in changed_since_captured(context, cwd, input.captured_index, input.landed_tree)? {
352 if !changed.contains(&path) {
353 changed.push(path);
354 }
355 }
356 return merge_landed_entries(context, cwd, &artifacts, &changed);
357}
358
359fn copy(source: &Path, destination: &Path) -> Result<(), GitFailure> {
369 return copy_index_with_timestamps(source, destination)
370 .map_err(|error| { return GitFailure(format!("copying {} failed: {error}", source.display())); });
371}
372
373#[cfg(test)]
375#[path = "landing_index_tests.rs"]
376mod tests;