git_policy_cli/worktree_copying/worktree_copy.rs
1//! What:
2//! The linked-worktree ignored-state copy around one forwarded Git invocation: the public entry point the
3//! integrator calls, the settlement and summary it writes, and the exit status it returns.
4//! Why:
5//! A worktree that Git creates (or a bare repository's) needs its ignored files from the source worktree.
6//! No lock stands around the command: the destination comes from the command line, the copy is a transaction of its own
7//! (a journal and a stage claim that name their owner process), and a copy a dead process left is recovered first by the
8//! next command. Concurrent adds in one repository never wait for one another (the owner's decision of 2026-11-03).
9//!
10//! Integrator contract:
11//! The caller decides the bypass facts (`--no-worktree-copy`, short-circuit global help or version, whether the
12//! command creates a worktree after alias resolution, and the destination its command line names) with the native
13//! modules that already own them, passes the repository identity and the effective working directory, and supplies the
14//! runner that starts real Git. This module never runs the commit refusal; it is not wired yet.
15//! A main-checkout source is observed only when the command creates a worktree; it then hands over its ignored
16//! state under the main checkout's leave-out rule (`worktree_copy_prune.rs`), with its registered worktrees excluded.
17//!
18//! In TS you'd write (pseudocode):
19//! ```ts
20//! await runGitWithWorktreeCopy({ args, gitPath, identity, command, environment });
21//! ```
22
23use crate::add_timing::{begin as begin_timing, finish as finish_timing, step as timed};
24use crate::json_record::quote;
25use crate::transaction_git::GitContext;
26use crate::worktree_copy_error::{WorktreeCopyError, lifecycle_failure, raw_failure};
27use crate::worktree_copy_recovery::recover_pending_worktree_copies;
28use crate::worktree_copy_selection::CopySelection;
29use crate::worktree_copy_registry::{
30 CreatedWorktrees, WorktreeCopyObservation, destination_is_registered, find_created_worktree, observe_copy_source,
31};
32use crate::worktree_copy_transaction::synchronize_created_worktree_noting;
33use crate::worktree_identity::WorktreeIdentity;
34use crate::worktree_add_plan::AddPlan;
35use crate::worktree_ladder::{LadderResult, begin_ladder};
36#[cfg(target_os = "linux")]
37use crate::package_repair::{RepairEnvironment, RepairRequest, RepairSwitch, repair_worktree};
38use std::io::Write;
39use std::path::{Path, PathBuf};
40
41/// What:
42/// How a real Git run ended.
43/// Why:
44/// Its numeric status is the exit status the wrapper keeps; a signal has none.
45///
46/// In TS you'd write (pseudocode):
47/// ```ts
48/// type ForwardedGitExecution = { failure?: SubprocessError };
49/// ```
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub struct GitStatus {
52 /// The exit code, absent when Git ended by a signal.
53 pub code: Option<i32>,
54}
55
56impl GitStatus {
57 /// Whether Git exited with status zero.
58 pub fn succeeded(&self) -> bool {
59 return self.code == Some(0);
60 }
61}
62
63/// What:
64/// Everything the lifecycle needs to decide how one invocation is settled.
65/// Why:
66/// The decisions that depend on argv and global options are made by their owners and passed in as facts.
67///
68/// In TS you'd write (pseudocode):
69/// ```ts
70/// runGitWithWorktreeCopy({ args, gitPath, identity, command, environment })
71/// ```
72#[derive(Clone, Debug, Eq, PartialEq)]
73pub struct Invocation {
74 /// The effective repository identity captured before real Git.
75 pub identity: WorktreeIdentity,
76 /// The working directory the repository queries run in.
77 pub effective_cwd: PathBuf,
78 /// Whether the command creates a worktree after alias resolution.
79 pub creates: bool,
80 /// The destination the creating command's line names, in the form Git records it; absent when the line has a form that is
81 /// not read, in which case no copy is made and the command says so.
82 pub destination: Option<PathBuf>,
83 /// Whether `--no-worktree-copy` appeared in flag position.
84 pub opted_out: bool,
85 /// Whether real Git only prints help or its version.
86 pub short_circuit: bool,
87 /// The real Git executable and the variables every Git child receives.
88 pub context: GitContext,
89 /// The destination of a `git worktree add` in a form the cheap-worktree ladder serves, when it is one.
90 pub add_plan: Option<AddPlan>,
91 /// The person's `--quiet` and `--no-checkout`, read from the arguments, which decide what the package-manager repair of
92 /// a new worktree may print and whether it runs.
93 #[cfg(target_os = "linux")]
94 pub repair: RepairSwitch,
95}
96
97/// What:
98/// The result of one settled invocation: Git's status and the copy failure, if any.
99/// Why:
100/// Both facts decide the exit status, so they travel together.
101///
102/// In TS you'd write (pseudocode):
103/// ```ts
104/// // a resolved run, or a ForwardedGitWorktreeCopyError carrying both facts
105/// ```
106#[derive(Clone, Debug, Eq, PartialEq)]
107pub struct Settlement {
108 /// How real Git ended.
109 pub git: GitStatus,
110 /// The ignored-state copy failure, when copying failed.
111 pub copy_failure: Option<WorktreeCopyError>,
112}
113
114/// What:
115/// Which Git run the settlement asks its runner to start.
116/// Why:
117/// The person's command is one run; the cheap-worktree ladder adds `--no-checkout` to it and starts the commands that
118/// complete the worktree itself, each with the caller's streams.
119///
120/// In TS you'd write (pseudocode):
121/// ```ts
122/// type GitStep = { kind: 'person' } | { kind: 'without-checkout'; insertAt: number } | { kind: 'other'; args: string[]; env: [string, string][] };
123/// ```
124#[derive(Clone, Copy, Debug, Eq, PartialEq)]
125pub enum GitStep<'a> {
126 /// The person's arguments, unchanged.
127 Person,
128 /// The person's arguments with `--no-checkout` inserted at this index.
129 WithoutCheckout(usize),
130 /// Another Git command with these arguments and these variables added.
131 Other {
132 /// The arguments, global options and subcommand included.
133 arguments: &'a [std::ffi::OsString],
134 /// The variables added to the run's environment.
135 environment: &'a [(std::ffi::OsString, std::ffi::OsString)],
136 },
137}
138
139/// Runs one real Git step.
140pub type GitRunner<'a> = dyn FnMut(&GitStep<'_>) -> Result<GitStatus, WorktreeCopyError> + 'a;
141
142/// What:
143/// The one summary line a successful copy writes.
144/// Why:
145/// Users and agents read the count, the source, and the destination count from it.
146///
147/// In TS you'd write (pseudocode):
148/// ```ts
149/// renderSummary({ copiedEntries, destinationCount, sourceRoot })
150/// ```
151pub fn summary_line(copied: usize, destinations: usize, source: Option<&Path>) -> String {
152 let source_text: String = match source {
153 None => String::from("bare repository with an empty source set"),
154 Some(path) => quote(path.to_string_lossy().as_ref()),
155 };
156 let worktree_noun: &str = if destinations == 1 {
157 "worktree"
158 } else {
159 "worktrees"
160 };
161 let entry_noun: &str = if copied == 1 { "entry" } else { "entries" };
162 return format!(
163 "cli-git: copied {copied} ignored filesystem {entry_noun} from {source_text} into {destinations} new {worktree_noun}.\n"
164 );
165}
166
167/// What:
168/// The exit status of a settled invocation.
169/// Why:
170/// A copy-only failure is 2; Git's own status is kept when Git failed; a signal without a status is 1.
171///
172/// In TS you'd write (pseudocode):
173/// ```ts
174/// // src/bin.ts: copy failure 2, Git status kept, signal 1
175/// ```
176pub fn exit_code(settlement: &Settlement) -> i32 {
177 let git_code: i32 = settlement.git.code.unwrap_or(1);
178 // `match` on whether Git and the copy each succeeded.
179 match (
180 settlement.git.succeeded(),
181 settlement.copy_failure.is_some(),
182 ) {
183 (true, false) => return 0,
184 (false, false) => return git_code,
185 (true, true) => return 2,
186 (false, true) => return git_code,
187 }
188}
189
190/// What:
191/// Runs one forwarded invocation with no synchronization, and settles its status.
192/// Why:
193/// Every bypass forwards exactly the same way, so the runner is called once.
194///
195/// In TS you'd write (pseudocode):
196/// ```ts
197/// await forwardOnly({ args, gitPath, environment });
198/// ```
199fn forward_only(run_git: &mut GitRunner) -> Result<Settlement, WorktreeCopyError> {
200 let git: GitStatus = run_git(&GitStep::Person)?;
201 return Ok(Settlement {
202 git,
203 copy_failure: None,
204 });
205}
206
207/// The line a successful `git worktree add` prints when its command line has a form whose destination is not read.
208const UNKNOWN_DESTINATION_NOTICE: &str = "cli-git: this `git worktree add` names its worktree in a form cli-git does not read, so the worktree received no ignored-state copy. Name the path as a plain operand (`git worktree add [-b <branch>] <path> [<commit-ish>]`, with `-C <directory>` or `-c <name>=<value>` before `worktree`) to have it copied.\n";
209
210/// What:
211/// The line for a destination Git made whose registration could not be confirmed.
212/// Why:
213/// A worktree that gets no copy must say so; here the pointer in its `.git` file and the registration it names do not
214/// name each other, so cli-git cannot tell the worktree is the command's own and copies nothing.
215///
216/// In TS you'd write (pseudocode):
217/// ```ts
218/// `cli-git: ${quote(destination)} was made by git worktree add, but ...`
219/// ```
220fn unconfirmed_destination_notice(destination: &Path) -> String {
221 return format!(
222 "cli-git: {} was made, but its registration could not be confirmed as this command's, so it received no ignored-state copy. `git worktree list` shows whether Git registered it.\n",
223 quote(destination.to_string_lossy().as_ref())
224 );
225}
226
227/// What:
228/// Synchronizes the worktree this command registered at its destination and writes the summary line for it; the root of
229/// the created worktree, a snapshot's included, comes back for the package-manager repair.
230/// Why:
231/// The destination is the one the command line named, so a worktree another add registered at the same time is never
232/// copied into here; a failure stops the copy with no summary written.
233///
234/// In TS you'd write (pseudocode):
235/// ```ts
236/// const found = await findCreatedWorktree(...); const copied = await synchronizeCreatedWorktree(...); process.stderr.write(renderSummary(copied));
237/// ```
238fn synchronize_after(
239 invocation: &Invocation,
240 observation: &WorktreeCopyObservation,
241 registered_before: bool,
242 git_succeeded: bool,
243 carried: Option<&Path>,
244 stderr: &mut dyn Write,
245) -> Result<Vec<PathBuf>, WorktreeCopyError> {
246 // A main checkout that names no path hands over nothing: no stage, no journal and no summary line.
247 if matches!(&observation.selection, CopySelection::Paths(paths) if paths.is_empty()) {
248 return Ok(carried.map(Path::to_path_buf).into_iter().collect());
249 }
250 let Some(destination) = invocation.destination.as_deref() else {
251 stderr
252 .write_all(UNKNOWN_DESTINATION_NOTICE.as_bytes())
253 .map_err(|error| return raw_failure(&error))?;
254 return Ok(Vec::new());
255 };
256 let mut found: CreatedWorktrees =
257 find_created_worktree(&invocation.context, observation, destination, registered_before)?;
258 // A destination Git made but whose registration cannot be confirmed is told, not skipped without a word.
259 if found.created.is_empty()
260 && git_succeeded
261 && !registered_before
262 && std::fs::symlink_metadata(destination.join(".git")).is_ok()
263 {
264 let line: String = unconfirmed_destination_notice(destination);
265 stderr
266 .write_all(line.as_bytes())
267 .map_err(|error| return raw_failure(&error))?;
268 return Ok(Vec::new());
269 }
270 let created_roots: Vec<PathBuf> = found.created.iter().map(|worktree| { return worktree.root.clone(); }).collect();
271 // A destination that received the source's ignored state with a snapshot needs no copy.
272 found.created.retain(|worktree| { return carried != Some(worktree.root.as_path()); });
273 if found.created.is_empty() {
274 return Ok(created_roots);
275 }
276 let mut copied: usize = 0;
277 // `match` on the source: a bare repository has nothing to copy, so only a worktree source is copied.
278 match observation.source_root.as_ref() {
279 None => {}
280 Some(source) => {
281 for created in &found.created {
282 copied += synchronize_created_worktree_noting(
283 &invocation.context,
284 observation.common_dir.as_path(),
285 source.as_path(),
286 created.root.as_path(),
287 found.registered_roots.as_slice(),
288 &observation.selection,
289 stderr,
290 )?;
291 }
292 }
293 }
294 let line: String = summary_line(
295 copied,
296 found.created.len(),
297 observation.source_root.as_deref(),
298 );
299 stderr
300 .write_all(line.as_bytes())
301 .map_err(|error| return raw_failure(&error))?;
302 return Ok(created_roots);
303}
304
305/// What:
306/// What is left to do once the ignored state is in place: the source the ignored files came from and the worktrees the
307/// command created.
308/// Why:
309/// The package-manager repairs touch only the new worktrees and read the source, so no other add has to wait for them; they
310/// run last, with the facts the copy found.
311///
312/// In TS you'd write (pseudocode):
313/// ```ts
314/// type AfterLock = { sourceRoot?: string; created: string[] };
315/// ```
316struct AfterLock {
317 /// The checkout the ignored files came from; none for a bare repository, which copies nothing.
318 source_root: Option<PathBuf>,
319 /// The roots of every worktree the command created, a snapshot's included; empty after a failed copy.
320 created: Vec<PathBuf>,
321}
322
323/// What:
324/// Repair the package-manager state of every worktree the command created, once its ignored state is in place.
325/// Why:
326/// A copy or a snapshot keeps links and build state exactly as the source had them; each tool's own offline repair makes
327/// them work at the new location (`package_repair.rs`). It never fails the add: a repair that cannot finish says so in one
328/// line that names the command to run. A bare repository has no source worktree, so nothing was copied and nothing is repaired.
329/// It runs on the worktree the registration binding confirmed, and no lock stands around it, so a slow tool never holds
330/// another add.
331///
332/// In TS you'd write (pseudocode):
333/// ```ts
334/// for (const root of roots) repairWorktree({ git, source, destination: root, switch, environment }, stderr);
335/// ```
336#[cfg(target_os = "linux")]
337fn repair_created(invocation: &Invocation, after: &AfterLock, stderr: &mut dyn Write) {
338 let Some(source) = after.source_root.as_deref() else { return };
339 let environment: RepairEnvironment = RepairEnvironment::from_process();
340 for root in &after.created {
341 let request: RepairRequest<'_> = RepairRequest { git: &invocation.context, source, destination: root.as_path(), switch: invocation.repair, environment: &environment };
342 repair_worktree(&request, stderr);
343 }
344}
345
346/// What:
347/// Runs one creating command: whether its destination was registered already, the ladder and real Git, and the copy.
348/// Why:
349/// Nothing here waits for another command: the destination, the stage claim and the journal belong to this command alone,
350/// so any number of adds run side by side in one repository.
351///
352/// In TS you'd write (pseudocode):
353/// ```ts
354/// const before = isRegistered(destination); const status = await runLadder(); await synchronize(destination, before);
355/// ```
356fn settle_creation(
357 invocation: &Invocation,
358 observation: &WorktreeCopyObservation,
359 run_git: &mut GitRunner,
360 stderr: &mut dyn Write,
361) -> Result<(Settlement, AfterLock), WorktreeCopyError> {
362 let registered_before: bool = invocation
363 .destination
364 .as_deref()
365 .is_some_and(|destination| { return destination_is_registered(observation.common_dir.as_path(), destination); });
366 let ladder = timed("chooseRung", || { return begin_ladder(invocation.add_plan.as_ref(), &invocation.context, &invocation.identity); });
367 let ran: Result<GitStatus, WorktreeCopyError> = timed("gitAdd", || { return run_git(&ladder.first_step()); });
368 let settled: Result<LadderResult, WorktreeCopyError> = match ran {
369 Ok(status) => Ok(timed("finishRung", || { return ladder.finish(status, run_git, &invocation.context, &mut std::io::stdout()); })),
370 Err(error) => {
371 // Git did not start: the ladder only has its empty subvolume to remove, whatever status it is given.
372 ladder.finish(GitStatus { code: None }, run_git, &invocation.context, &mut std::io::sink());
373 Err(error)
374 }
375 };
376 let LadderResult { git, carried } = settled?;
377 let (copy_failure, created): (Option<WorktreeCopyError>, Vec<PathBuf>) =
378 if git.succeeded() || invocation.destination.is_some() {
379 match timed("copyIgnored", || { return synchronize_after(invocation, observation, registered_before, git.succeeded(), carried.as_deref(), stderr); }) {
380 Ok(roots) => (None, roots),
381 // A copy that failed leaves a worktree that is not whole; repairing it would hide that.
382 Err(failure) => (Some(failure), Vec::new()),
383 }
384 } else {
385 (None, Vec::new())
386 };
387 return Ok((Settlement { git, copy_failure }, AfterLock { source_root: observation.source_root.clone(), created }));
388}
389
390/// What:
391/// Settles one forwarded invocation: bypasses forward untouched; every other command first recovers what dead owners left,
392/// and a creating command then copies the ignored state into the worktree it created.
393/// Why:
394/// The integrator calls this once per forwarded command and turns the settlement into the exit status with [`exit_code`].
395///
396/// In TS you'd write (pseudocode):
397/// ```ts
398/// await runGitWithWorktreeCopy({ args, gitPath, identity, command, environment });
399/// ```
400pub fn run_with_worktree_copy(
401 invocation: &Invocation,
402 run_git: &mut GitRunner,
403 stderr: &mut dyn Write,
404) -> Result<Settlement, WorktreeCopyError> {
405 return settle_invocation(invocation, run_git, stderr).map_err(lifecycle_failure);
406}
407
408/// What:
409/// The settlement of one invocation, before the lifecycle words its raw failures.
410/// Why:
411/// Only the top of the lifecycle words raw failures, as the incumbent's `asWorktreeCopyError` does.
412///
413/// In TS you'd write (pseudocode):
414/// ```ts
415/// // body of runGitWithWorktreeCopy
416/// ```
417fn settle_invocation(
418 invocation: &Invocation,
419 run_git: &mut GitRunner,
420 stderr: &mut dyn Write,
421) -> Result<Settlement, WorktreeCopyError> {
422 // `if` chain in the incumbent's order: each bypass forwards without touching the repository.
423 if invocation.opted_out || invocation.short_circuit {
424 return forward_only(run_git);
425 }
426 let Some(observation) = observe_copy_source(
427 &invocation.identity,
428 invocation.effective_cwd.as_path(),
429 )?
430 else {
431 return forward_only(run_git);
432 };
433 // The record of an add's steps covers a creating command the ladder serves; every other command is forwarded unrecorded.
434 let recorded: bool = invocation.creates && invocation.add_plan.is_some();
435 if recorded {
436 begin_timing(observation.common_dir.clone());
437 }
438 let settled: Result<Settlement, WorktreeCopyError> = settle_observed(invocation, &observation, run_git, stderr);
439 if recorded {
440 finish_timing(settled.as_ref().map_or(1, exit_code));
441 }
442 return settled;
443}
444
445/// What:
446/// The part of a forwarded command's settlement that follows the observation of its source: recovery, then, for a creating
447/// command, the ladder, the copy of the ignored state and the repairs.
448/// Why:
449/// The caller records the steps of a creating command around this whole part, whichever way it ends.
450///
451/// In TS you'd write (pseudocode):
452/// ```ts
453/// await settleObserved({ invocation, observation, runGit, stderr });
454/// ```
455fn settle_observed(
456 invocation: &Invocation,
457 observation: &WorktreeCopyObservation,
458 run_git: &mut GitRunner,
459 stderr: &mut dyn Write,
460) -> Result<Settlement, WorktreeCopyError> {
461 // Recovery never waits: it takes over only journals and claims whose owner is dead, and one rename picks its single winner.
462 timed("recover", || { return recover_pending_worktree_copies(observation.common_dir.as_path(), stderr); })?;
463 if !invocation.creates {
464 return forward_only(run_git);
465 }
466 let (settlement, after) = settle_creation(invocation, observation, run_git, stderr)?;
467 // No lock stands around the add: the repairs touch only the new worktree and read the source, and another add never waits
468 // for them.
469 #[cfg(target_os = "linux")]
470 timed("repair", || { repair_created(invocation, &after, stderr); });
471 #[cfg(not(target_os = "linux"))]
472 let _after = after;
473 return Ok(settlement);
474}
475
476/// Lifecycle controls stay out of the release executable.
477#[cfg(test)]
478#[path = "worktree_copy_lifecycle_tests.rs"]
479mod tests;