Skip to main content

git_policy_cli/
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 carries the settlement lease.
4//! Why:
5//!  A command that creates or moves worktrees must stay under the settlement lock while real Git runs and the
6//!  ignored state is copied, and a command run from a linked worktree must first recover an interrupted copy;
7//!  replacing the process with Git would drop both duties, so these invocations run Git as a child.
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 super::forwarding::{ChildOutcome, exit_code as child_exit_code, run_real_git};
17use super::worktree_identity::WorktreeIdentity;
18use std::ffi::OsString;
19use std::path::{Path, PathBuf};
20
21/// What:
22///  Everything the copy needs to know about one forwarded invocation, decided before Git starts.
23/// Why:
24///   The plan decides that the copy applies; the run performs it with real Git and the process environment.
25///
26/// In TS you'd write (pseudocode):
27/// ```ts
28/// type CopyForward = { identity: GitWorktreeIdentity; command: ResolvedGitCommand; mainCheckoutPaths: string[] };
29/// ```
30#[derive(Clone, Debug, Eq, PartialEq)]
31pub struct CopyFacts {
32    /// The repository shape the invocation selects.
33    pub identity: WorktreeIdentity,
34    /// Whether the command, after alias resolution, creates or moves worktrees.
35    pub creates_or_moves: bool,
36    /// Repository paths a main-checkout source hands over (the configured rules file).
37    pub main_checkout_paths: Vec<String>,
38    /// The invocation's global options, replayed before the repository queries that ask for the identity again.
39    pub global_prefix: Vec<OsString>,
40    /// The environment the invocation inherited, which may carry an outer settlement's lease.
41    pub environment: Vec<(OsString, OsString)>,
42}
43
44/// What:
45///  Whether the copy applies to an invocation.
46/// Why:
47///   Linked worktrees and bare repositories always take the lifecycle (recovery, lease check, creation);
48///  the main checkout only when it creates a worktree and has paths to hand over, so its other commands
49///  keep their plain forwarding and cost no filesystem reads.
50///
51/// In TS you'd write (pseudocode):
52/// ```ts
53/// identity.kind !== 'outside-worktree' && (identity.kind !== 'main-worktree' || (createsOrMoves && paths.length > 0))
54/// ```
55pub fn copy_applies(
56    identity: &WorktreeIdentity,
57    creates_or_moves: bool,
58    main_checkout_paths: &[String],
59) -> bool {
60    // `match` on the repository shape; `||` keeps the main checkout's two conditions together.
61    match identity {
62        WorktreeIdentity::OutsideWorktree => return false,
63        WorktreeIdentity::MainWorktree { .. } => {
64            return creates_or_moves && !main_checkout_paths.is_empty();
65        }
66        WorktreeIdentity::LinkedWorktree { .. } | WorktreeIdentity::BareRepository { .. } => {
67            return true;
68        }
69    }
70}
71
72/// What:
73///  How one forwarded run ended.
74/// Why:
75///   Only a run that failed before or after Git needs its own text; otherwise the status is Git's.
76///
77/// In TS you'd write (pseudocode):
78/// ```ts
79/// type ForwardedRun = { exitCode: number } | { stderr: string; exitCode: number };
80/// ```
81#[derive(Clone, Debug, Eq, PartialEq)]
82pub enum ForwardedRun {
83    /// Git ran (and the copy, when one applied, finished); this is the exit status.
84    Ran(i32),
85    /// The run could not start or settle; the standard error text and exit status.
86    Failed {
87        /// Text for standard error.
88        stderr: String,
89        /// Exit status.
90        code: i32,
91    },
92}
93
94/// What:
95///  The directory the repository queries of the copy run in.
96/// Why:
97///   Every query names absolute paths, so any directory of the repository works; the worktree root exists whenever
98///  the identity has a worktree, and a bare repository's common directory is itself a repository.
99///
100/// In TS you'd write (pseudocode):
101/// ```ts
102/// identity.effectiveCwd
103/// ```
104#[cfg(unix)]
105fn query_directory(identity: &WorktreeIdentity) -> PathBuf {
106    // `match` on the repository shape: a worktree root, or the bare repository's directory.
107    match identity {
108        WorktreeIdentity::MainWorktree { worktree_root, .. }
109        | WorktreeIdentity::LinkedWorktree { worktree_root, .. } => return worktree_root.clone(),
110        WorktreeIdentity::BareRepository { common_dir, .. } => return common_dir.clone(),
111        WorktreeIdentity::OutsideWorktree => return PathBuf::new(),
112    }
113}
114
115/// What:
116///  Runs real Git as a child with the given variables and reports how it ended.
117/// Why:
118///   One start for the plain and the settled runs, so both report an unstartable Git the same way.
119///
120/// In TS you'd write (pseudocode):
121/// ```ts
122/// await nanoSpawn(gitPath, args, { env, stdio: 'inherit' });
123/// ```
124fn run_plain(
125    real_git: &Path,
126    arguments: &[OsString],
127    overlay: &[(OsString, OsString)],
128) -> ForwardedRun {
129    // `match` on the start result: the status is Git's, and an unstartable Git is an engine failure.
130    match run_real_git(real_git, arguments, overlay) {
131        Ok(outcome) => return ForwardedRun::Ran(child_exit_code(outcome)),
132        Err(error) => {
133            return ForwardedRun::Failed {
134                stderr: format!("cli-git: cannot start {}: {error}\n", real_git.display()),
135                code: super::action::ENGINE_FAILURE_EXIT_CODE,
136            };
137        }
138    }
139}
140
141/// What:
142///  Runs the forwarded Git command, settling the ignored-state copy around it when one applies.
143/// Why:
144///   Git's status is the answer unless the copy failed: a copy failure after a successful Git is status 2,
145///  after a failed Git it keeps Git's status, and a failure of the lifecycle itself is status 2, each with its message.
146///
147/// In TS you'd write (pseudocode):
148/// ```ts
149/// await runGitWithWorktreeCopy({ args, gitPath, identity, command, environment });
150/// ```
151pub fn run_forwarded_git(
152    real_git: &Path,
153    arguments: &[OsString],
154    overlay: &[(OsString, OsString)],
155    copy: Option<&CopyFacts>,
156) -> ForwardedRun {
157    #[cfg(unix)]
158    {
159        if let Some(facts) = copy {
160            return run_settled(real_git, arguments, overlay, facts);
161        }
162    }
163    #[cfg(not(unix))]
164    {
165        let _ = copy;
166    }
167    return run_plain(real_git, arguments, overlay);
168}
169
170/// What:
171///  The settled run: the lifecycle with real Git as its runner and the process's standard error as its stream.
172/// Why:
173///   The runner receives the lease token to put into the child's environment while the settlement lock is held.
174///
175/// In TS you'd write (pseudocode):
176/// ```ts
177/// await runGitWithWorktreeCopy({ args, gitPath, identity, command, environment });
178/// ```
179#[cfg(unix)]
180fn run_settled(
181    real_git: &Path,
182    arguments: &[OsString],
183    overlay: &[(OsString, OsString)],
184    facts: &CopyFacts,
185) -> ForwardedRun {
186    use super::child_environment::environment_value;
187    use std::io::Write;
188    use super::repository_facts::{RepositoryFacts, git_facts};
189    use super::transaction_git::GitContext;
190    use super::worktree_copy::{GitStatus, Invocation, exit_code, run_with_worktree_copy};
191    use super::worktree_copy_error::WorktreeCopyError;
192    use super::worktree_copy_lock::WORKTREE_COPY_LEASE_ENV;
193
194    let context: GitContext = GitContext {
195        real_git: real_git.to_path_buf(),
196        overlay: overlay.to_vec(),
197        // Every copy query names absolute paths and leaves the caller's global options out.
198        global_prefix: Vec::new(),
199    };
200    let invocation: Invocation = Invocation {
201        identity: facts.identity.clone(),
202        effective_cwd: query_directory(&facts.identity),
203        creates_or_moves: facts.creates_or_moves,
204        opted_out: false,
205        short_circuit: false,
206        inherited_token: environment_value(facts.environment.as_slice(), WORKTREE_COPY_LEASE_ENV)
207            .map(|token| return token.to_string_lossy().into_owned()),
208        context,
209        main_checkout_paths: facts.main_checkout_paths.clone(),
210    };
211    let mut stderr = std::io::stderr();
212    // The runner starts Git as a child with the lease token, so nested wrappers see the lock they are inside.
213    let mut run_git = |token: Option<&str>| -> Result<GitStatus, WorktreeCopyError> {
214        let mut child_overlay: Vec<(OsString, OsString)> = overlay.to_vec();
215        if let Some(value) = token {
216            child_overlay.push((OsString::from(WORKTREE_COPY_LEASE_ENV), OsString::from(value)));
217        }
218        match run_real_git(real_git, arguments, child_overlay.as_slice()) {
219            Ok(ChildOutcome::Exited(code)) => return Ok(GitStatus { code: Some(code) }),
220            Ok(ChildOutcome::Signaled(_)) => return Ok(GitStatus { code: None }),
221            Err(error) => {
222                return Err(WorktreeCopyError(format!(
223                    "cli-git: cannot start {}: {error}",
224                    real_git.display()
225                )));
226            }
227        }
228    };
229    // The identity is asked again once the lock is held, as the incumbent re-observes its repository.
230    let mut identity_after_lock = || -> Result<WorktreeIdentity, WorktreeCopyError> {
231        let mut facts_again = git_facts(real_git, facts.global_prefix.as_slice(), overlay);
232        match facts_again.location() {
233            Ok(location) => return Ok(location.identity),
234            Err(message) => return Err(WorktreeCopyError(format!("cli-git: {message}"))),
235        }
236    };
237    match run_with_worktree_copy(&invocation, &mut run_git, &mut identity_after_lock, &mut stderr) {
238        Ok(settlement) => {
239            if let Some(failure) = &settlement.copy_failure {
240                // A closed standard error leaves nowhere to report the failure, and the exit status still carries it.
241                let _ = writeln!(stderr, "{}", failure.0);
242            }
243            return ForwardedRun::Ran(exit_code(&settlement));
244        }
245        Err(failure) => {
246            return ForwardedRun::Failed {
247                stderr: format!("{}\n", failure.0),
248                code: super::action::ENGINE_FAILURE_EXIT_CODE,
249            };
250        }
251    }
252}
253
254/// Forwarding-run controls stay out of the release executable.
255#[cfg(test)]
256#[path = "worktree_copy_forward_tests.rs"]
257mod tests;