Skip to main content

git_policy_cli/
service_commit.rs

1//! What:
2//!  The commit command's path through the resident service: prepare the commit in this process (private index,
3//!  policies), send the prepared change to the service, and report what it landed.
4//! Why:
5//!  The service is the only writer, so a commit takes no lock and polls nothing: preparation and policies run here,
6//!  outside the service's serialized step, and the service moves the branch once for a whole batch of commits
7//!  (`doc/planning/cli-git-resident-service.md`, "Where preparation and policies run").
8//!  It covers the commit lines `service_commit_args.rs` accepts (message and identity options, sign-off, trailers, a
9//!  reused or fixup message, `-a`, an index commit, and `--amend`); any other command line, a commit in a merge, on an
10//!  unborn branch or on a detached head, a policy that rewrites a file, and anything with nothing to commit return `None`
11//!  so the transaction handles them as before. Which route a commit takes is decided by `service_route.rs`.
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! const result = await tryServiceCommit(invocation, checks, request); // undefined when native git commit must run
16//! ```
17
18use super::capture_invocation::{InvocationCapture, base_revision, capture_invocation_base, capture_invocation_layout};
19use super::capture_order::{CaptureIndexes, indexed_request};
20use super::commit_convergence::{CommitConvergenceInput, converge_commit_policies};
21use super::commit_facts::PrivateIndexFacts;
22use super::commit_index::{IndexSelection, initialize_commit_index, list_changed_index_paths, list_unmerged_index_paths};
23use super::commit_transaction::{CommitInvocation, TransactionResult, TransactionStop, base_object, decide_selection, failed, private_checks, staged_at_base};
24use super::lfs_pre_push::uses_lfs;
25use super::policy_checks::ShippedChecks;
26use super::policy_engine::{StageRequest, pass_exit_code};
27use super::policy_events::PolicyEvent;
28use super::private_index_stage::PrivateStage;
29use super::private_storage::{create_private_directory, remove_tree};
30use super::random_id::random_uuid;
31use super::repository_facts::RepositoryFacts;
32use super::service_client::{Activation, COMMIT_WAIT, ClientConfig, START_WAIT, Submitted, submit};
33use super::diagnostic_log::warn;
34use super::service_commit_args::{ServiceCommitArgs, opens_editor, parse_service_commit};
35use super::service_native::{BuildInput, GitBuilt, build_with_git};
36use super::service_amend::{AmendSource, amended_author, read_amend_source};
37use super::service_message::{author_overrides, build_message, identity};
38use super::service_land::LANDING_ATTEMPTS;
39use super::service_messages::{ClientFailure, render};
40use super::service_protocol::{ALLOWED_MODES, Change, CommitRequest, MAX_CHANGES, is_object_name, is_plain_path};
41use super::service_handoff::ServiceHandoff;
42use super::service_provision::provision;
43use super::service_route::{Route, RouteInputs, choose_route, remembered_own_executable};
44use super::config_snapshot::ConfigSnapshot;
45use super::transaction_git::{GitContext, run_git_checked};
46use super::transaction_journal::{Base, Conclusion, SymbolicHead, TransactionMode};
47use std::ffi::OsString;
48use std::io::Write;
49use std::path::{Path, PathBuf};
50
51/// What the service path returns: the transaction result for the lifecycle, and the handoff when the commit landed.
52pub struct ServiceCommit {
53    /// What the policies and the landing came to.
54    pub result: TransactionResult,
55    /// How to leave the after-commit work for the service; absent when nothing landed.
56    pub handoff: Option<ServiceHandoff>,
57}
58
59/// How many times a commit Git built is built again on the new tip when the branch moves before it lands, before the
60///  plain failure. A build is one signature, so the bound keeps a busy branch from keeping a person's command running.
61pub const REBUILD_ATTEMPTS: usize = 5;
62
63/// A scratch directory for the private indexes, removed when it goes out of scope.
64struct Scratch(PathBuf);
65
66impl Drop for Scratch {
67    fn drop(&mut self) {
68        let _removed = remove_tree(self.0.as_path());
69    }
70}
71
72/// What a preparation came to.
73enum Prepared {
74    /// Native `git commit` must handle this command.
75    Decline,
76    /// The policies stopped the commit.
77    Blocked(TransactionResult),
78    /// The change is ready to send.
79    Ready(Box<Ready>),
80}
81
82struct Ready {
83    repository: PathBuf,
84    common_dir: PathBuf,
85    reference: String,
86    base: String,
87    changes: Vec<Change>,
88    events: Vec<PolicyEvent>,
89    uses_lfs: bool,
90    /// The commit's scratch directory, kept until the commit is landed because Git's scratch repository lives in it.
91    scratch: Scratch,
92    /// Whether the repository's configuration asks Git to build the commit (a template, a signature, a clean-up mode).
93    needs_native: bool,
94    /// What real Git needs to build the commit, or what the embedded route needs to make its tree.
95    inputs: NativeInputs,
96}
97
98/// What real Git needs from the preparation to build the commit in a scratch repository.
99struct NativeInputs {
100    capture: InvocationCapture,
101    arguments: Vec<OsString>,
102    pathspecs: Vec<String>,
103    mode: TransactionMode,
104    staged_all: bool,
105    commit_index: PathBuf,
106}
107
108/// The changes between the base and the private index: path, mode and object, deletions as no object.
109fn changes_of(context: &GitContext, cwd: &Path, indexes: &CaptureIndexes, base: &str) -> Result<Option<Vec<Change>>, TransactionStop> {
110    let arguments: Vec<String> = ["diff-index", "--cached", "-r", "--raw", "-z", "--no-renames", "--no-abbrev", base].map(String::from).to_vec();
111    let output = run_git_checked(
112        context,
113        &indexed_request(cwd, &arguments, Some(indexes.commit_index.as_path()), Some(indexes.object_directory.as_path())),
114    )?;
115    let text: String = match String::from_utf8(output.stdout) {
116        Ok(text) => text,
117        Err(_) => return Ok(None),
118    };
119    let mut fields = text.split('\0').filter(|field| { return !field.is_empty(); });
120    let mut changes: Vec<Change> = Vec::new();
121    while let Some(header) = fields.next() {
122        let Some(path) = fields.next() else { return Ok(None) };
123        let parts: Vec<&str> = header.trim_start_matches(':').split(' ').collect();
124        let [_, new_mode, _, new_name, status] = parts.as_slice() else { return Ok(None) };
125        let deletion: bool = status.starts_with('D');
126        let mode: u32 = if deletion { ALLOWED_MODES[0] } else { u32::from_str_radix(new_mode, 8).unwrap_or(0) };
127        if !is_plain_path(path) || !ALLOWED_MODES.contains(&mode) || (!deletion && !is_object_name(new_name)) {
128            return Ok(None);
129        }
130        changes.push(Change { path: path.to_string(), mode, blob: if deletion { None } else { Some((*new_name).to_string()) } });
131    }
132    return Ok(if changes.len() > MAX_CHANGES { None } else { Some(changes) });
133}
134
135/// Prepares the commit once against the branch as it is now.
136fn prepare<F: RepositoryFacts>(
137    invocation: &CommitInvocation<'_>,
138    checks: &mut ShippedChecks<F>,
139    request: &StageRequest,
140    scratch_name: &str,
141    allow_empty: bool,
142) -> Result<Prepared, TransactionStop> {
143    let context: &GitContext = invocation.context;
144    let cwd: &Path = invocation.cwd;
145    let layout = capture_invocation_layout(context, cwd, false)?;
146    if layout.conclusion != Conclusion::None {
147        return Ok(Prepared::Decline);
148    }
149    let selection = decide_selection(invocation.region, layout.conclusion);
150    let SymbolicHead::Branch(reference) = layout.symbolic_head.clone() else { return Ok(Prepared::Decline) };
151    // An index commit takes the whole index (`-a` first stages every tracked change); a commit of named paths takes only
152    //  those. A mix of the two, or anything that stages by hand, is the transaction's.
153    let named: bool = selection.mode == TransactionMode::ExplicitPath;
154    if selection.read_only || (selection.mode == TransactionMode::Index && selection.selects_paths) {
155        return Ok(Prepared::Decline);
156    }
157    let capture: InvocationCapture = capture_invocation_base(context, cwd, layout)?;
158    if !matches!(capture.base, Base::Commit(_)) {
159        return Ok(Prepared::Decline);
160    }
161    // Read from the snapshot the layout already holds, so the cheap declines above all come before anything starts Git for
162    //  the message or the identities, and before a service is provisioned.
163    let needs_native: bool = config_needs_native(capture.layout.config_snapshot.as_ref());
164    // An amend still replaces the last commit when no path changed (a new message, a new committer).
165    let amends: bool = invocation.region.amend;
166    let base: String = base_revision(&capture);
167    let scratch: Scratch = Scratch(capture.layout.git_dir.join(scratch_name));
168    create_private_directory(scratch.0.as_path()).map_err(|error| { return failed(format!("preparing the commit failed: {error}")); })?;
169    let indexes: CaptureIndexes = CaptureIndexes {
170        captured_index: scratch.0.join("captured.index"),
171        commit_index: scratch.0.join("commit.index"),
172        object_directory: capture.layout.object_directory.clone(),
173    };
174    let region_arguments: &[OsString] = &invocation.arguments[invocation.subcommand_index + 1..];
175    let pathspecs: Vec<OsString> = invocation.region.pathspecs.iter().map(|at| { return region_arguments[*at].clone(); }).collect();
176    let index_selection: IndexSelection<'_> = IndexSelection {
177        mode: selection.mode,
178        pathspecs: if named { pathspecs.as_slice() } else { &[] },
179        pathspec_file: None,
180        pathspec_file_nul: false,
181        stage_into_index: false,
182        stage_tracked_changes: selection.staged_all,
183        base_revision: base.as_str(),
184    };
185    initialize_commit_index(context, cwd, capture.layout.real_index_path.as_path(), &indexes, &index_selection)
186        .map_err(|error| { return failed(error.0); })?;
187    if !list_unmerged_index_paths(context, cwd, indexes.commit_index.as_path())?.is_empty() {
188        return Ok(Prepared::Decline);
189    }
190    let candidate_paths: Vec<Vec<u8>> =
191        list_changed_index_paths(context, cwd, indexes.commit_index.as_path(), base.as_str(), indexes.object_directory.as_path())?;
192    if candidate_paths.is_empty() && !allow_empty && !amends {
193        return Ok(Prepared::Decline);
194    }
195    let mut private: ShippedChecks<PrivateIndexFacts<&mut F>> = private_checks(
196        checks,
197        indexes.commit_index.as_path(),
198        indexes.object_directory.as_path(),
199        staged_at_base(base_object(&capture)?, candidate_paths.as_slice()),
200    );
201    let outcome = converge_commit_policies(CommitConvergenceInput {
202        request,
203        checks: &mut private,
204        stage: PrivateStage {
205            context,
206            cwd,
207            index: indexes.commit_index.as_path(),
208            object_directory: Some(indexes.object_directory.as_path()),
209        },
210    });
211    let code: i32 = pass_exit_code(outcome.pass.events.as_slice(), outcome.pass.end);
212    if code != 0 {
213        return Ok(Prepared::Blocked(TransactionResult { events: outcome.pass.events, exit_code: code, landed_oid: None, uses_lfs: false }));
214    }
215    if !outcome.changed_paths.is_empty() {
216        // A policy rewrote a file: the worktree copy of that rewrite is native work.
217        return Ok(Prepared::Decline);
218    }
219    let Some(changes) = changes_of(context, cwd, &indexes, base.as_str())? else { return Ok(Prepared::Decline) };
220    if changes.is_empty() && !allow_empty && !amends {
221        return Ok(Prepared::Decline);
222    }
223    return Ok(Prepared::Ready(Box::new(Ready {
224        repository: capture.layout.repository_root.clone(),
225        common_dir: capture.layout.common_dir.clone(),
226        reference,
227        base,
228        changes,
229        events: outcome.pass.events,
230        uses_lfs: capture.layout.config_snapshot.as_ref().is_none_or(uses_lfs),
231        needs_native,
232        inputs: NativeInputs {
233            arguments: outcome.pass.arguments.clone(),
234            pathspecs: pathspecs.iter().map(|token| { return token.to_string_lossy().into_owned(); }).collect(),
235            mode: selection.mode,
236            staged_all: selection.staged_all,
237            commit_index: indexes.commit_index.clone(),
238            capture,
239        },
240        scratch,
241    })));
242}
243
244/// Whether the repository's configuration makes native `git commit` do something the service does not: sign, apply a
245///  template or a clean-up mode, use another message encoding or another comment character, or change trailers.
246fn config_needs_native(snapshot: Option<&ConfigSnapshot>) -> bool {
247    const KEYS: [&str; 7] = ["commit.template", "commit.cleanup", "i18n.commitencoding", "core.commentchar", "core.commentstring", "commit.signoff", "commit.verbose"];
248    let Some(found) = snapshot else { return true };
249    return found.entries().iter().any(|entry| {
250        let value: String = entry.value.as_deref().map(|bytes| { return String::from_utf8_lossy(bytes).to_lowercase(); }).unwrap_or_default();
251        let signs: bool = entry.key == "commit.gpgsign" && !["false", "no", "off", "0"].contains(&value.as_str());
252        return signs || KEYS.contains(&entry.key.as_str()) || entry.key.starts_with("trailer.");
253    });
254}
255
256/// What:
257///  Lands the commit through the service when the person opted in and the command line is one the service covers.
258/// Why:
259///   Returns `None` for everything else, before anything was changed, so native `git commit` runs unchanged.
260///
261/// In TS you'd write (pseudocode):
262/// ```ts
263/// await tryServiceCommit(invocation, checks, request)
264/// ```
265pub fn try_service_commit<F: RepositoryFacts>(
266    invocation: &CommitInvocation<'_>,
267    checks: &mut ShippedChecks<F>,
268    request: &StageRequest,
269) -> Result<Option<ServiceCommit>, TransactionStop> {
270    let path_variable = |name: &str| -> Option<PathBuf> { return std::env::var_os(name).filter(|value| { return !value.is_empty(); }).map(PathBuf::from); };
271    let runtime: Option<PathBuf> = path_variable("XDG_RUNTIME_DIR");
272    let config_home: Option<PathBuf> = path_variable("XDG_CONFIG_HOME");
273    let home: Option<PathBuf> = path_variable("HOME");
274    let bus_address: Option<String> = std::env::var("DBUS_SESSION_BUS_ADDRESS").ok();
275    let route: Route = choose_route(&RouteInputs {
276        runtime_dir: runtime.as_deref(),
277        bus_address: bus_address.as_deref(),
278        config_home: config_home.as_deref(),
279        home: home.as_deref(),
280        own_executable: remembered_own_executable(),
281        real_git: invocation.context.real_git.as_path(),
282    });
283    let Route::Service { address, provision: provisioning } = route else { return Ok(None) };
284    let region = invocation.region;
285    if region.include || region.interactive || region.patch || region.dry_run || region.pathspec_from_file.is_some() {
286        return Ok(None);
287    }
288    let after: &[OsString] = invocation.arguments.get(invocation.subcommand_index + 1..).unwrap_or(&[]);
289    let id: String = random_uuid().map_err(|error| { return failed(format!("an identity for this commit could not be made: {error:?}")); })?;
290    let allow_empty: bool = region.allow_empty;
291    let mut provisioned: bool = false;
292    // Whether an editor may have run, which decides what a moved branch costs the person: a message to keep or a signature to redo.
293    let editor_possible: bool = opens_editor(after);
294    let config: ClientConfig = ClientConfig { address, activation: Activation::Managed, start_wait: START_WAIT, commit_wait: COMMIT_WAIT };
295    let scratch_name: String = format!("cli-git-service-{id}");
296    // The service builds the commits it can from the command line; real Git builds the rest in a scratch repository. Which
297    //  it is gets decided once the preparation has passed every cheap decline (head, configuration, index, policies), so a
298    //  commit the service never takes starts no Git for a message or an identity.
299    let mut embedded: Option<Embedded> = None;
300    let mut decided: bool = false;    for attempt in 0..REBUILD_ATTEMPTS {
301        let ready: Box<Ready> = match prepare(invocation, checks, request, scratch_name.as_str(), allow_empty)? {
302            Prepared::Decline => return Ok(None),
303            Prepared::Blocked(result) => return Ok(Some(ServiceCommit { result, handoff: None })),
304            Prepared::Ready(ready) => ready,
305        };
306        if !decided {
307            decided = true;
308            if !ready.needs_native
309                && let Some(parsed) = parse_service_commit(after)
310            {
311                embedded = embedded_commit(invocation, parsed)?;
312            }
313        }
314        let native: bool = embedded.is_none();
315        let inputs: &NativeInputs = &ready.inputs;
316        // An amend the service builds is made from the commit `HEAD` named when the message was read; if the branch left it
317        //  since, the amend is not made. Its tree is the private index's, and native Git refuses an amend that leaves the
318        //  commit empty against its first parent (a merge excepted) unless `--allow-empty` says it is meant.
319        let mut tree: Option<String> = None;
320        if let Some(source) = embedded.as_ref().and_then(|made| { return made.source.as_ref(); }) {
321            if source.base != ready.base {
322                let branch: String = ready.reference.trim_start_matches("refs/heads/").to_string();
323                return Err(TransactionStop::Message(render(&ClientFailure::BranchMoved { branch })));
324            }
325            let written = run_git_checked(
326                invocation.context,
327                &indexed_request(
328                    invocation.cwd,
329                    &["write-tree"],
330                    Some(inputs.commit_index.as_path()),
331                    Some(inputs.capture.layout.object_directory.as_path()),
332                ),
333            )?;
334            let made: String = String::from_utf8_lossy(written.stdout.as_slice()).trim().to_string();
335            let against: &str = source.parent_tree.as_deref().unwrap_or(inputs.capture.layout.empty_tree_oid.as_str());
336            if source.parents.len() < 2 && !allow_empty && made == against {
337                return Ok(None);
338            }
339            tree = Some(made);
340        }
341        let mut typed: Option<String> = None;
342        let (sent, first_parent): (CommitRequest, String) = match embedded.as_ref() {
343            None => {
344                let built: GitBuilt = build_with_git(&BuildInput {
345                    context: invocation.context,
346                    cwd: invocation.cwd,
347                    capture: &inputs.capture,
348                    scratch: ready.scratch.0.as_path(),
349                    commit_index: inputs.commit_index.as_path(),
350                    arguments: inputs.arguments.as_slice(),
351                    pathspecs: inputs.pathspecs.as_slice(),
352                    mode: inputs.mode,
353                    staged_all: inputs.staged_all,
354                    quiet: attempt > 0,
355                })?;
356                let parent: String = built.parents.first().cloned().unwrap_or_default();
357                typed = Some(built.message.clone());
358                let sent: CommitRequest = CommitRequest {
359                    id: id.clone(),
360                    repository: ready.repository.clone(),
361                    common_dir: ready.common_dir.clone(),
362                    reference: ready.reference.clone(),
363                    base: ready.base.clone(),
364                    changes: ready.changes.clone(),
365                    // The commit is Git's whole: message, identities and tree are in it.
366                    message: String::new(),
367                    author: None,
368                    committer: String::new(),
369                    amend: invocation.region.amend,
370                    tree: None,
371                    commit: Some(built.commit),
372                };
373                (sent, parent)
374            }
375            Some(made) => {
376                let sent: CommitRequest = CommitRequest {
377                    id: id.clone(),
378                    repository: ready.repository.clone(),
379                    common_dir: ready.common_dir.clone(),
380                    reference: ready.reference.clone(),
381                    base: ready.base.clone(),
382                    changes: ready.changes.clone(),
383                    message: made.message.clone(),
384                    author: Some(made.author.clone()),
385                    committer: made.committer.clone(),
386                    amend: tree.is_some(),
387                    tree: tree.clone(),
388                    commit: None,
389                };
390                // The new commit's first parent: the tip it was made on, or for an amend the replaced commit's own parent.
391                let parent: String = match made.source.as_ref() {
392                    Some(replaced) => replaced.parents.first().cloned().unwrap_or_default(),
393                    None => ready.base.clone(),
394                };
395                (sent, parent)
396            }
397        };
398        // The first commit that reaches the service sets up its socket activation, and only then: a commit that was
399        //  declined, blocked by a policy, or abandoned in the editor never needs it, and the manager starts the service.
400        if let Some(host) = provisioning.as_ref().filter(|_| { return !provisioned; }) {
401            provision(host).map_err(|failure| { return TransactionStop::Message(render(&failure)); })?;
402            provisioned = true;
403        }
404        match submit(&config, &sent, &mut std::io::stderr()) {
405            Ok(Submitted::Landed { commit, .. }) => {
406                if let Some(made) = embedded.as_ref().filter(|made| { return !native && !made.quiet; }) {
407                    let subject: &str = made.message.lines().next().unwrap_or("");
408                    let short: &str = commit.get(..7).unwrap_or(commit.as_str());
409                    let branch: &str = ready.reference.trim_start_matches("refs/heads/");
410                    let _printed = writeln!(std::io::stdout(), "[{branch} {short}] {subject}");
411                }
412                if native && attempt > 0 {
413                    // Git printed no summary for the build that landed, and the first one named a commit that did not land.
414                    let subject: &str = typed.as_deref().and_then(|text| { return text.lines().next(); }).unwrap_or("");
415                    let short: &str = commit.get(..7).unwrap_or(commit.as_str());
416                    let branch: &str = ready.reference.trim_start_matches("refs/heads/");
417                    let _printed = writeln!(std::io::stdout(), "[{branch} {short}] {subject}");
418                }
419                let handoff: ServiceHandoff = ServiceHandoff {
420                    config: config.clone(),
421                    repository: ready.repository.clone(),
422                    common_dir: ready.common_dir.clone(),
423                    reference: ready.reference.clone(),
424                    parent: first_parent,
425                };
426                let result: TransactionResult = TransactionResult { events: ready.events, exit_code: 0, landed_oid: Some(commit), uses_lfs: ready.uses_lfs };
427                return Ok(Some(ServiceCommit { result, handoff: Some(handoff) }));
428            }
429            Ok(Submitted::PrepareAgain { .. }) => {
430                if native || sent.amend {
431                    // An amend, and a commit Git built, are never sent for another tip; the service answers a moved branch as a failure.
432                    let branch: String = ready.reference.trim_start_matches("refs/heads/").to_string();
433                    return Err(TransactionStop::Message(render(&ClientFailure::BranchMoved { branch })));
434                }
435                if attempt + 1 == LANDING_ATTEMPTS {
436                    let branch: String = ready.reference.trim_start_matches("refs/heads/").to_string();
437                    return Err(TransactionStop::Message(render(&ClientFailure::BranchKeepsChanging { branch, times: LANDING_ATTEMPTS })));
438                }
439            }
440            Ok(Submitted::Moved) => {
441                let branch: String = ready.reference.trim_start_matches("refs/heads/").to_string();
442                if native && !sent.amend && !editor_possible {
443                    // Nobody typed anything: Git builds it again on the new tip (and signs it again), up to the bound.
444                    if attempt + 1 == REBUILD_ATTEMPTS {
445                        return Err(TransactionStop::Message(render(&ClientFailure::BranchKeepsChanging { branch, times: REBUILD_ATTEMPTS })));
446                    }
447                    continue;
448                }
449                if native && editor_possible && let Some(text) = typed.as_deref() {
450                    // The person typed this message: keep it where they can reuse it.
451                    let file: PathBuf = inputs.capture.layout.git_dir.join(format!("cli-git-saved-message-{}", id.get(..8).unwrap_or(id.as_str())));
452                    match std::fs::write(&file, text) {
453                        Ok(()) => {
454                            let failure: ClientFailure = ClientFailure::BranchMovedMessageSaved { branch, file: file.display().to_string(), amend: sent.amend };
455                            return Err(TransactionStop::Message(render(&failure)));
456                        }
457                        Err(error) => warn("serviceCommit", format!("saving the typed message failed: {error}").as_str()),
458                    }
459                    let again: String = ready.reference.trim_start_matches("refs/heads/").to_string();
460                    return Err(TransactionStop::Message(render(&ClientFailure::BranchMoved { branch: again })));
461                }
462                return Err(TransactionStop::Message(render(&ClientFailure::BranchMoved { branch })));
463            }
464            Err(failure) => return Err(TransactionStop::Message(render(&failure))),
465        }
466    }
467    return Ok(None);
468}
469
470/// What the embedded route made of a command line: the message, the identities and, for an amend, the replaced commit.
471struct Embedded {
472    message: String,
473    author: String,
474    committer: String,
475    source: Option<AmendSource>,
476    quiet: bool,
477}
478
479/// What:
480///  Builds the message and the identities of a command line the service covers, the way native Git would.
481/// Why:
482///   Returns nothing when Git alone can say what the line means (an unreadable message file, a date it does not read, no
483///  identity configured, an empty message), and Git then builds the commit and gives the person its own words.
484///
485/// In TS you'd write (pseudocode):
486/// ```ts
487/// await embeddedCommit(invocation, parsed) // undefined when Git must build it
488/// ```
489fn embedded_commit(invocation: &CommitInvocation<'_>, parsed: ServiceCommitArgs) -> Result<Option<Embedded>, TransactionStop> {
490    let Some(committer) = identity(invocation.context, invocation.cwd, "GIT_COMMITTER_IDENT", &[])? else { return Ok(None) };
491    let source: Option<AmendSource> = if parsed.amend {
492        let Some(read) = read_amend_source(invocation.context, invocation.cwd)? else { return Ok(None) };
493        Some(read)
494    } else {
495        None
496    };
497    let previous: Option<&str> = source.as_ref().map(|found| { return found.message.as_str(); });
498    let Some(built) = build_message(invocation, &parsed, committer.as_str(), previous)? else { return Ok(None) };
499    let author: String = match built.reused_author {
500        Some(reused) => reused,
501        None => match source.as_ref() {
502            Some(replaced) => {
503                let Some(made) = amended_author(invocation.context, invocation.cwd, replaced, &parsed)? else { return Ok(None) };
504                made
505            }
506            None => {
507                let Some(made) = identity(invocation.context, invocation.cwd, "GIT_AUTHOR_IDENT", author_overrides(&parsed).as_slice())? else {
508                    return Ok(None);
509                };
510                made
511            }
512        },
513    };
514    return Ok(Some(Embedded { message: built.message, author, committer, source, quiet: parsed.quiet }));
515}
516
517/// Service commit controls stay out of the release executable.
518#[cfg(test)]
519#[path = "service_commit_tests.rs"]
520mod tests;