git_policy_cli/worktree_copy_forward.rs
1//! What:
2//! Running a forwarded Git command through the linked-worktree ignored-state copy:
3//! which invocations the copy applies to, and the child-process run that carries the settlement lease.
4//! Why:
5//! A command that creates or moves worktrees must stay under the settlement lock while real Git runs and the
6//! ignored state is copied, and a command run from a linked worktree must first recover an interrupted copy;
7//! replacing the process with Git would drop both duties, so these invocations run Git as a child.
8//! The main checkout is a source only for the configured rules file, so it takes this path only when the
9//! command creates a worktree and the configuration names that file.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! await runGitWithWorktreeCopy({ args, gitPath, identity, command, environment });
14//! ```
15
16use super::forwarding::{ChildOutcome, exit_code as child_exit_code, run_real_git};
17use super::worktree_identity::WorktreeIdentity;
18use std::ffi::OsString;
19use std::path::{Path, PathBuf};
20
21/// What:
22/// Everything the copy needs to know about one forwarded invocation, decided before Git starts.
23/// Why:
24/// The plan decides that the copy applies; the run performs it with real Git and the process environment.
25///
26/// In TS you'd write (pseudocode):
27/// ```ts
28/// type CopyForward = { identity: GitWorktreeIdentity; command: ResolvedGitCommand; mainCheckoutPaths: string[] };
29/// ```
30#[derive(Clone, Debug, Eq, PartialEq)]
31pub struct CopyFacts {
32 /// The repository shape the invocation selects.
33 pub identity: WorktreeIdentity,
34 /// Whether the command, after alias resolution, creates or moves worktrees.
35 pub creates_or_moves: bool,
36 /// Repository paths a main-checkout source hands over (the configured rules file).
37 pub main_checkout_paths: Vec<String>,
38 /// The invocation's global options, replayed before the repository queries that ask for the identity again.
39 pub global_prefix: Vec<OsString>,
40 /// The environment the invocation inherited, which may carry an outer settlement's lease.
41 pub environment: Vec<(OsString, OsString)>,
42}
43
44/// What:
45/// Whether the copy applies to an invocation.
46/// Why:
47/// Linked worktrees and bare repositories always take the lifecycle (recovery, lease check, creation);
48/// the main checkout only when it creates a worktree and has paths to hand over, so its other commands
49/// keep their plain forwarding and cost no filesystem reads.
50///
51/// In TS you'd write (pseudocode):
52/// ```ts
53/// identity.kind !== 'outside-worktree' && (identity.kind !== 'main-worktree' || (createsOrMoves && paths.length > 0))
54/// ```
55pub fn copy_applies(
56 identity: &WorktreeIdentity,
57 creates_or_moves: bool,
58 main_checkout_paths: &[String],
59) -> bool {
60 // `match` on the repository shape; `||` keeps the main checkout's two conditions together.
61 match identity {
62 WorktreeIdentity::OutsideWorktree => return false,
63 WorktreeIdentity::MainWorktree { .. } => {
64 return creates_or_moves && !main_checkout_paths.is_empty();
65 }
66 WorktreeIdentity::LinkedWorktree { .. } | WorktreeIdentity::BareRepository { .. } => {
67 return true;
68 }
69 }
70}
71
72/// What:
73/// How one forwarded run ended.
74/// Why:
75/// Only a run that failed before or after Git needs its own text; otherwise the status is Git's.
76///
77/// In TS you'd write (pseudocode):
78/// ```ts
79/// type ForwardedRun = { exitCode: number } | { stderr: string; exitCode: number };
80/// ```
81#[derive(Clone, Debug, Eq, PartialEq)]
82pub enum ForwardedRun {
83 /// Git ran (and the copy, when one applied, finished); this is the exit status.
84 Ran(i32),
85 /// The run could not start or settle; the standard error text and exit status.
86 Failed {
87 /// Text for standard error.
88 stderr: String,
89 /// Exit status.
90 code: i32,
91 },
92}
93
94/// What:
95/// The directory the repository queries of the copy run in.
96/// Why:
97/// Every query names absolute paths, so any directory of the repository works; the worktree root exists whenever
98/// the identity has a worktree, and a bare repository's common directory is itself a repository.
99///
100/// In TS you'd write (pseudocode):
101/// ```ts
102/// identity.effectiveCwd
103/// ```
104#[cfg(unix)]
105fn query_directory(identity: &WorktreeIdentity) -> PathBuf {
106 // `match` on the repository shape: a worktree root, or the bare repository's directory.
107 match identity {
108 WorktreeIdentity::MainWorktree { worktree_root, .. }
109 | WorktreeIdentity::LinkedWorktree { worktree_root, .. } => return worktree_root.clone(),
110 WorktreeIdentity::BareRepository { common_dir, .. } => return common_dir.clone(),
111 WorktreeIdentity::OutsideWorktree => return PathBuf::new(),
112 }
113}
114
115/// What:
116/// Runs real Git as a child with the given variables and reports how it ended.
117/// Why:
118/// One start for the plain and the settled runs, so both report an unstartable Git the same way.
119///
120/// In TS you'd write (pseudocode):
121/// ```ts
122/// await nanoSpawn(gitPath, args, { env, stdio: 'inherit' });
123/// ```
124fn run_plain(
125 real_git: &Path,
126 arguments: &[OsString],
127 overlay: &[(OsString, OsString)],
128) -> ForwardedRun {
129 // `match` on the start result: the status is Git's, and an unstartable Git is an engine failure.
130 match run_real_git(real_git, arguments, overlay) {
131 Ok(outcome) => return ForwardedRun::Ran(child_exit_code(outcome)),
132 Err(error) => {
133 return ForwardedRun::Failed {
134 stderr: format!("cli-git: cannot start {}: {error}\n", real_git.display()),
135 code: super::action::ENGINE_FAILURE_EXIT_CODE,
136 };
137 }
138 }
139}
140
141/// What:
142/// Runs the forwarded Git command, settling the ignored-state copy around it when one applies.
143/// Why:
144/// Git's status is the answer unless the copy failed: a copy failure after a successful Git is status 2,
145/// after a failed Git it keeps Git's status, and a failure of the lifecycle itself is status 2, each with its message.
146///
147/// In TS you'd write (pseudocode):
148/// ```ts
149/// await runGitWithWorktreeCopy({ args, gitPath, identity, command, environment });
150/// ```
151pub fn run_forwarded_git(
152 real_git: &Path,
153 arguments: &[OsString],
154 overlay: &[(OsString, OsString)],
155 copy: Option<&CopyFacts>,
156) -> ForwardedRun {
157 #[cfg(unix)]
158 {
159 if let Some(facts) = copy {
160 return run_settled(real_git, arguments, overlay, facts);
161 }
162 }
163 // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
164 #[cfg(not(unix))]
165 {
166 let _ = copy;
167 }
168 return run_plain(real_git, arguments, overlay);
169}
170
171/// What:
172/// The settled run: the lifecycle with real Git as its runner and the process's standard error as its stream.
173/// Why:
174/// The runner receives the lease token to put into the child's environment while the settlement lock is held.
175///
176/// In TS you'd write (pseudocode):
177/// ```ts
178/// await runGitWithWorktreeCopy({ args, gitPath, identity, command, environment });
179/// ```
180#[cfg(unix)]
181fn run_settled(
182 real_git: &Path,
183 arguments: &[OsString],
184 overlay: &[(OsString, OsString)],
185 facts: &CopyFacts,
186) -> ForwardedRun {
187 use super::child_environment::environment_value;
188 use std::io::Write;
189 use super::repository_facts::{RepositoryFacts, git_facts};
190 use super::transaction_git::GitContext;
191 use super::worktree_copy::{GitStatus, Invocation, exit_code, run_with_worktree_copy};
192 use super::worktree_copy_error::WorktreeCopyError;
193 use super::worktree_copy_lock::WORKTREE_COPY_LEASE_ENV;
194
195 let context: GitContext = GitContext {
196 real_git: real_git.to_path_buf(),
197 overlay: overlay.to_vec(),
198 // Every copy query names absolute paths and leaves the caller's global options out.
199 global_prefix: Vec::new(),
200 };
201 let invocation: Invocation = Invocation {
202 identity: facts.identity.clone(),
203 effective_cwd: query_directory(&facts.identity),
204 creates_or_moves: facts.creates_or_moves,
205 opted_out: false,
206 short_circuit: false,
207 inherited_token: environment_value(facts.environment.as_slice(), WORKTREE_COPY_LEASE_ENV)
208 .map(|token| return token.to_string_lossy().into_owned()),
209 context,
210 main_checkout_paths: facts.main_checkout_paths.clone(),
211 };
212 let mut stderr = std::io::stderr();
213 // The runner starts Git as a child with the lease token, so nested wrappers see the lock they are inside.
214 let mut run_git = |token: Option<&str>| -> Result<GitStatus, WorktreeCopyError> {
215 let mut child_overlay: Vec<(OsString, OsString)> = overlay.to_vec();
216 if let Some(value) = token {
217 child_overlay.push((OsString::from(WORKTREE_COPY_LEASE_ENV), OsString::from(value)));
218 }
219 match run_real_git(real_git, arguments, child_overlay.as_slice()) {
220 Ok(ChildOutcome::Exited(code)) => return Ok(GitStatus { code: Some(code) }),
221 Ok(ChildOutcome::Signaled(_)) => return Ok(GitStatus { code: None }),
222 Err(error) => {
223 return Err(WorktreeCopyError(format!(
224 "cli-git: cannot start {}: {error}",
225 real_git.display()
226 )));
227 }
228 }
229 };
230 // The identity is asked again once the lock is held, as the incumbent re-observes its repository.
231 let mut identity_after_lock = || -> Result<WorktreeIdentity, WorktreeCopyError> {
232 let mut facts_again = git_facts(real_git, facts.global_prefix.as_slice(), overlay);
233 match facts_again.location() {
234 Ok(location) => return Ok(location.identity),
235 Err(message) => return Err(WorktreeCopyError(format!("cli-git: {message}"))),
236 }
237 };
238 match run_with_worktree_copy(&invocation, &mut run_git, &mut identity_after_lock, &mut stderr) {
239 Ok(settlement) => {
240 if let Some(failure) = &settlement.copy_failure {
241 // A closed standard error leaves nowhere to report the failure, and the exit status still carries it.
242 let _ = writeln!(stderr, "{}", failure.0);
243 }
244 return ForwardedRun::Ran(exit_code(&settlement));
245 }
246 Err(failure) => {
247 return ForwardedRun::Failed {
248 stderr: format!("{}\n", failure.0),
249 code: super::action::ENGINE_FAILURE_EXIT_CODE,
250 };
251 }
252 }
253}
254
255/// Forwarding-run controls stay out of the release executable.
256#[cfg(test)]
257#[path = "worktree_copy_forward_tests.rs"]
258mod tests;