Skip to main content

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;