Skip to main content

git_policy_cli/
replay_merge.rs

1//! What:
2//!  The merge and rebuild steps of one replay:
3//!       the replay merge base, the tree from `git merge-tree --write-tree`, the first landed commit that touched
4//!       a conflicting path, and the replayed commit written from the prepared one.
5//! Why:
6//!  Replay runs outside both landing locks in the shadow repository.
7//!       An unsigned prepared commit is rebuilt by rewriting its raw object, which keeps every byte;
8//!       a signed one is re-signed with `git commit-tree -S`, because no Git primitive re-signs a raw object.
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! await mergeReplayTree({ gitPath, shadowPath, mergeBase, current, prepared, worktreeRoot });
13//! await writeReplayedCommit({ gitPath, shadowPath, directory, attempt, commit, treeOid, parentOid, signingKey, globalArgs });
14//! ```
15
16/// Raw commit parsing, rewriting, identities, and dropped headers.
17use super::path_bytes::os_from_path_bytes;
18use super::replay_object::{RawCommit, dropped_replay_headers, identity_environment, rewrite_commit_object};
19/// Shadow runs and the raw runner for a merge whose exit status matters.
20use super::replay_shadow::run_shadow_checked;
21use super::private_storage::write_private_file;
22use super::shadow_git::shadow_request;
23use super::transaction_git::{GitContext, GitFailure, GitRequest, run_git, run_git_checked};
24/// Names and environment entries.
25use std::ffi::OsString;
26/// Filesystem paths.
27use std::path::{Path, PathBuf};
28
29/// `git merge-tree` exit status reporting conflicts; a tree ID still prints.
30const MERGE_CONFLICT_EXIT: i32 = 1;
31
32/// What:
33///  The outcome of merging the prepared change onto the current target.
34/// Why:
35///   A clean merge yields a tree to rebuild a commit on; a conflict yields the paths that name the winning commit.
36///
37/// In TS you'd write (pseudocode):
38/// ```ts
39/// type ReplayMerge = { kind: 'merged'; treeOid } | { kind: 'conflict'; paths };
40/// ```
41#[derive(Clone, Debug, Eq, PartialEq)]
42pub enum ReplayMerge {
43    /// The change applies cleanly; the merged tree is in the shadow store.
44    Merged {
45        /// Merged tree.
46        tree_oid: String,
47    },
48    /// The change conflicts with the target.
49    Conflict {
50        /// Conflicting paths in Git path byte order, each once, as the exact bytes Git reported.
51        paths: Vec<Vec<u8>>,
52    },
53}
54
55/// What:
56///  A merge or rebuild failure:
57///       a Git command failed, or the commit could not be rebuilt.
58/// Why:
59///   Replay reports each as `transaction-failed`, never as a conflict.
60///
61/// In TS you'd write (pseudocode):
62/// ```ts
63/// throw new CommitTransactionGitError(message);
64/// ```
65#[derive(Clone, Debug, Eq, PartialEq)]
66pub enum ReplayMergeError {
67    /// A Git command failed, or `merge-tree` exited above `1`.
68    Git(GitFailure),
69    /// The prepared commit could not be rebuilt.
70    Rebuild(String),
71}
72
73/// What:
74///  The replay merge base:
75///       the preparation base, or for an unborn branch a root commit of the empty tree written into the shadow.
76/// Why:
77///   Git 2.40 accepts only a commit as `--merge-base`, so an unborn base needs a commit over the empty tree.
78///
79/// In TS you'd write (pseudocode):
80/// ```ts
81/// await replayMergeBase({ gitPath, shadowPath, base, emptyTreeOid });
82/// ```
83pub fn replay_merge_base(
84    context: &GitContext,
85    shadow: &Path,
86    cwd: &Path,
87    base: Option<&str>,
88    empty_tree_oid: &str,
89) -> Result<String, ReplayMergeError> {
90    if let Some(commit) = base {
91        return Ok(String::from(commit));
92    }
93    let message: String = format!(
94        "tree {empty_tree_oid}\nauthor cli-git <cli-git@localhost> 0 +0000\ncommitter cli-git <cli-git@localhost> 0 +0000\n\ncli-git replay base for an unborn branch\n"
95    );
96    let written = run_shadow_checked(
97        context,
98        shadow,
99        cwd,
100        &["hash-object", "-t", "commit", "-w", "--stdin"],
101        Some(message.as_bytes()),
102        None,
103        &[],
104    )
105    .map_err(ReplayMergeError::Git)?;
106    return Ok(String::from_utf8_lossy(&written.stdout).trim().to_string());
107}
108
109/// What:
110///  Merges the prepared change onto the current target in the shadow:
111///       the tree when clean, or the conflicted paths in byte order.
112/// Why:
113///   `git merge-tree --write-tree` gives the answer with exit `1` for a conflict and a tree ID in both cases;
114///       any other non-zero exit is a failure.
115///
116/// In TS you'd write (pseudocode):
117/// ```ts
118/// await mergeReplayTree({ gitPath, shadowPath, mergeBase, current, prepared, worktreeRoot });
119/// ```
120pub fn merge_replay_tree(
121    context: &GitContext,
122    shadow: &Path,
123    worktree_root: &Path,
124    merge_base: &str,
125    current: &str,
126    prepared: &str,
127) -> Result<ReplayMerge, ReplayMergeError> {
128    let merge_base_argument: String = format!("--merge-base={merge_base}");
129    let request: GitRequest = shadow_request(
130        shadow,
131        worktree_root,
132        &[
133            "merge-tree",
134            "--write-tree",
135            "--name-only",
136            "-z",
137            merge_base_argument.as_str(),
138            current,
139            prepared,
140        ],
141    );
142    let output = run_git(context, &request).map_err(|error| {
143        return ReplayMergeError::Git(GitFailure(format!("git merge-tree could not start: {error}")));
144    })?;
145    let code: Option<i32> = output.code;
146    if code != Some(0) && code != Some(MERGE_CONFLICT_EXIT) {
147        return Err(ReplayMergeError::Git(GitFailure(format!(
148            "git merge-tree could not replay {prepared} onto {current} (exit {code:?}): {}",
149            String::from_utf8_lossy(&output.stderr).trim()
150        ))));
151    }
152    // The first field is the tree; on a conflict the conflicted paths follow up to an empty record.
153    // The paths stay bytes: a name that is not UTF-8 is reported with its exact bytes.
154    let mut fields = output.stdout.split(|byte| { return *byte == 0; });
155    let tree_oid: String = String::from_utf8_lossy(fields.next().unwrap_or_default()).into_owned();
156    if code == Some(0) {
157        return Ok(ReplayMerge::Merged { tree_oid });
158    }
159    let mut paths: Vec<Vec<u8>> = Vec::new();
160    for field in fields {
161        if field.is_empty() {
162            break;
163        }
164        if !paths.iter().any(|known| { return known.as_slice() == field; }) {
165            paths.push(field.to_vec());
166        }
167    }
168    paths.sort();
169    return Ok(ReplayMerge::Conflict { paths });
170}
171
172/// What:
173///  The first commit after the preparation base, in the landed history, that touches one of the paths;
174///       the current target when none does.
175/// Why:
176///   A conflict names the commit it lost to, so the integrator can show what changed under the prepared commit.
177///
178/// In TS you'd write (pseudocode):
179/// ```ts
180/// await firstCommitTouching({ gitPath, cwd, base, current, paths });
181/// ```
182pub fn first_commit_touching(
183    context: &GitContext,
184    cwd: &Path,
185    base: Option<&str>,
186    current: &str,
187    paths: &[Vec<u8>],
188) -> Result<String, ReplayMergeError> {
189    let exclusion: String = base.map(|oid| { return format!("^{oid}"); }).unwrap_or_default();
190    let pathspecs: Vec<OsString> = paths
191        .iter()
192        .map(|path| {
193            let mut pathspec: Vec<u8> = b":(top,literal)".to_vec();
194            pathspec.extend_from_slice(path.as_slice());
195            return os_from_path_bytes(pathspec.as_slice());
196        })
197        .collect();
198    // `mut` lets the arguments be assembled in order.
199    let mut arguments: Vec<&str> = vec!["rev-list", "--reverse", current];
200    if base.is_some() {
201        arguments.push(exclusion.as_str());
202    }
203    arguments.push("--");
204    let mut request: GitRequest = GitRequest::new(cwd, &arguments);
205    request.arguments.extend(pathspecs);
206    request.environment = vec![(OsString::from("GIT_LITERAL_PATHSPECS"), OsString::from("0"))];
207    let listed = run_git_checked(context, &request).map_err(ReplayMergeError::Git)?;
208    // The listing holds object names, which are ASCII.
209    let text: String = String::from_utf8_lossy(&listed.stdout).into_owned();
210    return Ok(text
211        .lines()
212        .find(|line| { return !line.is_empty(); })
213        .map(String::from)
214        .unwrap_or_else(|| { return String::from(current); }));
215}
216
217/// What:
218///  The replayed commit written to the shadow store, and the custom headers a signed rebuild dropped.
219/// Why:
220///   The integrator reports `commit-replayed` with the OID and `replay-headers-dropped` with the names.
221///
222/// In TS you'd write (pseudocode):
223/// ```ts
224/// type ReplayedCommit = { oid: string; droppedHeaders: readonly string[] };
225/// ```
226#[derive(Clone, Debug, Eq, PartialEq)]
227pub struct ReplayedCommit {
228    /// Replayed commit.
229    pub oid: String,
230    /// Custom headers a signed rebuild dropped, empty for an unsigned rewrite.
231    pub dropped_headers: Vec<String>,
232}
233
234/// What:
235///  Writes the prepared commit onto a new tree and parent in the shadow store.
236/// Why:
237///   An unsigned commit keeps every byte except `tree` and `parent`; a signed one is re-signed under the same identities and dates.
238///
239/// In TS you'd write (pseudocode):
240/// ```ts
241/// await writeReplayedCommit({ gitPath, shadowPath, directory, attempt, commit, treeOid, parentOid, signingKey, globalArgs });
242/// ```
243pub fn write_replayed_commit(
244    context: &GitContext,
245    shadow: &Path,
246    cwd: &Path,
247    directory: &Path,
248    attempt: u64,
249    commit: &RawCommit,
250    tree_oid: &str,
251    parent_oid: &str,
252    signing_key: Option<&str>,
253    global_arguments: &[String],
254) -> Result<ReplayedCommit, ReplayMergeError> {
255    if !commit.signed {
256        let rewritten: Vec<u8> = rewrite_commit_object(commit, tree_oid, parent_oid);
257        let written = run_shadow_checked(
258            context,
259            shadow,
260            cwd,
261            &["hash-object", "-t", "commit", "-w", "--stdin"],
262            Some(&rewritten),
263            None,
264            &[],
265        )
266        .map_err(ReplayMergeError::Git)?;
267        return Ok(ReplayedCommit {
268            oid: String::from_utf8_lossy(&written.stdout).trim().to_string(),
269            dropped_headers: Vec::new(),
270        });
271    }
272    let message_path: PathBuf = directory.join(format!("replay-message-{attempt}"));
273    write_private_file(message_path.as_path(), &commit.message)
274        .map_err(|error| { return ReplayMergeError::Rebuild(format!("message file {}: {error}", message_path.display())); })?;
275    let encoding: String = commit.encoding.clone().unwrap_or_else(|| { return String::from("utf8"); });
276    let signed_key: String = format!("-S{}", signing_key.unwrap_or(""));
277    let encoding_option: String = format!("i18n.commitEncoding={encoding}");
278    let message_text: String = message_path.to_string_lossy().into_owned();
279    // `mut` lets the caller's global options come first, as the incumbent passes them.
280    let mut arguments: Vec<&str> = global_arguments.iter().map(String::as_str).collect();
281    arguments.extend([
282        "-c",
283        encoding_option.as_str(),
284        "commit-tree",
285        tree_oid,
286        "-p",
287        parent_oid,
288        signed_key.as_str(),
289        "-F",
290        message_text.as_str(),
291    ]);
292    let mut environment: Vec<(OsString, OsString)> = Vec::new();
293    for role in ["author", "committer"] {
294        let identity = identity_environment(commit, role).map_err(|error| { return ReplayMergeError::Rebuild(error.0); })?;
295        for (name, value) in identity {
296            environment.push((OsString::from(name), OsString::from(value)));
297        }
298    }
299    let signed = run_shadow_checked(context, shadow, cwd, &arguments, None, None, &environment)
300        .map_err(ReplayMergeError::Git)?;
301    return Ok(ReplayedCommit {
302        oid: String::from_utf8_lossy(&signed.stdout).trim().to_string(),
303        dropped_headers: dropped_replay_headers(commit),
304    });
305}
306
307#[cfg(test)]
308#[path = "replay_merge_tests.rs"]
309mod tests;