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