Skip to main content

zeph_config/
worktree.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Configuration for the per-subagent git worktree isolation feature.
5//!
6//! The `[worktree]` section controls whether subagents execute inside an isolated
7//! git worktree, how that worktree is branched, and how background agents behave.
8//! All fields have sensible defaults — existing configs without a `[worktree]`
9//! section parse as if the feature is disabled (`enabled = false`).
10//!
11//! # Example
12//!
13//! ```toml
14//! [worktree]
15//! enabled = true
16//! base_ref = "head"
17//! default_branch = "main"
18//! root = ".claude/worktrees"
19//! branch_prefix = "agent/"
20//! prune_branch_on_remove = false
21//! cleanup_on_completion = true
22//! bg_isolation = "worktree"
23//! ```
24
25use serde::{Deserialize, Serialize};
26
27/// Configuration for the per-subagent git worktree isolation feature.
28///
29/// When `enabled = true`, each subagent that opts in via
30/// `SubAgentPermissions::worktree` receives a dedicated git worktree on a
31/// fresh branch, ensuring that file edits from concurrent agents do not
32/// interfere with each other or with the main working tree.
33///
34/// # Examples
35///
36/// ```
37/// use zeph_config::WorktreeConfig;
38///
39/// let cfg = WorktreeConfig::default();
40/// assert!(!cfg.enabled);
41/// assert_eq!(cfg.root, ".claude/worktrees");
42/// assert_eq!(cfg.branch_prefix, "agent/");
43/// assert_eq!(cfg.git_timeout_secs, 30);
44/// ```
45#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(default)]
47#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic for TOML-deserialized configuration
48pub struct WorktreeConfig {
49    /// Enable per-subagent git worktrees. When `false`, no worktrees are created
50    /// regardless of other settings.
51    pub enabled: bool,
52    /// Base commit strategy for new worktree branches.
53    pub base_ref: WorktreeBaseRef,
54    /// Default remote branch used when `base_ref = "fresh"`.
55    ///
56    /// Empty string triggers auto-detection of `origin/HEAD`.
57    pub default_branch: String,
58    /// Root directory for worktrees, relative to the repository root.
59    ///
60    /// Each worktree is placed in a subdirectory named after the subagent ID.
61    pub root: String,
62    /// Branch name prefix. The full branch name is `"{prefix}{subagent_id}"`.
63    pub branch_prefix: String,
64    /// Delete the worktree branch after the worktree is removed.
65    ///
66    /// When `false` (default), the branch persists so the agent's work can be
67    /// reviewed, merged, or discarded manually.
68    pub prune_branch_on_remove: bool,
69    /// Remove the worktree when the agent completes or is cancelled.
70    ///
71    /// When `false`, worktrees persist until an explicit `worktree clean` command.
72    pub cleanup_on_completion: bool,
73    /// Background subagent isolation mode.
74    ///
75    /// Controls whether background subagents receive a dedicated worktree or
76    /// edit the working copy directly.
77    pub bg_isolation: BgIsolation,
78    /// Per-command timeout for `git` invocations, in seconds.
79    ///
80    /// Applied to every `git` call issued by the worktree subsystem (e.g.
81    /// `git worktree add`, `git fetch`, `git rev-parse`).  Increase this value
82    /// on repositories that are slow to clone or when running over high-latency
83    /// network links.  A value of `0` is clamped to `1` second by
84    /// [`DefaultGitRunner`](https://docs.rs/zeph-worktree/latest/zeph_worktree/git_runner/struct.DefaultGitRunner.html),
85    /// not by any call site.
86    pub git_timeout_secs: u64,
87    /// Maximum number of concurrent worktrees under `root`. `None` (default) means
88    /// unlimited.
89    ///
90    /// Enforced as a creation-time admission cap: a `create()` call that would push
91    /// the count of git-registered secondary worktrees to `max_worktrees` or beyond
92    /// fails with `WorktreeError::QuotaExceeded` instead of silently growing disk
93    /// usage. The count includes worktrees created by *other*, concurrently running
94    /// zeph sessions over the same `root` — `max_worktrees` bounds total disk-safe
95    /// usage under the repository, not just this session's own worktrees. The check
96    /// is a best-effort soft cap: it is not atomic across processes, so two
97    /// concurrent `create()` calls can both pass and briefly exceed the configured
98    /// maximum by a small margin. Lowering this value below the current worktree
99    /// count does not evict existing worktrees; it only blocks new admissions until
100    /// an operator runs `zeph worktree clean` or raises the limit. A value of
101    /// `Some(0)` is rejected at config-validation time (it would block all worktree
102    /// creation).
103    pub max_worktrees: Option<usize>,
104    /// Soft total-disk-usage threshold, in megabytes, across all worktrees under
105    /// `root`. `None` (default) disables disk accounting.
106    ///
107    /// When exceeded, the reconcile sweep (startup and/or periodic, see
108    /// `auto_reconcile_secs` / `reconcile_on_startup`) emits a warning status
109    /// indicator and auto-reclaims only git-`prunable` entries — an intact worktree
110    /// is never force-removed to satisfy this threshold. The reported total is a sum
111    /// of logical file sizes (`metadata().len()`), not on-disk block usage; content
112    /// shared via hardlinks across worktrees (e.g. zeph-session blobs) can be
113    /// double-counted, so treat the total as an approximation, not exact `du`
114    /// output. A value of `Some(0)` is rejected at config-validation time (it would
115    /// leave every non-empty worktree permanently over quota).
116    pub disk_quota_mb: Option<u64>,
117    /// Interval, in seconds, for the supervised background reconcile-and-quota
118    /// sweep. `0` (default) disables the periodic sweep.
119    ///
120    /// When greater than zero, one task is registered with the session
121    /// `TaskSupervisor` that, on this cadence, reconciles the git worktree
122    /// registry, auto-reclaims `prunable` entries via the same path as
123    /// `zeph worktree clean` (non-force), and evaluates `max_worktrees` /
124    /// `disk_quota_mb`. Each tick may perform a filesystem walk of every worktree
125    /// (potentially multi-gigabyte `target/` directories), so a short interval is
126    /// wasteful; an hourly cadence (`3600`) is a reasonable default when enabling
127    /// this. `Config::validate` rejects any value in `1..60` — a sub-minute interval
128    /// would run a full filesystem walk in a tight loop.
129    pub auto_reconcile_secs: u64,
130    /// Run one reconcile-and-quota sweep at bootstrap, immediately after the
131    /// worktree manager is constructed. Default `true`.
132    ///
133    /// Recovers from a crash that left `prunable` worktrees behind without waiting
134    /// for the first periodic tick, and evaluates `disk_quota_mb` / `max_worktrees`
135    /// once per launch even when `auto_reconcile_secs = 0` — without this, a
136    /// `disk_quota_mb` set by itself would never be evaluated at all. `Config::validate`
137    /// rejects `disk_quota_mb.is_some()` combined with both this field `false` and
138    /// `auto_reconcile_secs == 0`, so that exact inert combination cannot reach a running
139    /// session. Safe by construction: the startup sweep only ever removes entries git itself
140    /// reports as `prunable` (directory or gitdir-link already gone), identical to
141    /// `zeph worktree clean` without `--force`.
142    pub reconcile_on_startup: bool,
143}
144
145fn default_git_timeout_secs() -> u64 {
146    30
147}
148
149impl Default for WorktreeConfig {
150    fn default() -> Self {
151        Self {
152            enabled: false,
153            base_ref: WorktreeBaseRef::default(),
154            default_branch: "main".to_owned(),
155            root: ".claude/worktrees".to_owned(),
156            branch_prefix: "agent/".to_owned(),
157            prune_branch_on_remove: false,
158            cleanup_on_completion: true,
159            bg_isolation: BgIsolation::default(),
160            git_timeout_secs: default_git_timeout_secs(),
161            max_worktrees: None,
162            disk_quota_mb: None,
163            auto_reconcile_secs: 0,
164            reconcile_on_startup: true,
165        }
166    }
167}
168
169/// Base commit strategy for worktree branches.
170///
171/// Determines where the new branch for an agent's worktree is forked from.
172///
173/// # Examples
174///
175/// ```
176/// use zeph_config::WorktreeBaseRef;
177///
178/// // Default is Head — no network access needed.
179/// let base = WorktreeBaseRef::default();
180/// assert!(matches!(base, WorktreeBaseRef::Head));
181/// ```
182#[derive(Debug, Clone, Default, Serialize, Deserialize)]
183#[serde(rename_all = "snake_case")]
184#[non_exhaustive]
185pub enum WorktreeBaseRef {
186    /// Branch from the local `HEAD` commit. No network access required.
187    #[default]
188    Head,
189    /// Fetch `origin/<default_branch>` and branch from that commit.
190    ///
191    /// Ensures the agent starts from the latest remote state, at the cost of
192    /// a `git fetch` on every spawn.
193    Fresh,
194}
195
196/// Background subagent isolation mode.
197///
198/// Controls whether background subagents (spawned implicitly, not by an explicit
199/// user command) receive an isolated git worktree or edit the shared working copy.
200///
201/// # Examples
202///
203/// ```
204/// use zeph_config::BgIsolation;
205///
206/// // Default is Worktree — background agents are fully isolated.
207/// let iso = BgIsolation::default();
208/// assert!(matches!(iso, BgIsolation::Worktree));
209/// ```
210#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
211#[serde(rename_all = "snake_case")]
212#[non_exhaustive]
213pub enum BgIsolation {
214    /// Background subagents receive an isolated git worktree (default).
215    ///
216    /// This is the recommended setting — it prevents background agents from
217    /// accidentally editing files that the user is working on.
218    #[default]
219    Worktree,
220    /// Background subagents edit the working copy directly, without a worktree.
221    ///
222    /// Use only when worktrees are impractical for the repository (e.g., bare
223    /// clones or repos with hooks that break under worktrees).
224    None,
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use std::assert_matches;
231
232    #[test]
233    fn worktree_config_default_values() {
234        let cfg = WorktreeConfig::default();
235        assert!(!cfg.enabled);
236        assert_matches!(cfg.base_ref, WorktreeBaseRef::Head);
237        assert_eq!(cfg.default_branch, "main");
238        assert_eq!(cfg.root, ".claude/worktrees");
239        assert_eq!(cfg.branch_prefix, "agent/");
240        assert!(!cfg.prune_branch_on_remove);
241        assert!(cfg.cleanup_on_completion);
242        assert_eq!(cfg.bg_isolation, BgIsolation::Worktree);
243        assert_eq!(cfg.git_timeout_secs, 30);
244        assert_eq!(cfg.max_worktrees, None);
245        assert_eq!(cfg.disk_quota_mb, None);
246        assert_eq!(cfg.auto_reconcile_secs, 0);
247        assert!(cfg.reconcile_on_startup);
248    }
249
250    #[test]
251    fn worktree_config_roundtrip_toml() {
252        let cfg = WorktreeConfig::default();
253        let serialized = toml::to_string(&cfg).expect("serialize");
254        let deserialized: WorktreeConfig = toml::from_str(&serialized).expect("deserialize");
255        assert!(!deserialized.enabled);
256        assert_eq!(deserialized.root, cfg.root);
257        assert_eq!(deserialized.branch_prefix, cfg.branch_prefix);
258        assert_eq!(deserialized.bg_isolation, cfg.bg_isolation);
259        assert_eq!(deserialized.git_timeout_secs, 30);
260        assert_eq!(deserialized.max_worktrees, cfg.max_worktrees);
261        assert_eq!(deserialized.disk_quota_mb, cfg.disk_quota_mb);
262        assert_eq!(deserialized.auto_reconcile_secs, cfg.auto_reconcile_secs);
263        assert_eq!(deserialized.reconcile_on_startup, cfg.reconcile_on_startup);
264    }
265
266    #[test]
267    fn worktree_base_ref_roundtrip_toml() {
268        #[derive(Serialize, Deserialize, Debug)]
269        struct Wrapper {
270            base_ref: WorktreeBaseRef,
271        }
272        let head = Wrapper {
273            base_ref: WorktreeBaseRef::Head,
274        };
275        let s = toml::to_string(&head).expect("serialize Head");
276        assert!(s.contains("head"), "expected 'head' in: {s}");
277        let rt: Wrapper = toml::from_str(&s).expect("deserialize Head");
278        assert_matches!(rt.base_ref, WorktreeBaseRef::Head);
279
280        let fresh = Wrapper {
281            base_ref: WorktreeBaseRef::Fresh,
282        };
283        let s = toml::to_string(&fresh).expect("serialize Fresh");
284        assert!(s.contains("fresh"), "expected 'fresh' in: {s}");
285        let rt: Wrapper = toml::from_str(&s).expect("deserialize Fresh");
286        assert_matches!(rt.base_ref, WorktreeBaseRef::Fresh);
287    }
288
289    #[test]
290    fn bg_isolation_roundtrip_toml() {
291        #[derive(Serialize, Deserialize, Debug)]
292        struct Wrapper {
293            bg_isolation: BgIsolation,
294        }
295        let iso = Wrapper {
296            bg_isolation: BgIsolation::Worktree,
297        };
298        let s = toml::to_string(&iso).expect("serialize Worktree");
299        assert!(s.contains("worktree"), "expected 'worktree' in: {s}");
300        let rt: Wrapper = toml::from_str(&s).expect("deserialize Worktree");
301        assert_eq!(rt.bg_isolation, BgIsolation::Worktree);
302
303        let none = Wrapper {
304            bg_isolation: BgIsolation::None,
305        };
306        let s = toml::to_string(&none).expect("serialize None");
307        assert!(s.contains("none"), "expected 'none' in: {s}");
308        let rt: Wrapper = toml::from_str(&s).expect("deserialize None");
309        assert_eq!(rt.bg_isolation, BgIsolation::None);
310    }
311
312    #[test]
313    fn worktree_config_enabled_roundtrip() {
314        let toml_src = r#"
315enabled = true
316base_ref = "fresh"
317default_branch = "develop"
318root = ".worktrees"
319branch_prefix = "bot/"
320prune_branch_on_remove = true
321cleanup_on_completion = false
322bg_isolation = "none"
323"#;
324        let cfg: WorktreeConfig = toml::from_str(toml_src).expect("deserialize custom");
325        assert!(cfg.enabled);
326        assert_matches!(cfg.base_ref, WorktreeBaseRef::Fresh);
327        assert_eq!(cfg.default_branch, "develop");
328        assert_eq!(cfg.root, ".worktrees");
329        assert_eq!(cfg.branch_prefix, "bot/");
330        assert!(cfg.prune_branch_on_remove);
331        assert!(!cfg.cleanup_on_completion);
332        assert_eq!(cfg.bg_isolation, BgIsolation::None);
333        // git_timeout_secs not set → must fall back to default
334        assert_eq!(cfg.git_timeout_secs, 30);
335    }
336
337    #[test]
338    fn worktree_config_git_timeout_secs_custom() {
339        let toml_src = "enabled = true\ngit_timeout_secs = 120\n";
340        let cfg: WorktreeConfig = toml::from_str(toml_src).expect("deserialize");
341        assert_eq!(cfg.git_timeout_secs, 120);
342    }
343
344    #[test]
345    fn worktree_config_git_timeout_secs_defaults_when_absent() {
346        // Configs written before this field was added must parse without error
347        // and resolve to the 30-second default.
348        let toml_src = "enabled = false\n";
349        let cfg: WorktreeConfig = toml::from_str(toml_src).expect("deserialize");
350        assert_eq!(cfg.git_timeout_secs, 30);
351    }
352
353    #[test]
354    fn worktree_config_quota_fields_default_when_absent() {
355        // Configs written before max_worktrees/disk_quota_mb/auto_reconcile_secs/
356        // reconcile_on_startup were added must parse without error and resolve to
357        // their defaults (unlimited, no accounting, no periodic sweep, startup
358        // sweep on).
359        let toml_src = "enabled = true\n";
360        let cfg: WorktreeConfig = toml::from_str(toml_src).expect("deserialize");
361        assert_eq!(cfg.max_worktrees, None);
362        assert_eq!(cfg.disk_quota_mb, None);
363        assert_eq!(cfg.auto_reconcile_secs, 0);
364        assert!(cfg.reconcile_on_startup);
365    }
366
367    #[test]
368    fn worktree_config_quota_fields_custom_values_roundtrip() {
369        let toml_src = "enabled = true\n\
370             max_worktrees = 5\n\
371             disk_quota_mb = 2048\n\
372             auto_reconcile_secs = 3600\n\
373             reconcile_on_startup = false\n";
374        let cfg: WorktreeConfig = toml::from_str(toml_src).expect("deserialize");
375        assert_eq!(cfg.max_worktrees, Some(5));
376        assert_eq!(cfg.disk_quota_mb, Some(2048));
377        assert_eq!(cfg.auto_reconcile_secs, 3600);
378        assert!(!cfg.reconcile_on_startup);
379    }
380}