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;