Skip to main content

git_policy_cli/
landing_index.rs

1//! What:
2//!  The real index a landing installs, computed against the real index as it is at landing,
3//!  never against the invocation-time copy.
4//! Why:
5//!  A landing that copied the invocation-time index would erase another process's staging
6//!  and stage a revert of content that already landed. Index mode takes the exact private index only
7//!  when nothing was restaged; otherwise it merges the landed entries path by path, and explicit-path
8//!  mode resets only the committed paths and the guarded paths still holding their invocation entry
9//!  (`src/policy-engine/commit-landing-index.ts`).
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! await computeLandingPostIndex({ gitPath, cwd, mode, preLandingIndexPath, capturedIndexPath, landedIndexPath, postIndexPath, ... });
14//! ```
15
16/// Index copy with the timestamps Git reads.
17use super::commit_index::copy_index_with_timestamps;
18/// Snapshot comparison: exact bytes, refusing symbolic links.
19use super::recovery_files::files_equal;
20/// Warning text to the diagnostic log.
21use super::diagnostic_log::warn;
22use super::landing_invariant::warn_when_entries_moved;
23/// Exact path bytes as command-line arguments, and as text for messages.
24use super::path_bytes::{os_from_path_bytes, path_message_text};
25/// Commit selection mode of the transaction.
26use super::transaction_journal::TransactionMode;
27/// Running real Git, checked and unchecked.
28use super::transaction_git::{GitContext, GitFailure, GitOutput, GitRequest, run_git, run_git_checked};
29/// Native `HashMap` of stage records and the ordered path list.
30use std::collections::HashMap;
31/// `OsString` is owned operating-system text; `Path`/`PathBuf` are filesystem paths.
32use std::ffi::OsString;
33use std::path::Path;
34
35/// Module tag for diagnostics.
36const TAG: &str = "computeLandingPostIndex";
37
38/// What:
39///  The inputs of one post-index computation.
40/// Why:
41///   The index artifacts and the landed tree travel together; a struct keeps the call readable.
42///
43/// In TS you'd write (pseudocode):
44/// ```ts
45/// { mode, preLandingIndexPath, capturedIndexPath, landedIndexPath, postIndexPath, landedTreeOid, baseRevision, committedPaths, guardedPaths, exactPrivateIndex }
46/// ```
47pub struct PostIndexInput<'input> {
48    /// Commit selection mode.
49    pub mode: TransactionMode,
50    /// Exact copy of the real index taken at landing.
51    pub pre_landing_index: &'input Path,
52    /// Invocation-time index copy.
53    pub captured_index: &'input Path,
54    /// Private index the landed commit was made from.
55    pub landed_index: &'input Path,
56    /// Post-index written by this computation.
57    pub post_index: &'input Path,
58    /// Tree the landing installs.
59    pub landed_tree: &'input str,
60    /// Preparation base commit, or the empty tree when unborn.
61    pub base_revision: &'input str,
62    /// Paths an explicit-path commit carries, including policy-added paths, as the exact bytes Git reported.
63    pub committed_paths: &'input [Vec<u8>],
64    /// Paths a commit hook changed, as the exact bytes Git reported.
65    pub guarded_paths: &'input [Vec<u8>],
66    /// Whether `landed_index` is exactly the index the commit was prepared from.
67    pub exact_private_index: bool,
68}
69
70/// What:
71///  Runs real Git with the literal-pathspec override that keeps `:(top,literal)` paths concrete.
72/// Why:
73///   The caller may disable pathspec magic, and the landing names exact paths.
74///
75/// In TS you'd write (pseudocode):
76/// ```ts
77/// environment: { GIT_LITERAL_PATHSPECS: '0' }
78/// ```
79fn literal_pathspecs() -> Vec<(OsString, OsString)> {
80    return vec![(OsString::from("GIT_LITERAL_PATHSPECS"), OsString::from("0"))];
81}
82
83/// What:
84///  The `:(top,literal)` pathspec of one exact path, as the operating system takes it.
85/// Why:
86///   The landing names exact paths, and a name that is not UTF-8 must reach Git unchanged.
87///
88/// In TS you'd write (pseudocode):
89/// ```ts
90/// `:(top,literal)${path}`
91/// ```
92fn literal_pathspec(path: &[u8]) -> OsString {
93    let mut pathspec: Vec<u8> = b":(top,literal)".to_vec();
94    pathspec.extend_from_slice(path);
95    return os_from_path_bytes(pathspec.as_slice());
96}
97
98/// What:
99///  Splits NUL-terminated records and drops empty ones, keeping the bytes of each.
100/// Why:
101///   Every `-z` listing ends each record with NUL, and a path in one is bytes, never decoded.
102///
103/// In TS you'd write (pseudocode):
104/// ```ts
105/// bytes.split(0).filter(nonempty)
106/// ```
107fn records(bytes: &[u8]) -> Vec<Vec<u8>> {
108    return bytes
109        .split(|byte| { return *byte == 0; })
110        .filter(|record| { return !record.is_empty(); })
111        .map(<[u8]>::to_vec)
112        .collect();
113}
114
115/// What:
116///  Lists the stage records of concrete paths in one index, grouped by path.
117/// Why:
118///   Comparing the stages of a path decides whether anyone restaged it since invocation.
119///
120/// In TS you'd write (pseudocode):
121/// ```ts
122/// listStages({ gitPath, cwd, indexPath, paths });
123/// ```
124fn list_stages(
125    context: &GitContext,
126    cwd: &Path,
127    index: &Path,
128    paths: &[Vec<u8>],
129) -> Result<HashMap<Vec<u8>, Vec<Vec<u8>>>, GitFailure> {
130    let mut grouped: HashMap<Vec<u8>, Vec<Vec<u8>>> = HashMap::new();
131    if paths.is_empty() {
132        return Ok(grouped);
133    }
134    let mut arguments: Vec<OsString> = ["ls-files", "--stage", "-z", "--full-name", "--"]
135        .iter()
136        .map(OsString::from)
137        .collect();
138    arguments.extend(paths.iter().map(|path| { return literal_pathspec(path.as_slice()); }));
139    let mut request = GitRequest::new(cwd, &arguments);
140    request.index = Some(index.to_path_buf());
141    request.environment = literal_pathspecs();
142    let output: GitOutput = run_git_checked(context, &request)?;
143    for record in records(&output.stdout) {
144        let Some(tab) = record.iter().position(|byte| { return *byte == b'\t'; }) else {
145            return Err(GitFailure(format!("ls-files record has no path: {}", path_message_text(record.as_slice()))));
146        };
147        grouped.entry(record[tab + 1..].to_vec()).or_default().push(record.clone());
148    }
149    return Ok(grouped);
150}
151
152/// What:
153///  Lists the paths two trees differ in, without rename pairing.
154/// Why:
155///   The landed tree's changes are the paths the landing introduces.
156///
157/// In TS you'd write (pseudocode):
158/// ```ts
159/// git diff-tree -r --no-renames --name-only -z from to
160/// ```
161fn changed_paths(context: &GitContext, cwd: &Path, from: &str, to: &str) -> Result<Vec<Vec<u8>>, GitFailure> {
162    let request = GitRequest::new(cwd, &["diff-tree", "-r", "--no-renames", "--name-only", "-z", from, to]);
163    let output: GitOutput = run_git_checked(context, &request)?;
164    return Ok(records(&output.stdout));
165}
166
167/// What:
168///  Lists paths the landed tree changes relative to what was staged at invocation.
169/// Why:
170///   When the invocation-time index cannot form a tree, nothing is added by this rule.
171///
172/// In TS you'd write (pseudocode):
173/// ```ts
174/// changedSinceCaptured({ gitPath, cwd, capturedIndexPath, landedTreeOid });
175/// ```
176fn changed_since_captured(
177    context: &GitContext,
178    cwd: &Path,
179    captured: &Path,
180    landed_tree: &str,
181) -> Result<Vec<Vec<u8>>, GitFailure> {
182    let mut request = GitRequest::new(cwd, &["write-tree"]);
183    request.index = Some(captured.to_path_buf());
184    let output: GitOutput = run_git(context, &request)
185        .map_err(|error| { return GitFailure(format!("git write-tree could not start: {error}")); })?;
186    if !output.succeeded() {
187        return Ok(Vec::new());
188    }
189    let tree: String = String::from_utf8_lossy(&output.stdout).trim().to_string();
190    return changed_paths(context, cwd, tree.as_str(), landed_tree);
191}
192
193/// What:
194///  Merges the landed entries of every changed path that nobody restaged since invocation.
195/// Why:
196///   Other staging stays as it is; only paths still holding their invocation entry take the landed one,
197///  and a path the commit deleted is removed from the index.
198///
199/// In TS you'd write (pseudocode):
200/// ```ts
201/// await runTransactionGit({ indexPath: postIndexPath, args: ['update-index', '-z', '--index-info'], input });
202/// ```
203fn merge_landed_entries(
204    context: &GitContext,
205    cwd: &Path,
206    artifacts: &Artifacts,
207    changed: &[Vec<u8>],
208) -> Result<(), GitFailure> {
209    let captured = list_stages(context, cwd, artifacts.captured, changed)?;
210    let current = list_stages(context, cwd, artifacts.post, changed)?;
211    let landed = list_stages(context, cwd, artifacts.landed, changed)?;
212    let untouched: Vec<&Vec<u8>> = changed
213        .iter()
214        .filter(|path| {
215            return captured.get(path.as_slice()).map(Vec::as_slice).unwrap_or(&[])
216                == current.get(path.as_slice()).map(Vec::as_slice).unwrap_or(&[]);
217        })
218        .collect();
219    if untouched.is_empty() {
220        return Ok(());
221    }
222    let zero: String = "0".repeat(artifacts.landed_tree.len());
223    let mut body: Vec<u8> = Vec::new();
224    for path in untouched {
225        match landed.get(path.as_slice()) {
226            Some(entries) => {
227                for entry in entries {
228                    body.extend_from_slice(entry.as_slice());
229                    body.push(0);
230                }
231            }
232            None => {
233                body.extend_from_slice(format!("0 {zero}\t").as_bytes());
234                body.extend_from_slice(path.as_slice());
235                body.push(0);
236            }
237        }
238    }
239    let mut request = GitRequest::new(cwd, &["update-index", "-z", "--index-info"]);
240    request.index = Some(artifacts.post.to_path_buf());
241    request.input = Some(body);
242    run_git_checked(context, &request)?;
243    return Ok(());
244}
245
246/// What:
247///  The four index files and the landed tree of one computation.
248/// Why:
249///   The helpers take the same paths together; grouping keeps their signatures short.
250///
251/// In TS you'd write (pseudocode):
252/// ```ts
253/// { capturedIndexPath, postIndexPath, landedIndexPath, landedTreeOid }
254/// ```
255struct Artifacts<'artifacts> {
256    /// Invocation-time index copy.
257    captured: &'artifacts Path,
258    /// Post-index being written.
259    post: &'artifacts Path,
260    /// Private index the commit was made from.
261    landed: &'artifacts Path,
262    /// Tree the landing installs.
263    landed_tree: &'artifacts str,
264}
265
266/// What:
267///  Resets guarded paths to the landed tree while they still hold their invocation entry.
268/// Why:
269///   A path restaged after invocation keeps the staging the other process made, and the warning names it.
270///
271/// In TS you'd write (pseudocode):
272/// ```ts
273/// await resetGuardedPaths({ gitPath, cwd, capturedIndexPath, postIndexPath, landedTreeOid, guardedPaths });
274/// ```
275fn reset_guarded_paths(
276    context: &GitContext,
277    cwd: &Path,
278    artifacts: &Artifacts,
279    guarded: &[Vec<u8>],
280) -> Result<(), GitFailure> {
281    if guarded.is_empty() {
282        return Ok(());
283    }
284    let captured = list_stages(context, cwd, artifacts.captured, guarded)?;
285    let current = list_stages(context, cwd, artifacts.post, guarded)?;
286    let mut untouched: Vec<&Vec<u8>> = Vec::new();
287    for path in guarded {
288        let before: &[Vec<u8>] = captured.get(path.as_slice()).map(Vec::as_slice).unwrap_or(&[]);
289        let now: &[Vec<u8>] = current.get(path.as_slice()).map(Vec::as_slice).unwrap_or(&[]);
290        if before == now {
291            untouched.push(path);
292        } else {
293            let shown: String = path_message_text(path.as_slice());
294            warn(
295                TAG,
296                &format!(
297                    "A commit hook changed {shown}, but it was staged again while the commit ran; the committed bytes are in HEAD and your staged entry was kept. Compare them with git diff --cached -- {shown}."
298                ),
299            );
300        }
301    }
302    if untouched.is_empty() {
303        return Ok(());
304    }
305    let mut arguments: Vec<OsString> = vec!["reset".into(), "--quiet".into(), artifacts.landed_tree.into(), "--".into()];
306    arguments.extend(untouched.iter().map(|path| { return literal_pathspec(path.as_slice()); }));
307    let mut request = GitRequest::new(cwd, &arguments);
308    request.index = Some(artifacts.post.to_path_buf());
309    request.environment = literal_pathspecs();
310    run_git_checked(context, &request)?;
311    return Ok(());
312}
313
314/// What:
315///  Computes the post-index a landing installs.
316/// Why:
317///   The landing's real index must reflect this commit and every other process's staging at the moment of landing.
318///
319/// In TS you'd write (pseudocode):
320/// ```ts
321/// await computeLandingPostIndex({ gitPath, cwd, mode, ... });
322/// ```
323pub fn compute_landing_post_index(
324    context: &GitContext,
325    cwd: &Path,
326    input: &PostIndexInput,
327) -> Result<(), GitFailure> {
328    let artifacts = Artifacts {
329        captured: input.captured_index,
330        post: input.post_index,
331        landed: input.landed_index,
332        landed_tree: input.landed_tree,
333    };
334    if input.mode == TransactionMode::ExplicitPath {
335        copy(input.pre_landing_index, input.post_index)?;
336        if !input.committed_paths.is_empty() {
337            let mut arguments: Vec<OsString> = vec!["reset".into(), "--quiet".into(), input.landed_tree.into(), "--".into()];
338            arguments.extend(input.committed_paths.iter().map(|path| { return literal_pathspec(path.as_slice()); }));
339            let mut request = GitRequest::new(cwd, &arguments);
340            request.index = Some(input.post_index.to_path_buf());
341            request.environment = literal_pathspecs();
342            run_git_checked(context, &request)?;
343        }
344        let outside: Vec<Vec<u8>> = input
345            .guarded_paths
346            .iter()
347            .filter(|path| { return !input.committed_paths.contains(path); })
348            .cloned()
349            .collect();
350        reset_guarded_paths(context, cwd, &artifacts, &outside)?;
351        // Every entry outside the committed and guarded paths must read as it did before the landing.
352        let mut allowed: Vec<Vec<u8>> = input.committed_paths.to_vec();
353        allowed.extend(input.guarded_paths.iter().cloned());
354        warn_when_entries_moved(context, cwd, input.pre_landing_index, input.post_index, &allowed);
355        return Ok(());
356    }
357    if input.exact_private_index && files_equal(input.pre_landing_index, input.captured_index)
358        .map_err(|error| { return GitFailure(error.0); })?
359    {
360        copy(input.landed_index, input.post_index)?;
361        return Ok(());
362    }
363    warn(TAG, "real index changed since invocation; merging landed entries per path");
364    copy(input.pre_landing_index, input.post_index)?;
365    let mut changed: Vec<Vec<u8>> = changed_paths(context, cwd, input.base_revision, input.landed_tree)?;
366    for path in changed_since_captured(context, cwd, input.captured_index, input.landed_tree)? {
367        if !changed.contains(&path) {
368            changed.push(path);
369        }
370    }
371    return merge_landed_entries(context, cwd, &artifacts, &changed);
372}
373
374/// What:
375///  Copies one index file with the timestamps Git reads.
376/// Why:
377///   The post-index keeps the stat data Git uses to skip re-hashing.
378///
379/// In TS you'd write (pseudocode):
380/// ```ts
381/// await copyIndexFile({ sourcePath, destinationPath });
382/// ```
383fn copy(source: &Path, destination: &Path) -> Result<(), GitFailure> {
384    return copy_index_with_timestamps(source, destination)
385        .map_err(|error| { return GitFailure(format!("copying {} failed: {error}", source.display())); });
386}
387
388/// Post-index controls stay out of the production code path.
389#[cfg(test)]
390#[path = "landing_index_tests.rs"]
391mod tests;