Skip to main content

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;