Skip to main content

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