Skip to main content

git_policy_cli/
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//!  copied under one settlement lock per repository and recovered first when an earlier copy was interrupted.
7//!
8//! Integrator contract:
9//!  The caller decides the bypass facts (`--no-worktree-copy`, short-circuit global help or version, whether the
10//!  command creates or moves worktrees after alias resolution) with the native modules that already own them,
11//!  passes the repository identity and the effective working directory, and supplies the runner that starts real Git
12//!  with the lease token in its environment. This module never runs the commit refusal; it is not wired yet.
13//!  A main-checkout source is a bypass unless the caller also names `Invocation::main_checkout_paths` (the configured
14//!  forbidden-strings rules file); then only those paths are copied, and only when Git ignores them.
15//!
16//! In TS you'd write (pseudocode):
17//! ```ts
18//! await runGitWithWorktreeCopy({ args, gitPath, identity, command, environment });
19//! ```
20
21use super::json_record::quote;
22use super::transaction_git::GitContext;
23use super::worktree_copy_error::{WorktreeCopyError, lifecycle_failure, raw_failure};
24use super::worktree_copy_lock::{acquire_worktree_copy_lock, validate_inherited_lease};
25use super::worktree_copy_recovery::{
26    recover_pending_worktree_copies, recover_worktree_copy_transactions, report_recovered,
27};
28use super::worktree_copy_registry::{
29    CreatedWorktrees, WorktreeCopyObservation, find_created_worktrees, observe_copy_source,
30};
31use super::worktree_copy_transaction::synchronize_created_worktree;
32use super::worktree_identity::WorktreeIdentity;
33use std::io::Write;
34use std::path::{Path, PathBuf};
35
36/// What:
37///  How a real Git run ended.
38/// Why:
39///   Its numeric status is the exit status the wrapper keeps; a signal has none.
40///
41/// In TS you'd write (pseudocode):
42/// ```ts
43/// type ForwardedGitExecution = { failure?: SubprocessError };
44/// ```
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub struct GitStatus {
47    /// The exit code, absent when Git ended by a signal.
48    pub code: Option<i32>,
49}
50
51impl GitStatus {
52    /// Whether Git exited with status zero.
53    pub fn succeeded(&self) -> bool {
54        return self.code == Some(0);
55    }
56}
57
58/// What:
59///  Everything the lifecycle needs to decide how one invocation is settled.
60/// Why:
61///   The decisions that depend on argv and global options are made by their owners and passed in as facts.
62///
63/// In TS you'd write (pseudocode):
64/// ```ts
65/// runGitWithWorktreeCopy({ args, gitPath, identity, command, environment })
66/// ```
67#[derive(Clone, Debug, Eq, PartialEq)]
68pub struct Invocation {
69    /// The effective repository identity captured before real Git.
70    pub identity: WorktreeIdentity,
71    /// The working directory the repository queries run in.
72    pub effective_cwd: PathBuf,
73    /// Whether the command creates or moves worktrees after alias resolution.
74    pub creates_or_moves: bool,
75    /// Whether `--no-worktree-copy` appeared in flag position.
76    pub opted_out: bool,
77    /// Whether real Git only prints help or its version.
78    pub short_circuit: bool,
79    /// The lease token inherited from an outer settlement, when the environment carried one.
80    pub inherited_token: Option<String>,
81    /// Repository paths a main-checkout source hands over (the configured rules file);
82    ///  empty keeps the main checkout a bypass, and linked and bare sources ignore it.
83    pub main_checkout_paths: Vec<String>,
84    /// The real Git executable and the variables every Git child receives.
85    pub context: GitContext,
86}
87
88/// What:
89///  The result of one settled invocation: Git's status and the copy failure, if any.
90/// Why:
91///   Both facts decide the exit status, so they travel together.
92///
93/// In TS you'd write (pseudocode):
94/// ```ts
95/// // a resolved run, or a ForwardedGitWorktreeCopyError carrying both facts
96/// ```
97#[derive(Clone, Debug, Eq, PartialEq)]
98pub struct Settlement {
99    /// How real Git ended.
100    pub git: GitStatus,
101    /// The ignored-state copy failure, when copying failed.
102    pub copy_failure: Option<WorktreeCopyError>,
103}
104
105/// Runs real Git once, with the given lease token (absent when the run inherits none).
106pub type GitRunner<'a> = dyn FnMut(Option<&str>) -> Result<GitStatus, WorktreeCopyError> + 'a;
107
108/// What:
109///  The one summary line a successful copy writes.
110/// Why:
111///   Users and agents read the count, the source, and the destination count from it.
112///
113/// In TS you'd write (pseudocode):
114/// ```ts
115/// renderSummary({ copiedEntries, destinationCount, sourceRoot })
116/// ```
117pub fn summary_line(copied: usize, destinations: usize, source: Option<&Path>) -> String {
118    let source_text: String = match source {
119        None => String::from("bare repository with an empty source set"),
120        Some(path) => quote(path.to_string_lossy().as_ref()),
121    };
122    let worktree_noun: &str = if destinations == 1 {
123        "worktree"
124    } else {
125        "worktrees"
126    };
127    let entry_noun: &str = if copied == 1 { "entry" } else { "entries" };
128    return format!(
129        "cli-git: copied {copied} ignored filesystem {entry_noun} from {source_text} into {destinations} new {worktree_noun}.\n"
130    );
131}
132
133/// What:
134///  The exit status of a settled invocation.
135/// Why:
136///   A copy-only failure is 2; Git's own status is kept when Git failed; a signal without a status is 1.
137///
138/// In TS you'd write (pseudocode):
139/// ```ts
140/// // src/bin.ts: copy failure 2, Git status kept, signal 1
141/// ```
142pub fn exit_code(settlement: &Settlement) -> i32 {
143    let git_code: i32 = settlement.git.code.unwrap_or(1);
144    // `match` on whether Git and the copy each succeeded.
145    match (
146        settlement.git.succeeded(),
147        settlement.copy_failure.is_some(),
148    ) {
149        (true, false) => return 0,
150        (false, false) => return git_code,
151        (true, true) => return 2,
152        (false, true) => return git_code,
153    }
154}
155
156/// What:
157///  Runs one forwarded invocation with no synchronization, and settles its status.
158/// Why:
159///   Every bypass forwards exactly the same way, so the runner is called once with no lease.
160///
161/// In TS you'd write (pseudocode):
162/// ```ts
163/// await forwardOnly({ args, gitPath, environment });
164/// ```
165fn forward_only(run_git: &mut GitRunner) -> Result<Settlement, WorktreeCopyError> {
166    let git: GitStatus = run_git(None)?;
167    return Ok(Settlement {
168        git,
169        copy_failure: None,
170    });
171}
172
173/// What:
174///  The error for a repository whose common directory changed while the lock was acquired.
175/// Why:
176///   The lock belongs to one repository; a different one means the copy would settle the wrong state.
177///
178/// In TS you'd write (pseudocode):
179/// ```ts
180/// new WorktreeCopyError('cli-git: effective repository changed while acquiring worktree-copy settlement lock.')
181/// ```
182fn repository_changed() -> WorktreeCopyError {
183    return WorktreeCopyError(String::from(
184        "cli-git: effective repository changed while acquiring worktree-copy settlement lock.",
185    ));
186}
187
188/// What:
189///  Synchronizes every worktree the command registered and writes the summary line for them.
190/// Why:
191///   Each destination is settled in turn, and the first failure stops the copy with no summary written.
192///
193/// In TS you'd write (pseudocode):
194/// ```ts
195/// const found = await findCreatedWorktrees(...); const summary = await synchronizeCreatedWorktrees(...); process.stderr.write(renderSummary(summary));
196/// ```
197fn synchronize_after(
198    context: &GitContext,
199    observation: &WorktreeCopyObservation,
200    stderr: &mut dyn Write,
201) -> Result<(), WorktreeCopyError> {
202    let found: CreatedWorktrees = find_created_worktrees(context, observation)?;
203    if found.created.is_empty() {
204        return Ok(());
205    }
206    let mut copied: usize = 0;
207    // `match` on the source: a bare repository has nothing to copy, so only a worktree source is copied.
208    match observation.source_root.as_ref() {
209        None => {}
210        Some(source) => {
211            for destination in &found.created {
212                copied += synchronize_created_worktree(
213                    context,
214                    observation.common_dir.as_path(),
215                    source.as_path(),
216                    destination.root.as_path(),
217                    found.registered_roots.as_slice(),
218                    &observation.selection,
219                )?;
220            }
221        }
222    }
223    let line: String = summary_line(
224        copied,
225        found.created.len(),
226        observation.source_root.as_deref(),
227    );
228    stderr
229        .write_all(line.as_bytes())
230        .map_err(|error| return raw_failure(&error))?;
231    return Ok(());
232}
233
234/// What:
235///  Runs the settled part of one applicable creating command: recovery, real Git, and synchronization, under the lock.
236/// Why:
237///   Recovery must finish before the new Git command can create another worktree in the same repository.
238///
239/// In TS you'd write (pseudocode):
240/// ```ts
241/// // inside the acquired lease: recover, executeRealGit, synchronize, report
242/// ```
243fn settle_under_lock(
244    invocation: &Invocation,
245    first: &WorktreeCopyObservation,
246    run_git: &mut GitRunner,
247    identity_after_lock: &mut dyn FnMut() -> Result<WorktreeIdentity, WorktreeCopyError>,
248    lease_token: &str,
249    stderr: &mut dyn Write,
250) -> Result<Settlement, WorktreeCopyError> {
251    let refreshed: Option<WorktreeCopyObservation> = observe_copy_source(
252        &identity_after_lock()?,
253        invocation.effective_cwd.as_path(),
254        invocation.main_checkout_paths.as_slice(),
255    )?;
256    let observation: WorktreeCopyObservation = match refreshed {
257        Some(observed) if observed.common_dir == first.common_dir => observed,
258        _ => return Err(repository_changed()),
259    };
260    let report = recover_worktree_copy_transactions(observation.common_dir.as_path())?;
261    report_recovered(&report, stderr)?;
262    let git: GitStatus = run_git(Some(lease_token))?;
263    let copy_failure: Option<WorktreeCopyError> =
264        synchronize_after(&invocation.context, &observation, stderr).err();
265    return Ok(Settlement { git, copy_failure });
266}
267
268/// What:
269///  Settles one forwarded invocation: bypasses forward untouched; applicable creating commands run under the settlement lock;
270///  other commands only recover pending journals, and only when one exists.
271/// Why:
272///   The integrator calls this once per forwarded command and turns the settlement into the exit status with [`exit_code`].
273///
274/// In TS you'd write (pseudocode):
275/// ```ts
276/// await runGitWithWorktreeCopy({ args, gitPath, identity, command, environment });
277/// ```
278pub fn run_with_worktree_copy(
279    invocation: &Invocation,
280    run_git: &mut GitRunner,
281    identity_after_lock: &mut dyn FnMut() -> Result<WorktreeIdentity, WorktreeCopyError>,
282    stderr: &mut dyn Write,
283) -> Result<Settlement, WorktreeCopyError> {
284    return settle_invocation(invocation, run_git, identity_after_lock, stderr)
285        .map_err(lifecycle_failure);
286}
287
288/// What:
289///  The settlement of one invocation, before the lifecycle words its raw failures.
290/// Why:
291///   Only the top of the lifecycle words raw failures, as the incumbent's `asWorktreeCopyError` does.
292///
293/// In TS you'd write (pseudocode):
294/// ```ts
295/// // body of runGitWithWorktreeCopy
296/// ```
297fn settle_invocation(
298    invocation: &Invocation,
299    run_git: &mut GitRunner,
300    identity_after_lock: &mut dyn FnMut() -> Result<WorktreeIdentity, WorktreeCopyError>,
301    stderr: &mut dyn Write,
302) -> Result<Settlement, WorktreeCopyError> {
303    // `if` chain in the incumbent's order: each bypass forwards without touching the repository.
304    if invocation.opted_out || invocation.short_circuit {
305        return forward_only(run_git);
306    }
307    let Some(observation) = observe_copy_source(
308        &invocation.identity,
309        invocation.effective_cwd.as_path(),
310        invocation.main_checkout_paths.as_slice(),
311    )?
312    else {
313        return forward_only(run_git);
314    };
315    let inherited: bool = match invocation.inherited_token.as_deref() {
316        Some(token) => validate_inherited_lease(observation.common_dir.as_path(), token)?,
317        None => false,
318    };
319    if inherited {
320        return forward_only(run_git);
321    }
322    if !invocation.creates_or_moves {
323        recover_pending_worktree_copies(observation.common_dir.as_path(), stderr)?;
324        return forward_only(run_git);
325    }
326    let lease = acquire_worktree_copy_lock(observation.common_dir.as_path(), stderr)?;
327    let settled: Result<Settlement, WorktreeCopyError> = settle_under_lock(
328        invocation,
329        &observation,
330        run_git,
331        identity_after_lock,
332        lease.lease_token(),
333        stderr,
334    );
335    lease.release()?;
336    return settled;
337}
338
339/// Lifecycle controls stay out of the release executable.
340#[cfg(test)]
341#[path = "worktree_copy_lifecycle_tests.rs"]
342mod tests;