Skip to main content

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