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