Skip to main content

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}