1use std::path::Path;
4use std::sync::mpsc;
5
6use console::style;
11use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport};
12
13use crate::console as cwconsole;
14use crate::constants::{
15 format_config_key, path_age_days, sanitize_branch_name, CONFIG_KEY_BASE_BRANCH,
16 CONFIG_KEY_INTENDED_BRANCH,
17};
18use crate::error::Result;
19use crate::git;
20
21use rayon::prelude::*;
22
23use super::pr_cache::PrCache;
24
25const MIN_TABLE_WIDTH: usize = 100;
27
28pub fn get_worktree_status(
45 path: &Path,
46 repo: &Path,
47 branch: Option<&str>,
48 pr_cache: &PrCache,
49) -> String {
50 if !path.exists() {
51 return "stale".to_string();
52 }
53
54 if !crate::operations::busy::detect_busy_lockfile_only(path).is_empty() {
64 return "busy".to_string();
65 }
66
67 if crate::operations::busy::active_claude_sessions(path).is_some() {
72 return "busy".to_string();
73 }
74
75 if let Ok(cwd) = std::env::current_dir() {
78 let cwd_canon = cwd.canonicalize().unwrap_or(cwd);
79 let path_canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
80 if cwd_canon.starts_with(&path_canon) {
81 return "active".to_string();
82 }
83 }
84
85 if let Some(branch_name) = branch {
87 let base_branch = {
88 let key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch_name);
89 git::get_config(&key, Some(repo))
90 .unwrap_or_else(|| git::detect_default_branch(Some(repo)))
91 };
92
93 if let Some(state) = pr_cache.state(branch_name) {
95 match state {
96 super::pr_cache::PrState::Merged => return "merged".to_string(),
97 super::pr_cache::PrState::Open => return "pr-open".to_string(),
98 _ => {}
100 }
101 }
102
103 if git::is_branch_merged(branch_name, &base_branch, Some(repo)) {
106 return "merged".to_string();
107 }
108 }
109
110 if let Ok(result) = git::git_command(&["status", "--porcelain"], Some(path), false, true) {
112 if result.returncode == 0 && !result.stdout.trim().is_empty() {
113 return "modified".to_string();
114 }
115 }
116
117 "clean".to_string()
118}
119
120pub fn format_age(age_days: f64) -> String {
122 if age_days < 1.0 {
123 let hours = (age_days * 24.0) as i64;
124 if hours > 0 {
125 format!("{}h ago", hours)
126 } else {
127 "just now".to_string()
128 }
129 } else if age_days < 7.0 {
130 format!("{}d ago", age_days as i64)
131 } else if age_days < 30.0 {
132 format!("{}w ago", (age_days / 7.0) as i64)
133 } else if age_days < 365.0 {
134 format!("{}mo ago", (age_days / 30.0) as i64)
135 } else {
136 format!("{}y ago", (age_days / 365.0) as i64)
137 }
138}
139
140pub fn format_selector_row(
154 branch: &str,
155 age: &str,
156 busy: bool,
157 path: &str,
158 branch_col: usize,
159) -> String {
160 const AGE_COL: usize = 9;
161 const BUSY_COL: usize = 7;
162 let busy_cell: String = if busy {
163 format!("{} ", style("[busy]").yellow())
165 } else {
166 " ".repeat(BUSY_COL)
167 };
168 format!(
169 "{branch:<branch_col$} {age:<AGE_COL$} {busy_cell}{path}",
170 branch = branch,
171 age = age,
172 busy_cell = busy_cell,
173 path = path,
174 branch_col = branch_col,
175 AGE_COL = AGE_COL,
176 )
177}
178
179fn path_age_str(path: &Path) -> String {
181 if !path.exists() {
182 return String::new();
183 }
184 path_age_days(path).map(format_age).unwrap_or_default()
185}
186
187struct WorktreeRow {
189 worktree_id: String,
190 current_branch: String,
191 status: String,
192 age: String,
193 rel_path: String,
194 path: std::path::PathBuf,
197}
198
199#[derive(Clone)]
203struct RowInput {
204 path: std::path::PathBuf,
205 current_branch: String,
206 worktree_id: String,
207 age: String,
208 rel_path: String,
209}
210
211impl RowInput {
212 fn into_row(self, status: String) -> WorktreeRow {
213 WorktreeRow {
214 worktree_id: self.worktree_id,
215 current_branch: self.current_branch,
216 status,
217 age: self.age,
218 rel_path: self.rel_path,
219 path: self.path,
220 }
221 }
222}
223
224fn prewarm_busy_caches() {
239 std::thread::spawn(crate::operations::busy::prewarm_cwd_scan);
240 std::thread::spawn(crate::operations::claude_process::prewarm);
241}
242
243pub fn list_worktrees() -> Result<()> {
245 let cwd = std::env::current_dir()?;
246 let scope = crate::scope::discover_scope(&cwd)?;
247
248 if scope.is_empty() {
249 println!(" {}\n", style("No worktrees found.").dim());
250 return Ok(());
251 }
252
253 let mut groups: Vec<(std::path::PathBuf, Vec<(String, std::path::PathBuf)>)> = Vec::new();
257 for w in scope.worktrees() {
258 let key = &w.repo_root;
259 let entry = match groups.iter_mut().find(|(k, _)| k == key) {
260 Some(e) => e,
261 None => {
262 groups.push((key.clone(), Vec::new()));
263 groups.last_mut().unwrap()
264 }
265 };
266 let branch_raw = w.branch.clone().unwrap_or_else(|| "(detached)".to_string());
270 entry.1.push((branch_raw, w.path.clone()));
271 }
272
273 for (i, (repo, worktrees)) in groups.iter().enumerate() {
274 if i > 0 {
275 println!(); }
277 render_repo_section(repo, worktrees)?;
278 }
279 Ok(())
280}
281
282pub fn list_worktrees_tsv() -> Result<()> {
290 let cwd = std::env::current_dir()?;
291 let scope = crate::scope::discover_scope(&cwd)?;
292
293 if scope.is_empty() {
294 return Ok(());
295 }
296
297 let mut groups: Vec<(std::path::PathBuf, Vec<(String, std::path::PathBuf)>)> = Vec::new();
299 for w in scope.worktrees() {
300 let key = &w.repo_root;
301 let entry = match groups.iter_mut().find(|(k, _)| k == key) {
302 Some(e) => e,
303 None => {
304 groups.push((key.clone(), Vec::new()));
305 groups.last_mut().unwrap()
306 }
307 };
308 let branch_raw = w.branch.clone().unwrap_or_else(|| "(detached)".to_string());
309 entry.1.push((branch_raw, w.path.clone()));
310 }
311
312 for (repo, worktrees) in &groups {
313 let pr_cache = PrCache::load_or_fetch(repo, false);
314
315 let inputs: Vec<RowInput> = worktrees
317 .iter()
318 .map(|(branch, path)| {
319 let current_branch = git::normalize_branch_name(branch).to_string();
320 let age = path_age_str(path);
321 let intended_branch = lookup_intended_branch(repo, ¤t_branch, path);
322 let worktree_id = intended_branch.unwrap_or_else(|| current_branch.clone());
323 RowInput {
324 path: path.clone(),
325 current_branch,
326 worktree_id,
327 age,
328 rel_path: String::new(),
330 }
331 })
332 .collect();
333
334 let rows: Vec<WorktreeRow> = inputs
337 .into_par_iter()
338 .map(|i| {
339 let status = get_worktree_status(&i.path, repo, Some(&i.current_branch), &pr_cache);
340 i.into_row(status)
341 })
342 .collect();
343
344 for (row, (_, path)) in rows.iter().zip(worktrees.iter()) {
345 println!(
346 "{}\t{}\t{}\t{}\t{}\t{}",
347 row.worktree_id,
348 row.current_branch,
349 row.status,
350 row.age,
351 repo.display(),
352 path.display(),
353 );
354 }
355 }
356
357 Ok(())
358}
359
360fn render_repo_section(
366 repo: &std::path::Path,
367 worktrees: &[(String, std::path::PathBuf)],
368) -> Result<()> {
369 println!(
370 "\n{} {}\n",
371 style("Worktrees for repository:").cyan().bold(),
372 repo.display()
373 );
374
375 let pr_cache = PrCache::load_or_fetch(repo, false);
376
377 let inputs: Vec<RowInput> = worktrees
379 .iter()
380 .map(|(branch, path)| {
381 let current_branch = git::normalize_branch_name(branch).to_string();
382 let rel_path = pathdiff::diff_paths(path, repo)
383 .map(|p: std::path::PathBuf| p.to_string_lossy().to_string())
384 .unwrap_or_else(|| path.to_string_lossy().to_string());
385 let age = path_age_str(path);
386 let intended_branch = lookup_intended_branch(repo, ¤t_branch, path);
387 let worktree_id = intended_branch.unwrap_or_else(|| current_branch.clone());
388 RowInput {
389 path: path.clone(),
390 current_branch,
391 worktree_id,
392 age,
393 rel_path,
394 }
395 })
396 .collect();
397
398 prewarm_busy_caches();
403
404 let is_tty = crate::tui::stdout_is_tty();
405 let term_width = cwconsole::terminal_width();
408 let narrow = term_width < MIN_TABLE_WIDTH;
411 let use_progressive = is_tty && !narrow;
412
413 let rows: Vec<WorktreeRow> = if use_progressive {
414 render_rows_progressive(repo, &pr_cache, inputs)?
415 } else {
416 inputs
418 .into_par_iter()
419 .map(|i| {
420 let status = get_worktree_status(&i.path, repo, Some(&i.current_branch), &pr_cache);
421 i.into_row(status)
422 })
423 .collect()
424 };
425
426 if !use_progressive {
429 if narrow {
430 print_worktree_compact(&rows);
431 } else {
432 print_worktree_table(&rows);
433 }
434 }
435
436 print_busy_details(&rows);
443 print_summary_footer(&rows);
444
445 println!();
446 Ok(())
447}
448
449type CrosstermTerminal = ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>;
456
457struct TerminalGuard(Option<CrosstermTerminal>);
464
465impl TerminalGuard {
466 fn new(terminal: CrosstermTerminal) -> Self {
467 Self(Some(terminal))
471 }
472
473 fn as_mut(&mut self) -> &mut CrosstermTerminal {
474 self.0.as_mut().expect("terminal already taken")
475 }
476}
477
478impl Drop for TerminalGuard {
479 fn drop(&mut self) {
480 let _ = self.0.take(); ratatui::restore();
482 crate::tui::mark_ratatui_inactive();
485 }
486}
487
488fn render_rows_progressive(
489 repo: &std::path::Path,
490 pr_cache: &PrCache,
491 inputs: Vec<RowInput>,
492) -> Result<Vec<WorktreeRow>> {
493 let row_data: Vec<crate::tui::list_view::RowData> = inputs
495 .iter()
496 .map(|i| crate::tui::list_view::RowData {
497 worktree_id: i.worktree_id.clone(),
498 current_branch: i.current_branch.clone(),
499 status: crate::tui::list_view::PLACEHOLDER.to_string(),
500 age: i.age.clone(),
501 rel_path: i.rel_path.clone(),
502 })
503 .collect();
504 let mut app = crate::tui::list_view::ListApp::new(row_data);
505
506 let viewport_height = u16::try_from(inputs.len())
509 .unwrap_or(u16::MAX)
510 .saturating_add(2)
511 .max(3);
512
513 let stdout = std::io::stdout();
514 let backend = CrosstermBackend::new(stdout);
515 crate::tui::mark_ratatui_active();
522 let terminal = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
523 Terminal::with_options(
524 backend,
525 TerminalOptions {
526 viewport: Viewport::Inline(viewport_height),
527 },
528 )
529 })) {
530 Ok(Ok(t)) => t,
531 Ok(Err(e)) => {
532 crate::tui::mark_ratatui_inactive();
533 return Err(e.into());
534 }
535 Err(panic) => {
536 crate::tui::mark_ratatui_inactive();
537 std::panic::resume_unwind(panic);
538 }
539 };
540 let mut guard = TerminalGuard::new(terminal);
541 let (tx, rx) = mpsc::channel();
556
557 guard.as_mut().draw(|f| app.render(f))?;
562
563 let paths: Vec<std::path::PathBuf> = inputs.iter().map(|i| i.path.clone()).collect();
567
568 std::thread::scope(|s| -> Result<()> {
573 let producer = s.spawn(move || {
574 inputs
575 .par_iter()
576 .enumerate()
577 .for_each_with(tx, |tx, (i, input)| {
578 let status = get_worktree_status(
579 &input.path,
580 repo,
581 Some(&input.current_branch),
582 pr_cache,
583 );
584 let _ = tx.send((i, status));
585 });
586 });
587
588 let run_result = crate::tui::list_view::run(guard.as_mut(), &mut app, rx);
589 let producer_result = producer.join();
590 if let Err(panic) = producer_result {
591 let msg = panic
593 .downcast_ref::<&str>()
594 .map(|s| (*s).to_string())
595 .or_else(|| panic.downcast_ref::<String>().cloned())
596 .unwrap_or_else(|| "non-string panic payload".to_string());
597 eprintln!(
598 "warning: status producer thread panicked, some rows may show \"unknown\": {}",
599 msg
600 );
601 }
602 run_result.map_err(crate::error::CwError::from)
603 })?;
604
605 if app.finalize_pending("unknown") {
612 guard.as_mut().draw(|f| app.render(f))?;
613 }
614
615 Ok(app
616 .into_rows()
617 .into_iter()
618 .zip(paths)
619 .map(|(r, path)| WorktreeRow {
620 worktree_id: r.worktree_id,
621 current_branch: r.current_branch,
622 status: r.status,
623 age: r.age,
624 rel_path: r.rel_path,
625 path,
626 })
627 .collect())
628}
629
630fn lookup_intended_branch(repo: &Path, current_branch: &str, path: &Path) -> Option<String> {
636 let key = format_config_key(CONFIG_KEY_INTENDED_BRANCH, current_branch);
638 if let Some(intended) = git::get_config(&key, Some(repo)) {
639 return Some(intended);
640 }
641
642 let result = git::git_command(
644 &[
645 "config",
646 "--local",
647 "--get-regexp",
648 r"^worktree\..*\.intendedBranch",
649 ],
650 Some(repo),
651 false,
652 true,
653 )
654 .ok()?;
655
656 if result.returncode != 0 {
657 return None;
658 }
659
660 let repo_name = repo.file_name()?.to_string_lossy().to_string();
661
662 for line in result.stdout.trim().lines() {
663 let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
664 if parts.len() == 2 {
665 let key_parts: Vec<&str> = parts[0].split('.').collect();
666 if key_parts.len() >= 2 {
667 let branch_from_key = key_parts[1];
668 let expected_path_name =
669 format!("{}-{}", repo_name, sanitize_branch_name(branch_from_key));
670 if let Some(name) = path.file_name() {
671 if name.to_string_lossy() == expected_path_name {
672 return Some(parts[1].to_string());
673 }
674 }
675 }
676 }
677 }
678
679 None
680}
681
682fn print_busy_details(rows: &[WorktreeRow]) {
692 let busy_rows: Vec<&WorktreeRow> = rows.iter().filter(|r| r.status == "busy").collect();
693 if busy_rows.is_empty() {
694 return;
695 }
696
697 for row in busy_rows {
698 let (hard, soft) = crate::operations::busy::detect_busy_tiered(&row.path);
699 if hard.is_empty() && soft.is_empty() {
703 continue;
704 }
705 let block =
706 crate::operations::busy_messages::render_busy_block(&row.worktree_id, &hard, &soft);
707 println!();
708 print!("{}", block);
710 }
711}
712
713fn print_summary_footer(rows: &[WorktreeRow]) {
714 let feature_count = if rows.len() > 1 { rows.len() - 1 } else { 0 };
717 if feature_count == 0 {
718 return;
719 }
720
721 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
722 for row in rows {
723 *counts.entry(row.status.as_str()).or_insert(0) += 1;
724 }
725
726 let mut summary_parts = Vec::new();
727 for &status_name in &[
728 "clean", "modified", "busy", "active", "pr-open", "merged", "stale",
729 ] {
730 if let Some(&count) = counts.get(status_name) {
731 if count > 0 {
732 let styled = cwconsole::status_style(status_name)
733 .apply_to(format!("{} {}", count, status_name));
734 summary_parts.push(styled.to_string());
735 }
736 }
737 }
738
739 let summary = if summary_parts.is_empty() {
740 format!("\n{} feature worktree(s)", feature_count)
741 } else {
742 format!(
743 "\n{} feature worktree(s) — {}",
744 feature_count,
745 summary_parts.join(", ")
746 )
747 };
748 println!("{}", summary);
749}
750
751fn print_worktree_table(rows: &[WorktreeRow]) {
752 let max_wt = rows.iter().map(|r| r.worktree_id.len()).max().unwrap_or(20);
753 let max_br = rows
754 .iter()
755 .map(|r| r.current_branch.len())
756 .max()
757 .unwrap_or(20);
758 let wt_col = max_wt.clamp(12, 35) + 2;
759 let br_col = max_br.clamp(12, 35) + 2;
760
761 println!(
762 " {} {:<wt_col$} {:<br_col$} {:<10} {:<12} {}",
763 style(" ").dim(),
764 style("WORKTREE").dim(),
765 style("BRANCH").dim(),
766 style("STATUS").dim(),
767 style("AGE").dim(),
768 style("PATH").dim(),
769 wt_col = wt_col,
770 br_col = br_col,
771 );
772 let line_width = (wt_col + br_col + 40).min(cwconsole::terminal_width().saturating_sub(4));
773 println!(" {}", style("─".repeat(line_width)).dim());
774
775 for row in rows {
776 let icon = cwconsole::status_icon(&row.status);
777 let st = cwconsole::status_style(&row.status);
778
779 let branch_display = if row.worktree_id != row.current_branch {
780 style(format!("{} ⚠", row.current_branch))
781 .yellow()
782 .to_string()
783 } else {
784 row.current_branch.clone()
785 };
786
787 let status_styled = st.apply_to(format!("{:<10}", row.status));
788
789 println!(
790 " {} {:<wt_col$} {:<br_col$} {} {:<12} {}",
791 st.apply_to(icon),
792 style(&row.worktree_id).bold(),
793 branch_display,
794 status_styled,
795 style(&row.age).dim(),
796 style(&row.rel_path).dim(),
797 wt_col = wt_col,
798 br_col = br_col,
799 );
800 }
801}
802
803fn print_worktree_compact(rows: &[WorktreeRow]) {
804 for row in rows {
805 let icon = cwconsole::status_icon(&row.status);
806 let st = cwconsole::status_style(&row.status);
807 let age_part = if row.age.is_empty() {
808 String::new()
809 } else {
810 format!(" {}", style(&row.age).dim())
811 };
812
813 println!(
814 " {} {} {}{}",
815 st.apply_to(icon),
816 style(&row.worktree_id).bold(),
817 st.apply_to(&row.status),
818 age_part,
819 );
820
821 let mut details = Vec::new();
822 if row.worktree_id != row.current_branch {
823 details.push(format!(
824 "branch: {}",
825 style(format!("{} ⚠", row.current_branch)).yellow()
826 ));
827 }
828 if !row.rel_path.is_empty() {
829 details.push(format!("{}", style(&row.rel_path).dim()));
830 }
831 if !details.is_empty() {
832 println!(" {}", details.join(" "));
833 }
834 }
835}
836
837#[cfg(test)]
838mod tests {
839 use super::*;
840
841 #[test]
842 fn test_format_age_just_now() {
843 assert_eq!(format_age(0.0), "just now");
844 assert_eq!(format_age(0.001), "just now"); }
846
847 #[test]
848 fn test_format_age_hours() {
849 assert_eq!(format_age(1.0 / 24.0), "1h ago"); assert_eq!(format_age(0.5), "12h ago"); assert_eq!(format_age(0.99), "23h ago"); }
853
854 #[test]
855 fn test_format_age_days() {
856 assert_eq!(format_age(1.0), "1d ago");
857 assert_eq!(format_age(1.5), "1d ago");
858 assert_eq!(format_age(6.9), "6d ago");
859 }
860
861 #[test]
862 fn test_format_age_weeks() {
863 assert_eq!(format_age(7.0), "1w ago");
864 assert_eq!(format_age(14.0), "2w ago");
865 assert_eq!(format_age(29.0), "4w ago");
866 }
867
868 #[test]
869 fn test_format_age_months() {
870 assert_eq!(format_age(30.0), "1mo ago");
871 assert_eq!(format_age(60.0), "2mo ago");
872 assert_eq!(format_age(364.0), "12mo ago");
873 }
874
875 #[test]
876 fn test_format_age_years() {
877 assert_eq!(format_age(365.0), "1y ago");
878 assert_eq!(format_age(730.0), "2y ago");
879 }
880
881 #[test]
882 fn test_format_age_boundary_below_one_hour() {
883 assert_eq!(format_age(0.04), "just now"); }
886
887 #[test]
888 fn format_selector_row_no_busy() {
889 let row = format_selector_row("feat/a", "2d ago", false, "feat-a", 30);
890 assert_eq!(
892 row,
893 "feat/a 2d ago feat-a"
894 );
895 }
896
897 #[test]
898 fn format_selector_row_busy_contains_badge() {
899 let row = format_selector_row("fix/b", "3w ago", true, "fix-b", 30);
900 assert!(
901 row.contains("[busy]"),
902 "expected [busy] in row, got: {:?}",
903 row
904 );
905 assert!(row.contains("fix/b"));
906 assert!(row.contains("3w ago"));
907 assert!(row.contains("fix-b"));
908 }
909
910 #[test]
911 fn format_selector_row_busy_visible_width_matches_no_busy() {
912 let plain = format_selector_row("x", "1d ago", false, "p", 30);
915 let busy = format_selector_row("x", "1d ago", true, "p", 30);
916 assert_eq!(
917 crate::tui::arrow_select::visible_len(&plain),
918 crate::tui::arrow_select::visible_len(&busy),
919 );
920 }
921
922 #[test]
923 fn format_selector_row_empty_age_pads_to_nine() {
924 let row = format_selector_row("feat/a", "", false, "feat-a", 30);
925 assert_eq!(&row[48..], "feat-a");
928 }
929
930 #[test]
931 fn format_selector_row_long_branch_does_not_truncate() {
932 let branch = "feat/extra-long-branch-name-well-past-thirty-chars";
933 let row = format_selector_row(branch, "1d ago", false, "p", 30);
934 assert!(
935 row.starts_with(branch),
936 "branch must not be truncated, got: {:?}",
937 row
938 );
939 }
940
941 #[test]
945 #[cfg(unix)]
946 fn test_get_worktree_status_busy_from_lockfile() {
947 use crate::operations::lockfile::LockEntry;
948 use std::fs;
949 use std::process::{Command, Stdio};
950
951 let tmp = tempfile::TempDir::new().unwrap();
952 let repo = tmp.path();
953 let wt = repo.join("wt1");
954 fs::create_dir_all(wt.join(".git")).unwrap();
955
956 let mut child = Command::new("sleep")
960 .arg("30")
961 .stdout(Stdio::null())
962 .stderr(Stdio::null())
963 .spawn()
964 .expect("spawn sleep");
965 let foreign_pid: u32 = child.id();
966
967 let entry = LockEntry {
968 version: crate::operations::lockfile::LOCK_VERSION,
969 pid: foreign_pid,
970 started_at: 0,
971 cmd: "claude".to_string(),
972 };
973 fs::write(
974 wt.join(".git").join("gw-session.lock"),
975 serde_json::to_string(&entry).unwrap(),
976 )
977 .unwrap();
978
979 let status = get_worktree_status(&wt, repo, Some("wt1"), &PrCache::default());
980
981 let _ = child.kill();
983 let _ = child.wait();
984
985 assert_eq!(status, "busy");
986 }
987
988 #[test]
994 #[cfg(any(target_os = "linux", target_os = "macos"))]
995 fn test_get_worktree_status_not_busy_when_jsonl_active_but_no_live_claude() {
996 use crate::operations::test_env::{env_lock, EnvGuard};
997 let _lock = env_lock();
998 let _guard = EnvGuard::capture(&["HOME"]);
999
1000 let home = tempfile::TempDir::new().unwrap();
1001 std::env::set_var("HOME", home.path());
1002
1003 let repo = tempfile::TempDir::new().unwrap();
1004 let wt = repo.path().join("wt1");
1005 std::fs::create_dir_all(wt.join(".git")).unwrap();
1006 let wt_canon = wt.canonicalize().unwrap_or(wt.clone());
1007
1008 let encoded = wt_canon.to_string_lossy().replace(['/', '.'], "-");
1011 let proj_dir = home.path().join(".claude").join("projects").join(encoded);
1012 std::fs::create_dir_all(&proj_dir).unwrap();
1013 let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
1014 let line = serde_json::json!({
1015 "timestamp": now,
1016 "cwd": wt_canon.to_string_lossy(),
1017 });
1018 std::fs::write(
1019 proj_dir.join("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl"),
1020 format!("{}\n", line),
1021 )
1022 .unwrap();
1023
1024 let status = get_worktree_status(&wt, repo.path(), Some("wt1"), &PrCache::default());
1025 assert_ne!(
1026 status, "busy",
1027 "expected non-busy without a live claude process, got busy"
1028 );
1029 }
1030
1031 #[test]
1032 fn test_get_worktree_status_stale() {
1033 use std::path::PathBuf;
1034 let non_existent = PathBuf::from("/tmp/gw-test-nonexistent-12345");
1035 let repo = PathBuf::from("/tmp");
1036 assert_eq!(
1037 get_worktree_status(&non_existent, &repo, None, &PrCache::default()),
1038 "stale"
1039 );
1040 }
1041}