git_policy_cli/commit_index.rs
1//! What:
2//! The private commit index of one transaction:
3//! its timestamp-preserving copies of the real index,
4//! its initialization for each selection mode,
5//! the intended tree written from it,
6//! its path listings,
7//! the pathspec-file source, and
8//! interactive and patch selection run against it.
9//! Why:
10//! Git's staging and selection run against a private index and the shadow object store,
11//! so the real index and object store change only at landing.
12//! A copy keeps the real index's timestamps, because Git re-reads a stat entry
13//! whose time is not older than the index file (`index-file-timestamps.ts`, issue #544).
14//!
15//! In TS you'd write (pseudocode):
16//! ```ts
17//! await initializeCommitIndex({ workspace, gitPath, cwd, mode, pathspecs, baseRevision, ... });
18//! ```
19
20/// One required Git start, its request builder and the failure text.
21use super::capture_order::{CaptureIndexes, indexed_request};
22use super::start_retry::RetryStart;
23/// Staged additions carried into the explicit-path index, and the pathspec-file reader.
24use super::commit_staged_additions::{
25 StagedAddition, carry_additions, explain_ignored_refusal, pathspecs_of_file, staged_additions_selected,
26};
27use super::transaction_git::{GitContext, GitFailure, GitOutput, run_git_checked};
28/// Commit selection mode of the transaction.
29use super::transaction_journal::TransactionMode;
30/// Exclusive private file writes for the pathspec snapshot.
31use super::private_storage::write_private_file;
32/// What marks the captured index carries.
33use super::index_flags::{IndexFlags, read_index_flags};
34/// Real-Git command builder shared with the forwarded commands.
35use super::forwarding::git_command;
36/// `Read` consumes the standard input snapshot; `FileTimes` carries the copied timestamps.
37use std::io::Read;
38use std::fs::FileTimes;
39/// `OsString` is owned operating-system text.
40use std::ffi::{OsStr, OsString};
41/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
42use std::path::{Path, PathBuf};
43
44/// Name of the single-consumption stdin snapshot inside the transaction directory.
45pub const PATHSPEC_INPUT_NAME: &str = "pathspec.input";
46
47/// What:
48/// Copies an index and gives the copy the source's access and modification times.
49/// Why:
50/// A copy with fresh times would make Git trust stat data captured in the same second
51/// as a later same-size edit (issue #544).
52/// A missing source is an error, as the incumbent's `copyFile` reports it.
53///
54/// In TS you'd write (pseudocode):
55/// ```ts
56/// await copyIndexFile({ sourcePath, destinationPath });
57/// ```
58pub fn copy_index_with_timestamps(source: &Path, destination: &Path) -> std::io::Result<()> {
59 // The times come from the open file, not from its path, so a rename of another index over the path between the
60 // read and the stat cannot give this content that file's times.
61 let mut opened: std::fs::File = std::fs::File::open(source)?;
62 let metadata: std::fs::Metadata = opened.metadata()?;
63 let times: FileTimes = FileTimes::new()
64 .set_accessed(metadata.accessed()?)
65 .set_modified(metadata.modified()?);
66 let mut copy: std::fs::File = std::fs::File::create(destination)?;
67 std::io::copy(&mut opened, &mut copy)?;
68 copy.set_permissions(metadata.permissions())?;
69 return copy.set_times(times);
70}
71
72/// What:
73/// Whether the captured real index may seed the private index of an explicit-path commit.
74/// Why:
75/// Only an index whose every entry was read and carries no assume-unchanged or skip-worktree mark may;
76/// one that cannot be read is treated like a marked one, and the commit builds its index from the base alone.
77///
78/// In TS you'd write (pseudocode):
79/// ```ts
80/// readIndexFlags(await readFile(capturedIndex).catch(() => undefined)) === 'plain'
81/// ```
82fn seeds_private_index(captured_index: &Path) -> bool {
83 let Ok(bytes) = std::fs::read(captured_index) else {
84 return false;
85 };
86 return read_index_flags(bytes.as_slice()) == IndexFlags::Plain;
87}
88
89/// What:
90/// The choices that shape one private index.
91/// Why:
92/// The incumbent's `initializeCommitIndex` takes these as one object,
93/// and the selection modes differ only in these values.
94///
95/// In TS you'd write (pseudocode):
96/// ```ts
97/// initializeCommitIndex({ mode, pathspecs, pathspecFile, pathspecFileNul, stageIntoIndex, stageTrackedChanges, baseRevision });
98/// ```
99#[derive(Clone, Copy, Debug)]
100pub struct IndexSelection<'selection> {
101 /// Commit selection mode.
102 pub mode: TransactionMode,
103 /// Positional pathspecs of the command,
104 /// exactly as the command line gave them.
105 pub pathspecs: &'selection [OsString],
106 /// Materialized pathspec file, when the command names one.
107 pub pathspec_file: Option<&'selection Path>,
108 /// Whether the pathspec file separates records with NUL.
109 pub pathspec_file_nul: bool,
110 /// Whether an index commit stages the selected worktree content into its copy.
111 pub stage_into_index: bool,
112 /// Whether an index commit stages every tracked change, as `commit -a` does.
113 pub stage_tracked_changes: bool,
114 /// Recorded base commit, or the empty tree for an unborn base.
115 pub base_revision: &'selection str,
116}
117
118/// What:
119/// The selection arguments of `git add`:
120/// the pathspecs after `--`, or the pathspec-file controls.
121/// Why:
122/// Staging reads the same selection the policies were given.
123///
124/// In TS you'd write (pseudocode):
125/// ```ts
126/// selectionArguments({ pathspecs, pathspecFile, pathspecFileNul });
127/// ```
128fn selection_arguments(selection: &IndexSelection) -> Vec<OsString> {
129 return match selection.pathspec_file {
130 None => {
131 let mut arguments: Vec<OsString> = vec![OsString::from("--")];
132 arguments.extend(selection.pathspecs.iter().cloned());
133 arguments
134 }
135 Some(file) => {
136 let mut option: OsString = OsString::from("--pathspec-from-file=");
137 option.push(file.as_os_str());
138 let mut arguments: Vec<OsString> = vec![option];
139 if selection.pathspec_file_nul {
140 arguments.push(OsString::from("--pathspec-file-nul"));
141 }
142 arguments
143 }
144 };
145}
146
147/// What:
148/// Runs one Git command against the private index and the shadow object store,
149/// requiring success.
150/// Why:
151/// Every private-index step uses the same index and object directory.
152///
153/// In TS you'd write (pseudocode):
154/// ```ts
155/// await runTransactionGit({ gitPath, cwd, indexPath, objectDirectory, args });
156/// ```
157fn private_git<S: AsRef<OsStr>>(
158 context: &GitContext,
159 cwd: &Path,
160 index: &Path,
161 object_directory: &Path,
162 arguments: &[S],
163) -> Result<GitOutput, GitFailure> {
164 return run_git_checked(
165 context,
166 &indexed_request(cwd, arguments, Some(index), Some(object_directory)),
167 );
168}
169
170/// What:
171/// Copies or builds the private commit index and snapshots the real index at invocation.
172/// Why:
173/// Index commits start from the real index, so their staged state is the state at invocation;
174/// explicit-path commits start from the recorded base, never live `HEAD`,
175/// because another landing may have moved it since invocation.
176///
177/// In TS you'd write (pseudocode):
178/// ```ts
179/// await initializeCommitIndex({ workspace, gitPath, cwd, mode, pathspecs, baseRevision, ... });
180/// ```
181pub fn initialize_commit_index(
182 context: &GitContext,
183 cwd: &Path,
184 real_index: &Path,
185 indexes: &CaptureIndexes,
186 selection: &IndexSelection,
187) -> Result<(), GitFailure> {
188 let copy_failure = |error: std::io::Error| { return GitFailure(format!("copying the index failed: {error}")); };
189 // A repository nothing was ever staged in has no index file, which Git reads as an empty index.
190 if !real_index.exists() {
191 run_git_checked(
192 context,
193 &indexed_request(cwd, &["read-tree".to_string(), "--empty".to_string()], Some(real_index), None),
194 )?;
195 }
196 copy_index_with_timestamps(real_index, indexes.captured_index.as_path()).map_err(copy_failure)?;
197 if selection.mode == TransactionMode::Index {
198 copy_index_with_timestamps(indexes.captured_index.as_path(), indexes.commit_index.as_path())
199 .map_err(copy_failure)?;
200 if selection.stage_tracked_changes {
201 // Whole-tree update of tracked paths, exactly what `commit -a` stages, captured once at invocation.
202 private_git(
203 context,
204 cwd,
205 indexes.commit_index.as_path(),
206 indexes.object_directory.as_path(),
207 &["add".to_string(), "--update".to_string(), "--".to_string(), ":/".to_string()],
208 )?;
209 }
210 if !selection.stage_into_index {
211 return Ok(());
212 }
213 } else {
214 // Starts from a copy of the real index and resets it to the base, as Git's own partial commit does, so every
215 // entry the base shares with the real index keeps its cached stat data. A `read-tree` into a fresh index
216 // leaves every entry without stat data, and the shadow `git commit` then reads and hashes every file of the
217 // worktree to refresh it (measured: 2.9 s against 6 ms on a 20 000-file checkout,
218 // `doc/planning/cli-git-commit-timeouts.md`).
219 // The copy also keeps assume-unchanged and skip-worktree marks, and `git add --all` then skips or refuses a
220 // marked file the commit named, so an index with such a mark (or one this crate cannot read) is built the
221 // slower way, from the base alone.
222 let seeded: bool = seeds_private_index(indexes.captured_index.as_path());
223 if seeded {
224 copy_index_with_timestamps(indexes.captured_index.as_path(), indexes.commit_index.as_path())
225 .map_err(copy_failure)?;
226 }
227 let mut read_tree: Vec<String> = vec![String::from("read-tree")];
228 if seeded {
229 read_tree.push(String::from("--reset"));
230 }
231 read_tree.push(selection.base_revision.to_string());
232 private_git(
233 context,
234 cwd,
235 indexes.commit_index.as_path(),
236 indexes.object_directory.as_path(),
237 &read_tree,
238 )?;
239 carry_selected_staged_additions(context, cwd, indexes, selection)?;
240 }
241 let mut arguments: Vec<OsString> = vec![OsString::from("add"), OsString::from("--all")];
242 arguments.extend(selection_arguments(selection));
243 private_git(
244 context,
245 cwd,
246 indexes.commit_index.as_path(),
247 indexes.object_directory.as_path(),
248 &arguments,
249 )
250 .map_err(explain_ignored_refusal)?;
251 return Ok(());
252}
253
254/// What:
255/// Makes the selected paths that the real index stages as additions tracked in the private index.
256/// Why:
257/// Native `git commit -- <path>` takes such a path's worktree content without asking an ignore rule,
258/// and the private index, which starts from the base, would otherwise apply the rule (issue #1807).
259/// A pathspec file that this wrapper cannot read leaves the selection to Git alone.
260///
261/// In TS you'd write (pseudocode):
262/// ```ts
263/// await carrySelectedStagedAdditions({ context, cwd, indexes, selection });
264/// ```
265fn carry_selected_staged_additions(
266 context: &GitContext,
267 cwd: &Path,
268 indexes: &CaptureIndexes,
269 selection: &IndexSelection,
270) -> Result<(), GitFailure> {
271 let mut pathspecs: Vec<OsString> = selection.pathspecs.to_vec();
272 if let Some(file) = selection.pathspec_file {
273 // A file that cannot be read now fails in `git add` with Git's own message.
274 let Ok(bytes) = std::fs::read(file) else {
275 return Ok(());
276 };
277 let Some(listed) = pathspecs_of_file(bytes.as_slice(), selection.pathspec_file_nul) else {
278 return Ok(());
279 };
280 pathspecs.extend(listed);
281 }
282 let additions: Vec<StagedAddition> = staged_additions_selected(
283 context,
284 cwd,
285 indexes.captured_index.as_path(),
286 selection.base_revision,
287 pathspecs.as_slice(),
288 )?;
289 return carry_additions(
290 context,
291 cwd,
292 indexes.commit_index.as_path(),
293 indexes.object_directory.as_path(),
294 additions.as_slice(),
295 );
296}
297
298/// What:
299/// Writes the intended tree from the private index into the shadow object store.
300/// Why:
301/// The tree is the exact content the commit will carry.
302///
303/// In TS you'd write (pseudocode):
304/// ```ts
305/// await writePrivateTree({ workspace, gitPath, cwd });
306/// ```
307pub fn write_private_tree(
308 context: &GitContext,
309 cwd: &Path,
310 commit_index: &Path,
311 object_directory: &Path,
312) -> Result<String, GitFailure> {
313 let output: GitOutput = private_git(
314 context,
315 cwd,
316 commit_index,
317 object_directory,
318 &[String::from("write-tree")],
319 )?;
320 let text: &str = std::str::from_utf8(output.stdout.as_slice())
321 .map_err(|_| { return GitFailure(String::from("Git returned non-UTF-8 output.")); })?;
322 let oid: &str = text.trim();
323 if oid.is_empty() {
324 return Err(GitFailure(String::from("Git returned empty private tree identity.")));
325 }
326 return Ok(String::from(oid));
327}
328
329/// What:
330/// Lists the repository paths with unmerged stages, once each, in first-seen order.
331/// Why:
332/// A conflicted path cannot be committed by automatic fixes, so preparation refuses it.
333///
334/// In TS you'd write (pseudocode):
335/// ```ts
336/// await listUnmergedIndexPaths({ gitPath, cwd, indexPath });
337/// ```
338pub fn list_unmerged_index_paths(
339 context: &GitContext,
340 cwd: &Path,
341 commit_index: &Path,
342) -> Result<Vec<Vec<u8>>, GitFailure> {
343 let output: GitOutput = run_git_checked(
344 context,
345 &indexed_request(cwd, &["ls-files", "--unmerged", "-z"], Some(commit_index), None),
346 )?;
347 let mut paths: Vec<Vec<u8>> = Vec::new();
348 for record in output.stdout.split(|byte| { return *byte == 0; }) {
349 let Some(tab) = record.iter().position(|byte| { return *byte == b'\t'; }) else {
350 continue;
351 };
352 let path: Vec<u8> = record[tab + 1..].to_vec();
353 if !paths.contains(&path) {
354 paths.push(path);
355 }
356 }
357 return Ok(paths);
358}
359
360/// What:
361/// Lists the paths the private index changes against the recorded base.
362/// Why:
363/// The base is the recorded one, so a landing that moved `HEAD` since invocation changes nothing here.
364///
365/// In TS you'd write (pseudocode):
366/// ```ts
367/// await listChangedIndexPaths({ gitPath, cwd, indexPath, baseRevision, objectDirectory });
368/// ```
369pub fn list_changed_index_paths(
370 context: &GitContext,
371 cwd: &Path,
372 commit_index: &Path,
373 base_revision: &str,
374 object_directory: &Path,
375) -> Result<Vec<Vec<u8>>, GitFailure> {
376 let output: GitOutput = run_git_checked(
377 context,
378 &indexed_request(
379 cwd,
380 &["diff", "--cached", "--name-only", "-z", base_revision],
381 Some(commit_index),
382 Some(object_directory),
383 ),
384 )?;
385 return Ok(output
386 .stdout
387 .split(|byte| { return *byte == 0; })
388 .filter(|path| { return !path.is_empty(); })
389 .map(<[u8]>::to_vec)
390 .collect());
391}
392
393/// What:
394/// Resolves a pathspec-file source to the file the private index reads.
395/// Why:
396/// A regular source is read where it is named, relative to the invocation directory,
397/// and standard input is copied once into the transaction, so no second read sees different bytes.
398///
399/// In TS you'd write (pseudocode):
400/// ```ts
401/// await materializePathspecFile({ workspace, effectiveCwd, source });
402/// ```
403pub fn materialize_pathspec_file(
404 directory: &Path,
405 effective_cwd: &Path,
406 source: Option<&str>,
407 standard_input: &mut dyn Read,
408) -> std::io::Result<Option<PathBuf>> {
409 let Some(name) = source else {
410 return Ok(None);
411 };
412 if name != "-" {
413 let path: PathBuf = PathBuf::from(name);
414 return Ok(Some(if path.is_absolute() {
415 path
416 } else {
417 effective_cwd.join(path)
418 }));
419 }
420 let mut bytes: Vec<u8> = Vec::new();
421 standard_input.read_to_end(&mut bytes)?;
422 let path: PathBuf = directory.join(PATHSPEC_INPUT_NAME);
423 write_private_file(path.as_path(), bytes.as_slice())?;
424 return Ok(Some(path));
425}
426
427/// What:
428/// Runs `git add --patch` or `git add --interactive` against the private index with inherited stdio.
429/// Why:
430/// The user's terminal answers the selection, and the choice lands only in the private copy.
431///
432/// In TS you'd write (pseudocode):
433/// ```ts
434/// await prepareInteractiveSelection({ workspace, gitPath, cwd, patch, pathspecs, pathspecFile, pathspecFileNul });
435/// ```
436pub fn prepare_interactive_selection(
437 context: &GitContext,
438 cwd: &Path,
439 commit_index: &Path,
440 object_directory: &Path,
441 patch: bool,
442 selection: &IndexSelection,
443) -> Result<(), GitFailure> {
444 let mode: &str = if patch { "--patch" } else { "--interactive" };
445 let mut arguments: Vec<OsString> = vec![OsString::from("add"), OsString::from(mode)];
446 arguments.extend(selection_arguments(selection));
447 // The message shows the command as text; the command itself keeps the exact bytes.
448 let shown: String = arguments
449 .iter()
450 .map(|argument| { return argument.to_string_lossy().into_owned(); })
451 .collect::<Vec<String>>()
452 .join(" ");
453 let mut command = git_command(context.real_git.as_path(), arguments.as_slice(), context.overlay.as_slice());
454 command
455 .current_dir(cwd)
456 .env("GIT_INDEX_FILE", commit_index)
457 .env("GIT_OBJECT_DIRECTORY", object_directory);
458 let status: std::process::ExitStatus = command
459 .status_retrying()
460 .map_err(|error| { return GitFailure(format!("git {shown} could not start: {error}")); })?;
461 if !status.success() {
462 return Err(GitFailure(format!(
463 "git {shown} exited {}",
464 status.code().map_or_else(|| { return String::from("by signal"); }, |code| { return code.to_string(); })
465 )));
466 }
467 return Ok(());
468}
469
470/// Private index controls stay out of the release executable.
471#[cfg(test)]
472#[path = "commit_index_tests.rs"]
473mod tests;
474
475/// Cached stat data of the explicit-path private index is checked in its own sidecar.
476#[cfg(test)]
477#[path = "commit_index_stat_tests.rs"]
478mod stat_tests;