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