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