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