Skip to main content

git_policy_cli/commit/
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 crate::private_index_request::{CaptureIndexes, indexed_request};
22use crate::start_retry::RetryStart;
23/// Staged additions carried into the explicit-path index, and the pathspec-file reader.
24use crate::commit_staged_additions::{
25    StagedAddition, carry_additions, explain_ignored_refusal, pathspecs_of_file, staged_additions_selected,
26};
27use crate::transaction_git::{GitContext, GitFailure, GitOutput, run_git_checked};
28/// Commit selection mode of the transaction.
29use crate::transaction_journal::TransactionMode;
30/// What marks the captured index carries.
31use crate::index_flags::{IndexFlags, read_index_flags};
32/// Real-Git command builder shared with the forwarded commands.
33use crate::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        // An index that already holds exactly the base has nothing for `read-tree --reset` to remove and no staged addition
225        // to carry, and the copy keeps everything the reset would have kept (the stat data and the extensions), so the Git
226        // start and the rewrite of the whole index are saved. Only the in-process readers can say so; a question they leave
227        // to Git is answered by the slow way below, never by one more Git start here.
228        if seeded && index_equals_base(context, cwd, indexes, selection.base_revision) {
229            return add_selected_paths(context, cwd, indexes, selection);
230        }
231        let mut read_tree: Vec<String> = vec![String::from("read-tree")];
232        if seeded {
233            read_tree.push(String::from("--reset"));
234        }
235        read_tree.push(selection.base_revision.to_string());
236        private_git(
237            context,
238            cwd,
239            indexes.commit_index.as_path(),
240            indexes.object_directory.as_path(),
241            &read_tree,
242        )?;
243        carry_selected_staged_additions(context, cwd, indexes, selection)?;
244    }
245    return add_selected_paths(context, cwd, indexes, selection);
246}
247
248/// What:
249///  Stages the named paths into the private index, as Git's partial commit does: what the worktree holds at them, deletions
250///  included.
251/// Why:
252///   Both ways of making the private index (reset to the base, or the real index kept as it is) end here.
253///
254/// In TS you'd write (pseudocode):
255/// ```ts
256/// await git(['add', '--all', ...selectionArguments], { index: commitIndex })
257/// ```
258fn add_selected_paths(
259    context: &GitContext,
260    cwd: &Path,
261    indexes: &CaptureIndexes,
262    selection: &IndexSelection,
263) -> Result<(), GitFailure> {
264    let mut arguments: Vec<OsString> = vec![OsString::from("add"), OsString::from("--all")];
265    arguments.extend(selection_arguments(selection));
266    private_git(
267        context,
268        cwd,
269        indexes.commit_index.as_path(),
270        indexes.object_directory.as_path(),
271        &arguments,
272    )
273    .map_err(explain_ignored_refusal)?;
274    return Ok(());
275}
276
277/// What:
278///  Whether the captured index holds exactly the tree of the base revision, answered without starting Git.
279/// Why:
280///   The in-process reader of `diff-index --cached` lists the paths whose entry differs from the tree; it declines an index
281///  it does not fully understand (unmerged, intent-to-add, sparse, split) and a tree it cannot read, and a declined question
282///  is "not known to be equal", which takes the slow way.
283///
284/// In TS you'd write (pseudocode):
285/// ```ts
286/// readerAnswer(['diff-index', '--cached', '--name-only', '-z', '--no-renames', base])?.stdout.length === 0
287/// ```
288fn index_equals_base(context: &GitContext, cwd: &Path, indexes: &CaptureIndexes, base_revision: &str) -> bool {
289    let request = indexed_request(
290        cwd,
291        &["diff-index", "--cached", "--name-only", "-z", "--no-renames", base_revision],
292        Some(indexes.captured_index.as_path()),
293        Some(indexes.object_directory.as_path()),
294    );
295    return crate::repo_read_hook::try_request(context, &request)
296        .is_some_and(|answer| { return answer.code == Some(0) && answer.stdout.is_empty(); });
297}
298
299/// What:
300///  Makes the selected paths that the real index stages as additions tracked in the private index.
301/// Why:
302///   Native `git commit -- <path>` takes such a path's worktree content without asking an ignore rule,
303///  and the private index, which starts from the base, would otherwise apply the rule (issue #1807).
304///  A pathspec file that this wrapper cannot read leaves the selection to Git alone.
305///
306/// In TS you'd write (pseudocode):
307/// ```ts
308/// await carrySelectedStagedAdditions({ context, cwd, indexes, selection });
309/// ```
310fn carry_selected_staged_additions(
311    context: &GitContext,
312    cwd: &Path,
313    indexes: &CaptureIndexes,
314    selection: &IndexSelection,
315) -> Result<(), GitFailure> {
316    let mut pathspecs: Vec<OsString> = selection.pathspecs.to_vec();
317    if let Some(file) = selection.pathspec_file {
318        // A file that cannot be read now fails in `git add` with Git's own message.
319        let Ok(bytes) = std::fs::read(file) else {
320            return Ok(());
321        };
322        let Some(listed) = pathspecs_of_file(bytes.as_slice(), selection.pathspec_file_nul) else {
323            return Ok(());
324        };
325        pathspecs.extend(listed);
326    }
327    let additions: Vec<StagedAddition> = staged_additions_selected(
328        context,
329        cwd,
330        indexes.captured_index.as_path(),
331        selection.base_revision,
332        pathspecs.as_slice(),
333    )?;
334    return carry_additions(
335        context,
336        cwd,
337        indexes.commit_index.as_path(),
338        indexes.object_directory.as_path(),
339        additions.as_slice(),
340    );
341}
342
343/// What:
344///  Writes the intended tree from the private index into the shadow object store.
345/// Why:
346///   The tree is the exact content the commit will carry.
347///
348/// In TS you'd write (pseudocode):
349/// ```ts
350/// await writePrivateTree({ workspace, gitPath, cwd });
351/// ```
352pub fn write_private_tree(
353    context: &GitContext,
354    cwd: &Path,
355    commit_index: &Path,
356    object_directory: &Path,
357) -> Result<String, GitFailure> {
358    let output: GitOutput = private_git(
359        context,
360        cwd,
361        commit_index,
362        object_directory,
363        &[String::from("write-tree")],
364    )?;
365    let text: &str = std::str::from_utf8(output.stdout.as_slice())
366        .map_err(|_| { return GitFailure(String::from("Git returned non-UTF-8 output.")); })?;
367    let oid: &str = text.trim();
368    if oid.is_empty() {
369        return Err(GitFailure(String::from("Git returned empty private tree identity.")));
370    }
371    return Ok(String::from(oid));
372}
373
374/// What:
375///  Lists the repository paths with unmerged stages, once each, in first-seen order.
376/// Why:
377///   A conflicted path cannot be committed by automatic fixes, so preparation refuses it.
378///
379/// In TS you'd write (pseudocode):
380/// ```ts
381/// await listUnmergedIndexPaths({ gitPath, cwd, indexPath });
382/// ```
383pub fn list_unmerged_index_paths(
384    context: &GitContext,
385    cwd: &Path,
386    commit_index: &Path,
387) -> Result<Vec<Vec<u8>>, GitFailure> {
388    let output: GitOutput = run_git_checked(
389        context,
390        &indexed_request(cwd, &["ls-files", "--unmerged", "-z"], Some(commit_index), None),
391    )?;
392    let mut paths: Vec<Vec<u8>> = Vec::new();
393    for record in output.stdout.split(|byte| { return *byte == 0; }) {
394        let Some(tab) = record.iter().position(|byte| { return *byte == b'\t'; }) else {
395            continue;
396        };
397        let path: Vec<u8> = record[tab + 1..].to_vec();
398        if !paths.contains(&path) {
399            paths.push(path);
400        }
401    }
402    return Ok(paths);
403}
404
405/// What:
406///  Lists the paths the private index changes against the recorded base.
407/// Why:
408///   The base is the recorded one, so a landing that moved `HEAD` since invocation changes nothing here.
409///
410/// In TS you'd write (pseudocode):
411/// ```ts
412/// await listChangedIndexPaths({ gitPath, cwd, indexPath, baseRevision, objectDirectory });
413/// ```
414pub fn list_changed_index_paths(
415    context: &GitContext,
416    cwd: &Path,
417    commit_index: &Path,
418    base_revision: &str,
419    object_directory: &Path,
420) -> Result<Vec<Vec<u8>>, GitFailure> {
421    let output: GitOutput = run_git_checked(
422        context,
423        &indexed_request(
424            cwd,
425            &["diff", "--cached", "--name-only", "-z", base_revision],
426            Some(commit_index),
427            Some(object_directory),
428        ),
429    )?;
430    return Ok(output
431        .stdout
432        .split(|byte| { return *byte == 0; })
433        .filter(|path| { return !path.is_empty(); })
434        .map(<[u8]>::to_vec)
435        .collect());
436}
437
438/// What:
439///  Runs `git add --patch` or `git add --interactive` against the private index with inherited stdio.
440/// Why:
441///   The user's terminal answers the selection, and the choice lands only in the private copy.
442///
443/// In TS you'd write (pseudocode):
444/// ```ts
445/// await prepareInteractiveSelection({ workspace, gitPath, cwd, patch, pathspecs, pathspecFile, pathspecFileNul });
446/// ```
447pub fn prepare_interactive_selection(
448    context: &GitContext,
449    cwd: &Path,
450    commit_index: &Path,
451    object_directory: &Path,
452    patch: bool,
453    selection: &IndexSelection,
454) -> Result<(), GitFailure> {
455    let mode: &str = if patch { "--patch" } else { "--interactive" };
456    let mut arguments: Vec<OsString> = vec![OsString::from("add"), OsString::from(mode)];
457    arguments.extend(selection_arguments(selection));
458    // The message shows the command as text; the command itself keeps the exact bytes.
459    let shown: String = arguments
460        .iter()
461        .map(|argument| { return argument.to_string_lossy().into_owned(); })
462        .collect::<Vec<String>>()
463        .join(" ");
464    let mut command = git_command(context.real_git.as_path(), arguments.as_slice(), context.overlay.as_slice());
465    command
466        .current_dir(cwd)
467        .env("GIT_INDEX_FILE", commit_index)
468        .env("GIT_OBJECT_DIRECTORY", object_directory);
469    let status: std::process::ExitStatus = command
470        .status_retrying()
471        .map_err(|error| { return GitFailure(format!("git {shown} could not start: {error}")); })?;
472    if !status.success() {
473        return Err(GitFailure(format!(
474            "git {shown} exited {}",
475            status.code().map_or_else(|| { return String::from("by signal"); }, |code| { return code.to_string(); })
476        )));
477    }
478    return Ok(());
479}
480
481/// Private index controls stay out of the release executable.
482#[cfg(test)]
483#[path = "commit_index_tests.rs"]
484mod tests;
485
486/// Cached stat data of the explicit-path private index is checked in its own sidecar.
487#[cfg(test)]
488#[path = "commit_index_stat_tests.rs"]
489mod stat_tests;