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)]
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}