Skip to main content

zeph_commands/handlers/
worktree.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Worktree command handler: `/worktree`.
5
6use std::future::Future;
7use std::pin::Pin;
8
9use crate::context::CommandContext;
10use crate::{CommandError, CommandHandler, CommandOutput, SlashCategory};
11
12/// List or clean the live session's git worktrees.
13///
14/// Subcommands: `list` (default) or `clean [--force]`. Reflects the running agent's actual
15/// worktree state rather than a fresh disk scan (contrast with the CLI's
16/// `zeph worktree list`/`clean`).
17pub struct WorktreeCommand;
18
19impl CommandHandler<CommandContext<'_>> for WorktreeCommand {
20    fn name(&self) -> &'static str {
21        "/worktree"
22    }
23
24    fn description(&self) -> &'static str {
25        "List or clean the live session's git worktrees"
26    }
27
28    fn args_hint(&self) -> &'static str {
29        "list | clean [--force]"
30    }
31
32    fn category(&self) -> SlashCategory {
33        SlashCategory::Advanced
34    }
35
36    fn requires_auth(&self) -> bool {
37        true
38    }
39
40    fn handle<'a>(
41        &'a self,
42        ctx: &'a mut CommandContext<'_>,
43        args: &'a str,
44    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
45        use tracing::Instrument as _;
46        let span = tracing::info_span!("commands.worktree.handle");
47        Box::pin(
48            async move {
49                let words: Vec<&str> = args.split_whitespace().collect();
50                let result = match words.as_slice() {
51                    [] | ["list"] => ctx.agent.list_worktrees().await?,
52                    ["clean"] => ctx.agent.clean_worktrees(false).await?,
53                    ["clean", "--force"] => ctx.agent.clean_worktrees(true).await?,
54                    _ => {
55                        return Err(CommandError::new(
56                            "Unknown /worktree subcommand. Available: /worktree list, \
57                             /worktree clean [--force]",
58                        ));
59                    }
60                };
61                match result {
62                    Some(msg) => Ok(CommandOutput::Message(msg)),
63                    None => Ok(CommandOutput::Message(
64                        "Worktree subsystem is not enabled for this session.".to_owned(),
65                    )),
66                }
67            }
68            .instrument(span),
69        )
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use crate::handlers::test_helpers::{MockDebug, MockMessages, MockSession, make_ctx};
77    use crate::sink::NullSink;
78
79    #[test]
80    fn worktree_name_and_description() {
81        assert_eq!(WorktreeCommand.name(), "/worktree");
82        assert!(!WorktreeCommand.description().is_empty());
83    }
84
85    #[tokio::test]
86    async fn worktree_none_returns_not_enabled_message() {
87        // NullAgent returns Ok(None), so the handler returns a "not enabled" message.
88        let mut sink = NullSink;
89        let mut debug = MockDebug;
90        let mut messages = MockMessages;
91        let session = MockSession;
92        let mut agent = crate::NullAgent;
93        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
94        let out = WorktreeCommand.handle(&mut ctx, "").await.unwrap();
95        let CommandOutput::Message(msg) = out else {
96            panic!("expected Message")
97        };
98        assert!(msg.contains("not enabled"));
99    }
100
101    #[tokio::test]
102    async fn worktree_list_subcommand_returns_not_enabled_message() {
103        let mut sink = NullSink;
104        let mut debug = MockDebug;
105        let mut messages = MockMessages;
106        let session = MockSession;
107        let mut agent = crate::NullAgent;
108        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
109        let out = WorktreeCommand.handle(&mut ctx, "list").await.unwrap();
110        let CommandOutput::Message(msg) = out else {
111            panic!("expected Message")
112        };
113        assert!(msg.contains("not enabled"));
114    }
115
116    #[tokio::test]
117    async fn worktree_clean_force_subcommand_returns_not_enabled_message() {
118        let mut sink = NullSink;
119        let mut debug = MockDebug;
120        let mut messages = MockMessages;
121        let session = MockSession;
122        let mut agent = crate::NullAgent;
123        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
124        let out = WorktreeCommand
125            .handle(&mut ctx, "clean --force")
126            .await
127            .unwrap();
128        let CommandOutput::Message(msg) = out else {
129            panic!("expected Message")
130        };
131        assert!(msg.contains("not enabled"));
132    }
133
134    #[tokio::test]
135    async fn worktree_clean_force_tolerates_irregular_whitespace() {
136        // Regression: exact-string matching on "clean --force" broke on double spaces or
137        // leading/trailing whitespace; split_whitespace tokenization must not.
138        let mut sink = NullSink;
139        let mut debug = MockDebug;
140        let mut messages = MockMessages;
141        let session = MockSession;
142        let mut agent = crate::NullAgent;
143        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
144        let out = WorktreeCommand
145            .handle(&mut ctx, "  clean   --force  ")
146            .await
147            .unwrap();
148        let CommandOutput::Message(msg) = out else {
149            panic!("expected Message")
150        };
151        assert!(msg.contains("not enabled"));
152    }
153
154    #[tokio::test]
155    async fn worktree_unknown_subcommand_returns_error() {
156        let mut sink = NullSink;
157        let mut debug = MockDebug;
158        let mut messages = MockMessages;
159        let session = MockSession;
160        let mut agent = crate::NullAgent;
161        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
162        let err = WorktreeCommand.handle(&mut ctx, "bogus").await.unwrap_err();
163        assert!(err.to_string().contains("Unknown"));
164    }
165}