Skip to main content

git_policy_cli/landing/
service_amend.rs

1//! What:
2//!  The facts of the commit an amend replaces, read once from the repository: its parents, its first parent's tree,
3//!  its author and its message, and the author line an amend makes of them.
4//! Why:
5//!  An amend keeps the parents of the commit it replaces, keeps its message when no message option is given and keeps
6//!  its author unless the command line says otherwise. All of that is arithmetic on the replaced commit's own record,
7//!  so the wrapper embeds it; each rule is compared field by field with native `git commit --amend` in
8//!  `binary_service_amend_tests.rs`. A commit with a header the amend would have to carry (another encoding, a merge
9//!  tag) returns nothing and native Git handles the line.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! const source = await readAmendSource(context, cwd); // undefined when native git must amend
14//! ```
15
16use crate::commit_transaction::{TransactionStop, failed};
17use crate::service_commit_args::ServiceCommitArgs;
18use crate::service_message::{author_overrides, identity};
19use crate::transaction_git::{GitContext, GitRequest, run_git};
20use std::path::Path;
21
22/// What an amend reads from the commit it replaces.
23#[derive(Clone, Debug, Eq, PartialEq)]
24pub struct AmendSource {
25    /// The commit the amend replaces, which is also the branch tip the amend must find when it lands.
26    pub base: String,
27    /// Its parents in order; the amended commit has the same ones.
28    pub parents: Vec<String>,
29    /// The tree of its first parent, or nothing for a root commit.
30    pub parent_tree: Option<String>,
31    /// Its author line, `Name <email> seconds zone`.
32    pub author: String,
33    /// Its message, exactly as stored.
34    pub message: String,
35}
36
37/// The parts of a stored commit object an amend reads.
38#[derive(Clone, Debug, Eq, PartialEq)]
39pub struct StoredCommit {
40    /// The parents in order.
41    pub parents: Vec<String>,
42    /// The author line without its `author ` label.
43    pub author: String,
44    /// The message after the blank line.
45    pub message: String,
46}
47
48/// What:
49///  Reads a stored commit object, or nothing when it has a header an amend would have to carry over.
50/// Why:
51///   An amend keeps extra headers (a merge tag, for one) and the encoding of the commit it replaces. The signature is
52///  the exception: native Git drops it, because the amended commit is not the one that was signed. Anything else is not
53///  carried here, so the line stays native.
54///
55/// In TS you'd write (pseudocode):
56/// ```ts
57/// parseStoredCommit(raw) // undefined when an unknown header is present
58/// ```
59pub fn parse_stored_commit(raw: &str) -> Option<StoredCommit> {
60    let (header, message) = raw.split_once("\n\n")?;
61    let mut parents: Vec<String> = Vec::new();
62    let mut author: Option<String> = None;
63    let mut in_signature: bool = false;
64    let mut has_tree: bool = false;
65    for line in header.lines() {
66        if in_signature && line.starts_with(' ') {
67            continue;
68        }
69        in_signature = false;
70        if line.starts_with("tree ") {
71            has_tree = true;
72        } else if let Some(parent) = line.strip_prefix("parent ") {
73            parents.push(parent.to_string());
74        } else if let Some(found) = line.strip_prefix("author ") {
75            author = Some(found.to_string());
76        } else if line.starts_with("committer ") {
77            // The committer is made again at the amend.
78        } else if line.starts_with("gpgsig ") || line.starts_with("gpgsig-sha256 ") {
79            in_signature = true;
80        } else {
81            return None;
82        }
83    }
84    if !has_tree {
85        return None;
86    }
87    return Some(StoredCommit { parents, author: author?, message: message.to_string() });
88}
89
90/// The text of a Git query that must succeed for the line to be covered.
91fn query(context: &GitContext, cwd: &Path, arguments: &[&str]) -> Result<Option<String>, TransactionStop> {
92    let output = run_git(context, &GitRequest::new(cwd, arguments)).map_err(|error| { return failed(format!("reading the commit to amend failed: {error:?}")); })?;
93    return Ok(if output.code == Some(0) { String::from_utf8(output.stdout).ok() } else { None });
94}
95
96/// What:
97///  Reads the commit `HEAD` names, the one an amend replaces.
98/// Why:
99///   Resolved once, before the message and the author are made, so everything an amend takes from the old commit comes
100///  from the same commit; the preparation then checks that the branch is still there.
101///
102/// In TS you'd write (pseudocode):
103/// ```ts
104/// await readAmendSource(context, cwd)
105/// ```
106pub fn read_amend_source(context: &GitContext, cwd: &Path) -> Result<Option<AmendSource>, TransactionStop> {
107    let Some(named) = query(context, cwd, &["rev-parse", "--verify", "--quiet", "HEAD^{commit}"])? else { return Ok(None) };
108    let base: String = named.trim().to_string();
109    let Some(raw) = query(context, cwd, &["cat-file", "commit", base.as_str()])? else { return Ok(None) };
110    let Some(stored) = parse_stored_commit(raw.as_str()) else { return Ok(None) };
111    let parent_tree: Option<String> = match stored.parents.first() {
112        Some(parent) => {
113            let Some(tree) = query(context, cwd, &["rev-parse", "--verify", "--quiet", format!("{parent}^{{tree}}").as_str()])? else {
114                return Ok(None);
115            };
116            Some(tree.trim().to_string())
117        }
118        None => None,
119    };
120    return Ok(Some(AmendSource { base, parents: stored.parents, parent_tree, author: stored.author, message: stored.message }));
121}
122
123/// The name, the address and the raw date of an author line.
124fn split_author(line: &str) -> Option<(String, String, String)> {
125    let (name, rest) = line.rsplit_once(" <")?;
126    let (email, date) = rest.split_once("> ")?;
127    return Some((name.to_string(), email.to_string(), date.to_string()));
128}
129
130/// What:
131///  The author line of an amended commit.
132/// Why:
133///   Native Git keeps the replaced commit's name, address and date; `--author` replaces the name and address, `--date`
134///  the date, and `--reset-author` makes the committer the author with a new date. Git's own `var` makes the line
135///  whenever a part is replaced, so a date or an identity is read as Git reads it.
136///
137/// In TS you'd write (pseudocode):
138/// ```ts
139/// await amendedAuthor(context, cwd, source, parsed)
140/// ```
141pub fn amended_author(context: &GitContext, cwd: &Path, source: &AmendSource, parsed: &ServiceCommitArgs) -> Result<Option<String>, TransactionStop> {
142    if parsed.reset_author {
143        return identity(context, cwd, "GIT_AUTHOR_IDENT", &[]);
144    }
145    let replaced: Vec<(&'static str, String)> = author_overrides(parsed);
146    if replaced.is_empty() {
147        return Ok(Some(source.author.clone()));
148    }
149    let Some((name, email, date)) = split_author(source.author.as_str()) else { return Ok(None) };
150    let mut parts: Vec<(&'static str, String)> = vec![("GIT_AUTHOR_NAME", name), ("GIT_AUTHOR_EMAIL", email), ("GIT_AUTHOR_DATE", date)];
151    for (key, value) in replaced {
152        if let Some(slot) = parts.iter_mut().find(|(existing, _)| { return *existing == key; }) {
153            slot.1 = value;
154        }
155    }
156    return identity(context, cwd, "GIT_AUTHOR_IDENT", parts.as_slice());
157}
158
159/// Amend controls stay out of the release executable.
160#[cfg(test)]
161#[path = "service_amend_tests.rs"]
162mod tests;