Skip to main content

git_policy_cli/
merge_isolation_changes.rs

1//! What:
2//!  The raw tree differences between the two commits of an isolated merge, and the state of a worktree path
3//!  against them.
4//! Why:
5//!  Recovery of a merge killed while Git was updating the worktree must tell, for each path the merge changes,
6//!  whether the worktree still holds the old content, already holds the new content, or holds something
7//!  else that belongs to someone else and must not be overwritten.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! const changes = parseRawChanges(await git(['diff-tree', '-r', '-z', '--raw', '--no-renames', '--no-abbrev', old, next]));
12//! ```
13
14/// Running real Git for a blob hash.
15use super::transaction_git::{GitContext, GitRequest, run_git_checked};
16/// `Path` and `PathBuf` are borrowed and owned filesystem paths.
17use std::path::{Path, PathBuf};
18
19/// One path the merge changes, with the mode and blob it has on each side.
20///
21/// In TS you'd write (pseudocode):
22/// ```ts
23/// type Change = { path: Uint8Array; oldMode: string; newMode: string; oldOid: string; newOid: string };
24/// ```
25#[derive(Clone, Debug, Eq, PartialEq)]
26pub struct Change {
27    /// Path from the top of the worktree, exact bytes.
28    pub path: Vec<u8>,
29    /// Mode before the merge; `000000` when the path is added.
30    pub old_mode: String,
31    /// Mode after the merge; `000000` when the path is deleted.
32    pub new_mode: String,
33    /// Object before the merge; all zeros when the path is added.
34    pub old_oid: String,
35    /// Object after the merge; all zeros when the path is deleted.
36    pub new_oid: String,
37}
38
39/// Methods over one change.
40impl Change {
41    /// Whether the path exists before the merge.
42    pub fn existed(&self) -> bool {
43        return self.old_mode != "000000";
44    }
45
46    /// Whether the path exists after the merge.
47    pub fn exists_after(&self) -> bool {
48        return self.new_mode != "000000";
49    }
50}
51
52/// What:
53///  Read the NUL-separated raw output of `git diff-tree -r -z --raw --no-renames --no-abbrev`.
54/// Why:
55///   Each change is a header (`:<old mode> <new mode> <old oid> <new oid> <status>`) followed by its path.
56///  Output that does not follow that shape is a failure, never a partial reading.
57///
58/// In TS you'd write (pseudocode):
59/// ```ts
60/// function parseRawChanges(output: Uint8Array): Change[]; // throws
61/// ```
62pub fn parse_raw_changes(output: &[u8]) -> Result<Vec<Change>, String> {
63    let mut changes: Vec<Change> = Vec::new();
64    let mut fields = output.split(|byte| { return *byte == 0; }).filter(|field| { return !field.is_empty(); });
65    while let Some(header) = fields.next() {
66        let text: String = String::from_utf8_lossy(header).into_owned();
67        let parts: Vec<&str> = text.trim_start_matches(':').split(' ').collect();
68        let Some(path) = fields.next() else {
69            return Err(format!("the difference entry {text:?} has no path"));
70        };
71        if !header.starts_with(b":") || parts.len() < 5 {
72            return Err(format!("the difference entry {text:?} is not in the raw format"));
73        }
74        changes.push(Change {
75            path: path.to_vec(),
76            old_mode: String::from(parts[0]),
77            new_mode: String::from(parts[1]),
78            old_oid: String::from(parts[2]),
79            new_oid: String::from(parts[3]),
80        });
81    }
82    return Ok(changes);
83}
84
85/// What the worktree holds at a path, as the object Git would record for it.
86///
87/// In TS you'd write (pseudocode):
88/// ```ts
89/// type WorktreeState = { kind: 'missing' } | { kind: 'content'; oid: string } | { kind: 'other' };
90/// ```
91#[derive(Clone, Debug, Eq, PartialEq)]
92pub enum WorktreeState {
93    /// Nothing exists at the path.
94    Missing,
95    /// A file or a symbolic link whose recorded object is this one.
96    Content(String),
97    /// A directory or another kind of entry.
98    Other,
99}
100
101/// The filesystem path of a repository path given as raw bytes.
102///
103/// In TS you'd write (pseudocode):
104/// ```ts
105/// join(root, bytesToPath(path))
106/// ```
107pub fn worktree_path(root: &Path, path: &[u8]) -> PathBuf {
108    #[cfg(unix)]
109    {
110        use std::os::unix::ffi::OsStrExt;
111        return root.join(std::ffi::OsStr::from_bytes(path));
112    }
113    // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
114    #[cfg(not(unix))]
115    {
116        return root.join(String::from_utf8_lossy(path).into_owned());
117    }
118}
119
120/// What:
121///  The state of one worktree path: missing, content with the object Git would record for it, or another kind of entry.
122/// Why:
123///   A file is hashed through Git with its path, so the clean filters and line-ending rules that apply to it apply
124///  here; a symbolic link is hashed from its target text, which is what Git records and never the file it points at.
125///
126/// In TS you'd write (pseudocode):
127/// ```ts
128/// async function worktreeState({ context, root, path }): Promise<WorktreeState>;
129/// ```
130pub fn worktree_state(context: &GitContext, root: &Path, path: &[u8]) -> Result<WorktreeState, String> {
131    let location: PathBuf = worktree_path(root, path);
132    let metadata: std::fs::Metadata = match std::fs::symlink_metadata(location.as_path()) {
133        Ok(found) => found,
134        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(WorktreeState::Missing),
135        Err(error) if error.kind() == std::io::ErrorKind::NotADirectory => return Ok(WorktreeState::Missing),
136        Err(error) => return Err(format!("{} could not be read: {error}", location.display())),
137    };
138    if metadata.file_type().is_symlink() {
139        let target: PathBuf = std::fs::read_link(location.as_path())
140            .map_err(|error| { return format!("{} could not be read: {error}", location.display()); })?;
141        let mut request: GitRequest = GitRequest::new(root, &["hash-object", "--stdin"]);
142        request.input = Some(target.as_os_str().as_encoded_bytes().to_vec());
143        let output = run_git_checked(context, &request).map_err(|error| { return error.to_string(); })?;
144        return Ok(WorktreeState::Content(String::from_utf8_lossy(&output.stdout).trim().to_owned()));
145    }
146    if !metadata.is_file() {
147        return Ok(WorktreeState::Other);
148    }
149    // The attribute lookup and the file are both named by the path from the worktree top, where Git runs.
150    let relative: std::ffi::OsString = worktree_path(Path::new(""), path).into_os_string();
151    let mut named: std::ffi::OsString = std::ffi::OsString::from("--path=");
152    named.push(relative.as_os_str());
153    let mut request: GitRequest = GitRequest::new(root, &["hash-object"]);
154    request.arguments.push(named);
155    request.arguments.push(std::ffi::OsString::from("--"));
156    request.arguments.push(relative);
157    let output = run_git_checked(context, &request).map_err(|error| { return error.to_string(); })?;
158    return Ok(WorktreeState::Content(String::from_utf8_lossy(&output.stdout).trim().to_owned()));
159}
160
161/// Raw difference reading and worktree states.
162#[cfg(test)]
163#[path = "merge_isolation_changes_tests.rs"]
164mod tests;