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 #[cfg(not(unix))]
164 {
165 let _ = copy;
166 }
167 return run_plain(real_git, arguments, overlay);
168}
169
170/// What:
171/// The settled run: the lifecycle with real Git as its runner and the process's standard error as its stream.
172/// Why:
173/// The runner receives the lease token to put into the child's environment while the settlement lock is held.
174///
175/// In TS you'd write (pseudocode):
176/// ```ts
177/// await runGitWithWorktreeCopy({ args, gitPath, identity, command, environment });
178/// ```
179#[cfg(unix)]
180fn run_settled(
181 real_git: &Path,
182 arguments: &[OsString],
183 overlay: &[(OsString, OsString)],
184 facts: &CopyFacts,
185) -> ForwardedRun {
186 use super::child_environment::environment_value;
187 use std::io::Write;
188 use super::repository_facts::{RepositoryFacts, git_facts};
189 use super::transaction_git::GitContext;
190 use super::worktree_copy::{GitStatus, Invocation, exit_code, run_with_worktree_copy};
191 use super::worktree_copy_error::WorktreeCopyError;
192 use super::worktree_copy_lock::WORKTREE_COPY_LEASE_ENV;
193
194 let context: GitContext = GitContext {
195 real_git: real_git.to_path_buf(),
196 overlay: overlay.to_vec(),
197 // Every copy query names absolute paths and leaves the caller's global options out.
198 global_prefix: Vec::new(),
199 };
200 let invocation: Invocation = Invocation {
201 identity: facts.identity.clone(),
202 effective_cwd: query_directory(&facts.identity),
203 creates_or_moves: facts.creates_or_moves,
204 opted_out: false,
205 short_circuit: false,
206 inherited_token: environment_value(facts.environment.as_slice(), WORKTREE_COPY_LEASE_ENV)
207 .map(|token| return token.to_string_lossy().into_owned()),
208 context,
209 main_checkout_paths: facts.main_checkout_paths.clone(),
210 };
211 let mut stderr = std::io::stderr();
212 // The runner starts Git as a child with the lease token, so nested wrappers see the lock they are inside.
213 let mut run_git = |token: Option<&str>| -> Result<GitStatus, WorktreeCopyError> {
214 let mut child_overlay: Vec<(OsString, OsString)> = overlay.to_vec();
215 if let Some(value) = token {
216 child_overlay.push((OsString::from(WORKTREE_COPY_LEASE_ENV), OsString::from(value)));
217 }
218 match run_real_git(real_git, arguments, child_overlay.as_slice()) {
219 Ok(ChildOutcome::Exited(code)) => return Ok(GitStatus { code: Some(code) }),
220 Ok(ChildOutcome::Signaled(_)) => return Ok(GitStatus { code: None }),
221 Err(error) => {
222 return Err(WorktreeCopyError(format!(
223 "cli-git: cannot start {}: {error}",
224 real_git.display()
225 )));
226 }
227 }
228 };
229 // The identity is asked again once the lock is held, as the incumbent re-observes its repository.
230 let mut identity_after_lock = || -> Result<WorktreeIdentity, WorktreeCopyError> {
231 let mut facts_again = git_facts(real_git, facts.global_prefix.as_slice(), overlay);
232 match facts_again.location() {
233 Ok(location) => return Ok(location.identity),
234 Err(message) => return Err(WorktreeCopyError(format!("cli-git: {message}"))),
235 }
236 };
237 match run_with_worktree_copy(&invocation, &mut run_git, &mut identity_after_lock, &mut stderr) {
238 Ok(settlement) => {
239 if let Some(failure) = &settlement.copy_failure {
240 // A closed standard error leaves nowhere to report the failure, and the exit status still carries it.
241 let _ = writeln!(stderr, "{}", failure.0);
242 }
243 return ForwardedRun::Ran(exit_code(&settlement));
244 }
245 Err(failure) => {
246 return ForwardedRun::Failed {
247 stderr: format!("{}\n", failure.0),
248 code: super::action::ENGINE_FAILURE_EXIT_CODE,
249 };
250 }
251 }
252}
253
254/// Forwarding-run controls stay out of the release executable.
255#[cfg(test)]
256#[path = "worktree_copy_forward_tests.rs"]
257mod tests;