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