git_policy_cli/startup_gate.rs
1//! What:
2//! The startup step of a wrapped, read-only or direct command: settle an isolated merge whose process died, stop on a
3//! commit journal of the previous executable's format, and mention commit records an earlier release left behind.
4//! Why:
5//! A commit is landed by the resident service, which recovers its own journal, so no commit transaction exists to
6//! recover. What stays is state other code still writes (the intent record of an isolated merge) and state an earlier
7//! release wrote (the single-journal directory and the registry of commit transactions): the first is settled before
8//! anything reads the checkout, the others are named to the person, because nothing here reads them and an interrupted
9//! commit of that release is for the person to look at. An empty registry costs one directory read and no Git process.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! await settleInterruptedMerges({ identity }); // throws the stop
14//! ```
15
16use super::diagnostic_log::debug;
17use super::diagnostics::{EngineFailureCode, render_engine_failure_with_hint};
18use super::merge_isolation_recovery::{RecoveryPlace, merge_intents_to_recover, recover_interrupted_merges};
19use super::random_id::is_canonical_uuid;
20use super::recovery_error::RecoveryError;
21use super::recovery_files::recovery_path_exists;
22use super::transaction_git::GitContext;
23use super::worktree_identity::WorktreeIdentity;
24use std::io::Write;
25use std::path::{Path, PathBuf};
26
27/// The directory an earlier release kept its commit transactions in, inside the worktree's Git directory.
28pub const REGISTRY_DIRECTORY_NAME: &str = "cli-git-transactions";
29
30/// The directory the previous executable kept one commit journal in, inside the worktree's Git directory.
31pub const LEGACY_JOURNAL_DIRECTORY_NAME: &str = "cli-git-transaction";
32
33/// What:
34/// How a command may go on after the startup step.
35/// Why:
36/// Callers continue or stop from this.
37///
38/// In TS you'd write (pseudocode):
39/// ```ts
40/// type Gate = { kind: 'clear' } | { kind: 'stop'; stderr: string };
41/// ```
42#[derive(Clone, Debug, Eq, PartialEq)]
43pub enum Gate {
44 /// The command goes on.
45 Clear,
46 /// The command stops; this is its standard error.
47 Stop(String),
48}
49
50/// What:
51/// The message for a commit journal of the previous executable's format.
52/// Why:
53/// The owner chose to stop with instructions instead of porting that recovery
54/// (`doc/planning/cli-git-rust-open-decisions.md`, "Legacy single-journal directory").
55///
56/// In TS you'd write (pseudocode):
57/// ```ts
58/// `cli-git: ${directory} holds a commit journal in a format this executable does not recover. ...`
59/// ```
60pub fn legacy_notice(directory: &Path) -> String {
61 return format!(
62 "cli-git: {} holds a commit journal in a format this executable does not recover. \
63 Nothing was changed. Run any git command once with the previous cli-git executable to \
64 recover it, then run this command again.\n",
65 directory.display()
66 );
67}
68
69/// What:
70/// The path as one shell word that a copied command line reads back as exactly that path.
71/// Why:
72/// The note below gives a command to paste, and a Git directory can sit under a name with spaces, quotes or bytes that are
73/// not UTF-8; a word cut by one of them would remove a different path. Text goes in single quotes, where only the quote
74/// itself needs the `'\''` spelling; bytes that are not UTF-8 go in the `$'...'` form that bash, zsh and ksh read, with
75/// octal escapes for every byte outside plain printable ASCII.
76///
77/// In TS you'd write (pseudocode):
78/// ```ts
79/// `'${path.replaceAll("'", "'\\''")}'`
80/// ```
81pub fn shell_word(path: &Path) -> String {
82 if let Some(text) = path.to_str() {
83 return format!("'{}'", text.replace('\'', "'\\''"));
84 }
85 #[cfg(unix)]
86 {
87 /// The Unix view of a path as raw bytes.
88 use std::os::unix::ffi::OsStrExt;
89 let mut word: String = String::from("$'");
90 for byte in path.as_os_str().as_bytes() {
91 match *byte {
92 b'\\' => word.push_str("\\\\"),
93 b'\'' => word.push_str("\\'"),
94 0x20..=0x7e => word.push(char::from(*byte)),
95 other => word.push_str(format!("\\{other:03o}").as_str()),
96 }
97 }
98 word.push('\'');
99 return word;
100 }
101 // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
102 #[cfg(not(unix))]
103 {
104 return format!("'{}'", path.to_string_lossy().replace('\'', "'\\''"));
105 }
106}
107
108/// What:
109/// The note about commit records an earlier release left in a registry directory, or nothing when it holds no transaction.
110/// Why:
111/// A transaction directory there is a commit that release did not finish. This release does not read it or use the
112/// directory for anything, so the person is told where it is, how to see whether the commit was made, and the exact command
113/// that removes the directory; lock directories and staging names carry nothing to look at.
114///
115/// In TS you'd write (pseudocode):
116/// ```ts
117/// (await readdir(registry)).some(isTransactionId) ? note(registry) : undefined
118/// ```
119pub fn registry_note(registry: &Path) -> Option<String> {
120 let entries: std::fs::ReadDir = std::fs::read_dir(registry).ok()?;
121 let left: bool = entries.flatten().any(|entry| { return is_canonical_uuid(entry.file_name().to_string_lossy().as_ref()); });
122 if !left {
123 return None;
124 }
125 return Some(format!(
126 "cli-git: {} holds commit records an earlier release left unfinished. This release no longer uses that directory and \
127 does not read it. Run `git log -1` and `git status` to see whether that commit was made. Once you have, remove the \
128 directory with: rm --recursive --force -- {}\n",
129 registry.display(),
130 shell_word(registry)
131 ));
132}
133
134/// What:
135/// The Git directory of the worktree an identity names, or nothing outside a worktree and in a bare repository.
136/// Why:
137/// Only worktrees hold merge records and commit records.
138///
139/// In TS you'd write (pseudocode):
140/// ```ts
141/// identity.kind === 'outside-worktree' || identity.kind === 'bare-repository' ? undefined : [identity.gitDir, identity.commonDir]
142/// ```
143fn worktree_directories(identity: &WorktreeIdentity) -> Option<(&Path, &Path)> {
144 match identity {
145 WorktreeIdentity::OutsideWorktree | WorktreeIdentity::BareRepository { .. } => return None,
146 WorktreeIdentity::MainWorktree { git_dir, common_dir, .. } | WorktreeIdentity::LinkedWorktree { git_dir, common_dir, .. } => {
147 return Some((git_dir.as_path(), common_dir.as_path()));
148 }
149 }
150}
151
152/// What:
153/// Settle the isolated merges of the worktree whose process died.
154/// Why:
155/// A merge killed between Git's checkout update and the branch move leaves the checkout half done, and the next command
156/// would read it as another session's staged work. A record of a merge that still runs is left alone.
157///
158/// In TS you'd write (pseudocode):
159/// ```ts
160/// if (await mergeIntentsToRecover(gitDir)) await recoverInterruptedMerges({ place, gitDir });
161/// ```
162fn settle_merges(context: &GitContext, git_dir: &Path, common_dir: &Path) -> Result<(), RecoveryError> {
163 if !merge_intents_to_recover(git_dir)? {
164 return Ok(());
165 }
166 let place: RecoveryPlace = RecoveryPlace {
167 git: context.clone(),
168 cwd: PathBuf::from("."),
169 common_dir: common_dir.to_path_buf(),
170 };
171 let outcomes = recover_interrupted_merges(&place, git_dir)?;
172 if !outcomes.is_empty() {
173 debug("startupGate", format!("isolated merge recovery outcomes: {outcomes:?}").as_str());
174 }
175 return Ok(());
176}
177
178/// What:
179/// The startup step for the invocation's worktree, and how the command may go on.
180/// Why:
181/// Shared by wrapped commands, read-only commands and direct commands. A recovery failure stops the command with exit 2,
182/// as the incumbent's `content-unavailable` engine failure does; the note about an earlier release's records goes to
183/// standard error and does not stop it.
184///
185/// In TS you'd write (pseudocode):
186/// ```ts
187/// async function recoverForCommand({ gitPath, identity }): Promise<Gate>;
188/// ```
189pub fn recover_for_command(context: &GitContext, identity: &WorktreeIdentity) -> Gate {
190 let Some((git_dir, common_dir)) = worktree_directories(identity) else {
191 return Gate::Clear;
192 };
193 let legacy: PathBuf = git_dir.join(LEGACY_JOURNAL_DIRECTORY_NAME);
194 match recovery_path_exists(legacy.as_path()) {
195 Ok(true) => return Gate::Stop(legacy_notice(legacy.as_path())),
196 Ok(false) => {}
197 Err(error) => return stopped(&error),
198 }
199 if let Err(error) = settle_merges(context, git_dir, common_dir) {
200 return stopped(&error);
201 }
202 if let Some(note) = registry_note(git_dir.join(REGISTRY_DIRECTORY_NAME).as_path()) {
203 let _noted = std::io::stderr().write_all(note.as_bytes());
204 }
205 return Gate::Clear;
206}
207
208/// What:
209/// The stop for a failed read or recovery.
210/// Why:
211/// The code stays `content-unavailable` (state that could not be read), but the generic hint for that code tells the person
212/// to fetch history, which does not apply: the message itself names the file or directory and the ways out.
213///
214/// In TS you'd write (pseudocode):
215/// ```ts
216/// renderEngineFailure({ code: 'content-unavailable', message, hint: undefined })
217/// ```
218fn stopped(error: &RecoveryError) -> Gate {
219 return Gate::Stop(render_engine_failure_with_hint(0, EngineFailureCode::ContentUnavailable, error.0.as_str(), None));
220}
221
222/// Startup step controls stay out of the release executable.
223#[cfg(test)]
224#[path = "startup_gate_tests.rs"]
225mod tests;