Skip to main content

git_policy_cli/
service_message.rs

1//! What:
2//!  The message and the identities of a commit the service lands, made from the command line the way native
3//!  `git commit` makes them when no editor opens.
4//! Why:
5//!  Everything here is text arithmetic that Git itself defines (clean-up, sign-off, trailers, the subject of a fixup,
6//!  the message and authorship of a reused commit), so the wrapper embeds it instead of starting `git commit`; each
7//!  rule is compared field by field with the native commit in `binary_service_options_tests.rs`. Whatever cannot be
8//!  made without guessing returns nothing and native `git commit` handles the line.
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! const { message, author } = await buildMessage(invocation, parsed, committer);
13//! ```
14
15use super::commit_transaction::{CommitInvocation, TransactionStop, failed};
16use super::service_commit_args::{FixupKind, ServiceCommitArgs, cleanup_message};
17use super::transaction_git::{GitContext, GitRequest, run_git};
18use std::ffi::OsString;
19use std::path::Path;
20
21/// What a command line's message came to.
22#[derive(Clone, Debug, Eq, PartialEq)]
23pub struct Built {
24    /// The whole commit message.
25    pub message: String,
26    /// The author line a reused commit supplies, `Name <email> seconds zone`.
27    pub reused_author: Option<String>,
28}
29
30/// One identity line (`Name <email> seconds zone`) from Git's own resolution of the variable, with the given overrides;
31///  nothing when Git cannot make one (a date it does not read), so native `git commit` gives its own message.
32pub fn identity(context: &GitContext, cwd: &Path, variable: &str, overrides: &[(&str, String)]) -> Result<Option<String>, TransactionStop> {
33    let mut request: GitRequest = GitRequest::new(cwd, &["var", variable]);
34    request.environment = overrides.iter().map(|(name, value)| { return (OsString::from(name), OsString::from(value)); }).collect();
35    let output = run_git(context, &request).map_err(|error| { return failed(format!("reading the commit identity failed: {error:?}")); })?;
36    if output.code != Some(0) {
37        return Ok(None);
38    }
39    return Ok(Some(String::from_utf8_lossy(output.stdout.as_slice()).trim().to_string()));
40}
41
42/// The author variables `--author` and `--date` stand for.
43pub fn author_overrides(parsed: &ServiceCommitArgs) -> Vec<(&'static str, String)> {
44    let mut overrides: Vec<(&'static str, String)> = Vec::new();
45    if let Some(author) = parsed.author.as_deref() {
46        let (name, rest): (&str, &str) = author.split_once('<').unwrap_or((author, ""));
47        overrides.push(("GIT_AUTHOR_NAME", name.trim().to_string()));
48        overrides.push(("GIT_AUTHOR_EMAIL", rest.trim_end().trim_end_matches('>').to_string()));
49    }
50    if let Some(date) = parsed.date.as_deref() {
51        overrides.push(("GIT_AUTHOR_DATE", date.to_string()));
52    }
53    return overrides;
54}
55
56/// The text of a Git query that must succeed for the line to be covered.
57fn query(context: &GitContext, cwd: &Path, arguments: &[&str]) -> Result<Option<Vec<u8>>, TransactionStop> {
58    let output = run_git(context, &GitRequest::new(cwd, arguments)).map_err(|error| { return failed(format!("reading the repository failed: {error:?}")); })?;
59    return Ok(if output.code == Some(0) { Some(output.stdout) } else { None });
60}
61
62/// The raw object of a commit named by the person: its author line and its message.
63fn reused_commit(context: &GitContext, cwd: &Path, revision: &str) -> Result<Option<(String, String)>, TransactionStop> {
64    if revision.starts_with('-') {
65        return Ok(None);
66    }
67    let Some(oid) = query(context, cwd, &["rev-parse", "--verify", "--quiet", format!("{revision}^{{commit}}").as_str()])? else { return Ok(None) };
68    let Some(raw) = query(context, cwd, &["cat-file", "commit", String::from_utf8_lossy(oid.as_slice()).trim()])? else { return Ok(None) };
69    let Ok(text) = String::from_utf8(raw) else { return Ok(None) };
70    let Some((header, message)) = text.split_once("\n\n") else { return Ok(None) };
71    // A commit in another encoding, or with extra headers that change the message's meaning, is Git's to reuse.
72    if header.lines().any(|line| { return line.starts_with("encoding "); }) {
73        return Ok(None);
74    }
75    let Some(author) = header.lines().find_map(|line| { return line.strip_prefix("author "); }) else { return Ok(None) };
76    return Ok(Some((author.to_string(), message.to_string())));
77}
78
79/// The subject Git puts after `fixup! ` or `squash! `.
80fn subject_of(context: &GitContext, cwd: &Path, revision: &str) -> Result<Option<String>, TransactionStop> {
81    if revision.starts_with('-') {
82        return Ok(None);
83    }
84    let Some(bytes) = query(context, cwd, &["log", "-1", "--format=%s", revision, "--"])? else { return Ok(None) };
85    return Ok(String::from_utf8(bytes).ok().map(|text| { return text.trim_end_matches('\n').to_string(); }));
86}
87
88/// The lines of a message without those that start with the comment character, as `--cleanup=strip` drops them.
89pub fn strip_comment_lines(text: &str) -> String {
90    return text.split('\n').filter(|line| { return !line.starts_with('#'); }).collect::<Vec<&str>>().join("\n");
91}
92
93/// What:
94///  The message the command line stands for: `-m` texts, the `-F` file, the message of the `-C` commit or the subject
95///  line of a fixup, cleaned as Git cleans it when no editor opens (or kept as given for `--cleanup=verbatim`), then
96///  given the sign-off and the `--trailer` lines by Git's own trailer interpreter.
97/// Why:
98///   Nothing here guesses: an empty message without `--allow-empty-message`, an unreadable file, a commit Git cannot
99///  resolve or a trailer Git refuses returns nothing, and native `git commit` then gives the person its own message.
100///
101/// In TS you'd write (pseudocode):
102/// ```ts
103/// await buildMessage(invocation, parsed, committer)
104/// ```
105pub fn build_message(invocation: &CommitInvocation<'_>, parsed: &ServiceCommitArgs, committer: &str) -> Result<Option<Built>, TransactionStop> {
106    let context: &GitContext = invocation.context;
107    let cwd: &Path = invocation.cwd;
108    let mut reused_author: Option<String> = None;
109    let sources: Vec<String> = if let Some(file) = parsed.file.as_ref() {
110        match std::fs::read(cwd.join(file)).ok().and_then(|bytes| { return String::from_utf8(bytes).ok(); }) {
111            Some(text) => vec![text],
112            None => return Ok(None),
113        }
114    } else if let Some(revision) = parsed.reuse.as_deref() {
115        let Some((author, message)) = reused_commit(context, cwd, revision)? else { return Ok(None) };
116        reused_author = Some(author);
117        vec![message]
118    } else if let Some((kind, revision)) = parsed.fixup.as_ref() {
119        let Some(subject) = subject_of(context, cwd, revision.as_str())? else { return Ok(None) };
120        let prefix: &str = match kind {
121            FixupKind::Fixup => "fixup!",
122        };
123        vec![format!("{prefix} {subject}")]
124    } else {
125        parsed.messages.clone()
126    };
127    let texts: Vec<String> = if parsed.strip { sources.iter().map(|text| { return strip_comment_lines(text.as_str()); }).collect() } else { sources };
128    let cleaned: Option<String> = if parsed.verbatim {
129        Some(texts.iter().map(|text| { return if text.ends_with('\n') { text.clone() } else { format!("{text}\n") }; }).collect::<Vec<String>>().join("\n"))
130    } else {
131        cleanup_message(texts.as_slice())
132    };
133    let message: String = match cleaned {
134        Some(text) if !text.trim().is_empty() || parsed.verbatim => text,
135        _ if parsed.allow_empty_message => String::new(),
136        _ => return Ok(None),
137    };
138    let mut trailers: Vec<String> = Vec::new();
139    if parsed.signoff {
140        let who: &str = committer.split_once('>').map_or(committer, |(name_and_email, _)| { return &committer[..name_and_email.len() + 1]; });
141        trailers.push(format!("Signed-off-by: {who}"));
142    }
143    trailers.extend(parsed.trailers.iter().cloned());
144    if trailers.is_empty() {
145        return Ok(Some(Built { message, reused_author }));
146    }
147    let mut arguments: Vec<String> = vec![String::from("interpret-trailers")];
148    for trailer in &trailers {
149        arguments.push(String::from("--trailer"));
150        arguments.push(trailer.clone());
151    }
152    let mut request: GitRequest = GitRequest::new(cwd, arguments.as_slice());
153    request.input = Some(message.into_bytes());
154    let output = run_git(context, &request).map_err(|error| { return failed(format!("preparing the commit message failed: {error:?}")); })?;
155    if output.code != Some(0) {
156        return Ok(None);
157    }
158    return Ok(String::from_utf8(output.stdout).ok().map(|trailed| { return Built { message: trailed, reused_author }; }));
159}