omni_dev/git/worktree_batch.rs
1//! Primitives shared by the batch-worktree git engines — [`worktree_rebase`] and
2//! [`worktree_push`].
3//!
4//! Both engines answer the same two questions before they do anything specific:
5//! *which* worktrees is this batch about, and *what* is checked out in each. They
6//! also both need one carefully-configured `git` subprocess seam. Extracting that
7//! common half here keeps a second engine from re-deriving it — the two would
8//! drift, and the subprocess seam in particular encodes a non-obvious
9//! environment-snapshot rule (see [`run_git_in`]) that is not safe to re-invent.
10//!
11//! Nothing here decides anything: classification, the mutation, and the outcome
12//! shapes belong to each engine.
13//!
14//! [`worktree_rebase`]: crate::git::worktree_rebase
15//! [`worktree_push`]: crate::git::worktree_push
16
17use std::path::{Path, PathBuf};
18use std::process::Command;
19
20use anyhow::{Context, Result};
21use git2::{Oid, Repository};
22
23/// Which worktrees a batch operation should target.
24///
25/// Shared by the rebase and push engines: both take an explicit selection and
26/// neither has a bare "do everything everywhere" mode. What a path that turns out
27/// to be unsuitable means — skipped and why — is each engine's own business.
28#[derive(Debug, Clone)]
29pub enum Selection {
30 /// Operate on exactly these worktree folders (each resolved to the worktree
31 /// that contains it). A path that is unsuitable is reported and skipped,
32 /// never acted on. The main working tree is a valid target like any other
33 /// (ADR-0060); a push additionally refuses to *force*-push the repository's
34 /// remote default branch, but that gate is on the branch, not the worktree
35 /// (ADR-0061).
36 Paths(Vec<PathBuf>),
37 /// Operate on every worktree of the repository that contains `base` (usually
38 /// the process working directory) — the main working tree included, alongside
39 /// every linked one (ADR-0060).
40 All {
41 /// The directory whose repository's worktrees are the target set.
42 base: PathBuf,
43 },
44}
45
46/// The concrete worktree paths a [`Selection`] targets.
47pub(crate) fn resolve_selection(selection: &Selection) -> Result<Vec<PathBuf>> {
48 match selection {
49 Selection::Paths(paths) => Ok(paths.clone()),
50 Selection::All { base } => all_worktree_paths(base),
51 }
52}
53
54/// Every worktree path of the repository containing `base` — the main working tree
55/// plus every linked one (ADR-0060). Mirrors the daemon service's repo enumeration:
56/// discover the repo, resolve its shared common dir's parent as the main root, then
57/// list the worktrees registered on the main repository.
58pub(crate) fn all_worktree_paths(base: &Path) -> Result<Vec<PathBuf>> {
59 let repo = Repository::discover(base)
60 .with_context(|| format!("not inside a git repository: {}", base.display()))?;
61 let root = main_root(&repo);
62 let main_repo = Repository::open(&root)
63 .with_context(|| format!("cannot open main repository: {}", root.display()))?;
64 let names = main_repo
65 .worktrees()
66 .context("cannot enumerate worktrees")?;
67 let mut paths = vec![root];
68 // `iter()` yields `Result<Option<&str>, _>`: the first `flatten` drops per-name
69 // errors, the second drops non-UTF-8 names (same idiom as the daemon service).
70 for name in names.iter().flatten().flatten() {
71 if let Ok(worktree) = main_repo.find_worktree(name) {
72 paths.push(worktree.path().to_path_buf());
73 }
74 }
75 Ok(paths)
76}
77
78/// The main working-tree root of `repo`: the parent of its shared common dir. For a
79/// linked worktree this is the original checkout every worktree shares.
80pub(crate) fn main_root(repo: &Repository) -> PathBuf {
81 let commondir = repo.commondir();
82 let commondir = std::fs::canonicalize(commondir).unwrap_or_else(|_| commondir.to_path_buf());
83 let parent = commondir.parent().map(Path::to_path_buf);
84 parent.unwrap_or(commondir)
85}
86
87/// The checked-out branch shorthand and HEAD oid. Both are `None` for a detached or
88/// unborn HEAD — which is exactly the "there is no branch to act on" case both
89/// engines skip. (A worktree sitting mid-rebase has a *detached* HEAD, so this is
90/// also what keeps a push away from one.)
91pub(crate) fn head_branch(repo: &Repository) -> (Option<String>, Option<Oid>) {
92 match repo.head() {
93 Ok(head) if head.is_branch() => (
94 head.shorthand().ok().map(ToString::to_string),
95 head.target(),
96 ),
97 Ok(head) => (None, head.target()),
98 Err(_) => (None, None),
99 }
100}
101
102/// Runs `git <args>` in `dir`, capturing its output.
103///
104/// The child receives a snapshot of the current environment (`env_clear` + `envs`)
105/// so the spawn stays out of the data race against concurrent `std::env::set_var`
106/// (issue #1022; same idiom as `crate::cli::git::worktree`). Shelling out to the
107/// user's `git` — rather than libgit2's network transport — is deliberate: it works
108/// across SSH/HTTPS and honours the user's authentication configuration (ADR-0003,
109/// issue #903).
110///
111/// That environment snapshot is also what makes the daemon host viable: under
112/// launchd the daemon's own environment carries the per-user `SSH_AUTH_SOCK`, so
113/// the child inherits the user's `ssh-agent` unchanged (ADR-0059). `git` itself is
114/// passed in resolved, because that environment's `PATH` is minimal.
115pub(crate) fn run_git_in(git: &Path, dir: &Path, args: &[&str]) -> Result<std::process::Output> {
116 let mut cmd = Command::new(git);
117 cmd.env_clear();
118 cmd.envs(std::env::vars_os());
119 cmd.current_dir(dir)
120 .args(args)
121 .output()
122 .with_context(|| format!("failed to execute {} in {}", git.display(), dir.display()))
123}
124
125/// `skip_serializing_if` predicate for a `bool` defaulting to `false`, so the field
126/// is dropped on the wire unless set — the protocol's forward-compatibility
127/// convention (the twin of the daemon service's helper of the same name).
128#[allow(clippy::trivially_copy_pass_by_ref)]
129pub(crate) fn is_false(b: &bool) -> bool {
130 !*b
131}
132
133/// The trimmed stderr of a git subprocess (falling back to stdout when stderr is
134/// empty), for a single-line error message.
135pub(crate) fn trimmed_stderr(output: &std::process::Output) -> String {
136 let stderr = String::from_utf8_lossy(&output.stderr);
137 let trimmed = stderr.trim();
138 if trimmed.is_empty() {
139 String::from_utf8_lossy(&output.stdout).trim().to_string()
140 } else {
141 trimmed.to_string()
142 }
143}
144
145/// A process-wide lock serializing the git-subprocess-heavy tests, shared across
146/// modules (the rebase and push engines' own tests, and the `worktrees
147/// rebase`/`push` CLI tests).
148///
149/// Each such test builds several repos by shelling out to `git`; run in parallel
150/// across the whole suite they burst dozens of processes at once, starving
151/// unrelated timing-sensitive tests (the daemon PR-poll debounce test). Holding one
152/// lock caps the combined concurrent `git` load at a single scenario, which keeps
153/// coverage without destabilising the suite. Poison is ignored — a panicking test
154/// still releases the guard's exclusion.
155#[cfg(test)]
156pub(crate) fn test_serial_lock() -> std::sync::MutexGuard<'static, ()> {
157 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
158 LOCK.lock()
159 .unwrap_or_else(std::sync::PoisonError::into_inner)
160}
161
162#[cfg(test)]
163#[allow(clippy::unwrap_used, clippy::expect_used)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn is_false_is_the_skip_predicate_for_a_defaulted_bool() {
169 assert!(is_false(&false), "an unset flag is dropped from the wire");
170 assert!(!is_false(&true), "a set flag is serialized");
171 }
172
173 #[test]
174 fn trimmed_stderr_falls_back_to_stdout_when_stderr_is_empty() {
175 let with_stderr = std::process::Output {
176 status: std::process::Command::new("true").status().unwrap(),
177 stdout: b"out\n".to_vec(),
178 stderr: b" boom \n".to_vec(),
179 };
180 assert_eq!(trimmed_stderr(&with_stderr), "boom");
181
182 let stdout_only = std::process::Output {
183 status: std::process::Command::new("true").status().unwrap(),
184 stdout: b" fallback \n".to_vec(),
185 stderr: b" \n".to_vec(),
186 };
187 assert_eq!(
188 trimmed_stderr(&stdout_only),
189 "fallback",
190 "a whitespace-only stderr must not shadow a real stdout message"
191 );
192 }
193}