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}