1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
//! What:
//! Running a forwarded Git command through the linked-worktree ignored-state copy:
//! which invocations the copy applies to, and the child-process run that carries the settlement lease.
//! Why:
//! A command that creates or moves worktrees must stay under the settlement lock while real Git runs and the
//! ignored state is copied, and a command run from a linked worktree must first recover an interrupted copy;
//! replacing the process with Git would drop both duties, so these invocations run Git as a child.
//! The main checkout is a source only for the configured rules file, so it takes this path only when the
//! command creates a worktree and the configuration names that file.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! await runGitWithWorktreeCopy({ args, gitPath, identity, command, environment });
//! ```
use super::forwarding::{ChildOutcome, exit_code as child_exit_code, run_real_git};
use super::worktree_add_plan::AddPlan;
use super::worktree_remove_plan::RemovePlan;
use super::worktree_identity::WorktreeIdentity;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
/// What:
/// Everything the copy needs to know about one forwarded invocation, decided before Git starts.
/// Why:
/// The plan decides that the copy applies; the run performs it with real Git and the process environment.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type CopyForward = { identity: GitWorktreeIdentity; command: ResolvedGitCommand; mainCheckoutPaths: string[] };
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CopyFacts {
/// The repository shape the invocation selects.
pub identity: WorktreeIdentity,
/// Whether the command, after alias resolution, creates or moves worktrees.
pub creates_or_moves: bool,
/// The invocation's global options, replayed before the repository queries that ask for the identity again.
pub global_prefix: Vec<OsString>,
/// The environment the invocation inherited, which may carry an outer settlement's lease.
pub environment: Vec<(OsString, OsString)>,
/// The destination of a `git worktree add` in a form the cheap-worktree ladder serves; absent for every other
/// command and every form the ladder does not know.
pub add_plan: Option<AddPlan>,
/// The directory of a `git worktree remove` in the plain form the wrapper may serve with a subvolume destroy;
/// absent for every other command.
pub remove_plan: Option<RemovePlan>,
}
/// What:
/// Whether the copy applies to an invocation.
/// Why:
/// Linked worktrees and bare repositories always take the lifecycle (recovery, lease check, creation);
/// the main checkout only when it creates a worktree and has paths to hand over, so its other commands
/// keep their plain forwarding and cost no filesystem reads.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// identity.kind !== 'outside-worktree' && (identity.kind !== 'main-worktree' || (createsOrMoves && paths.length > 0))
/// ```
pub fn copy_applies(identity: &WorktreeIdentity, creates_or_moves: bool) -> bool {
return copy_applies_with(identity, creates_or_moves, false);
}
/// What:
/// Whether the copy applies to an invocation, when a `git worktree add` or `git worktree remove` that the ladder
/// serves may also run in the main checkout.
/// Why:
/// The ladder snapshots or clones from the creating worktree, and the main checkout is one; a removal is served from
/// any checkout. A creating command from the main checkout takes the lifecycle whatever the configuration names.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// copyApplies(identity, createsOrMoves) || (servesLadder && identity.kind === 'main-worktree')
/// ```
pub fn copy_applies_with(identity: &WorktreeIdentity, creates_or_moves: bool, serves_ladder: bool) -> bool {
// `match` on the repository shape; the main checkout takes the lifecycle when the command creates or moves a worktree
// or the ladder serves it.
match identity {
WorktreeIdentity::OutsideWorktree => return false,
WorktreeIdentity::MainWorktree { .. } => {
return serves_ladder || creates_or_moves;
}
WorktreeIdentity::LinkedWorktree { .. } | WorktreeIdentity::BareRepository { .. } => {
return true;
}
}
}
/// What:
/// How one forwarded run ended.
/// Why:
/// Only a run that failed before or after Git needs its own text; otherwise the status is Git's.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type ForwardedRun = { exitCode: number } | { stderr: string; exitCode: number };
/// ```
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ForwardedRun {
/// Git ran (and the copy, when one applied, finished); this is the exit status.
Ran(i32),
/// The run could not start or settle; the standard error text and exit status.
Failed {
/// Text for standard error.
stderr: String,
/// Exit status.
code: i32,
},
}
/// What:
/// The directory the repository queries of the copy run in.
/// Why:
/// Every query names absolute paths, so any directory of the repository works; the worktree root exists whenever
/// the identity has a worktree, and a bare repository's common directory is itself a repository.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// identity.effectiveCwd
/// ```
#[cfg(unix)]
fn query_directory(identity: &WorktreeIdentity) -> PathBuf {
// `match` on the repository shape: a worktree root, or the bare repository's directory.
match identity {
WorktreeIdentity::MainWorktree { worktree_root, .. }
| WorktreeIdentity::LinkedWorktree { worktree_root, .. } => return worktree_root.clone(),
WorktreeIdentity::BareRepository { common_dir, .. } => return common_dir.clone(),
WorktreeIdentity::OutsideWorktree => return PathBuf::new(),
}
}
/// What:
/// Runs real Git as a child with the given variables and reports how it ended.
/// Why:
/// One start for the plain and the settled runs, so both report an unstartable Git the same way.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await nanoSpawn(gitPath, args, { env, stdio: 'inherit' });
/// ```
fn run_plain(
real_git: &Path,
arguments: &[OsString],
overlay: &[(OsString, OsString)],
) -> ForwardedRun {
// `match` on the start result: the status is Git's, and an unstartable Git is an engine failure.
match run_real_git(real_git, arguments, overlay) {
Ok(outcome) => return ForwardedRun::Ran(child_exit_code(outcome)),
Err(error) => {
return ForwardedRun::Failed {
stderr: format!("cli-git: cannot start {}: {error}\n", real_git.display()),
code: super::action::ENGINE_FAILURE_EXIT_CODE,
};
}
}
}
/// What:
/// Runs the forwarded Git command, settling the ignored-state copy around it when one applies.
/// Why:
/// Git's status is the answer unless the copy failed: a copy failure after a successful Git is status 2,
/// after a failed Git it keeps Git's status, and a failure of the lifecycle itself is status 2, each with its message.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await runGitWithWorktreeCopy({ args, gitPath, identity, command, environment });
/// ```
pub fn run_forwarded_git(
real_git: &Path,
arguments: &[OsString],
overlay: &[(OsString, OsString)],
copy: Option<&CopyFacts>,
) -> ForwardedRun {
#[cfg(unix)]
{
if let Some(facts) = copy {
#[cfg(target_os = "linux")]
if remove_by_subvolume(real_git, overlay, facts) {
return ForwardedRun::Ran(0);
}
return run_settled(real_git, arguments, overlay, facts);
}
}
// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
#[cfg(not(unix))]
{
let _ = copy;
}
return run_plain(real_git, arguments, overlay);
}
/// What:
/// Remove the worktree a plain `git worktree remove` names by destroying its subvolume, when every condition holds.
/// Why:
/// Git's own removal unlinks every file; the destroy is one call. `false` leaves the whole command to Git.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// facts.removePlan !== undefined && (await removeSubvolumeWorktree(context, facts.removePlan))
/// ```
#[cfg(all(unix, target_os = "linux"))]
fn remove_by_subvolume(real_git: &Path, overlay: &[(OsString, OsString)], facts: &CopyFacts) -> bool {
let Some(plan) = facts.remove_plan.as_ref() else { return false };
let context: super::transaction_git::GitContext = super::transaction_git::GitContext {
real_git: real_git.to_path_buf(),
overlay: overlay.to_vec(),
global_prefix: Vec::new(),
common_dir: None,
};
return super::worktree_remove::remove_subvolume_worktree(&context, plan);
}
/// What:
/// The settled run: the lifecycle with real Git as its runner and the process's standard error as its stream.
/// Why:
/// The runner receives the lease token to put into the child's environment while the settlement lock is held.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await runGitWithWorktreeCopy({ args, gitPath, identity, command, environment });
/// ```
#[cfg(unix)]
fn run_settled(
real_git: &Path,
arguments: &[OsString],
overlay: &[(OsString, OsString)],
facts: &CopyFacts,
) -> ForwardedRun {
use super::child_environment::environment_value;
use std::io::Write;
use super::repository_facts::{RepositoryFacts, git_facts};
use super::transaction_git::GitContext;
use super::worktree_copy::{GitStatus, GitStep, Invocation, exit_code, run_with_worktree_copy};
use super::worktree_copy_error::WorktreeCopyError;
use super::worktree_copy_lock::WORKTREE_COPY_LEASE_ENV;
let context: GitContext = GitContext {
real_git: real_git.to_path_buf(),
overlay: overlay.to_vec(),
// Every copy query names absolute paths and leaves the caller's global options out.
global_prefix: Vec::new(),
// The copy queries send no input, so no scratch directory is needed.
common_dir: None,
};
let invocation: Invocation = Invocation {
identity: facts.identity.clone(),
effective_cwd: query_directory(&facts.identity),
creates_or_moves: facts.creates_or_moves,
opted_out: false,
short_circuit: false,
inherited_token: environment_value(facts.environment.as_slice(), WORKTREE_COPY_LEASE_ENV)
.map(|token| return token.to_string_lossy().into_owned()),
context,
add_plan: facts.add_plan.clone(),
};
let mut stderr = std::io::stderr();
// The runner starts Git as a child with the lease token, so nested wrappers see the lock they are inside.
let mut run_git = |token: Option<&str>, step: &GitStep<'_>| -> Result<GitStatus, WorktreeCopyError> {
let mut child_overlay: Vec<(OsString, OsString)> = overlay.to_vec();
if let Some(value) = token {
child_overlay.push((OsString::from(WORKTREE_COPY_LEASE_ENV), OsString::from(value)));
}
let mut child_arguments: Vec<OsString> = arguments.to_vec();
match step {
GitStep::Person => {}
GitStep::WithoutCheckout(at) => {
// The index was read from the same arguments, so the word before it is `add`.
if *at == 0 || *at > child_arguments.len() || child_arguments[*at - 1] != "add" {
return Err(WorktreeCopyError(String::from("cli-git: the worktree plan does not match the arguments.")));
}
child_arguments.insert(*at, OsString::from("--no-checkout"));
}
GitStep::Other { arguments: other, environment } => {
child_arguments = other.to_vec();
child_overlay.extend(environment.iter().cloned());
}
}
match run_real_git(real_git, child_arguments.as_slice(), child_overlay.as_slice()) {
Ok(ChildOutcome::Exited(code)) => return Ok(GitStatus { code: Some(code) }),
Ok(ChildOutcome::Signaled(_)) => return Ok(GitStatus { code: None }),
Err(error) => {
return Err(WorktreeCopyError(format!(
"cli-git: cannot start {}: {error}",
real_git.display()
)));
}
}
};
// The identity is asked again once the lock is held, as the incumbent re-observes its repository.
let mut identity_after_lock = || -> Result<WorktreeIdentity, WorktreeCopyError> {
let mut facts_again = git_facts(real_git, facts.global_prefix.as_slice(), overlay);
match facts_again.location() {
Ok(location) => return Ok(location.identity),
Err(message) => return Err(WorktreeCopyError(format!("cli-git: {message}"))),
}
};
match run_with_worktree_copy(&invocation, &mut run_git, &mut identity_after_lock, &mut stderr) {
Ok(settlement) => {
if let Some(failure) = &settlement.copy_failure {
// A closed standard error leaves nowhere to report the failure, and the exit status still carries it.
let _ = writeln!(stderr, "{}", failure.0);
}
return ForwardedRun::Ran(exit_code(&settlement));
}
Err(failure) => {
return ForwardedRun::Failed {
stderr: format!("{}\n", failure.0),
code: super::action::ENGINE_FAILURE_EXIT_CODE,
};
}
}
}
/// Forwarding-run controls stay out of the release executable.
#[cfg(test)]
#[path = "worktree_copy_forward_tests.rs"]
mod tests;