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//!  Every command line lands through it. The lines `service_commit_args.rs` accepts are built here from the command line
9//!  (message and identity options, sign-off, trailers, a reused or fixup message, `-a`, an index commit, `--amend`); every
10//!  other line, and every commit that is not on an ordinary branch with an ordinary state, is built by real Git in a scratch
11//!  repository and landed as Git made it. Which route a commit takes is decided by `service_route.rs`; what is prepared is
12//!  `service_prepare.rs`.
13//!
14//! In TS you'd write (pseudocode):
15//! ```ts
16//! const result = await tryServiceCommit(invocation, checks, request); // undefined only when no service can be reached
17//! ```
18
19use super::commit_timing::CommitTiming;
20use super::private_index_request::indexed_request;
21use super::commit_transaction::{CommitInvocation, TransactionResult, TransactionStop, failed};
22use super::diagnostic_log::warn;
23use super::policy_checks::ShippedChecks;
24use super::policy_engine::StageRequest;
25use super::random_id::random_uuid;
26use super::repository_facts::RepositoryFacts;
27use super::service_amend::{AmendSource, amended_author, read_amend_source};
28use super::service_client::{Activation, COMMIT_WAIT, ClientConfig, START_WAIT, ScratchWatch, Submitted, submit_traced, watch_scratch};
29use super::landed_summary::{change_summary, short_name, subject_of, summary_line};
30use super::service_commit_args::{ServiceCommitArgs, asks_for_quiet, opens_editor, parse_service_commit, with_fixed_message};
31use super::service_handoff::ServiceHandoff;
32use super::service_land::LANDING_ATTEMPTS;
33use super::service_message::{author_overrides, build_message, identity};
34use super::service_messages::{ClientFailure, render};
35use super::service_native::{BuildInput, GitBuilt, build_with_git};
36use super::service_prepare::{NativeInputs, PrepareInputs, Prepared, Ready, prepare};
37use super::service_replay::{Captured, Retry, conflict_result, retry_of};
38use super::service_protocol::CommitRequest;
39use super::service_provision::{platform_without_manager, provision};
40use super::service_pruned::{content_was_removed, prepared_content_missing, reach_service_phase};
41use super::transaction_phase::Phase;
42use super::service_route::{Route, RouteInputs, choose_route, remembered_own_executable, worktree_git_dir};
43use super::transaction_git::{GitRequest, run_git, run_git_checked};
44use super::worktree_completion::install_added_worktree_files;
45use std::ffi::OsString;
46use std::io::{Read, Write};
47use std::path::PathBuf;
48use std::time::Instant;
49
50/// What the service path returns: the transaction result for the lifecycle, and the handoff when the commit landed.
51pub struct ServiceCommit {
52    /// What the policies and the landing came to.
53    pub result: TransactionResult,
54    /// How to leave the after-commit work for the service; absent when nothing landed.
55    pub handoff: Option<ServiceHandoff>,
56}
57
58/// How many times a commit Git built is built again on the new tip when the branch moves before it lands, before the
59///  plain failure. A build is one signature, so the bound keeps a busy branch from keeping a person's command running.
60pub const REBUILD_ATTEMPTS: usize = 5;
61
62/// The plain failure of a branch that moved under a commit that cannot be made again on another tip.
63fn branch_moved(ready: &Ready) -> TransactionStop {
64    let branch: String = ready.reference.trim_start_matches("refs/heads/").to_string();
65    return TransactionStop::Message(render(&ClientFailure::BranchMoved { branch }));
66}
67
68/// The standard input of the command when a pathspec file is read from it, read once.
69fn standard_input_for(invocation: &CommitInvocation<'_>) -> Result<Option<Vec<u8>>, TransactionStop> {
70    let after: &[OsString] = invocation.arguments.get(invocation.subcommand_index + 1..).unwrap_or(&[]);
71    let Some(value) = invocation.region.pathspec_from_file else { return Ok(None) };
72    if super::command_options_query::value_bytes(after, value) != b"-" {
73        return Ok(None);
74    }
75    let mut bytes: Vec<u8> = Vec::new();
76    std::io::stdin().read_to_end(&mut bytes).map_err(|error| { return failed(format!("reading the pathspec file failed: {error}")); })?;
77    return Ok(Some(bytes));
78}
79
80/// What:
81///  What a commit run outside any repository says: Git's own words and status for the same question.
82/// Why:
83///   No worktree means no service instance to send the commit to. Asking Git which repository the directory belongs to gives
84///  the person the text and the exit status native `git commit` gives there, and that question can make no commit.
85///
86/// In TS you'd write (pseudocode):
87/// ```ts
88/// repositoryNotFound(invocation) // Git's stderr, then its exit status
89/// ```
90fn repository_not_found(invocation: &CommitInvocation<'_>) -> TransactionStop {
91    let asked = run_git(invocation.context, &GitRequest::new(invocation.cwd, &["rev-parse", "--git-dir"]));
92    let output = match asked {
93        Ok(found) => found,
94        Err(error) => return failed(format!("asking Git which repository this directory belongs to failed: {error}")),
95    };
96    if output.succeeded() {
97        return failed(String::from("this directory belongs to a repository whose Git directory could not be located"));
98    }
99    let _reported = std::io::stderr().write_all(output.stderr.as_slice());
100    return TransactionStop::NativeExit(output.code.unwrap_or(1));
101}
102
103/// What:
104///  Lands the commit through the service, for every command line.
105/// Why:
106///   The service is the only writer of a commit. A machine where none can be reached or started ends the command with the
107///  plain message of its platform, before anything was changed; every other ending is the commit's.
108///
109/// In TS you'd write (pseudocode):
110/// ```ts
111/// await serviceCommit(invocation, checks, request, timing)
112/// ```
113pub fn service_commit<F: RepositoryFacts>(
114    invocation: &CommitInvocation<'_>,
115    checks: &mut ShippedChecks<F>,
116    request: &StageRequest,
117    timing: &mut CommitTiming,
118) -> Result<ServiceCommit, TransactionStop> {
119    let path_variable = |name: &str| -> Option<PathBuf> { return std::env::var_os(name).filter(|value| { return !value.is_empty(); }).map(PathBuf::from); };
120    let runtime: Option<PathBuf> = path_variable("XDG_RUNTIME_DIR");
121    let config_home: Option<PathBuf> = path_variable("XDG_CONFIG_HOME");
122    let home: Option<PathBuf> = path_variable("HOME");
123    let bus_address: Option<String> = std::env::var("DBUS_SESSION_BUS_ADDRESS").ok();
124    // The worktree's own Git directory names the service instance, so no route exists outside a repository.
125    let Some(git_dir) = worktree_git_dir(invocation.context, invocation.cwd) else { return Err(repository_not_found(invocation)) };
126    let route: Option<Route> = choose_route(&RouteInputs {
127        runtime_dir: runtime.as_deref(),
128        bus_address: bus_address.as_deref(),
129        config_home: config_home.as_deref(),
130        home: home.as_deref(),
131        own_executable: remembered_own_executable(),
132        real_git: invocation.context.real_git.as_path(),
133        git_dir: git_dir.as_path(),
134    });
135    let Some(Route { address, provision: provisioning }) = route else {
136        return Err(TransactionStop::Message(render(&ClientFailure::NoActivation(platform_without_manager()))));
137    };
138    // From here on the commit is recorded; one that stopped above, on a machine with no way to start a service, took no time
139    //  worth splitting and leaves no record (nothing it does changes the repository).
140    timing.place_at(git_dir.as_path());
141    let after: &[OsString] = invocation.arguments.get(invocation.subcommand_index + 1..).unwrap_or(&[]);
142    let id: String = random_uuid().map_err(|error| { return failed(format!("an identity for this commit could not be made: {error:?}")); })?;
143    let standard_input: Option<Vec<u8>> = standard_input_for(invocation)?;
144    let mut provisioned: bool = false;
145    // Whether an editor may have run, which decides what a moved branch costs the person: a message to keep or a signature to redo.
146    let editor_possible: bool = opens_editor(after);
147    let config: ClientConfig = ClientConfig { address, activation: Activation::Managed, start_wait: START_WAIT, commit_wait: COMMIT_WAIT };
148    let scratch_name: String = format!("cli-git-service-{id}");
149    // The service builds the commits it can from the command line; real Git builds the rest in a scratch repository. Which
150    //  it is gets decided once the preparation has passed every cheap decision (head, configuration, index, policies), so a
151    //  commit the service never takes starts no Git for a message or an identity.
152    let mut embedded: Option<Embedded> = None;
153    let mut decided: bool = false;
154    // The message the person typed once the editor has run, kept in a file so a commit moved by a tip that changed is built again from it.
155    let mut fixed_message: Option<PathBuf> = None;
156    // The message of the command as the last preparation settled it, kept for the person when the commit cannot be made.
157    let mut last_message: Option<String> = None;
158    // What the previous preparation captured when the service sent the commit back because the branch moved: the next
159    //  preparation merges it onto the branch as it is and never reads the worktree again.
160    let mut retry: Option<Captured> = None;
161    // The branch (or `HEAD`) the first preparation found, which every later preparation must find again.
162    let mut first_reference: Option<String> = None;
163    // The commit's scratch directory, handed to the service once the first preparation made it: the service removes it when this
164    //  command ends, and when this process is killed, because the connection closes either way. Without a service of this
165    //  release to take it, the directory is removed here as before.
166    let mut scratch_watch: Option<ScratchWatch> = None;
167    for attempt in 0..REBUILD_ATTEMPTS {
168        let inputs: PrepareInputs<'_> = PrepareInputs {
169            scratch_name: scratch_name.as_str(),
170            allow_empty: invocation.region.allow_empty,
171            standard_input: standard_input.as_deref(),
172            first: attempt == 0,
173            replay: retry.as_ref().map(|captured| { return Retry { captured, attempt: u64::try_from(attempt).unwrap_or(u64::MAX) }; }),
174        };
175        let preparing: Instant = Instant::now();
176        let prepared: Result<Prepared, TransactionStop> = prepare(invocation, checks, request, &inputs);
177        timing.prepare += preparing.elapsed();
178        timing.attempts += 1;
179        let ready: Box<Ready> = match prepared? {
180            Prepared::Conflict(conflict) => {
181                // The message is kept so the person can commit again once their files agree with the commit that landed first.
182                let saved: PathBuf = conflict.git_dir.join(format!("cli-git-saved-message-{}", id.get(..8).unwrap_or(id.as_str())));
183                let kept: Option<&PathBuf> = last_message.as_ref().and_then(|text| {
184                    return match std::fs::write(&saved, text.as_str()) {
185                        Ok(()) => Some(&saved),
186                        Err(error) => {
187                            warn("serviceCommit", format!("saving the message failed: {error}").as_str());
188                            None
189                        }
190                    };
191                });
192                let result: TransactionResult = conflict_result(&conflict, kept.map(PathBuf::as_path));
193                return Ok(ServiceCommit { result, handoff: None });
194            }
195            Prepared::Ended(result) => {
196                // The policies could not read an object this commit had just written: something removed it meanwhile.
197                //  The captured bytes are gone, and the worktree is not read again to replace them.
198                if content_was_removed(result.events.as_slice()) {
199                    return Err(TransactionStop::Message(render(&ClientFailure::ContentPruned)));
200                }
201                return Ok(ServiceCommit { result, handoff: None });
202            }
203            Prepared::Ready(ready) => ready,
204        };
205        // The branch the first preparation found. A worktree that switched since (the service refuses the landing, and a
206        //  new preparation would read the new branch) is a failure like native Git's, with the typed message kept in its file.
207        let building: Instant = Instant::now();
208        if timing.reference.is_none() {
209            timing.reference = Some(ready.reference.clone());
210        }
211        let first_branch: &str = first_reference.get_or_insert_with(|| { return ready.reference.clone(); });
212        if first_branch != ready.reference {
213            let named = |reference: &str| -> String { return if reference == "HEAD" { String::from("a detached head") } else { reference.trim_start_matches("refs/heads/").to_string() }; };
214            let failure: ClientFailure = ClientFailure::BranchSwitched {
215                branch: named(first_branch),
216                now: named(ready.reference.as_str()),
217                file: fixed_message.as_ref().map(|file| { return file.display().to_string(); }),
218            };
219            return Err(TransactionStop::Message(render(&failure)));
220        }
221        if scratch_watch.is_none() {
222            scratch_watch = watch_scratch(&config, ready.inputs.capture.layout.git_dir.as_path(), ready.scratch.0.as_path());
223        }
224        if !decided {
225            decided = true;
226            if !ready.needs_native
227                && let Some(parsed) = parse_service_commit(after)
228            {
229                embedded = embedded_commit(invocation, parsed)?;
230            }
231        }
232        let native_inputs: &NativeInputs = &ready.inputs;
233        // An amend the service builds is made from the commit `HEAD` named when the message was read; if the branch left it
234        //  since, the amend is not made. Its tree is the private index's, and native Git refuses an amend that leaves the
235        //  commit empty against its first parent (a merge excepted) unless `--allow-empty` says it is meant, which Git then
236        //  says itself.
237        let mut tree: Option<String> = None;
238        if !ready.needs_native
239            && let Some(source) = embedded.as_ref().and_then(|made| { return made.source.as_ref(); })
240        {
241            if source.base != ready.base {
242                return Err(branch_moved(&ready));
243            }
244            let written = run_git_checked(
245                invocation.context,
246                &indexed_request(
247                    invocation.cwd,
248                    &["write-tree"],
249                    Some(native_inputs.commit_index.as_path()),
250                    Some(native_inputs.capture.layout.object_directory.as_path()),
251                ),
252            )?;
253            let made: String = String::from_utf8_lossy(written.stdout.as_slice()).trim().to_string();
254            let against: &str = source.parent_tree.as_deref().unwrap_or(native_inputs.capture.layout.empty_tree_oid.as_str());
255            if source.parents.len() < 2 && !invocation.region.allow_empty && made == against {
256                embedded = None;
257            } else {
258                tree = Some(made);
259            }
260        }
261        let native: bool = ready.needs_native || embedded.is_none();
262        let mut typed: Option<String> = None;
263        let (sent, first_parent): (CommitRequest, String) = match embedded.as_ref().filter(|_| { return !native; }) {
264            None => {
265                let layout_prefix: usize = super::global_arguments::global_layout(native_inputs.arguments.as_slice()).prefix_len;
266                let rebuilt_arguments: Option<Vec<OsString>> = fixed_message.as_deref().and_then(|file| { return with_fixed_message(native_inputs.arguments.as_slice(), layout_prefix, file); });
267                let built: GitBuilt = build_with_git(&BuildInput {
268                    context: invocation.context,
269                    cwd: invocation.cwd,
270                    capture: &native_inputs.capture,
271                    scratch: ready.scratch.0.as_path(),
272                    commit_index: native_inputs.commit_index.as_path(),
273                    arguments: rebuilt_arguments.as_deref().unwrap_or(native_inputs.arguments.as_slice()),
274                    pathspecs: native_inputs.pathspecs.as_slice(),
275                    mode: native_inputs.mode,
276                    staged_all: native_inputs.staged_all,
277                    selected_privately: native_inputs.selected_privately,
278                    // The summary is printed once the commit has landed, by this process.
279                    quiet: true,
280                })?;
281                let parent: String = built.parents.first().cloned().unwrap_or_default();
282                typed = Some(built.message.clone());
283                let sent: CommitRequest = CommitRequest {
284                    id: id.clone(),
285                    repository: ready.repository.clone(),
286                    common_dir: ready.common_dir.clone(),
287                    git_dir: native_inputs.capture.layout.git_dir.clone(),
288                    reference: ready.reference.clone(),
289                    base: ready.base.clone(),
290                    // The service names the index entries from the commit itself, so no list of changes travels.
291                    changes: Vec::new(),
292                    // The commit is Git's whole: message, identities and tree are in it.
293                    message: String::new(),
294                    author: None,
295                    committer: String::new(),
296                    amend: invocation.region.amend,
297                    tree: None,
298                    commit: Some(built.commit),
299                    cleanup: ready.cleanup.clone(),
300                    index: native_inputs.capture.layout.real_index_path.clone(),
301                    captured: ready.captured_at,
302                };
303                (sent, parent)
304            }
305            Some(made) => {
306                let sent: CommitRequest = CommitRequest {
307                    id: id.clone(),
308                    repository: ready.repository.clone(),
309                    common_dir: ready.common_dir.clone(),
310                    git_dir: native_inputs.capture.layout.git_dir.clone(),
311                    reference: ready.reference.clone(),
312                    base: ready.base.clone(),
313                    changes: ready.changes.clone().unwrap_or_default(),
314                    message: made.message.clone(),
315                    author: Some(made.author.clone()),
316                    committer: made.committer.clone(),
317                    amend: tree.is_some(),
318                    tree: tree.clone(),
319                    commit: None,
320                    cleanup: None,
321                    index: native_inputs.capture.layout.real_index_path.clone(),
322                    captured: ready.captured_at,
323                };
324                // The new commit's first parent: the tip it was made on, or for an amend the replaced commit's own parent.
325                let parent: String = match made.source.as_ref() {
326                    Some(replaced) => replaced.parents.first().cloned().unwrap_or_default(),
327                    None => ready.base.clone(),
328                };
329                (sent, parent)
330            }
331        };
332        last_message = typed.clone().or_else(|| { return embedded.as_ref().filter(|_| { return !native; }).map(|made| { return made.message.clone(); }); });
333        // A test removes the prepared content here, between the preparation and the landing.
334        timing.build += building.elapsed();
335        reach_service_phase(Phase::ServicePrepared)?;
336        // The first commit that reaches the service sets up its socket activation, and only then: a commit that was
337        //  declined, blocked by a policy, or abandoned in the editor never needs it, and the manager starts the service.
338        if let Some(host) = provisioning.as_ref().filter(|_| { return !provisioned; }) {
339            let provisioning_began: Instant = Instant::now();
340            let provided = provision(host);
341            timing.provision += provisioning_began.elapsed();
342            provided.map_err(|failure| { return TransactionStop::Message(render(&failure)); })?;
343            provisioned = true;
344        }
345        match submit_traced(&config, &sent, &mut std::io::stderr(), &mut timing.submit) {
346            Ok(Submitted::Landed { commit, .. }) => {
347                let after_landing: Instant = Instant::now();
348                if let Some(file) = fixed_message.as_ref() {
349                    // The commit was made from the saved message; the file has done its work.
350                    let _removed = std::fs::remove_file(file);
351                }
352                // One summary line, printed once the branch moved, so it names the commit that landed. Builds made by Git are told to
353                //  print none, because the build that lands is not always the first one.
354                let embedded_made: Option<&Embedded> = embedded.as_ref().filter(|_| { return !native; });
355                let quiet: bool = embedded_made.map_or_else(|| { return asks_for_quiet(after); }, |made| { return made.quiet; });
356                if !quiet {
357                    let message: &str = embedded_made.map(|made| { return made.message.as_str(); }).or(typed.as_deref()).unwrap_or("");
358                    let name: String = short_name(
359                        invocation.context,
360                        invocation.cwd,
361                        native_inputs.capture.layout.object_directory.as_path(),
362                        native_inputs.capture.layout.config_snapshot.as_ref(),
363                        commit.as_str(),
364                    );
365                    print_summary(summary_line(ready.reference.as_str(), first_parent.as_str(), name.as_str(), subject_of(message).as_str()).as_str());
366                    if embedded_made.is_none() {
367                        // Git's own build printed what changed; it was told to be quiet, so the same lines are asked once.
368                        let changes: String = change_summary(invocation.context, invocation.cwd, first_parent.as_str(), commit.as_str());
369                        if !changes.is_empty() {
370                            print_summary(changes.as_str());
371                        }
372                    }
373                }
374                // The corrections a policy made reach the worktree once the commit has landed, as the earlier landing did.
375                install_added_worktree_files(
376                    invocation.context,
377                    invocation.cwd,
378                    ready.repository.as_path(),
379                    ready.worktree_records.as_slice(),
380                    Some(native_inputs.capture.layout.object_directory.as_path()),
381                )
382                .map_err(|error| { return TransactionStop::Message(error.0); })?;
383                let handoff: ServiceHandoff = ServiceHandoff {
384                    config: config.clone(),
385                    repository: ready.repository.clone(),
386                    git_dir: native_inputs.capture.layout.git_dir.clone(),
387                    reference: ready.reference.clone(),
388                    parent: first_parent,
389                };
390                let result: TransactionResult = TransactionResult { events: ready.events, exit_code: 0, landed_oid: Some(commit), uses_lfs: ready.uses_lfs };
391                timing.after_landing += after_landing.elapsed();
392                return Ok(ServiceCommit { result, handoff: Some(handoff) });
393            }
394            Ok(Submitted::PrepareAgain { landed, .. }) => {
395                if native || sent.amend {
396                    // An amend, and a commit Git built, are never sent for another tip; the service answers a moved branch as a failure.
397                    return Err(branch_moved(&ready));
398                }
399                if attempt + 1 == LANDING_ATTEMPTS {
400                    let branch: String = ready.reference.trim_start_matches("refs/heads/").to_string();
401                    return Err(TransactionStop::Message(render(&ClientFailure::BranchKeepsChanging { branch, times: LANDING_ATTEMPTS })));
402                }
403                // What this preparation captured is merged onto the branch by the next one.
404                retry = Some(retry_of(invocation.context, invocation.cwd, &ready, landed)?);
405            }
406            Ok(Submitted::Moved) => {
407                let branch: String = ready.reference.trim_start_matches("refs/heads/").to_string();
408                // Nobody typed or chose anything, and the commit does not depend on the tip it was made on: Git builds it
409                //  again on the new tip (and signs it again), up to the bound.
410                if native && !sent.amend && !editor_possible && !ready.interactive && !ready.fixed_parents {
411                    if attempt + 1 == REBUILD_ATTEMPTS {
412                        return Err(TransactionStop::Message(render(&ClientFailure::BranchKeepsChanging { branch, times: REBUILD_ATTEMPTS })));
413                    }
414                    // Git builds it again from the tree this build captured, not from the worktree.
415                    retry = Some(retry_of(invocation.context, invocation.cwd, &ready, Vec::new())?);
416                    continue;
417                }
418                if native && editor_possible && let Some(text) = typed.as_deref() {
419                    // The person typed this message: keep it where they can reuse it.
420                    let file: PathBuf = native_inputs.capture.layout.git_dir.join(format!("cli-git-saved-message-{}", id.get(..8).unwrap_or(id.as_str())));
421                    match std::fs::write(&file, text) {
422                        Ok(()) => {
423                            // After the editor the message is fixed: while the bound allows, Git builds the commit again on the new
424                            //  tip from `-F <file>`, with no editor and no person, unless the line borrows its author or its parents.
425                            let prefix: usize = super::global_arguments::global_layout(native_inputs.arguments.as_slice()).prefix_len;
426                            let repeatable: bool = !sent.amend
427                                && !ready.interactive
428                                && !ready.fixed_parents
429                                && with_fixed_message(native_inputs.arguments.as_slice(), prefix, file.as_path()).is_some();
430                            if repeatable && attempt + 1 < REBUILD_ATTEMPTS {
431                                fixed_message = Some(file);
432                                retry = Some(retry_of(invocation.context, invocation.cwd, &ready, Vec::new())?);
433                                continue;
434                            }
435                            let failure: ClientFailure = ClientFailure::BranchMovedMessageSaved { branch, file: file.display().to_string(), amend: sent.amend };
436                            return Err(TransactionStop::Message(render(&failure)));
437                        }
438                        Err(error) => warn("serviceCommit", format!("saving the typed message failed: {error}").as_str()),
439                    }
440                }
441                return Err(branch_moved(&ready));
442            }
443            Err(ClientFailure::Unavailable)
444                if prepared_content_missing(
445                    native_inputs.capture.layout.common_dir.as_path(),
446                    native_inputs.capture.layout.object_directory.as_path(),
447                    native_inputs.capture.layout.object_format.as_str(),
448                    &sent,
449                ) =>
450            {
451                // The service could not land the request because an object it names is gone from the repository, and the
452                //  worktree is not read again to make it.
453                return Err(TransactionStop::Message(render(&ClientFailure::ContentPruned)));
454            }
455            Err(failure) => return Err(TransactionStop::Message(render(&failure))),
456        }
457    }
458    return Err(TransactionStop::Message(render(&ClientFailure::BranchKeepsChanging { branch: String::from("the branch"), times: REBUILD_ATTEMPTS })));
459}
460
461/// What:
462///  Writes the summary line of a landed commit and ends the command as Git does when it cannot be written.
463/// Why:
464///   Git prints its summary after the branch moved and dies with `SIGPIPE` when the reader is gone and with a write error when
465///  the device is full or closed, so a command whose output is broken ends the same way whichever program built the commit.
466///
467/// In TS you'd write (pseudocode):
468/// ```ts
469/// printSummary(line)
470/// ```
471fn print_summary(line: &str) {
472    let mut out = std::io::stdout();
473    let written = writeln!(out, "{line}").and_then(|()| { return out.flush(); });
474    let Err(error) = written else { return };
475    if error.kind() == std::io::ErrorKind::BrokenPipe {
476        // SAFETY: restoring the default action of a signal and raising it takes no pointers.
477        unsafe {
478            libc::signal(libc::SIGPIPE, libc::SIG_DFL);
479            libc::raise(libc::SIGPIPE);
480        }
481    }
482    let _reported = writeln!(std::io::stderr(), "fatal: write error: {error}");
483    std::process::exit(128);
484}
485
486/// What the embedded route made of a command line: the message, the identities and, for an amend, the replaced commit.
487struct Embedded {
488    message: String,
489    author: String,
490    committer: String,
491    source: Option<AmendSource>,
492    quiet: bool,
493}
494
495/// What:
496///  Builds the message and the identities of a command line the service covers, the way native Git would.
497/// Why:
498///   Returns nothing when Git alone can say what the line means (an unreadable message file, a date it does not read, no
499///  identity configured, an empty message), and Git then builds the commit and gives the person its own words.
500///
501/// In TS you'd write (pseudocode):
502/// ```ts
503/// await embeddedCommit(invocation, parsed) // undefined when Git must build it
504/// ```
505fn embedded_commit(invocation: &CommitInvocation<'_>, parsed: ServiceCommitArgs) -> Result<Option<Embedded>, TransactionStop> {
506    let Some(committer) = identity(invocation.context, invocation.cwd, "GIT_COMMITTER_IDENT", &[])? else { return Ok(None) };
507    let source: Option<AmendSource> = if parsed.amend {
508        let Some(read) = read_amend_source(invocation.context, invocation.cwd)? else { return Ok(None) };
509        Some(read)
510    } else {
511        None
512    };
513    let previous: Option<&str> = source.as_ref().map(|found| { return found.message.as_str(); });
514    let Some(built) = build_message(invocation, &parsed, committer.as_str(), previous)? else { return Ok(None) };
515    let author: String = match built.reused_author {
516        Some(reused) => reused,
517        None => match source.as_ref() {
518            Some(replaced) => {
519                let Some(made) = amended_author(invocation.context, invocation.cwd, replaced, &parsed)? else { return Ok(None) };
520                made
521            }
522            None => {
523                let Some(made) = identity(invocation.context, invocation.cwd, "GIT_AUTHOR_IDENT", author_overrides(&parsed).as_slice())? else {
524                    return Ok(None);
525                };
526                made
527            }
528        },
529    };
530    return Ok(Some(Embedded { message: built.message, author, committer, source, quiet: parsed.quiet }));
531}
532
533/// Service commit controls stay out of the release executable.
534#[cfg(test)]
535#[path = "service_commit_tests.rs"]
536mod tests;