Skip to main content

git_policy_cli/
commit_worktree_records.rs

1//! What:
2//!  The worktree completion records a landed commit owes the worktree:
3//!  selected final-newline corrections, and the paths a `pre-commit` hook re-staged.
4//! Why:
5//!  The commit keeps what the private index holds, so the real worktree still holds the bytes the user wrote.
6//!  After the landing, each recorded file takes the committed bytes only while it still holds its earlier bytes
7//!  (`src/policy-engine/commit-transaction-selected-worktree.ts` and `commit-hook-changes.ts`).
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! const selected = await selectedWorktreeRecords({ gitPath, cwd, repositoryRoot, indexPath, initialCandidates, newlinePaths });
12//! const changes = await describeHookChanges({ gitPath, cwd, objectDirectory, fromTree, toTree });
13//! ```
14
15use super::candidate_object::CandidateMode;
16use super::diagnostic_log::debug;
17use super::direct_fix_install::InstallChange;
18use super::path_bytes::os_from_path_bytes;
19use super::transaction_git::{GitContext, GitFailure, GitOutput, GitRequest, run_git_checked};
20use super::transaction_journal::AddedPath;
21use super::worktree_completion::{WorktreeFileState, inspect_worktree_file};
22use std::path::Path;
23
24/// Module tag for diagnostics.
25const TAG: &str = "commitWorktreeRecords";
26
27/// The fields of one raw diff record: source mode, destination mode, source object, destination object, status.
28const RAW_FIELD_COUNT: usize = 5;
29
30/// What:
31///  What a `pre-commit` hook staged beyond the tree it saw.
32/// Why:
33///   The landing guards these paths against a concurrent staging, and the worktree takes the committed bytes.
34///
35/// In TS you'd write (pseudocode):
36/// ```ts
37/// type HookChanges = { paths: readonly Uint8Array[]; worktreeRecords: readonly AddedPathRecord[] };
38/// ```
39#[derive(Clone, Debug, Default, Eq, PartialEq)]
40pub struct HookChanges {
41    /// Every path whose entry differs between the tree the hook saw and the tree it left.
42    pub paths: Vec<Vec<u8>>,
43    /// Modified ordinary files whose worktree copy receives the committed bytes while it holds the pre-hook bytes.
44    pub worktree_records: Vec<AddedPath>,
45}
46
47impl HookChanges {
48    /// What:
49    ///  Join the changes of successive preparations and revalidations.
50    /// Why:
51    ///   A replay starts again from the prepared commit, so the union of paths and every completion stay.
52    ///
53    /// In TS you'd write (pseudocode):
54    /// ```ts
55    /// combineHookChanges({ earlier, later });
56    /// ```
57    pub fn combine(earlier: &HookChanges, later: &HookChanges) -> HookChanges {
58        let mut paths: Vec<Vec<u8>> = earlier.paths.clone();
59        for path in &later.paths {
60            if !paths.contains(path) {
61                paths.push(path.clone());
62            }
63        }
64        let mut worktree_records: Vec<AddedPath> = earlier.worktree_records.clone();
65        worktree_records.extend(later.worktree_records.iter().cloned());
66        return HookChanges {
67            paths,
68            worktree_records,
69        };
70    }
71}
72
73/// What:
74///  Parse NUL-terminated `diff-tree -r -z --raw --no-renames --no-abbrev` output into the records of ordinary files.
75/// Why:
76///   A malformed record is a Git failure, not a silent skip.
77///
78/// In TS you'd write (pseudocode):
79/// ```ts
80/// parseRawChanges(output);
81/// ```
82fn parse_hook_changes(output: &[u8]) -> Result<HookChanges, GitFailure> {
83    let fields: Vec<&[u8]> = output.split(|byte| { return *byte == 0; }).collect();
84    let mut changes: HookChanges = HookChanges::default();
85    let mut index: usize = 0;
86    while index < fields.len() {
87        // The metadata is ASCII; a path is bytes and is never decoded.
88        let field: String = String::from_utf8_lossy(fields[index]).into_owned();
89        if field.is_empty() {
90            index += 1;
91            continue;
92        }
93        let Some(path) = fields.get(index + 1) else {
94            return Err(GitFailure(format!("git diff-tree returned a malformed record: {field:?}")));
95        };
96        let metadata: Vec<&str> = field.trim_start_matches(':').split(' ').collect();
97        if metadata.len() != RAW_FIELD_COUNT {
98            return Err(GitFailure(format!("git diff-tree returned a malformed record: {field:?}")));
99        }
100        let (from_mode, to_mode, from_oid, to_oid) = (metadata[0], metadata[1], metadata[2], metadata[3]);
101        changes.paths.push(path.to_vec());
102        if from_mode == to_mode && (to_mode == "100644" || to_mode == "100755") {
103            changes.worktree_records.push(AddedPath {
104                path: path.to_vec(),
105                git_mode: String::from(to_mode),
106                original_oid: String::from(from_oid),
107                intended_oid: String::from(to_oid),
108            });
109        }
110        index += 2;
111    }
112    return Ok(changes);
113}
114
115/// What:
116///  Describe what a hook changed between the tree it saw and the tree it left.
117/// Why:
118///   The commit keeps the hook's staged changes, as native Git does; landing reconciles those paths.
119///
120/// In TS you'd write (pseudocode):
121/// ```ts
122/// await describeHookChanges({ gitPath, cwd, objectDirectory, fromTree, toTree });
123/// ```
124pub fn describe_hook_changes(
125    context: &GitContext,
126    cwd: &Path,
127    object_directory: &Path,
128    from_tree: &str,
129    to_tree: &str,
130) -> Result<HookChanges, GitFailure> {
131    if from_tree == to_tree {
132        return Ok(HookChanges::default());
133    }
134    let mut request: GitRequest = GitRequest::new(
135        cwd,
136        &["diff-tree", "-r", "-z", "--raw", "--no-renames", "--no-abbrev", from_tree, to_tree],
137    );
138    request.without_prefix = true;
139    request.object_directory = Some(object_directory.to_path_buf());
140    let output: GitOutput = run_git_checked(context, &request)?;
141    let changes: HookChanges = parse_hook_changes(output.stdout.as_slice())?;
142    debug(
143        TAG,
144        format!("pre-commit changed {} staged paths; the commit keeps them as native Git does", changes.paths.len()).as_str(),
145    );
146    return Ok(changes);
147}
148
149/// What:
150///  The Git mode text a candidate mode carries, when it is an ordinary file.
151/// Why:
152///   Only regular and executable files take a corrected worktree copy.
153///
154/// In TS you'd write (pseudocode):
155/// ```ts
156/// candidate.mode === 'executable' ? '100755' : '100644'
157/// ```
158fn ordinary_git_mode(mode: CandidateMode) -> Option<&'static str> {
159    return match mode {
160        CandidateMode::Regular => Some("100644"),
161        CandidateMode::Executable => Some("100755"),
162        _ => None,
163    };
164}
165
166/// What:
167///  The blob hash of some bytes, without writing an object.
168/// Why:
169///   The record names the original staged blob, which only its bytes determine.
170///
171/// In TS you'd write (pseudocode):
172/// ```ts
173/// gitBlobId(bytes)
174/// ```
175fn blob_id(context: &GitContext, cwd: &Path, bytes: &[u8]) -> Result<String, GitFailure> {
176    let mut request: GitRequest = GitRequest::new(cwd, &["hash-object", "--stdin"]);
177    request.without_prefix = true;
178    request.input = Some(bytes.to_vec());
179    let output: GitOutput = run_git_checked(context, &request)?;
180    return Ok(String::from_utf8_lossy(output.stdout.as_slice()).trim().to_string());
181}
182
183/// What:
184///  The settled mode and object of one path in the private index, or nothing when it has no stage-zero entry.
185/// Why:
186///   A correction counts only while the settled entry still has the ordinary mode of the original.
187///
188/// In TS you'd write (pseudocode):
189/// ```ts
190/// (await loadIndexEntries({ gitPath, cwd, indexPath, paths })).get(path)
191/// ```
192pub fn settled_entry(
193    context: &GitContext,
194    cwd: &Path,
195    index: &Path,
196    path: &[u8],
197) -> Result<Option<(String, String)>, GitFailure> {
198    let mut pathspec: Vec<u8> = b":(top,literal)".to_vec();
199    pathspec.extend_from_slice(path);
200    let mut request: GitRequest = GitRequest::new(cwd, &["ls-files", "--stage", "-z", "--"]);
201    request.arguments.push(os_from_path_bytes(pathspec.as_slice()));
202    request.without_prefix = true;
203    request.index = Some(index.to_path_buf());
204    let output: GitOutput = run_git_checked(context, &request)?;
205    for record in output.stdout.split(|byte| { return *byte == 0; }) {
206        let Some(tab) = record.iter().position(|byte| { return *byte == b'\t'; }) else {
207            continue;
208        };
209        let metadata: String = String::from_utf8_lossy(&record[..tab]).into_owned();
210        let fields: Vec<&str> = metadata.split(' ').collect();
211        if fields.len() == 3 && fields[2] == "0" {
212            return Ok(Some((String::from(fields[0]), String::from(fields[1]))));
213        }
214    }
215    return Ok(None);
216}
217
218/// What:
219///  The records for selected files that final-newline corrected, only where the worktree copy still equals the
220///  original staged bytes.
221/// Why:
222///   A partially staged edit cannot be replaced by a commit correction; the installer re-checks before replacing.
223///
224/// In TS you'd write (pseudocode):
225/// ```ts
226/// await selectedWorktreeRecords({ gitPath, cwd, repositoryRoot, indexPath, initialCandidates, newlinePaths });
227/// ```
228pub fn selected_worktree_records(
229    context: &GitContext,
230    cwd: &Path,
231    repository_root: &Path,
232    index: &Path,
233    changes: &[InstallChange],
234) -> Result<Vec<AddedPath>, GitFailure> {
235    let mut records: Vec<AddedPath> = Vec::new();
236    for change in changes {
237        let Some(git_mode) = ordinary_git_mode(change.mode) else {
238            continue;
239        };
240        let original_oid: String = blob_id(context, cwd, &change.original)?;
241        let Some((entry_mode, entry_oid)) = settled_entry(context, cwd, index, change.path.as_slice())? else {
242            continue;
243        };
244        if entry_mode != git_mode || entry_oid == original_oid {
245            continue;
246        }
247        let destination = repository_root.join(path_from_bytes(change.path.as_slice()));
248        match inspect_worktree_file(destination.as_path(), git_mode, &change.original, &change.replacement) {
249            Ok(WorktreeFileState::Original(_)) => {}
250            // A missing copy is reported as a conflict by the inspection, so it is skipped here with the other conflicts.
251            Ok(_) => continue,
252            Err(error) => {
253                return Err(GitFailure(format!(
254                    "reading {} failed: {error}",
255                    destination.display()
256                )));
257            }
258        }
259        records.push(AddedPath {
260            path: change.path.clone(),
261            git_mode: String::from(git_mode),
262            original_oid,
263            intended_oid: entry_oid,
264        });
265    }
266    return Ok(records);
267}
268
269/// What:
270///  A repository-relative path from its bytes.
271/// Why:
272///   The worktree path is joined from the exact bytes Git reported.
273///
274/// In TS you'd write (pseudocode):
275/// ```ts
276/// join(repositoryRoot, path)
277/// ```
278fn path_from_bytes(bytes: &[u8]) -> std::path::PathBuf {
279    return std::path::PathBuf::from(os_from_path_bytes(bytes));
280}
281
282/// Controls for this module stay out of the release executable.
283#[cfg(test)]
284#[path = "commit_worktree_records_tests.rs"]
285mod tests;