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::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    pub pathspecs: &'selection [String],
79    /// Materialized pathspec file, when the command names one.
80    pub pathspec_file: Option<&'selection Path>,
81    /// Whether the pathspec file separates records with NUL.
82    pub pathspec_file_nul: bool,
83    /// Whether an index commit stages the selected worktree content into its copy.
84    pub stage_into_index: bool,
85    /// Whether an index commit stages every tracked change, as `commit -a` does.
86    pub stage_tracked_changes: bool,
87    /// Recorded base commit, or the empty tree for an unborn base.
88    pub base_revision: &'selection str,
89}
90
91/// What:
92///  The selection arguments of `git add`:
93///  the pathspecs after `--`, or the pathspec-file controls.
94/// Why:
95///   Staging reads the same selection the policies were given.
96///
97/// In TS you'd write (pseudocode):
98/// ```ts
99/// selectionArguments({ pathspecs, pathspecFile, pathspecFileNul });
100/// ```
101fn selection_arguments(selection: &IndexSelection) -> Vec<String> {
102    return match selection.pathspec_file {
103        None => {
104            let mut arguments: Vec<String> = vec![String::from("--")];
105            arguments.extend(selection.pathspecs.iter().cloned());
106            arguments
107        }
108        Some(file) => {
109            let mut arguments: Vec<String> =
110                vec![format!("--pathspec-from-file={}", file.display())];
111            if selection.pathspec_file_nul {
112                arguments.push(String::from("--pathspec-file-nul"));
113            }
114            arguments
115        }
116    };
117}
118
119/// What:
120///  Runs one Git command against the private index and the shadow object store,
121///  requiring success.
122/// Why:
123///   Every private-index step uses the same index and object directory.
124///
125/// In TS you'd write (pseudocode):
126/// ```ts
127/// await runTransactionGit({ gitPath, cwd, indexPath, objectDirectory, args });
128/// ```
129fn private_git(
130    context: &GitContext,
131    cwd: &Path,
132    index: &Path,
133    object_directory: &Path,
134    arguments: &[String],
135) -> Result<GitOutput, GitFailure> {
136    return run_git_checked(
137        context,
138        &indexed_request(cwd, arguments, Some(index), Some(object_directory)),
139    );
140}
141
142/// What:
143///  Copies or builds the private commit index and snapshots the real index at invocation.
144/// Why:
145///   Index commits start from the real index, so their staged state is the state at invocation;
146///  explicit-path commits start from the recorded base, never live `HEAD`,
147///  because another landing may have moved it since invocation.
148///
149/// In TS you'd write (pseudocode):
150/// ```ts
151/// await initializeCommitIndex({ workspace, gitPath, cwd, mode, pathspecs, baseRevision, ... });
152/// ```
153pub fn initialize_commit_index(
154    context: &GitContext,
155    cwd: &Path,
156    real_index: &Path,
157    indexes: &CaptureIndexes,
158    selection: &IndexSelection,
159) -> Result<(), GitFailure> {
160    let copy_failure = |error: std::io::Error| { return GitFailure(format!("copying the index failed: {error}")); };
161    copy_index_with_timestamps(real_index, indexes.captured_index.as_path()).map_err(copy_failure)?;
162    if selection.mode == TransactionMode::Index {
163        copy_index_with_timestamps(indexes.captured_index.as_path(), indexes.commit_index.as_path())
164            .map_err(copy_failure)?;
165        if selection.stage_tracked_changes {
166            // Whole-tree update of tracked paths, exactly what `commit -a` stages, captured once at invocation.
167            private_git(
168                context,
169                cwd,
170                indexes.commit_index.as_path(),
171                indexes.object_directory.as_path(),
172                &["add".to_string(), "--update".to_string(), "--".to_string(), ":/".to_string()],
173            )?;
174        }
175        if !selection.stage_into_index {
176            return Ok(());
177        }
178    } else {
179        private_git(
180            context,
181            cwd,
182            indexes.commit_index.as_path(),
183            indexes.object_directory.as_path(),
184            &["read-tree".to_string(), selection.base_revision.to_string()],
185        )?;
186    }
187    let mut arguments: Vec<String> = vec![String::from("add"), String::from("--all")];
188    arguments.extend(selection_arguments(selection));
189    private_git(
190        context,
191        cwd,
192        indexes.commit_index.as_path(),
193        indexes.object_directory.as_path(),
194        &arguments,
195    )?;
196    return Ok(());
197}
198
199/// What:
200///  Writes the intended tree from the private index into the shadow object store.
201/// Why:
202///   The tree is the exact content the commit will carry.
203///
204/// In TS you'd write (pseudocode):
205/// ```ts
206/// await writePrivateTree({ workspace, gitPath, cwd });
207/// ```
208pub fn write_private_tree(
209    context: &GitContext,
210    cwd: &Path,
211    commit_index: &Path,
212    object_directory: &Path,
213) -> Result<String, GitFailure> {
214    let output: GitOutput = private_git(
215        context,
216        cwd,
217        commit_index,
218        object_directory,
219        &[String::from("write-tree")],
220    )?;
221    let text: &str = std::str::from_utf8(output.stdout.as_slice())
222        .map_err(|_| { return GitFailure(String::from("Git returned non-UTF-8 output.")); })?;
223    let oid: &str = text.trim();
224    if oid.is_empty() {
225        return Err(GitFailure(String::from("Git returned empty private tree identity.")));
226    }
227    return Ok(String::from(oid));
228}
229
230/// What:
231///  Lists the repository paths with unmerged stages, once each, in first-seen order.
232/// Why:
233///   A conflicted path cannot be committed by automatic fixes, so preparation refuses it.
234///
235/// In TS you'd write (pseudocode):
236/// ```ts
237/// await listUnmergedIndexPaths({ gitPath, cwd, indexPath });
238/// ```
239pub fn list_unmerged_index_paths(
240    context: &GitContext,
241    cwd: &Path,
242    commit_index: &Path,
243) -> Result<Vec<String>, GitFailure> {
244    let output: GitOutput = run_git_checked(
245        context,
246        &indexed_request(cwd, &["ls-files", "--unmerged", "-z"], Some(commit_index), None),
247    )?;
248    let text: &str = std::str::from_utf8(output.stdout.as_slice())
249        .map_err(|_| { return GitFailure(String::from("Git returned non-UTF-8 output.")); })?;
250    let mut paths: Vec<String> = Vec::new();
251    for record in text.split('\0') {
252        let Some(tab) = record.find('\t') else {
253            continue;
254        };
255        let path: String = String::from(&record[tab + 1..]);
256        if !paths.contains(&path) {
257            paths.push(path);
258        }
259    }
260    return Ok(paths);
261}
262
263/// What:
264///  Lists the paths the private index changes against the recorded base.
265/// Why:
266///   The base is the recorded one, so a landing that moved `HEAD` since invocation changes nothing here.
267///
268/// In TS you'd write (pseudocode):
269/// ```ts
270/// await listChangedIndexPaths({ gitPath, cwd, indexPath, baseRevision, objectDirectory });
271/// ```
272pub fn list_changed_index_paths(
273    context: &GitContext,
274    cwd: &Path,
275    commit_index: &Path,
276    base_revision: &str,
277    object_directory: &Path,
278) -> Result<Vec<String>, GitFailure> {
279    let output: GitOutput = run_git_checked(
280        context,
281        &indexed_request(
282            cwd,
283            &["diff", "--cached", "--name-only", "-z", base_revision],
284            Some(commit_index),
285            Some(object_directory),
286        ),
287    )?;
288    let text: &str = std::str::from_utf8(output.stdout.as_slice())
289        .map_err(|_| { return GitFailure(String::from("Git returned non-UTF-8 output.")); })?;
290    return Ok(text
291        .split('\0')
292        .filter(|path| { return !path.is_empty(); })
293        .map(String::from)
294        .collect());
295}
296
297/// What:
298///  Resolves a pathspec-file source to the file the private index reads.
299/// Why:
300///   A regular source is read where it is named, relative to the invocation directory,
301///  and standard input is copied once into the transaction, so no second read sees different bytes.
302///
303/// In TS you'd write (pseudocode):
304/// ```ts
305/// await materializePathspecFile({ workspace, effectiveCwd, source });
306/// ```
307pub fn materialize_pathspec_file(
308    directory: &Path,
309    effective_cwd: &Path,
310    source: Option<&str>,
311    standard_input: &mut dyn Read,
312) -> std::io::Result<Option<PathBuf>> {
313    let Some(name) = source else {
314        return Ok(None);
315    };
316    if name != "-" {
317        let path: PathBuf = PathBuf::from(name);
318        return Ok(Some(if path.is_absolute() {
319            path
320        } else {
321            effective_cwd.join(path)
322        }));
323    }
324    let mut bytes: Vec<u8> = Vec::new();
325    standard_input.read_to_end(&mut bytes)?;
326    let path: PathBuf = directory.join(PATHSPEC_INPUT_NAME);
327    write_private_file(path.as_path(), bytes.as_slice())?;
328    return Ok(Some(path));
329}
330
331/// What:
332///  Runs `git add --patch` or `git add --interactive` against the private index with inherited stdio.
333/// Why:
334///   The user's terminal answers the selection, and the choice lands only in the private copy.
335///
336/// In TS you'd write (pseudocode):
337/// ```ts
338/// await prepareInteractiveSelection({ workspace, gitPath, cwd, patch, pathspecs, pathspecFile, pathspecFileNul });
339/// ```
340pub fn prepare_interactive_selection(
341    context: &GitContext,
342    cwd: &Path,
343    commit_index: &Path,
344    object_directory: &Path,
345    patch: bool,
346    selection: &IndexSelection,
347) -> Result<(), GitFailure> {
348    let mode: &str = if patch { "--patch" } else { "--interactive" };
349    let mut arguments: Vec<String> = vec![String::from("add"), String::from(mode)];
350    arguments.extend(selection_arguments(selection));
351    let os_arguments: Vec<OsString> = arguments.iter().map(OsString::from).collect();
352    let mut command = git_command(context.real_git.as_path(), os_arguments.as_slice(), context.overlay.as_slice());
353    command
354        .current_dir(cwd)
355        .env("GIT_INDEX_FILE", commit_index)
356        .env("GIT_OBJECT_DIRECTORY", object_directory);
357    let status: std::process::ExitStatus = command
358        .status()
359        .map_err(|error| { return GitFailure(format!("git {} could not start: {error}", arguments.join(" "))); })?;
360    if !status.success() {
361        return Err(GitFailure(format!(
362            "git {} exited {}",
363            arguments.join(" "),
364            status.code().map_or_else(|| { return String::from("by signal"); }, |code| { return code.to_string(); })
365        )));
366    }
367    return Ok(());
368}
369
370/// Private index controls stay out of the release executable.
371#[cfg(test)]
372#[path = "commit_index_tests.rs"]
373mod tests;