Skip to main content

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;