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)]
47pub struct WorktreeConfig {
48 /// Enable per-subagent git worktrees. When `false`, no worktrees are created
49 /// regardless of other settings.
50 pub enabled: bool,
51 /// Base commit strategy for new worktree branches.
52 pub base_ref: WorktreeBaseRef,
53 /// Default remote branch used when `base_ref = "fresh"`.
54 ///
55 /// Empty string triggers auto-detection of `origin/HEAD`.
56 pub default_branch: String,
57 /// Root directory for worktrees, relative to the repository root.
58 ///
59 /// Each worktree is placed in a subdirectory named after the subagent ID.
60 pub root: String,
61 /// Branch name prefix. The full branch name is `"{prefix}{subagent_id}"`.
62 pub branch_prefix: String,
63 /// Delete the worktree branch after the worktree is removed.
64 ///
65 /// When `false` (default), the branch persists so the agent's work can be
66 /// reviewed, merged, or discarded manually.
67 pub prune_branch_on_remove: bool,
68 /// Remove the worktree when the agent completes or is cancelled.
69 ///
70 /// When `false`, worktrees persist until an explicit `worktree clean` command.
71 pub cleanup_on_completion: bool,
72 /// Background subagent isolation mode.
73 ///
74 /// Controls whether background subagents receive a dedicated worktree or
75 /// edit the working copy directly.
76 pub bg_isolation: BgIsolation,
77 /// Per-command timeout for `git` invocations, in seconds.
78 ///
79 /// Applied to every `git` call issued by the worktree subsystem (e.g.
80 /// `git worktree add`, `git fetch`, `git rev-parse`). Increase this value
81 /// on repositories that are slow to clone or when running over high-latency
82 /// network links. A value of `0` is treated as `1` at the call site.
83 pub git_timeout_secs: u64,
84}
85
86fn default_git_timeout_secs() -> u64 {
87 30
88}
89
90impl Default for WorktreeConfig {
91 fn default() -> Self {
92 Self {
93 enabled: false,
94 base_ref: WorktreeBaseRef::default(),
95 default_branch: "main".to_owned(),
96 root: ".claude/worktrees".to_owned(),
97 branch_prefix: "agent/".to_owned(),
98 prune_branch_on_remove: false,
99 cleanup_on_completion: true,
100 bg_isolation: BgIsolation::default(),
101 git_timeout_secs: default_git_timeout_secs(),
102 }
103 }
104}
105
106/// Base commit strategy for worktree branches.
107///
108/// Determines where the new branch for an agent's worktree is forked from.
109///
110/// # Examples
111///
112/// ```
113/// use zeph_config::WorktreeBaseRef;
114///
115/// // Default is Head — no network access needed.
116/// let base = WorktreeBaseRef::default();
117/// assert!(matches!(base, WorktreeBaseRef::Head));
118/// ```
119#[derive(Debug, Clone, Default, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121#[non_exhaustive]
122pub enum WorktreeBaseRef {
123 /// Branch from the local `HEAD` commit. No network access required.
124 #[default]
125 Head,
126 /// Fetch `origin/<default_branch>` and branch from that commit.
127 ///
128 /// Ensures the agent starts from the latest remote state, at the cost of
129 /// a `git fetch` on every spawn.
130 Fresh,
131}
132
133/// Background subagent isolation mode.
134///
135/// Controls whether background subagents (spawned implicitly, not by an explicit
136/// user command) receive an isolated git worktree or edit the shared working copy.
137///
138/// # Examples
139///
140/// ```
141/// use zeph_config::BgIsolation;
142///
143/// // Default is Worktree — background agents are fully isolated.
144/// let iso = BgIsolation::default();
145/// assert!(matches!(iso, BgIsolation::Worktree));
146/// ```
147#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
148#[serde(rename_all = "snake_case")]
149#[non_exhaustive]
150pub enum BgIsolation {
151 /// Background subagents receive an isolated git worktree (default).
152 ///
153 /// This is the recommended setting — it prevents background agents from
154 /// accidentally editing files that the user is working on.
155 #[default]
156 Worktree,
157 /// Background subagents edit the working copy directly, without a worktree.
158 ///
159 /// Use only when worktrees are impractical for the repository (e.g., bare
160 /// clones or repos with hooks that break under worktrees).
161 None,
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167 use std::assert_matches;
168
169 #[test]
170 fn worktree_config_default_values() {
171 let cfg = WorktreeConfig::default();
172 assert!(!cfg.enabled);
173 assert_matches!(cfg.base_ref, WorktreeBaseRef::Head);
174 assert_eq!(cfg.default_branch, "main");
175 assert_eq!(cfg.root, ".claude/worktrees");
176 assert_eq!(cfg.branch_prefix, "agent/");
177 assert!(!cfg.prune_branch_on_remove);
178 assert!(cfg.cleanup_on_completion);
179 assert_eq!(cfg.bg_isolation, BgIsolation::Worktree);
180 assert_eq!(cfg.git_timeout_secs, 30);
181 }
182
183 #[test]
184 fn worktree_config_roundtrip_toml() {
185 let cfg = WorktreeConfig::default();
186 let serialized = toml::to_string(&cfg).expect("serialize");
187 let deserialized: WorktreeConfig = toml::from_str(&serialized).expect("deserialize");
188 assert!(!deserialized.enabled);
189 assert_eq!(deserialized.root, cfg.root);
190 assert_eq!(deserialized.branch_prefix, cfg.branch_prefix);
191 assert_eq!(deserialized.bg_isolation, cfg.bg_isolation);
192 assert_eq!(deserialized.git_timeout_secs, 30);
193 }
194
195 #[test]
196 fn worktree_base_ref_roundtrip_toml() {
197 #[derive(Serialize, Deserialize, Debug)]
198 struct Wrapper {
199 base_ref: WorktreeBaseRef,
200 }
201 let head = Wrapper {
202 base_ref: WorktreeBaseRef::Head,
203 };
204 let s = toml::to_string(&head).expect("serialize Head");
205 assert!(s.contains("head"), "expected 'head' in: {s}");
206 let rt: Wrapper = toml::from_str(&s).expect("deserialize Head");
207 assert_matches!(rt.base_ref, WorktreeBaseRef::Head);
208
209 let fresh = Wrapper {
210 base_ref: WorktreeBaseRef::Fresh,
211 };
212 let s = toml::to_string(&fresh).expect("serialize Fresh");
213 assert!(s.contains("fresh"), "expected 'fresh' in: {s}");
214 let rt: Wrapper = toml::from_str(&s).expect("deserialize Fresh");
215 assert_matches!(rt.base_ref, WorktreeBaseRef::Fresh);
216 }
217
218 #[test]
219 fn bg_isolation_roundtrip_toml() {
220 #[derive(Serialize, Deserialize, Debug)]
221 struct Wrapper {
222 bg_isolation: BgIsolation,
223 }
224 let iso = Wrapper {
225 bg_isolation: BgIsolation::Worktree,
226 };
227 let s = toml::to_string(&iso).expect("serialize Worktree");
228 assert!(s.contains("worktree"), "expected 'worktree' in: {s}");
229 let rt: Wrapper = toml::from_str(&s).expect("deserialize Worktree");
230 assert_eq!(rt.bg_isolation, BgIsolation::Worktree);
231
232 let none = Wrapper {
233 bg_isolation: BgIsolation::None,
234 };
235 let s = toml::to_string(&none).expect("serialize None");
236 assert!(s.contains("none"), "expected 'none' in: {s}");
237 let rt: Wrapper = toml::from_str(&s).expect("deserialize None");
238 assert_eq!(rt.bg_isolation, BgIsolation::None);
239 }
240
241 #[test]
242 fn worktree_config_enabled_roundtrip() {
243 let toml_src = r#"
244enabled = true
245base_ref = "fresh"
246default_branch = "develop"
247root = ".worktrees"
248branch_prefix = "bot/"
249prune_branch_on_remove = true
250cleanup_on_completion = false
251bg_isolation = "none"
252"#;
253 let cfg: WorktreeConfig = toml::from_str(toml_src).expect("deserialize custom");
254 assert!(cfg.enabled);
255 assert_matches!(cfg.base_ref, WorktreeBaseRef::Fresh);
256 assert_eq!(cfg.default_branch, "develop");
257 assert_eq!(cfg.root, ".worktrees");
258 assert_eq!(cfg.branch_prefix, "bot/");
259 assert!(cfg.prune_branch_on_remove);
260 assert!(!cfg.cleanup_on_completion);
261 assert_eq!(cfg.bg_isolation, BgIsolation::None);
262 // git_timeout_secs not set → must fall back to default
263 assert_eq!(cfg.git_timeout_secs, 30);
264 }
265
266 #[test]
267 fn worktree_config_git_timeout_secs_custom() {
268 let toml_src = "enabled = true\ngit_timeout_secs = 120\n";
269 let cfg: WorktreeConfig = toml::from_str(toml_src).expect("deserialize");
270 assert_eq!(cfg.git_timeout_secs, 120);
271 }
272
273 #[test]
274 fn worktree_config_git_timeout_secs_defaults_when_absent() {
275 // Configs written before this field was added must parse without error
276 // and resolve to the 30-second default.
277 let toml_src = "enabled = false\n";
278 let cfg: WorktreeConfig = toml::from_str(toml_src).expect("deserialize");
279 assert_eq!(cfg.git_timeout_secs, 30);
280 }
281}