1use 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 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 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 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 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 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 let allowed_paths: Vec<std::path::PathBuf> =
170 if self.services.tool_state.allowed_paths.is_empty() {
171 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 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 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 #[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 #[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 #[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 #[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}