Skip to main content

zeph_core/agent/
worktree_commands.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Channel-free `/worktree` command implementation for use via
5//! [`zeph_commands::traits::agent::AgentAccess`].
6//!
7//! Operates on the same live [`zeph_worktree::DefaultWorktreeManager`] instance the running
8//! agent's [`zeph_subagent::SubAgentManager`] uses to create per-subagent worktrees, so
9//! `/worktree list` and `/worktree clean` reflect this session's actual state. Contrast with
10//! the CLI's `zeph worktree list`/`clean` (`src/commands/worktree.rs`), which constructs a
11//! fresh manager from a disk scan on every invocation.
12
13use std::fmt::Write as _;
14use std::future::Future;
15use std::pin::Pin;
16
17use zeph_commands::{CommandError, WorktreeAccess};
18
19use super::command_macros::delegate_cmd;
20use super::{Agent, error::AgentError};
21use crate::channel::Channel;
22
23impl<C: Channel> Agent<C> {
24    /// Channel-free `/worktree list` — formats active and stale worktrees tracked by the
25    /// live session's worktree manager.
26    ///
27    /// Returns `Ok(None)` when the worktree subsystem is disabled for this session.
28    ///
29    /// # Errors
30    ///
31    /// Returns `Err` when git reconciliation fails.
32    pub(super) async fn handle_worktree_list_as_string(
33        &mut self,
34    ) -> Result<Option<String>, AgentError> {
35        let Some(mgr) = &self.services.orchestration.subagent_manager else {
36            return Ok(None);
37        };
38        let Some(wm) = mgr.worktree_manager() else {
39            return Ok(None);
40        };
41
42        let stale = wm.reconcile().await?;
43        let active = wm.list();
44
45        if active.is_empty() && stale.is_empty() {
46            return Ok(Some("No active worktrees.".to_owned()));
47        }
48
49        let mut out = String::new();
50        if !active.is_empty() {
51            let _ = writeln!(out, "{:<36}  PATH", "AGENT ID");
52            for handle in &active {
53                let _ = writeln!(out, "{:<36}  {}", handle.subagent_id, handle.path.display());
54            }
55        }
56        if !stale.is_empty() {
57            if !active.is_empty() {
58                out.push('\n');
59            }
60            out.push_str("Stale (on disk but not tracked):\n");
61            for stale_wt in &stale {
62                match &stale_wt.prunable_reason {
63                    Some(reason) => {
64                        let _ = writeln!(
65                            out,
66                            "  {}  [prunable: {reason}]",
67                            stale_wt.handle.path.display()
68                        );
69                    }
70                    None => {
71                        let _ = writeln!(
72                            out,
73                            "  {}  [in use — not marked prunable by git; may belong to \
74                             another session]",
75                            stale_wt.handle.path.display()
76                        );
77                    }
78                }
79            }
80        }
81        // Always forces a fresh walk (never reads `cached_disk_usage()`), matching the CLI's
82        // `zeph worktree list` (`src/commands/worktree.rs`) exactly — this is a deliberate,
83        // infrequent, user-triggered command, not the `create()` hot path, so the walk cost is
84        // acceptable here (see `WorktreeManager::disk_usage`'s doc comment). Reading the cache
85        // instead previously made this command silently omit the usage footer under the default
86        // config (review N2 / cross-mode divergence).
87        let usage = wm.disk_usage().await?;
88        let count = active.len() + stale.len();
89        let _ = write!(
90            out,
91            "\n{}",
92            zeph_worktree::format_usage_summary(&usage, count, wm.config())
93        );
94        Ok(Some(out.trim_end().to_owned()))
95    }
96
97    /// Channel-free `/worktree clean [--force]` — removes stale worktrees tracked by the
98    /// live session's worktree manager.
99    ///
100    /// `force` mirrors `zeph worktree clean --force`: also removes worktrees whose directory
101    /// git does not report as prunable. Returns `Ok(None)` when the worktree subsystem is
102    /// disabled for this session.
103    ///
104    /// Delegates the actual reconcile/remove/prune pipeline and outcome counting to
105    /// [`WorktreeManager::clean`][zeph_worktree::WorktreeManager::clean], shared with the
106    /// CLI's `zeph worktree clean` (`src/commands/worktree.rs`), so the two surfaces cannot
107    /// silently diverge in behavior (#6142) — only the `--force` hint text differs.
108    ///
109    /// # Errors
110    ///
111    /// Returns `Err` only when the initial git reconciliation fails (nothing has been
112    /// removed yet, so there is no summary to lose). Per-worktree removal failures and a
113    /// failure of the final registry-prune step are both reported inline in the summary
114    /// instead of aborting.
115    pub(super) async fn handle_worktree_clean_as_string(
116        &mut self,
117        force: bool,
118    ) -> Result<Option<String>, AgentError> {
119        let Some(mgr) = &self.services.orchestration.subagent_manager else {
120            return Ok(None);
121        };
122        let Some(wm) = mgr.worktree_manager() else {
123            return Ok(None);
124        };
125        let prune_branch_on_remove = wm.prune_branch_on_remove();
126
127        let outcome = wm
128            .clean(force, prune_branch_on_remove, "`/worktree clean --force`")
129            .await?;
130
131        let mut out = String::new();
132        for warning in &outcome.warnings {
133            let _ = writeln!(out, "{warning}");
134        }
135        out.push_str(&zeph_worktree::format_clean_summary(&outcome));
136        Ok(Some(out))
137    }
138}
139
140impl<C: Channel + Send + 'static> WorktreeAccess for Agent<C> {
141    // ----- /worktree -----
142
143    delegate_cmd!(list_worktrees, handle_worktree_list_as_string => Option<String>);
144
145    delegate_cmd!(clean_worktrees, handle_worktree_clean_as_string, force: bool => Option<String>);
146
147    // ----- /cd -----
148
149    fn change_working_directory<'a>(
150        &'a mut self,
151        path: &'a str,
152    ) -> Pin<Box<dyn Future<Output = Result<String, CommandError>> + Send + 'a>> {
153        use tracing::Instrument as _;
154
155        Box::pin(
156            async move {
157                let path = path.trim();
158                if path.is_empty() {
159                    let cwd = std::env::current_dir().map_err(|e| {
160                        CommandError::new(format!("failed to read current working directory: {e}"))
161                    })?;
162                    return Ok(format!("Current working directory: {}", cwd.display()));
163                }
164                // Reuses the same path-resolution + `set_current_dir` logic as the
165                // LLM-invoked `set_working_directory` tool (#6032 FR-001/FR-011) — no
166                // parallel implementation. `allowed_paths` empty means no build site has set
167                // it yet; default to `[cwd]` rather than "allow every path", matching
168                // `FileExecutor::new`/`SetCwdExecutor::new`'s convention (SEC-2).
169                let allowed_paths: Vec<std::path::PathBuf> =
170                    if self.services.tool_state.allowed_paths.is_empty() {
171                        // Canonicalize for byte-for-byte parity with `FileExecutor::new`/
172                        // `SetCwdExecutor::new`'s fallback, which both canonicalize their
173                        // default `[cwd]` entry (`.map(|p| p.canonicalize().unwrap_or(p))`).
174                        std::env::current_dir()
175                            .map(|p| p.canonicalize().unwrap_or(p))
176                            .into_iter()
177                            .collect()
178                    } else {
179                        self.services.tool_state.allowed_paths.clone()
180                    };
181                let new_cwd = zeph_tools::resolve_and_set_cwd(path, &allowed_paths)
182                    .map_err(|e| CommandError::new(format!("cannot change to '{path}': {e}")))?;
183                // Drives the same post-change pipeline the tool-invoked path gets for free
184                // after a tool batch (`tier_loop.rs`) — a bare slash command must call it
185                // explicitly (FR-002/FR-003/FR-004): mirror-update, `cwd_changed` hooks,
186                // repo-map invalidation, and (unless safe-mode) instruction re-discovery.
187                self.check_cwd_changed().await;
188                Ok(format!(
189                    "Working directory changed to: {}",
190                    new_cwd.display()
191                ))
192            }
193            .instrument(tracing::info_span!("core.commands.cd")),
194        )
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use std::sync::Arc;
201
202    use zeph_config::WorktreeConfig;
203    use zeph_worktree::{DefaultGitRunner, DefaultWorktreeManager};
204
205    use super::*;
206    use crate::testing::{MockChannel, MockToolExecutor, mock_provider};
207
208    fn git(args: &[&str], cwd: &std::path::Path) -> std::process::Output {
209        std::process::Command::new("git")
210            .args(args)
211            .current_dir(cwd)
212            .output()
213            .expect("git must be on PATH for this test")
214    }
215
216    fn init_repo() -> tempfile::TempDir {
217        let dir = tempfile::tempdir().expect("tempdir");
218        let path = dir.path();
219        assert!(git(&["init", "-q"], path).status.success());
220        git(&["config", "user.email", "test@example.com"], path);
221        git(&["config", "user.name", "Test"], path);
222        std::fs::write(path.join("README.md"), "test\n").expect("write README");
223        git(&["add", "."], path);
224        assert!(git(&["commit", "-q", "-m", "init"], path).status.success());
225        dir
226    }
227
228    fn worktree_config() -> WorktreeConfig {
229        WorktreeConfig {
230            enabled: true,
231            root: "worktrees".to_string(),
232            branch_prefix: "agent/".to_string(),
233            ..Default::default()
234        }
235    }
236
237    fn test_agent() -> Agent<MockChannel> {
238        Agent::new(
239            mock_provider(vec!["ignored".to_string()]),
240            MockChannel::new(Vec::<String>::new()),
241            zeph_skills::registry::SkillRegistry::load(&Vec::<std::path::PathBuf>::new()),
242            None,
243            5,
244            MockToolExecutor::no_tools(),
245        )
246    }
247
248    /// Builds an `Agent` whose `SubAgentManager` is wired to a real, live
249    /// `DefaultWorktreeManager` over `repo_root` — mirroring how bootstrap wires the
250    /// two together in production (`src/agent_setup.rs`) — so
251    /// `handle_worktree_list_as_string`/`handle_worktree_clean_as_string` exercise the
252    /// actual `Some(wm)` branch instead of only the `None` (disabled-subsystem)
253    /// short-circuit that `zeph-commands`' `NullAgent`-backed tests already cover.
254    async fn agent_with_live_worktree_manager(repo_root: std::path::PathBuf) -> Agent<MockChannel> {
255        let wm = Arc::new(
256            DefaultWorktreeManager::new(repo_root, worktree_config(), DefaultGitRunner::new())
257                .await
258                .expect("construct live worktree manager"),
259        );
260        let mut sam = zeph_subagent::SubAgentManager::new(4);
261        sam.set_worktree_manager(wm);
262
263        let mut agent = test_agent();
264        agent.services.orchestration.subagent_manager = Some(sam);
265        agent
266    }
267
268    #[tokio::test]
269    async fn list_reports_no_worktrees_when_none_exist() {
270        let repo = init_repo();
271        let repo_root = repo.path().canonicalize().expect("canonicalize");
272        let mut agent = agent_with_live_worktree_manager(repo_root).await;
273
274        let out = agent.handle_worktree_list_as_string().await.unwrap();
275        assert_eq!(out.as_deref(), Some("No active worktrees."));
276    }
277
278    /// Covers the "active" (in-memory, non-stale) branch of `list` — a worktree created
279    /// through the *same* live manager instance the agent holds shows up via
280    /// `WorktreeManager::list()`, not `reconcile()`. This is genuinely new coverage: the
281    /// CLI's `zeph worktree list` always constructs a fresh manager per invocation, so its
282    /// own `list()` is trivially always empty and this branch is unreachable from there.
283    #[tokio::test]
284    async fn list_reports_active_worktrees_created_by_this_session() {
285        let repo = init_repo();
286        let repo_root = repo.path().canonicalize().expect("canonicalize");
287        let mut agent = agent_with_live_worktree_manager(repo_root).await;
288
289        {
290            let mgr = agent
291                .services
292                .orchestration
293                .subagent_manager
294                .as_ref()
295                .unwrap();
296            let wm = mgr.worktree_manager().unwrap();
297            wm.create("agent-1").await.expect("create worktree");
298            wm.create("agent-2").await.expect("create worktree");
299        }
300
301        let out = agent
302            .handle_worktree_list_as_string()
303            .await
304            .unwrap()
305            .unwrap();
306        assert!(out.contains("AGENT ID"), "got: {out}");
307        assert!(out.contains("agent-1"), "got: {out}");
308        assert!(out.contains("agent-2"), "got: {out}");
309        assert!(!out.contains("Stale"), "got: {out}");
310    }
311
312    /// Covers the "stale" branch of `list` with both a prunable and a non-prunable entry
313    /// in the same result — using a *separate* creator manager (over the same repo) so
314    /// the agent's own live manager discovers them purely via `reconcile()`, exactly as a
315    /// worktree left behind by a crashed or concurrently running session would appear.
316    #[tokio::test]
317    async fn list_reports_stale_worktrees_with_prunable_and_in_use_reasons() {
318        let repo = init_repo();
319        let repo_root = repo.path().canonicalize().expect("canonicalize");
320
321        let creator = DefaultWorktreeManager::new(
322            repo_root.clone(),
323            worktree_config(),
324            DefaultGitRunner::new(),
325        )
326        .await
327        .expect("construct creator manager");
328        let prunable = creator.create("prunable-1").await.expect("create");
329        std::fs::remove_dir_all(&prunable.path).expect("remove prunable dir");
330        let in_use = creator.create("in-use-1").await.expect("create");
331
332        let mut agent = agent_with_live_worktree_manager(repo_root).await;
333        let out = agent
334            .handle_worktree_list_as_string()
335            .await
336            .unwrap()
337            .unwrap();
338
339        assert!(
340            out.contains("Stale (on disk but not tracked):"),
341            "got: {out}"
342        );
343        assert!(
344            out.contains(&format!("{}  [prunable:", prunable.path.display())),
345            "got: {out}"
346        );
347        assert!(
348            out.contains(&format!(
349                "{}  [in use — not marked prunable by git; may belong to another session]",
350                in_use.path.display()
351            )),
352            "got: {out}"
353        );
354    }
355
356    /// End-to-end fixture test for #6142 part A: a mixed prunable/non-prunable stale
357    /// list through the real `handle_worktree_clean_as_string`, `force = false`. Confirms
358    /// the live-manager `Some(wm)` branch actually reaches `WorktreeManager::clean` with
359    /// the right semantics (prunable removed, non-prunable left alone) — not just the
360    /// `None`-manager short-circuit `zeph-commands`' existing tests cover.
361    #[tokio::test]
362    async fn clean_removes_prunable_and_skips_in_use_without_force() {
363        let repo = init_repo();
364        let repo_root = repo.path().canonicalize().expect("canonicalize");
365
366        let creator = DefaultWorktreeManager::new(
367            repo_root.clone(),
368            worktree_config(),
369            DefaultGitRunner::new(),
370        )
371        .await
372        .expect("construct creator manager");
373        let prunable = creator.create("prunable-1").await.expect("create");
374        std::fs::remove_dir_all(&prunable.path).expect("remove prunable dir");
375        let in_use = creator.create("in-use-1").await.expect("create");
376
377        let mut agent = agent_with_live_worktree_manager(repo_root.clone()).await;
378        let out = agent
379            .handle_worktree_clean_as_string(false)
380            .await
381            .unwrap()
382            .unwrap();
383
384        assert!(
385            out.contains("Removed 1 stale worktree(s), skipped 1 in-use candidate(s), 0 error(s)."),
386            "got: {out}"
387        );
388        assert!(
389            in_use.path.exists(),
390            "in-use worktree must survive without --force"
391        );
392
393        let list = git(&["worktree", "list", "--porcelain"], &repo_root);
394        let list_str = String::from_utf8_lossy(&list.stdout);
395        assert!(
396            !list_str.contains(&*prunable.path.to_string_lossy()),
397            "prunable worktree must be gone from the registry: {list_str}"
398        );
399        assert!(
400            list_str.contains(&*in_use.path.to_string_lossy()),
401            "in-use worktree must remain in the registry: {list_str}"
402        );
403    }
404
405    /// `--force` variant reaching the live `WorktreeManager` with correct semantics: the
406    /// same non-prunable entry that survives without `force` is actually removed once
407    /// `force = true` is threaded through.
408    #[tokio::test]
409    async fn clean_with_force_removes_in_use_entry_too() {
410        let repo = init_repo();
411        let repo_root = repo.path().canonicalize().expect("canonicalize");
412
413        let creator = DefaultWorktreeManager::new(
414            repo_root.clone(),
415            worktree_config(),
416            DefaultGitRunner::new(),
417        )
418        .await
419        .expect("construct creator manager");
420        let in_use = creator.create("in-use-1").await.expect("create");
421
422        let mut agent = agent_with_live_worktree_manager(repo_root.clone()).await;
423        let out = agent
424            .handle_worktree_clean_as_string(true)
425            .await
426            .unwrap()
427            .unwrap();
428
429        assert!(
430            out.contains("Removed 1 stale worktree(s), skipped 0 in-use candidate(s), 0 error(s)."),
431            "got: {out}"
432        );
433        assert!(
434            !in_use.path.exists(),
435            "in-use worktree must be removed once --force is passed"
436        );
437    }
438
439    #[tokio::test]
440    async fn list_and_clean_return_none_when_worktree_subsystem_disabled() {
441        let mut agent = test_agent();
442        assert!(agent.services.orchestration.subagent_manager.is_none());
443
444        assert_eq!(agent.handle_worktree_list_as_string().await.unwrap(), None);
445        assert_eq!(
446            agent.handle_worktree_clean_as_string(false).await.unwrap(),
447            None
448        );
449    }
450}