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