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
243fn group_worktrees(
249 scope: &crate::scope::Scope,
250) -> Vec<(std::path::PathBuf, Vec<(String, std::path::PathBuf)>)> {
251 let mut groups: Vec<(std::path::PathBuf, Vec<(String, std::path::PathBuf)>)> = Vec::new();
252 for w in scope.worktrees() {
253 let key = &w.repo_root;
254 let entry = match groups.iter_mut().find(|(k, _)| k == key) {
255 Some(e) => e,
256 None => {
257 groups.push((key.clone(), Vec::new()));
258 groups.last_mut().unwrap()
259 }
260 };
261 let branch_raw = w.branch.clone().unwrap_or_else(|| "(detached)".to_string());
262 entry.1.push((branch_raw, w.path.clone()));
263 }
264 groups
265}
266
267pub fn list_worktrees() -> Result<()> {
269 let cwd = std::env::current_dir()?;
270 let scope = crate::scope::discover_scope(&cwd)?;
271
272 if scope.is_empty() {
273 println!(" {}\n", style("No worktrees found.").dim());
274 return Ok(());
275 }
276
277 let groups = group_worktrees(&scope);
278
279 for (i, (repo, worktrees)) in groups.iter().enumerate() {
280 if i > 0 {
281 println!(); }
283 render_repo_section(repo, worktrees)?;
284 }
285 Ok(())
286}
287
288pub fn list_worktrees_tsv() -> Result<()> {
296 let cwd = std::env::current_dir()?;
297 let scope = crate::scope::discover_scope(&cwd)?;
298
299 if scope.is_empty() {
300 return Ok(());
301 }
302
303 let groups = group_worktrees(&scope);
304
305 for (repo, worktrees) in &groups {
306 let pr_cache = PrCache::load_or_fetch(repo, false);
307
308 let inputs: Vec<RowInput> = worktrees
310 .iter()
311 .map(|(branch, path)| {
312 let current_branch = git::normalize_branch_name(branch).to_string();
313 let age = path_age_str(path);
314 let intended_branch = lookup_intended_branch(repo, ¤t_branch, path);
315 let worktree_id = intended_branch.unwrap_or_else(|| current_branch.clone());
316 RowInput {
317 path: path.clone(),
318 current_branch,
319 worktree_id,
320 age,
321 rel_path: String::new(),
323 }
324 })
325 .collect();
326
327 let rows: Vec<WorktreeRow> = inputs
330 .into_par_iter()
331 .map(|i| {
332 let status = get_worktree_status(&i.path, repo, Some(&i.current_branch), &pr_cache);
333 i.into_row(status)
334 })
335 .collect();
336
337 for (row, (_, path)) in rows.iter().zip(worktrees.iter()) {
338 println!(
339 "{}\t{}\t{}\t{}\t{}\t{}",
340 row.worktree_id,
341 row.current_branch,
342 row.status,
343 row.age,
344 repo.display(),
345 path.display(),
346 );
347 }
348 }
349
350 Ok(())
351}
352
353fn render_repo_section(
359 repo: &std::path::Path,
360 worktrees: &[(String, std::path::PathBuf)],
361) -> Result<()> {
362 println!(
363 "\n{} {}\n",
364 style("Worktrees for repository:").cyan().bold(),
365 repo.display()
366 );
367
368 let pr_cache = PrCache::load_or_fetch(repo, false);
369
370 let inputs: Vec<RowInput> = worktrees
372 .iter()
373 .map(|(branch, path)| {
374 let current_branch = git::normalize_branch_name(branch).to_string();
375 let rel_path = pathdiff::diff_paths(path, repo)
376 .map(|p: std::path::PathBuf| p.to_string_lossy().to_string())
377 .unwrap_or_else(|| path.to_string_lossy().to_string());
378 let age = path_age_str(path);
379 let intended_branch = lookup_intended_branch(repo, ¤t_branch, path);
380 let worktree_id = intended_branch.unwrap_or_else(|| current_branch.clone());
381 RowInput {
382 path: path.clone(),
383 current_branch,
384 worktree_id,
385 age,
386 rel_path,
387 }
388 })
389 .collect();
390
391 prewarm_busy_caches();
396
397 let is_tty = crate::tui::stdout_is_tty();
398 let term_width = cwconsole::terminal_width();
401 let narrow = term_width < MIN_TABLE_WIDTH;
404 let use_progressive = is_tty && !narrow;
405
406 let rows: Vec<WorktreeRow> = if use_progressive {
407 render_rows_progressive(repo, &pr_cache, inputs)?
408 } else {
409 inputs
411 .into_par_iter()
412 .map(|i| {
413 let status = get_worktree_status(&i.path, repo, Some(&i.current_branch), &pr_cache);
414 i.into_row(status)
415 })
416 .collect()
417 };
418
419 if !use_progressive {
422 if narrow {
423 print_worktree_compact(&rows);
424 } else {
425 print_worktree_table(&rows);
426 }
427 }
428
429 print_busy_details(&rows);
436 print_summary_footer(&rows);
437
438 println!();
439 Ok(())
440}
441
442type CrosstermTerminal = ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>;
449
450struct TerminalGuard(Option<CrosstermTerminal>);
457
458impl TerminalGuard {
459 fn new(terminal: CrosstermTerminal) -> Self {
460 Self(Some(terminal))
464 }
465
466 fn as_mut(&mut self) -> &mut CrosstermTerminal {
467 self.0.as_mut().expect("terminal already taken")
468 }
469}
470
471impl Drop for TerminalGuard {
472 fn drop(&mut self) {
473 let _ = self.0.take(); ratatui::restore();
475 crate::tui::mark_ratatui_inactive();
478 }
479}
480
481fn render_rows_progressive(
482 repo: &std::path::Path,
483 pr_cache: &PrCache,
484 inputs: Vec<RowInput>,
485) -> Result<Vec<WorktreeRow>> {
486 let row_data: Vec<crate::tui::list_view::RowData> = inputs
488 .iter()
489 .map(|i| crate::tui::list_view::RowData {
490 worktree_id: i.worktree_id.clone(),
491 current_branch: i.current_branch.clone(),
492 status: crate::tui::list_view::PLACEHOLDER.to_string(),
493 age: i.age.clone(),
494 rel_path: i.rel_path.clone(),
495 })
496 .collect();
497 let mut app = crate::tui::list_view::ListApp::new(row_data);
498
499 let viewport_height = u16::try_from(inputs.len())
502 .unwrap_or(u16::MAX)
503 .saturating_add(2)
504 .max(3);
505
506 let stdout = std::io::stdout();
507 let backend = CrosstermBackend::new(stdout);
508 crate::tui::mark_ratatui_active();
515 let terminal = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
516 Terminal::with_options(
517 backend,
518 TerminalOptions {
519 viewport: Viewport::Inline(viewport_height),
520 },
521 )
522 })) {
523 Ok(Ok(t)) => t,
524 Ok(Err(e)) => {
525 crate::tui::mark_ratatui_inactive();
526 return Err(e.into());
527 }
528 Err(panic) => {
529 crate::tui::mark_ratatui_inactive();
530 std::panic::resume_unwind(panic);
531 }
532 };
533 let mut guard = TerminalGuard::new(terminal);
534 let (tx, rx) = mpsc::channel();
549
550 guard.as_mut().draw(|f| app.render(f))?;
555
556 let paths: Vec<std::path::PathBuf> = inputs.iter().map(|i| i.path.clone()).collect();
560
561 std::thread::scope(|s| -> Result<()> {
566 let producer = s.spawn(move || {
567 inputs
568 .par_iter()
569 .enumerate()
570 .for_each_with(tx, |tx, (i, input)| {
571 let status = get_worktree_status(
572 &input.path,
573 repo,
574 Some(&input.current_branch),
575 pr_cache,
576 );
577 let _ = tx.send((i, status));
578 });
579 });
580
581 let run_result = crate::tui::list_view::run(guard.as_mut(), &mut app, rx);
582 let producer_result = producer.join();
583 if let Err(panic) = producer_result {
584 let msg = panic
586 .downcast_ref::<&str>()
587 .map(|s| (*s).to_string())
588 .or_else(|| panic.downcast_ref::<String>().cloned())
589 .unwrap_or_else(|| "non-string panic payload".to_string());
590 eprintln!(
591 "warning: status producer thread panicked, some rows may show \"unknown\": {}",
592 msg
593 );
594 }
595 run_result.map_err(crate::error::CwError::from)
596 })?;
597
598 if app.finalize_pending("unknown") {
605 guard.as_mut().draw(|f| app.render(f))?;
606 }
607
608 Ok(app
609 .into_rows()
610 .into_iter()
611 .zip(paths)
612 .map(|(r, path)| WorktreeRow {
613 worktree_id: r.worktree_id,
614 current_branch: r.current_branch,
615 status: r.status,
616 age: r.age,
617 rel_path: r.rel_path,
618 path,
619 })
620 .collect())
621}
622
623fn lookup_intended_branch(repo: &Path, current_branch: &str, path: &Path) -> Option<String> {
629 let key = format_config_key(CONFIG_KEY_INTENDED_BRANCH, current_branch);
631 if let Some(intended) = git::get_config(&key, Some(repo)) {
632 return Some(intended);
633 }
634
635 let result = git::git_command(
637 &[
638 "config",
639 "--local",
640 "--get-regexp",
641 r"^worktree\..*\.intendedBranch",
642 ],
643 Some(repo),
644 false,
645 true,
646 )
647 .ok()?;
648
649 if result.returncode != 0 {
650 return None;
651 }
652
653 let repo_canon = repo.canonicalize().unwrap_or_else(|_| repo.to_path_buf());
656 let repo_name = repo_canon.file_name()?.to_string_lossy().to_string();
657 let path_canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
658
659 for line in result.stdout.trim().lines() {
660 let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
661 if parts.len() == 2 {
662 let key_parts: Vec<&str> = parts[0].split('.').collect();
663 if key_parts.len() >= 2 {
664 let branch_from_key = key_parts[1];
665 let expected_path_name =
666 format!("{}-{}", repo_name, sanitize_branch_name(branch_from_key));
667 if let Some(name) = path_canon.file_name() {
668 if name.to_string_lossy() == expected_path_name {
669 return Some(parts[1].to_string());
670 }
671 }
672 }
673 }
674 }
675
676 None
677}
678
679fn print_busy_details(rows: &[WorktreeRow]) {
689 let busy_rows: Vec<&WorktreeRow> = rows.iter().filter(|r| r.status == "busy").collect();
690 if busy_rows.is_empty() {
691 return;
692 }
693
694 for row in busy_rows {
695 let (hard, soft) = crate::operations::busy::detect_busy_tiered(&row.path);
696 if hard.is_empty() && soft.is_empty() {
700 continue;
701 }
702 let block =
703 crate::operations::busy_messages::render_busy_block(&row.worktree_id, &hard, &soft);
704 println!();
705 print!("{}", block);
707 }
708}
709
710fn print_summary_footer(rows: &[WorktreeRow]) {
711 let feature_count = if rows.len() > 1 { rows.len() - 1 } else { 0 };
714 if feature_count == 0 {
715 return;
716 }
717
718 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
719 for row in rows {
720 *counts.entry(row.status.as_str()).or_insert(0) += 1;
721 }
722
723 let mut summary_parts = Vec::new();
724 for &status_name in &[
725 "clean", "modified", "busy", "active", "pr-open", "merged", "stale",
726 ] {
727 if let Some(&count) = counts.get(status_name) {
728 if count > 0 {
729 let styled = cwconsole::status_style(status_name)
730 .apply_to(format!("{} {}", count, status_name));
731 summary_parts.push(styled.to_string());
732 }
733 }
734 }
735
736 let summary = if summary_parts.is_empty() {
737 format!("\n{} feature worktree(s)", feature_count)
738 } else {
739 format!(
740 "\n{} feature worktree(s) — {}",
741 feature_count,
742 summary_parts.join(", ")
743 )
744 };
745 println!("{}", summary);
746}
747
748fn print_worktree_table(rows: &[WorktreeRow]) {
749 let max_wt = rows.iter().map(|r| r.worktree_id.len()).max().unwrap_or(20);
750 let max_br = rows
751 .iter()
752 .map(|r| r.current_branch.len())
753 .max()
754 .unwrap_or(20);
755 let wt_col = max_wt.clamp(12, 35) + 2;
756 let br_col = max_br.clamp(12, 35) + 2;
757
758 println!(
759 " {} {:<wt_col$} {:<br_col$} {:<10} {:<12} {}",
760 style(" ").dim(),
761 style("WORKTREE").dim(),
762 style("BRANCH").dim(),
763 style("STATUS").dim(),
764 style("AGE").dim(),
765 style("PATH").dim(),
766 wt_col = wt_col,
767 br_col = br_col,
768 );
769 let line_width = (wt_col + br_col + 40).min(cwconsole::terminal_width().saturating_sub(4));
770 println!(" {}", style("─".repeat(line_width)).dim());
771
772 for row in rows {
773 let icon = cwconsole::status_icon(&row.status);
774 let st = cwconsole::status_style(&row.status);
775
776 let branch_display = if row.worktree_id != row.current_branch {
777 style(format!("{} ⚠", row.current_branch))
778 .yellow()
779 .to_string()
780 } else {
781 row.current_branch.clone()
782 };
783
784 let status_styled = st.apply_to(format!("{:<10}", row.status));
785
786 println!(
787 " {} {:<wt_col$} {:<br_col$} {} {:<12} {}",
788 st.apply_to(icon),
789 style(&row.worktree_id).bold(),
790 branch_display,
791 status_styled,
792 style(&row.age).dim(),
793 style(&row.rel_path).dim(),
794 wt_col = wt_col,
795 br_col = br_col,
796 );
797 }
798}
799
800fn print_worktree_compact(rows: &[WorktreeRow]) {
801 for row in rows {
802 let icon = cwconsole::status_icon(&row.status);
803 let st = cwconsole::status_style(&row.status);
804 let age_part = if row.age.is_empty() {
805 String::new()
806 } else {
807 format!(" {}", style(&row.age).dim())
808 };
809
810 println!(
811 " {} {} {}{}",
812 st.apply_to(icon),
813 style(&row.worktree_id).bold(),
814 st.apply_to(&row.status),
815 age_part,
816 );
817
818 let mut details = Vec::new();
819 if row.worktree_id != row.current_branch {
820 details.push(format!(
821 "branch: {}",
822 style(format!("{} ⚠", row.current_branch)).yellow()
823 ));
824 }
825 if !row.rel_path.is_empty() {
826 details.push(format!("{}", style(&row.rel_path).dim()));
827 }
828 if !details.is_empty() {
829 println!(" {}", details.join(" "));
830 }
831 }
832}
833
834#[cfg(test)]
835mod tests {
836 use super::*;
837
838 #[test]
839 fn test_format_age_just_now() {
840 assert_eq!(format_age(0.0), "just now");
841 assert_eq!(format_age(0.001), "just now"); }
843
844 #[test]
845 fn test_format_age_hours() {
846 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"); }
850
851 #[test]
852 fn test_format_age_days() {
853 assert_eq!(format_age(1.0), "1d ago");
854 assert_eq!(format_age(1.5), "1d ago");
855 assert_eq!(format_age(6.9), "6d ago");
856 }
857
858 #[test]
859 fn test_format_age_weeks() {
860 assert_eq!(format_age(7.0), "1w ago");
861 assert_eq!(format_age(14.0), "2w ago");
862 assert_eq!(format_age(29.0), "4w ago");
863 }
864
865 #[test]
866 fn test_format_age_months() {
867 assert_eq!(format_age(30.0), "1mo ago");
868 assert_eq!(format_age(60.0), "2mo ago");
869 assert_eq!(format_age(364.0), "12mo ago");
870 }
871
872 #[test]
873 fn test_format_age_years() {
874 assert_eq!(format_age(365.0), "1y ago");
875 assert_eq!(format_age(730.0), "2y ago");
876 }
877
878 #[test]
879 fn test_format_age_boundary_below_one_hour() {
880 assert_eq!(format_age(0.04), "just now"); }
883
884 #[test]
885 fn format_selector_row_no_busy() {
886 let row = format_selector_row("feat/a", "2d ago", false, "feat-a", 30);
887 assert_eq!(
889 row,
890 "feat/a 2d ago feat-a"
891 );
892 }
893
894 #[test]
895 fn format_selector_row_busy_contains_badge() {
896 let row = format_selector_row("fix/b", "3w ago", true, "fix-b", 30);
897 assert!(
898 row.contains("[busy]"),
899 "expected [busy] in row, got: {:?}",
900 row
901 );
902 assert!(row.contains("fix/b"));
903 assert!(row.contains("3w ago"));
904 assert!(row.contains("fix-b"));
905 }
906
907 #[test]
908 fn format_selector_row_busy_visible_width_matches_no_busy() {
909 let plain = format_selector_row("x", "1d ago", false, "p", 30);
912 let busy = format_selector_row("x", "1d ago", true, "p", 30);
913 assert_eq!(
914 crate::tui::arrow_select::visible_len(&plain),
915 crate::tui::arrow_select::visible_len(&busy),
916 );
917 }
918
919 #[test]
920 fn format_selector_row_empty_age_pads_to_nine() {
921 let row = format_selector_row("feat/a", "", false, "feat-a", 30);
922 assert_eq!(&row[48..], "feat-a");
925 }
926
927 #[test]
928 fn format_selector_row_long_branch_does_not_truncate() {
929 let branch = "feat/extra-long-branch-name-well-past-thirty-chars";
930 let row = format_selector_row(branch, "1d ago", false, "p", 30);
931 assert!(
932 row.starts_with(branch),
933 "branch must not be truncated, got: {:?}",
934 row
935 );
936 }
937
938 #[test]
942 #[cfg(unix)]
943 fn test_get_worktree_status_busy_from_lockfile() {
944 use crate::operations::lockfile::LockEntry;
945 use std::fs;
946 use std::process::{Command, Stdio};
947
948 let tmp = tempfile::TempDir::new().unwrap();
949 let repo = tmp.path();
950 let wt = repo.join("wt1");
951 fs::create_dir_all(wt.join(".git")).unwrap();
952
953 let mut child = Command::new("sleep")
957 .arg("30")
958 .stdout(Stdio::null())
959 .stderr(Stdio::null())
960 .spawn()
961 .expect("spawn sleep");
962 let foreign_pid: u32 = child.id();
963
964 let entry = LockEntry {
965 version: crate::operations::lockfile::LOCK_VERSION,
966 pid: foreign_pid,
967 started_at: 0,
968 cmd: "claude".to_string(),
969 };
970 fs::write(
971 wt.join(".git").join("gw-session.lock"),
972 serde_json::to_string(&entry).unwrap(),
973 )
974 .unwrap();
975
976 let status = get_worktree_status(&wt, repo, Some("wt1"), &PrCache::default());
977
978 let _ = child.kill();
980 let _ = child.wait();
981
982 assert_eq!(status, "busy");
983 }
984
985 #[test]
991 #[cfg(any(target_os = "linux", target_os = "macos"))]
992 fn test_get_worktree_status_not_busy_when_jsonl_active_but_no_live_claude() {
993 use crate::operations::test_env::{env_lock, EnvGuard};
994 let _lock = env_lock();
995 let _guard = EnvGuard::capture(&["HOME"]);
996
997 let home = tempfile::TempDir::new().unwrap();
998 std::env::set_var("HOME", home.path());
999
1000 let repo = tempfile::TempDir::new().unwrap();
1001 let wt = repo.path().join("wt1");
1002 std::fs::create_dir_all(wt.join(".git")).unwrap();
1003 let wt_canon = wt.canonicalize().unwrap_or(wt.clone());
1004
1005 let encoded = wt_canon.to_string_lossy().replace(['/', '.'], "-");
1008 let proj_dir = home.path().join(".claude").join("projects").join(encoded);
1009 std::fs::create_dir_all(&proj_dir).unwrap();
1010 let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
1011 let line = serde_json::json!({
1012 "timestamp": now,
1013 "cwd": wt_canon.to_string_lossy(),
1014 });
1015 std::fs::write(
1016 proj_dir.join("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl"),
1017 format!("{}\n", line),
1018 )
1019 .unwrap();
1020
1021 let status = get_worktree_status(&wt, repo.path(), Some("wt1"), &PrCache::default());
1022 assert_ne!(
1023 status, "busy",
1024 "expected non-busy without a live claude process, got busy"
1025 );
1026 }
1027
1028 #[test]
1029 fn test_get_worktree_status_stale() {
1030 use std::path::PathBuf;
1031 let non_existent = PathBuf::from("/tmp/gw-test-nonexistent-12345");
1032 let repo = PathBuf::from("/tmp");
1033 assert_eq!(
1034 get_worktree_status(&non_existent, &repo, None, &PrCache::default()),
1035 "stale"
1036 );
1037 }
1038}