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::transaction_git::{GitContext, GitFailure, GitOutput, GitRequest, run_git_checked};
19use super::transaction_journal::AddedPath;
20use super::worktree_completion::{WorktreeFileState, inspect_worktree_file};
21use std::ffi::OsString;
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 string[]; 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<String>,
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<String> = 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: &str) -> Result<HookChanges, GitFailure> {
83    let fields: Vec<&str> = output.split('\0').collect();
84    let mut changes: HookChanges = HookChanges::default();
85    let mut index: usize = 0;
86    while index < fields.len() {
87        let field: &str = fields[index];
88        if field.is_empty() {
89            index += 1;
90            continue;
91        }
92        let Some(path) = fields.get(index + 1) else {
93            return Err(GitFailure(format!("git diff-tree returned a malformed record: {field:?}")));
94        };
95        let metadata: Vec<&str> = field.trim_start_matches(':').split(' ').collect();
96        if metadata.len() != RAW_FIELD_COUNT {
97            return Err(GitFailure(format!("git diff-tree returned a malformed record: {field:?}")));
98        }
99        let (from_mode, to_mode, from_oid, to_oid) = (metadata[0], metadata[1], metadata[2], metadata[3]);
100        changes.paths.push(String::from(*path));
101        if from_mode == to_mode && (to_mode == "100644" || to_mode == "100755") {
102            changes.worktree_records.push(AddedPath {
103                path: String::from(*path),
104                git_mode: String::from(to_mode),
105                original_oid: String::from(from_oid),
106                intended_oid: String::from(to_oid),
107            });
108        }
109        index += 2;
110    }
111    return Ok(changes);
112}
113
114/// What:
115///  Describe what a hook changed between the tree it saw and the tree it left.
116/// Why:
117///   The commit keeps the hook's staged changes, as native Git does; landing reconciles those paths.
118///
119/// In TS you'd write (pseudocode):
120/// ```ts
121/// await describeHookChanges({ gitPath, cwd, objectDirectory, fromTree, toTree });
122/// ```
123pub fn describe_hook_changes(
124    context: &GitContext,
125    cwd: &Path,
126    object_directory: &Path,
127    from_tree: &str,
128    to_tree: &str,
129) -> Result<HookChanges, GitFailure> {
130    if from_tree == to_tree {
131        return Ok(HookChanges::default());
132    }
133    let mut request: GitRequest = GitRequest::new(
134        cwd,
135        &["diff-tree", "-r", "-z", "--raw", "--no-renames", "--no-abbrev", from_tree, to_tree],
136    );
137    request.without_prefix = true;
138    request.object_directory = Some(object_directory.to_path_buf());
139    let output: GitOutput = run_git_checked(context, &request)?;
140    let text: &str = std::str::from_utf8(output.stdout.as_slice())
141        .map_err(|_| { return GitFailure(String::from("Git returned non-UTF-8 output.")); })?;
142    let changes: HookChanges = parse_hook_changes(text)?;
143    debug(
144        TAG,
145        format!("pre-commit changed {} staged paths; the commit keeps them as native Git does", changes.paths.len()).as_str(),
146    );
147    return Ok(changes);
148}
149
150/// What:
151///  The Git mode text a candidate mode carries, when it is an ordinary file.
152/// Why:
153///   Only regular and executable files take a corrected worktree copy.
154///
155/// In TS you'd write (pseudocode):
156/// ```ts
157/// candidate.mode === 'executable' ? '100755' : '100644'
158/// ```
159fn ordinary_git_mode(mode: CandidateMode) -> Option<&'static str> {
160    return match mode {
161        CandidateMode::Regular => Some("100644"),
162        CandidateMode::Executable => Some("100755"),
163        _ => None,
164    };
165}
166
167/// What:
168///  The blob hash of some bytes, without writing an object.
169/// Why:
170///   The record names the original staged blob, which only its bytes determine.
171///
172/// In TS you'd write (pseudocode):
173/// ```ts
174/// gitBlobId(bytes)
175/// ```
176fn blob_id(context: &GitContext, cwd: &Path, bytes: &[u8]) -> Result<String, GitFailure> {
177    let mut request: GitRequest = GitRequest::new(cwd, &["hash-object", "--stdin"]);
178    request.without_prefix = true;
179    request.input = Some(bytes.to_vec());
180    let output: GitOutput = run_git_checked(context, &request)?;
181    return Ok(String::from_utf8_lossy(output.stdout.as_slice()).trim().to_string());
182}
183
184/// What:
185///  The settled mode and object of one path in the private index, or nothing when it has no stage-zero entry.
186/// Why:
187///   A correction counts only while the settled entry still has the ordinary mode of the original.
188///
189/// In TS you'd write (pseudocode):
190/// ```ts
191/// (await loadIndexEntries({ gitPath, cwd, indexPath, paths })).get(path)
192/// ```
193pub fn settled_entry(
194    context: &GitContext,
195    cwd: &Path,
196    index: &Path,
197    path: &[u8],
198) -> Result<Option<(String, String)>, GitFailure> {
199    let mut pathspec: Vec<u8> = b":(top,literal)".to_vec();
200    pathspec.extend_from_slice(path);
201    let mut request: GitRequest = GitRequest::new(cwd, &["ls-files", "--stage", "-z", "--"]);
202    request.arguments.push(os_from_bytes(pathspec));
203    request.without_prefix = true;
204    request.index = Some(index.to_path_buf());
205    let output: GitOutput = run_git_checked(context, &request)?;
206    for record in output.stdout.split(|byte| { return *byte == 0; }) {
207        let Some(tab) = record.iter().position(|byte| { return *byte == b'\t'; }) else {
208            continue;
209        };
210        let metadata: String = String::from_utf8_lossy(&record[..tab]).into_owned();
211        let fields: Vec<&str> = metadata.split(' ').collect();
212        if fields.len() == 3 && fields[2] == "0" {
213            return Ok(Some((String::from(fields[0]), String::from(fields[1]))));
214        }
215    }
216    return Ok(None);
217}
218
219/// What:
220///  Raw bytes as an operating-system string.
221/// Why:
222///   A Git path is bytes, and only the platform decides how to pass them on.
223///
224/// In TS you'd write (pseudocode):
225/// ```ts
226/// Buffer.from(bytes)
227/// ```
228fn os_from_bytes(bytes: Vec<u8>) -> OsString {
229    #[cfg(unix)]
230    {
231        use std::os::unix::ffi::OsStringExt;
232        return OsString::from_vec(bytes);
233    }
234    #[cfg(not(unix))]
235    {
236        return OsString::from(String::from_utf8_lossy(bytes.as_slice()).into_owned());
237    }
238}
239
240/// What:
241///  The records for selected files that final-newline corrected, only where the worktree copy still equals the
242///  original staged bytes.
243/// Why:
244///   A partially staged edit cannot be replaced by a commit correction; the installer re-checks before replacing.
245///
246/// In TS you'd write (pseudocode):
247/// ```ts
248/// await selectedWorktreeRecords({ gitPath, cwd, repositoryRoot, indexPath, initialCandidates, newlinePaths });
249/// ```
250pub fn selected_worktree_records(
251    context: &GitContext,
252    cwd: &Path,
253    repository_root: &Path,
254    index: &Path,
255    changes: &[InstallChange],
256) -> Result<Vec<AddedPath>, GitFailure> {
257    let mut records: Vec<AddedPath> = Vec::new();
258    for change in changes {
259        let Some(git_mode) = ordinary_git_mode(change.mode) else {
260            continue;
261        };
262        let original_oid: String = blob_id(context, cwd, &change.original)?;
263        let Some((entry_mode, entry_oid)) = settled_entry(context, cwd, index, change.path.as_slice())? else {
264            continue;
265        };
266        if entry_mode != git_mode || entry_oid == original_oid {
267            continue;
268        }
269        let destination = repository_root.join(path_from_bytes(change.path.as_slice()));
270        match inspect_worktree_file(destination.as_path(), git_mode, &change.original, &change.replacement) {
271            Ok(WorktreeFileState::Original(_)) => {}
272            // A missing copy is reported as a conflict by the inspection, so it is skipped here with the other conflicts.
273            Ok(_) => continue,
274            Err(error) => {
275                return Err(GitFailure(format!(
276                    "reading {} failed: {error}",
277                    destination.display()
278                )));
279            }
280        }
281        records.push(AddedPath {
282            path: String::from_utf8_lossy(change.path.as_slice()).into_owned(),
283            git_mode: String::from(git_mode),
284            original_oid,
285            intended_oid: entry_oid,
286        });
287    }
288    return Ok(records);
289}
290
291/// What:
292///  A repository-relative path from its bytes.
293/// Why:
294///   The worktree path is joined from the exact bytes Git reported.
295///
296/// In TS you'd write (pseudocode):
297/// ```ts
298/// join(repositoryRoot, path)
299/// ```
300fn path_from_bytes(bytes: &[u8]) -> std::path::PathBuf {
301    return std::path::PathBuf::from(os_from_bytes(bytes.to_vec()));
302}
303
304/// Controls for this module stay out of the release executable.
305#[cfg(test)]
306#[path = "commit_worktree_records_tests.rs"]
307mod tests;