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
168 #[test]
169 fn worktree_config_default_values() {
170 let cfg = WorktreeConfig::default();
171 assert!(!cfg.enabled);
172 assert!(matches!(cfg.base_ref, WorktreeBaseRef::Head));
173 assert_eq!(cfg.default_branch, "main");
174 assert_eq!(cfg.root, ".claude/worktrees");
175 assert_eq!(cfg.branch_prefix, "agent/");
176 assert!(!cfg.prune_branch_on_remove);
177 assert!(cfg.cleanup_on_completion);
178 assert_eq!(cfg.bg_isolation, BgIsolation::Worktree);
179 assert_eq!(cfg.git_timeout_secs, 30);
180 }
181
182 #[test]
183 fn worktree_config_roundtrip_toml() {
184 let cfg = WorktreeConfig::default();
185 let serialized = toml::to_string(&cfg).expect("serialize");
186 let deserialized: WorktreeConfig = toml::from_str(&serialized).expect("deserialize");
187 assert!(!deserialized.enabled);
188 assert_eq!(deserialized.root, cfg.root);
189 assert_eq!(deserialized.branch_prefix, cfg.branch_prefix);
190 assert_eq!(deserialized.bg_isolation, cfg.bg_isolation);
191 assert_eq!(deserialized.git_timeout_secs, 30);
192 }
193
194 #[test]
195 fn worktree_base_ref_roundtrip_toml() {
196 #[derive(Serialize, Deserialize, Debug)]
197 struct Wrapper {
198 base_ref: WorktreeBaseRef,
199 }
200 let head = Wrapper {
201 base_ref: WorktreeBaseRef::Head,
202 };
203 let s = toml::to_string(&head).expect("serialize Head");
204 assert!(s.contains("head"), "expected 'head' in: {s}");
205 let rt: Wrapper = toml::from_str(&s).expect("deserialize Head");
206 assert!(matches!(rt.base_ref, WorktreeBaseRef::Head));
207
208 let fresh = Wrapper {
209 base_ref: WorktreeBaseRef::Fresh,
210 };
211 let s = toml::to_string(&fresh).expect("serialize Fresh");
212 assert!(s.contains("fresh"), "expected 'fresh' in: {s}");
213 let rt: Wrapper = toml::from_str(&s).expect("deserialize Fresh");
214 assert!(matches!(rt.base_ref, WorktreeBaseRef::Fresh));
215 }
216
217 #[test]
218 fn bg_isolation_roundtrip_toml() {
219 #[derive(Serialize, Deserialize, Debug)]
220 struct Wrapper {
221 bg_isolation: BgIsolation,
222 }
223 let iso = Wrapper {
224 bg_isolation: BgIsolation::Worktree,
225 };
226 let s = toml::to_string(&iso).expect("serialize Worktree");
227 assert!(s.contains("worktree"), "expected 'worktree' in: {s}");
228 let rt: Wrapper = toml::from_str(&s).expect("deserialize Worktree");
229 assert_eq!(rt.bg_isolation, BgIsolation::Worktree);
230
231 let none = Wrapper {
232 bg_isolation: BgIsolation::None,
233 };
234 let s = toml::to_string(&none).expect("serialize None");
235 assert!(s.contains("none"), "expected 'none' in: {s}");
236 let rt: Wrapper = toml::from_str(&s).expect("deserialize None");
237 assert_eq!(rt.bg_isolation, BgIsolation::None);
238 }
239
240 #[test]
241 fn worktree_config_enabled_roundtrip() {
242 let toml_src = r#"
243enabled = true
244base_ref = "fresh"
245default_branch = "develop"
246root = ".worktrees"
247branch_prefix = "bot/"
248prune_branch_on_remove = true
249cleanup_on_completion = false
250bg_isolation = "none"
251"#;
252 let cfg: WorktreeConfig = toml::from_str(toml_src).expect("deserialize custom");
253 assert!(cfg.enabled);
254 assert!(matches!(cfg.base_ref, WorktreeBaseRef::Fresh));
255 assert_eq!(cfg.default_branch, "develop");
256 assert_eq!(cfg.root, ".worktrees");
257 assert_eq!(cfg.branch_prefix, "bot/");
258 assert!(cfg.prune_branch_on_remove);
259 assert!(!cfg.cleanup_on_completion);
260 assert_eq!(cfg.bg_isolation, BgIsolation::None);
261 // git_timeout_secs not set → must fall back to default
262 assert_eq!(cfg.git_timeout_secs, 30);
263 }
264
265 #[test]
266 fn worktree_config_git_timeout_secs_custom() {
267 let toml_src = "enabled = true\ngit_timeout_secs = 120\n";
268 let cfg: WorktreeConfig = toml::from_str(toml_src).expect("deserialize");
269 assert_eq!(cfg.git_timeout_secs, 120);
270 }
271
272 #[test]
273 fn worktree_config_git_timeout_secs_defaults_when_absent() {
274 // Configs written before this field was added must parse without error
275 // and resolve to the 30-second default.
276 let toml_src = "enabled = false\n";
277 let cfg: WorktreeConfig = toml::from_str(toml_src).expect("deserialize");
278 assert_eq!(cfg.git_timeout_secs, 30);
279 }
280}