git_policy_cli/worktree_copying/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 performs it.
4//! Why:
5//! A command that creates a worktree must copy the ignored state once real Git returns, and a command run from a linked
6//! worktree must first recover a copy a dead process left; replacing the process with Git would drop both duties, so
7//! these invocations run Git as a child. No lock is held: concurrent commands never wait for one another.
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 crate::forwarding::{ChildOutcome, exit_code as child_exit_code, run_real_git};
17use crate::worktree_add_plan::AddPlan;
18use crate::worktree_remove_plan::RemovePlan;
19use crate::worktree_identity::WorktreeIdentity;
20use std::ffi::OsString;
21use std::path::{Path, PathBuf};
22
23/// What:
24/// Everything the copy needs to know about one forwarded invocation, decided before Git starts.
25/// Why:
26/// The plan decides that the copy applies; the run performs it with real Git and the process environment.
27///
28/// In TS you'd write (pseudocode):
29/// ```ts
30/// type CopyForward = { identity: GitWorktreeIdentity; command: ResolvedGitCommand; mainCheckoutPaths: string[] };
31/// ```
32#[derive(Clone, Debug, Eq, PartialEq)]
33pub struct CopyFacts {
34 /// The repository shape the invocation selects.
35 pub identity: WorktreeIdentity,
36 /// Whether the command, after alias resolution, creates a worktree.
37 pub creates: bool,
38 /// The destination the creating command's line names, in the form Git records it; absent for every other command and for
39 /// a form of `git worktree add` whose destination is not read.
40 pub destination: Option<PathBuf>,
41 /// The destination of a `git worktree add` in a form the cheap-worktree ladder serves; absent for every other
42 /// command and every form the ladder does not know.
43 pub add_plan: Option<AddPlan>,
44 /// The directory of a `git worktree remove` in the plain form the wrapper may serve with a subvolume destroy;
45 /// absent for every other command.
46 pub remove_plan: Option<RemovePlan>,
47}
48
49/// What:
50/// Whether the copy applies to an invocation.
51/// Why:
52/// Linked worktrees and bare repositories always take the lifecycle (recovery, creation);
53/// the main checkout only when it creates a worktree and has paths to hand over, so its other commands
54/// keep their plain forwarding and cost no filesystem reads.
55///
56/// In TS you'd write (pseudocode):
57/// ```ts
58/// identity.kind !== 'outside-worktree' && (identity.kind !== 'main-worktree' || (creates && paths.length > 0))
59/// ```
60pub fn copy_applies(identity: &WorktreeIdentity, creates: bool) -> bool {
61 return copy_applies_with(identity, creates, false);
62}
63
64/// What:
65/// Whether the copy applies to an invocation, when a `git worktree add` or `git worktree remove` that the ladder
66/// serves may also run in the main checkout.
67/// Why:
68/// The ladder snapshots or clones from the creating worktree, and the main checkout is one; a removal is served from
69/// any checkout. A creating command from the main checkout takes the lifecycle whatever the configuration names.
70///
71/// In TS you'd write (pseudocode):
72/// ```ts
73/// copyApplies(identity, creates) || (servesLadder && identity.kind === 'main-worktree')
74/// ```
75pub fn copy_applies_with(identity: &WorktreeIdentity, creates: bool, serves_ladder: bool) -> bool {
76 // `match` on the repository shape; the main checkout takes the lifecycle when the command creates a worktree
77 // or the ladder serves it.
78 match identity {
79 WorktreeIdentity::OutsideWorktree => return false,
80 WorktreeIdentity::MainWorktree { .. } => {
81 return serves_ladder || creates;
82 }
83 WorktreeIdentity::LinkedWorktree { .. } | WorktreeIdentity::BareRepository { .. } => {
84 return true;
85 }
86 }
87}
88
89/// What:
90/// How one forwarded run ended.
91/// Why:
92/// Only a run that failed before or after Git needs its own text; otherwise the status is Git's.
93///
94/// In TS you'd write (pseudocode):
95/// ```ts
96/// type ForwardedRun = { exitCode: number } | { stderr: string; exitCode: number };
97/// ```
98#[derive(Clone, Debug, Eq, PartialEq)]
99pub enum ForwardedRun {
100 /// Git ran (and the copy, when one applied, finished); this is the exit status.
101 Ran(i32),
102 /// The run could not start or settle; the standard error text and exit status.
103 Failed {
104 /// Text for standard error.
105 stderr: String,
106 /// Exit status.
107 code: i32,
108 },
109}
110
111/// What:
112/// The directory the repository queries of the copy run in.
113/// Why:
114/// Every query names absolute paths, so any directory of the repository works; the worktree root exists whenever
115/// the identity has a worktree, and a bare repository's common directory is itself a repository.
116///
117/// In TS you'd write (pseudocode):
118/// ```ts
119/// identity.effectiveCwd
120/// ```
121#[cfg(unix)]
122fn query_directory(identity: &WorktreeIdentity) -> PathBuf {
123 // `match` on the repository shape: a worktree root, or the bare repository's directory.
124 match identity {
125 WorktreeIdentity::MainWorktree { worktree_root, .. }
126 | WorktreeIdentity::LinkedWorktree { worktree_root, .. } => return worktree_root.clone(),
127 WorktreeIdentity::BareRepository { common_dir, .. } => return common_dir.clone(),
128 WorktreeIdentity::OutsideWorktree => return PathBuf::new(),
129 }
130}
131
132/// What:
133/// Runs real Git as a child with the given variables and reports how it ended.
134/// Why:
135/// One start for the plain and the settled runs, so both report an unstartable Git the same way.
136///
137/// In TS you'd write (pseudocode):
138/// ```ts
139/// await nanoSpawn(gitPath, args, { env, stdio: 'inherit' });
140/// ```
141fn run_plain(
142 real_git: &Path,
143 arguments: &[OsString],
144 overlay: &[(OsString, OsString)],
145) -> ForwardedRun {
146 // `match` on the start result: the status is Git's, and an unstartable Git is an engine failure.
147 match run_real_git(real_git, arguments, overlay) {
148 Ok(outcome) => return ForwardedRun::Ran(child_exit_code(outcome)),
149 Err(error) => {
150 return ForwardedRun::Failed {
151 stderr: format!("cli-git: cannot start {}: {error}\n", real_git.display()),
152 code: crate::action::ENGINE_FAILURE_EXIT_CODE,
153 };
154 }
155 }
156}
157
158/// What:
159/// Runs the forwarded Git command, settling the ignored-state copy around it when one applies.
160/// Why:
161/// Git's status is the answer unless the copy failed: a copy failure after a successful Git is status 2,
162/// after a failed Git it keeps Git's status, and a failure of the lifecycle itself is status 2, each with its message.
163///
164/// In TS you'd write (pseudocode):
165/// ```ts
166/// await runGitWithWorktreeCopy({ args, gitPath, identity, command, environment });
167/// ```
168pub fn run_forwarded_git(
169 real_git: &Path,
170 arguments: &[OsString],
171 overlay: &[(OsString, OsString)],
172 copy: Option<&CopyFacts>,
173) -> ForwardedRun {
174 #[cfg(unix)]
175 {
176 if let Some(facts) = copy {
177 #[cfg(target_os = "linux")]
178 if let Some(finished) = remove_by_subvolume(real_git, overlay, facts) {
179 return finished;
180 }
181 return run_settled(real_git, arguments, overlay, facts);
182 }
183 }
184 // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
185 #[cfg(not(unix))]
186 {
187 let _ = copy;
188 }
189 return run_plain(real_git, arguments, overlay);
190}
191
192/// What:
193/// Remove the worktree a plain `git worktree remove` names by destroying its subvolume, when every condition holds.
194/// Why:
195/// Git's own removal unlinks every file; the destroy is one call. `None` leaves the whole command to Git; a removal
196/// that destroyed the files but could not remove the registration fails the command with the repair steps and is never
197/// handed to Git, whose removal would find the worktree already gone.
198///
199/// In TS you'd write (pseudocode):
200/// ```ts
201/// facts.removePlan === undefined ? undefined : outcomeToRun(await removeSubvolumeWorktree(request))
202/// ```
203#[cfg(all(unix, target_os = "linux"))]
204fn remove_by_subvolume(real_git: &Path, overlay: &[(OsString, OsString)], facts: &CopyFacts) -> Option<ForwardedRun> {
205 use std::io::Write;
206 use crate::worktree_remove::{RemoveOutcome, RemoveRequest, remove_subvolume_worktree};
207 use crate::worktree_identity::WorktreeIdentity;
208 let plan = facts.remove_plan.as_ref()?;
209 let common_dir: &Path = match &facts.identity {
210 WorktreeIdentity::MainWorktree { common_dir, .. }
211 | WorktreeIdentity::LinkedWorktree { common_dir, .. }
212 | WorktreeIdentity::BareRepository { common_dir, .. } => common_dir.as_path(),
213 WorktreeIdentity::OutsideWorktree => return None,
214 };
215 let context: crate::transaction_git::GitContext = crate::transaction_git::GitContext {
216 real_git: real_git.to_path_buf(),
217 overlay: overlay.to_vec(),
218 global_prefix: Vec::new(),
219 common_dir: None,
220 };
221 match remove_subvolume_worktree(&RemoveRequest { context: &context, plan, common_dir }) {
222 RemoveOutcome::LeftToGit => return None,
223 RemoveOutcome::Removed => {
224 // Exact terminal output: the person runs a destroy that cannot be undone and accepts the risk that comes with it.
225 let _closed = writeln!(std::io::stderr(), "{}", crate::worktree_remove::DESTROY_NOTICE);
226 return Some(ForwardedRun::Ran(0));
227 }
228 RemoveOutcome::Incomplete(report) => {
229 return Some(ForwardedRun::Failed { stderr: format!("{report}\n"), code: crate::action::ENGINE_FAILURE_EXIT_CODE });
230 }
231 }
232}
233
234/// What:
235/// The settled run: the lifecycle with real Git as its runner and the process's standard error as its stream.
236/// Why:
237/// Real Git runs as a child so the wrapper can copy the ignored state once it returns; no lock is held around it.
238///
239/// In TS you'd write (pseudocode):
240/// ```ts
241/// await runGitWithWorktreeCopy({ args, gitPath, identity, command, environment });
242/// ```
243#[cfg(unix)]
244fn run_settled(
245 real_git: &Path,
246 arguments: &[OsString],
247 overlay: &[(OsString, OsString)],
248 facts: &CopyFacts,
249) -> ForwardedRun {
250 use std::io::Write;
251 use crate::checkout_workers::{insert_parallel_checkout, is_fallback_checkout, parallel_checkout_wanted};
252 use crate::transaction_git::GitContext;
253 use crate::worktree_copy::{GitStatus, GitStep, Invocation, exit_code, run_with_worktree_copy};
254 use crate::worktree_copy_error::WorktreeCopyError;
255
256 let context: GitContext = GitContext {
257 real_git: real_git.to_path_buf(),
258 overlay: overlay.to_vec(),
259 // Every copy query names absolute paths and leaves the caller's global options out.
260 global_prefix: Vec::new(),
261 // The copy queries send no input, so no scratch directory is needed.
262 common_dir: None,
263 };
264 let invocation: Invocation = Invocation {
265 identity: facts.identity.clone(),
266 effective_cwd: query_directory(&facts.identity),
267 creates: facts.creates,
268 destination: facts.destination.clone(),
269 opted_out: false,
270 short_circuit: false,
271 context,
272 add_plan: facts.add_plan.clone(),
273 #[cfg(target_os = "linux")]
274 repair: crate::package_repair::RepairSwitch::from_arguments(arguments),
275 };
276 let mut stderr = std::io::stderr();
277 // Whether the checkout may get parallel workers, decided at the first checkout step from what the person set, and not again.
278 let mut parallel: Option<bool> = None;
279 // The runner starts Git as a child, so the wrapper is still there to copy once Git returns.
280 let mut run_git = |step: &GitStep<'_>| -> Result<GitStatus, WorktreeCopyError> {
281 let mut child_overlay: Vec<(OsString, OsString)> = overlay.to_vec();
282 let mut child_arguments: Vec<OsString> = arguments.to_vec();
283 match step {
284 GitStep::Person => {
285 // The person's `git worktree add` is the checkout of the new worktree: it gets parallel workers where nothing sets them.
286 if facts.creates && *parallel.get_or_insert_with(|| { return parallel_checkout_wanted(&invocation.context, invocation.effective_cwd.as_path(), arguments); }) {
287 insert_parallel_checkout(&mut child_arguments);
288 }
289 }
290 GitStep::WithoutCheckout(at) => {
291 // The index was read from the same arguments, so the word before it is `add`.
292 if *at == 0 || *at > child_arguments.len() || child_arguments[*at - 1] != "add" {
293 return Err(WorktreeCopyError(String::from("cli-git: the worktree plan does not match the arguments.")));
294 }
295 child_arguments.insert(*at, OsString::from("--no-checkout"));
296 }
297 GitStep::Other { arguments: other, environment } => {
298 child_arguments = other.to_vec();
299 child_overlay.extend(environment.iter().cloned());
300 // The ladder's own checkout after a failed snapshot is the same checkout, so it takes the same option.
301 if is_fallback_checkout(child_arguments.as_slice()) && *parallel.get_or_insert_with(|| { return parallel_checkout_wanted(&invocation.context, invocation.effective_cwd.as_path(), arguments); }) {
302 insert_parallel_checkout(&mut child_arguments);
303 }
304 }
305 }
306 match run_real_git(real_git, child_arguments.as_slice(), child_overlay.as_slice()) {
307 Ok(ChildOutcome::Exited(code)) => return Ok(GitStatus { code: Some(code) }),
308 Ok(ChildOutcome::Signaled(_)) => return Ok(GitStatus { code: None }),
309 Err(error) => {
310 return Err(WorktreeCopyError(format!(
311 "cli-git: cannot start {}: {error}",
312 real_git.display()
313 )));
314 }
315 }
316 };
317 match run_with_worktree_copy(&invocation, &mut run_git, &mut stderr) {
318 Ok(settlement) => {
319 if let Some(failure) = &settlement.copy_failure {
320 // A closed standard error leaves nowhere to report the failure, and the exit status still carries it.
321 let _ = writeln!(stderr, "{}", failure.0);
322 }
323 return ForwardedRun::Ran(exit_code(&settlement));
324 }
325 Err(failure) => {
326 return ForwardedRun::Failed {
327 stderr: format!("{}\n", failure.0),
328 code: crate::action::ENGINE_FAILURE_EXIT_CODE,
329 };
330 }
331 }
332}
333
334/// Forwarding-run controls stay out of the release executable.
335#[cfg(test)]
336#[path = "worktree_copy_forward_tests.rs"]
337mod tests;