1use std::collections::BTreeSet;
21use std::path::{Path, PathBuf};
22use std::process::{Command, Stdio};
23use std::sync::{Arc, Mutex, PoisonError};
24use std::time::Duration;
25
26use anyhow::{anyhow, bail, Context, Result};
27use async_trait::async_trait;
28use git2::Repository;
29use serde::Serialize;
30use serde_json::{json, Value};
31use tokio::task::JoinHandle;
32use tokio_util::sync::CancellationToken;
33
34use crate::daemon::service::{DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus};
35use crate::worktrees::{RegisterRequest, WindowEntry, WorktreesRegistry};
36
37pub const SERVICE_NAME: &str = "worktrees";
39
40const SUBMENU_TITLE: &str = "Worktrees";
42
43const VSCODE_BIN_ENV: &str = "OMNI_DEV_VSCODE_BIN";
46
47const MENU_REFRESH_INTERVAL: Duration = Duration::from_secs(2);
52
53struct RefreshTask {
55 token: CancellationToken,
57 handle: JoinHandle<()>,
59}
60
61pub struct WorktreesService {
63 registry: Arc<WorktreesRegistry>,
66 menu_cache: Arc<Mutex<Option<Vec<MenuItem>>>>,
72 refresh: Mutex<Option<RefreshTask>>,
74}
75
76impl WorktreesService {
77 #[must_use]
82 pub fn new() -> Self {
83 Self {
84 registry: Arc::new(WorktreesRegistry::new()),
85 menu_cache: Arc::new(Mutex::new(None)),
86 refresh: Mutex::new(None),
87 }
88 }
89
90 pub fn start_menu_refresh(&self) {
98 if tokio::runtime::Handle::try_current().is_err() {
99 tracing::debug!("no tokio runtime; worktrees menu refresh not started");
100 return;
101 }
102 let mut guard = self.refresh.lock().unwrap_or_else(PoisonError::into_inner);
103 if guard.is_some() {
104 return;
105 }
106 let token = CancellationToken::new();
107 let loop_token = token.clone();
108 let registry = self.registry.clone();
109 let cache = self.menu_cache.clone();
110 let handle = tokio::spawn(async move {
111 loop {
112 let entries = registry.list();
116 if let Ok(items) =
117 tokio::task::spawn_blocking(move || menu_items_for(&entries)).await
118 {
119 *cache.lock().unwrap_or_else(PoisonError::into_inner) = Some(items);
120 }
121 tokio::select! {
122 () = loop_token.cancelled() => break,
123 () = tokio::time::sleep(MENU_REFRESH_INTERVAL) => {}
124 }
125 }
126 });
127 *guard = Some(RefreshTask { token, handle });
128 }
129}
130
131impl Default for WorktreesService {
132 fn default() -> Self {
133 Self::new()
134 }
135}
136
137#[async_trait]
138impl DaemonService for WorktreesService {
139 fn name(&self) -> &'static str {
140 SERVICE_NAME
141 }
142
143 async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
144 match op {
145 "register" => {
146 let req: RegisterRequest =
147 serde_json::from_value(payload).context("invalid `register` payload")?;
148 if req.key.trim().is_empty() {
149 bail!("`register` requires a non-empty `key`");
150 }
151 self.registry.register(req);
152 Ok(json!({ "ok": true }))
153 }
154 "heartbeat" => {
155 let key = require_key(&payload, "heartbeat")?;
156 Ok(json!({ "known": self.registry.heartbeat(key) }))
157 }
158 "unregister" => {
159 let key = require_key(&payload, "unregister")?;
160 Ok(json!({ "removed": self.registry.unregister(key) }))
161 }
162 "list" => Ok(json!({ "windows": enriched_windows(self.registry.list()).await })),
163 other => bail!("unknown worktrees op: {other}"),
164 }
165 }
166
167 fn menu(&self) -> MenuSnapshot {
168 let cached = self
173 .menu_cache
174 .lock()
175 .unwrap_or_else(PoisonError::into_inner)
176 .clone();
177 let items = cached.unwrap_or_else(|| menu_items_for(&self.registry.list()));
178 MenuSnapshot {
179 title: SUBMENU_TITLE.to_string(),
180 items,
181 }
182 }
183
184 async fn menu_action(&self, action_id: &str) -> Result<()> {
185 if let Some(key) = action_id.strip_prefix("focus:") {
186 let folder = self
189 .registry
190 .first_folder(key)
191 .ok_or_else(|| anyhow!("no open window with key {key} (it may have closed)"))?;
192 focus_window(&folder)?;
193 return Ok(());
194 }
195 bail!("unknown worktrees menu action: {action_id}")
196 }
197
198 async fn status(&self) -> ServiceStatus {
199 let entries = self.registry.list();
200 let repos: BTreeSet<&str> = entries.iter().filter_map(|e| e.repo.as_deref()).collect();
201 let summary = format!("{} window(s) across {} repo(s)", entries.len(), repos.len());
202 let windows = enriched_windows(entries).await;
203 ServiceStatus {
204 name: SERVICE_NAME.to_string(),
205 healthy: true,
206 summary,
207 detail: json!({ "windows": windows }),
208 }
209 }
210
211 async fn shutdown(&self) {
212 let task = self
216 .refresh
217 .lock()
218 .unwrap_or_else(PoisonError::into_inner)
219 .take();
220 if let Some(task) = task {
221 task.token.cancel();
222 let _ = task.handle.await;
223 }
224 }
225}
226
227fn require_key<'a>(payload: &'a Value, op: &str) -> Result<&'a str> {
230 payload
231 .get("key")
232 .and_then(Value::as_str)
233 .ok_or_else(|| anyhow!("`{op}` requires `key`"))
234}
235
236#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
247struct GitStatus {
248 #[serde(skip_serializing_if = "Option::is_none")]
250 branch: Option<String>,
251 #[serde(skip_serializing_if = "Option::is_none")]
253 ahead: Option<usize>,
254 #[serde(skip_serializing_if = "Option::is_none")]
256 behind: Option<usize>,
257 #[serde(skip_serializing_if = "Option::is_none")]
262 main_repo: Option<String>,
263 #[serde(skip_serializing_if = "is_false")]
266 is_worktree: bool,
267}
268
269#[allow(clippy::trivially_copy_pass_by_ref)]
273fn is_false(b: &bool) -> bool {
274 !*b
275}
276
277fn git_status(folder: &Path) -> GitStatus {
282 let Ok(repo) = Repository::discover(folder) else {
283 return GitStatus::default();
284 };
285 let base = GitStatus {
288 main_repo: main_repo_name(repo.commondir()),
289 is_worktree: repo.is_worktree(),
290 ..GitStatus::default()
291 };
292 let Ok(head) = repo.head() else {
293 return base;
295 };
296 let Some(name) = head
300 .shorthand()
301 .ok()
302 .filter(|_| head.is_branch())
303 .map(str::to_string)
304 else {
305 return base;
306 };
307 let branch = git2::Branch::wrap(head);
308 let (ahead, behind) = match upstream_ahead_behind(&repo, &branch) {
309 Some((ahead, behind)) => (Some(ahead), Some(behind)),
310 None => (None, None),
311 };
312 GitStatus {
313 branch: Some(name),
314 ahead,
315 behind,
316 ..base
317 }
318}
319
320fn main_repo_name(commondir: &Path) -> Option<String> {
326 let file_name = commondir.file_name()?.to_string_lossy().into_owned();
327 if file_name == ".git" {
328 commondir
330 .parent()
331 .and_then(Path::file_name)
332 .map(|n| n.to_string_lossy().into_owned())
333 } else {
334 Some(
336 file_name
337 .strip_suffix(".git")
338 .unwrap_or(&file_name)
339 .to_string(),
340 )
341 }
342}
343
344fn upstream_ahead_behind(repo: &Repository, branch: &git2::Branch<'_>) -> Option<(usize, usize)> {
347 let upstream = branch.upstream().ok()?;
348 let local_oid = branch.get().target()?;
349 let upstream_oid = upstream.get().target()?;
350 repo.graph_ahead_behind(local_oid, upstream_oid).ok()
351}
352
353#[derive(Serialize)]
359struct EnrichedEntry<'a> {
360 #[serde(flatten)]
361 entry: &'a WindowEntry,
362 #[serde(flatten)]
363 git: GitStatus,
364}
365
366fn enriched_entry(entry: &WindowEntry) -> Value {
371 let git = entry
372 .folders
373 .first()
374 .map(|folder| git_status(folder))
375 .unwrap_or_default();
376 serde_json::to_value(EnrichedEntry { entry, git }).unwrap_or_else(|_| json!({}))
377}
378
379async fn enriched_windows(entries: Vec<WindowEntry>) -> Vec<Value> {
383 tokio::task::spawn_blocking(move || entries.iter().map(enriched_entry).collect())
384 .await
385 .unwrap_or_default()
386}
387
388fn display_name(entry: &WindowEntry) -> String {
391 if let Some(repo) = &entry.repo {
392 return repo.clone();
393 }
394 if let Some(folder) = entry.folders.first() {
395 return folder.file_name().map_or_else(
396 || folder.display().to_string(),
397 |n| n.to_string_lossy().into_owned(),
398 );
399 }
400 "(no folder)".to_string()
401}
402
403const REPO_SEP: char = '·';
405const WORKTREE_SEP: char = '⑂';
408
409fn menu_items_for(entries: &[WindowEntry]) -> Vec<MenuItem> {
414 if entries.is_empty() {
415 vec![MenuItem::Label("No open windows".to_string())]
416 } else {
417 window_menu_items(entries)
418 }
419}
420
421fn window_menu_items(entries: &[WindowEntry]) -> Vec<MenuItem> {
428 entries
429 .iter()
430 .map(|entry| {
431 let label = window_label(entry);
432 if entry.folders.is_empty() {
433 MenuItem::Label(label)
434 } else {
435 MenuItem::Action(MenuAction {
436 id: format!("focus:{}", entry.key),
437 label,
438 enabled: true,
439 })
440 }
441 })
442 .collect()
443}
444
445fn window_label(entry: &WindowEntry) -> String {
451 let status = entry
452 .folders
453 .first()
454 .map(|folder| git_status(folder))
455 .unwrap_or_default();
456 let name = status
459 .main_repo
460 .clone()
461 .unwrap_or_else(|| display_name(entry));
462 if let Some(branch) = &status.branch {
463 let sep = if status.is_worktree {
464 WORKTREE_SEP
465 } else {
466 REPO_SEP
467 };
468 return match sync_indicator(status.ahead, status.behind) {
469 Some(sync) => format!("{name} {sep} {branch} {sync}"),
470 None => format!("{name} {sep} {branch}"),
471 };
472 }
473 match &entry.title {
475 Some(title) if title != &name => format!("{name} {REPO_SEP} {title}"),
476 _ => name,
477 }
478}
479
480fn sync_indicator(ahead: Option<usize>, behind: Option<usize>) -> Option<String> {
483 match (ahead, behind) {
484 (Some(ahead), Some(behind)) => Some(format!("(+{ahead} -{behind})")),
485 _ => None,
486 }
487}
488
489const CODE_BINARY_CANDIDATES: &[&str] = &[
492 "/usr/local/bin/code",
493 "/opt/homebrew/bin/code",
494 "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
495 "/usr/bin/code",
496];
497
498fn focus_window(folder: &Path) -> Result<()> {
501 focus_window_with(&resolve_code_binary(), folder)
502}
503
504fn focus_window_with(program: &Path, folder: &Path) -> Result<()> {
511 if !folder.is_absolute() {
514 bail!(
515 "refusing to focus a non-absolute folder path: {}",
516 folder.display()
517 );
518 }
519 if !folder.is_dir() {
520 bail!("worktree folder no longer exists: {}", folder.display());
521 }
522 let child = Command::new(program)
525 .arg(folder)
526 .stdin(Stdio::null())
527 .stdout(Stdio::null())
528 .stderr(Stdio::null())
529 .spawn()
530 .with_context(|| {
531 format!(
532 "failed to launch `{}` to focus {}",
533 program.display(),
534 folder.display()
535 )
536 })?;
537 std::thread::spawn(move || {
539 let mut child = child;
540 let _ = child.wait();
541 });
542 Ok(())
543}
544
545fn resolve_code_binary() -> PathBuf {
550 resolve_code_binary_from(std::env::var_os(VSCODE_BIN_ENV), CODE_BINARY_CANDIDATES)
551}
552
553fn resolve_code_binary_from(
556 env_override: Option<std::ffi::OsString>,
557 candidates: &[&str],
558) -> PathBuf {
559 if let Some(path) = env_override {
560 return PathBuf::from(path);
561 }
562 for candidate in candidates {
563 let path = Path::new(candidate);
564 if path.exists() {
565 return path.to_path_buf();
566 }
567 }
568 PathBuf::from("code")
569}
570
571#[cfg(test)]
572#[allow(clippy::unwrap_used, clippy::expect_used)]
573mod tests {
574 use super::*;
575 use chrono::Utc;
576
577 fn register_payload(key: &str, repo: Option<&str>, folder: &str) -> Value {
578 json!({
579 "key": key,
580 "folders": [folder],
581 "repo": repo,
582 "title": format!("{key}-title"),
583 "pid": 1234,
584 })
585 }
586
587 fn windows_of(payload: &Value) -> &Vec<Value> {
589 payload
590 .get("windows")
591 .and_then(Value::as_array)
592 .expect("windows array")
593 }
594
595 #[tokio::test]
596 async fn name_and_unknown_op() {
597 let svc = WorktreesService::new();
598 assert_eq!(svc.name(), "worktrees");
599 assert!(svc.handle("frobnicate", Value::Null).await.is_err());
600 }
601
602 #[tokio::test]
603 async fn handle_routes_ops_and_shapes_payloads() {
604 let svc = WorktreesService::new();
605 let payload = svc.handle("list", Value::Null).await.unwrap();
607 assert_eq!(payload, json!({ "windows": [] }));
608
609 let reply = svc
611 .handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
612 .await
613 .unwrap();
614 assert_eq!(reply, json!({ "ok": true }));
615 let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
616 assert_eq!(windows.len(), 1);
617 assert_eq!(windows[0].get("key").and_then(Value::as_str), Some("w1"));
618 assert!(windows[0].get("last_seen").is_some());
619
620 let known = svc
622 .handle("heartbeat", json!({ "key": "w1" }))
623 .await
624 .unwrap();
625 assert_eq!(known, json!({ "known": true }));
626 let unknown = svc
627 .handle("heartbeat", json!({ "key": "nope" }))
628 .await
629 .unwrap();
630 assert_eq!(unknown, json!({ "known": false }));
631
632 let gone = svc
634 .handle("unregister", json!({ "key": "w1" }))
635 .await
636 .unwrap();
637 assert_eq!(gone, json!({ "removed": true }));
638 let again = svc
639 .handle("unregister", json!({ "key": "w1" }))
640 .await
641 .unwrap();
642 assert_eq!(again, json!({ "removed": false }));
643 }
644
645 #[tokio::test]
646 async fn handle_rejects_missing_or_empty_key() {
647 let svc = WorktreesService::new();
648 assert!(svc.handle("register", json!({})).await.is_err());
650 assert!(svc
651 .handle("register", json!({ "key": " " }))
652 .await
653 .is_err());
654 assert!(svc.handle("heartbeat", json!({})).await.is_err());
656 assert!(svc.handle("unregister", json!({})).await.is_err());
657 }
658
659 #[test]
660 fn display_name_prefers_repo_then_folder_basename() {
661 let base = WindowEntry {
662 key: "k".to_string(),
663 folders: vec![PathBuf::from("/home/me/project")],
664 repo: Some("my-repo".to_string()),
665 title: None,
666 pid: None,
667 last_seen: Utc::now(),
668 };
669 assert_eq!(display_name(&base), "my-repo");
670
671 let no_repo = WindowEntry {
672 repo: None,
673 ..base.clone()
674 };
675 assert_eq!(display_name(&no_repo), "project");
676
677 let nothing = WindowEntry {
678 repo: None,
679 folders: vec![],
680 ..base.clone()
681 };
682 assert_eq!(display_name(¬hing), "(no folder)");
683
684 let rootish = WindowEntry {
687 repo: None,
688 folders: vec![PathBuf::from("/")],
689 ..base
690 };
691 assert_eq!(display_name(&rootish), "/");
692 }
693
694 #[test]
695 fn window_menu_items_merge_stats_and_focus_into_one_clickable_line() {
696 let now = Utc::now();
697 let entries = vec![
698 WindowEntry {
703 key: "k2".to_string(),
704 folders: vec![],
705 repo: Some("solo".to_string()),
706 title: Some("solo".to_string()),
707 pid: None,
708 last_seen: now,
709 },
710 WindowEntry {
713 key: "k1".to_string(),
714 folders: vec![PathBuf::from("/tmp/a")],
715 repo: Some("repo".to_string()),
716 title: Some("a branch".to_string()),
717 pid: None,
718 last_seen: now,
719 },
720 ];
721 let items = window_menu_items(&entries);
722 assert_eq!(items.len(), 2);
724 assert!(!items.iter().any(|i| matches!(i, MenuItem::Separator)));
725
726 let action = items
729 .iter()
730 .find_map(|i| match i {
731 MenuItem::Action(a) => Some(a),
732 _ => None,
733 })
734 .expect("a focus action");
735 assert_eq!(action.id, "focus:k1");
736 assert_eq!(action.label, "repo · a branch");
737
738 let labels: Vec<&str> = items
740 .iter()
741 .filter_map(|i| match i {
742 MenuItem::Label(t) => Some(t.as_str()),
743 _ => None,
744 })
745 .collect();
746 assert_eq!(labels, vec!["solo"]);
747 }
748
749 #[tokio::test]
750 async fn menu_and_status_shapes() {
751 let svc = WorktreesService::new();
752 let menu = svc.menu();
754 assert_eq!(menu.title, "Worktrees");
755 assert!(matches!(
756 menu.items.first(),
757 Some(MenuItem::Label(text)) if text == "No open windows"
758 ));
759 let status = svc.status().await;
760 assert_eq!(status.name, "worktrees");
761 assert!(status.healthy);
762 assert_eq!(status.summary, "0 window(s) across 0 repo(s)");
763
764 svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
767 .await
768 .unwrap();
769 svc.handle("register", register_payload("w2", Some("repo-a"), "/tmp/b"))
770 .await
771 .unwrap();
772 svc.handle(
773 "register",
774 json!({ "key": "w3", "repo": "repo-a", "folders": [] }),
775 )
776 .await
777 .unwrap();
778 let status = svc.status().await;
779 assert_eq!(status.summary, "3 window(s) across 1 repo(s)");
780
781 let menu = svc.menu();
782 assert_eq!(menu.items.len(), 3);
784 assert!(!menu.items.iter().any(|i| matches!(i, MenuItem::Separator)));
785 let action_ids: Vec<&str> = menu
786 .items
787 .iter()
788 .filter_map(|i| match i {
789 MenuItem::Action(a) => Some(a.id.as_str()),
790 _ => None,
791 })
792 .collect();
793 assert!(action_ids.contains(&"focus:w1"));
796 assert!(action_ids.contains(&"focus:w2"));
797 assert!(!action_ids.contains(&"focus:w3"));
798 }
799
800 #[test]
801 fn start_menu_refresh_is_a_noop_outside_a_runtime() {
802 let svc = WorktreesService::new();
805 svc.start_menu_refresh();
806 assert!(svc.refresh.lock().unwrap().is_none());
807 }
808
809 #[tokio::test]
810 async fn start_menu_refresh_populates_cache_and_shutdown_stops_it() {
811 let svc = WorktreesService::new();
812 svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
813 .await
814 .unwrap();
815 assert!(svc.menu_cache.lock().unwrap().is_none());
817
818 svc.start_menu_refresh();
819 svc.start_menu_refresh();
821
822 let mut filled = false;
824 for _ in 0..100 {
825 if svc.menu_cache.lock().unwrap().is_some() {
826 filled = true;
827 break;
828 }
829 tokio::time::sleep(Duration::from_millis(10)).await;
830 }
831 assert!(filled, "background refresh should populate the menu cache");
832
833 let menu = svc.menu();
835 assert_eq!(menu.title, "Worktrees");
836 assert!(menu
837 .items
838 .iter()
839 .any(|i| matches!(i, MenuItem::Action(a) if a.id == "focus:w1")));
840
841 svc.shutdown().await;
843 assert!(svc.refresh.lock().unwrap().is_none());
844 }
845
846 #[tokio::test]
847 async fn default_constructs_an_empty_service() {
848 let svc = WorktreesService::default();
849 let payload = svc.handle("list", Value::Null).await.unwrap();
850 assert_eq!(payload, json!({ "windows": [] }));
851 }
852
853 #[tokio::test]
854 async fn menu_action_rejects_unknown_and_missing_window() {
855 let svc = WorktreesService::new();
856 assert!(svc.menu_action("bogus").await.is_err());
857 assert!(svc.menu_action("focus:nope").await.is_err());
859 svc.shutdown().await;
860 }
861
862 struct VscodeBinGuard(Option<std::ffi::OsString>);
866 impl Drop for VscodeBinGuard {
867 fn drop(&mut self) {
868 match self.0.take() {
869 Some(v) => std::env::set_var(VSCODE_BIN_ENV, v),
870 None => std::env::remove_var(VSCODE_BIN_ENV),
871 }
872 }
873 }
874
875 #[tokio::test]
876 async fn menu_action_focus_resolves_folder_and_spawns() {
877 let dir = tempfile::tempdir().unwrap();
878 let svc = WorktreesService::new();
879 svc.handle(
880 "register",
881 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
882 )
883 .await
884 .unwrap();
885
886 let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
889 std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
890 svc.menu_action("focus:w1").await.unwrap();
891 }
892
893 #[test]
894 fn focus_window_with_validates_folder_then_spawns() {
895 let dir = tempfile::tempdir().unwrap();
896 assert!(focus_window_with(Path::new("/bin/sh"), Path::new("relative/dir")).is_err());
898 assert!(
899 focus_window_with(Path::new("/bin/sh"), Path::new("/no/such/abs/dir/xyzzy")).is_err()
900 );
901 focus_window_with(Path::new("/bin/sh"), dir.path()).unwrap();
903 assert!(focus_window_with(Path::new("/no/such/launcher/xyzzy"), dir.path()).is_err());
905 }
906
907 #[test]
908 fn resolve_code_binary_from_prefers_env_then_candidate_then_fallback() {
909 assert_eq!(
911 resolve_code_binary_from(Some("/custom/code".into()), &["/usr/bin/code"]),
912 PathBuf::from("/custom/code")
913 );
914 let existing = tempfile::NamedTempFile::new().unwrap();
916 let existing_path = existing.path().to_str().unwrap();
917 assert_eq!(
918 resolve_code_binary_from(None, &["/no/such/candidate/xyzzy", existing_path]),
919 PathBuf::from(existing_path)
920 );
921 assert_eq!(
923 resolve_code_binary_from(None, &["/no/such/candidate/xyzzy"]),
924 PathBuf::from("code")
925 );
926 let _ = resolve_code_binary();
928 }
929
930 fn init_repo(dir: &Path) -> Repository {
935 let repo = Repository::init(dir).unwrap();
936 let mut cfg = repo.config().unwrap();
937 cfg.set_str("user.name", "Test").unwrap();
938 cfg.set_str("user.email", "test@example.com").unwrap();
939 repo
940 }
941
942 fn empty_commit(
945 repo: &Repository,
946 refname: Option<&str>,
947 parents: &[&git2::Commit<'_>],
948 msg: &str,
949 ) -> git2::Oid {
950 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
951 let tree = repo
952 .find_tree(repo.treebuilder(None).unwrap().write().unwrap())
953 .unwrap();
954 repo.commit(refname, &sig, &sig, msg, &tree, parents)
955 .unwrap()
956 }
957
958 fn diverging_repo(dir: &Path) -> Repository {
961 let repo = init_repo(dir);
962 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
964 let a_commit = repo.find_commit(a).unwrap();
965 let c = empty_commit(&repo, None, &[&a_commit], "C");
967 repo.reference("refs/remotes/origin/main", c, true, "origin main")
968 .unwrap();
969 empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
971 drop(a_commit);
973 repo.set_head("refs/heads/main").unwrap();
974 let mut cfg = repo.config().unwrap();
976 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
977 .unwrap();
978 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
979 .unwrap();
980 cfg.set_str("branch.main.remote", "origin").unwrap();
981 cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
982 repo
983 }
984
985 #[test]
986 fn git_status_reads_branch_and_ahead_behind() {
987 let dir = tempfile::tempdir().unwrap();
988 let _repo = diverging_repo(dir.path());
989 let status = git_status(dir.path());
990 assert_eq!(status.branch.as_deref(), Some("main"));
991 assert_eq!(status.ahead, Some(1));
992 assert_eq!(status.behind, Some(1));
993 assert_eq!(
995 status.main_repo.as_deref(),
996 dir.path().file_name().and_then(|n| n.to_str())
997 );
998 assert!(!status.is_worktree);
999 }
1000
1001 #[test]
1002 fn git_status_empty_repo_is_unborn() {
1003 let dir = tempfile::tempdir().unwrap();
1007 init_repo(dir.path());
1008 let status = git_status(dir.path());
1009 assert_eq!(status.branch, None);
1010 assert_eq!(status.ahead, None);
1011 assert_eq!(status.behind, None);
1012 assert_eq!(
1013 status.main_repo.as_deref(),
1014 dir.path().file_name().and_then(|n| n.to_str())
1015 );
1016 assert!(!status.is_worktree);
1017 }
1018
1019 #[test]
1020 fn git_status_no_upstream_reports_branch_only() {
1021 let dir = tempfile::tempdir().unwrap();
1022 let repo = init_repo(dir.path());
1023 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
1024 repo.set_head("refs/heads/main").unwrap();
1025 let status = git_status(dir.path());
1026 assert_eq!(status.branch.as_deref(), Some("main"));
1027 assert_eq!(status.ahead, None);
1029 assert_eq!(status.behind, None);
1030 }
1031
1032 #[test]
1033 fn git_status_non_repo_is_empty_detached_reports_repo_without_branch() {
1034 let plain = tempfile::tempdir().unwrap();
1036 assert_eq!(git_status(plain.path()), GitStatus::default());
1037
1038 let dir = tempfile::tempdir().unwrap();
1041 let repo = init_repo(dir.path());
1042 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
1043 repo.set_head_detached(a).unwrap();
1044 let status = git_status(dir.path());
1045 assert_eq!(status.branch, None);
1046 assert_eq!(status.ahead, None);
1047 assert_eq!(status.behind, None);
1048 assert_eq!(
1049 status.main_repo.as_deref(),
1050 dir.path().file_name().and_then(|n| n.to_str())
1051 );
1052 assert!(!status.is_worktree);
1053 }
1054
1055 #[test]
1056 fn sync_indicator_formats_only_with_upstream() {
1057 assert_eq!(sync_indicator(Some(2), Some(1)).as_deref(), Some("(+2 -1)"));
1058 assert_eq!(sync_indicator(Some(0), Some(0)).as_deref(), Some("(+0 -0)"));
1059 assert_eq!(sync_indicator(None, None), None);
1060 assert_eq!(sync_indicator(Some(1), None), None);
1062 }
1063
1064 #[tokio::test]
1065 async fn list_enriches_entries_with_git_status() {
1066 let dir = tempfile::tempdir().unwrap();
1067 let repo = init_repo(dir.path());
1068 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
1069 repo.set_head("refs/heads/main").unwrap();
1070
1071 let svc = WorktreesService::new();
1072 svc.handle(
1073 "register",
1074 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
1075 )
1076 .await
1077 .unwrap();
1078 let payload = svc.handle("list", Value::Null).await.unwrap();
1079 let windows = windows_of(&payload);
1080 assert_eq!(windows.len(), 1);
1081 assert_eq!(
1082 windows[0].get("branch").and_then(Value::as_str),
1083 Some("main")
1084 );
1085 assert!(windows[0].get("ahead").is_none());
1087 assert!(windows[0].get("behind").is_none());
1088 assert_eq!(
1090 windows[0].get("main_repo").and_then(Value::as_str),
1091 dir.path().file_name().and_then(|n| n.to_str())
1092 );
1093
1094 let plain = tempfile::tempdir().unwrap();
1096 svc.handle(
1097 "register",
1098 json!({ "key": "w2", "folders": [plain.path()], "repo": "plain" }),
1099 )
1100 .await
1101 .unwrap();
1102 let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
1103 let w2 = windows
1104 .iter()
1105 .find(|w| w.get("key").and_then(Value::as_str) == Some("w2"))
1106 .unwrap();
1107 assert!(w2.get("branch").is_none());
1108 assert!(w2.get("main_repo").is_none());
1109 }
1110
1111 #[test]
1112 fn window_label_prefers_git_branch_over_title() {
1113 let dir = tempfile::tempdir().unwrap();
1114 let repo = init_repo(dir.path());
1115 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
1116 repo.set_head("refs/heads/main").unwrap();
1117 let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
1118 let entry = WindowEntry {
1119 key: "k".to_string(),
1120 folders: vec![dir.path().to_path_buf()],
1121 repo: Some("companion-repo".to_string()),
1124 title: Some("ignored title".to_string()),
1125 pid: None,
1126 last_seen: Utc::now(),
1127 };
1128 assert_eq!(window_label(&entry), format!("{repo_name} · main"));
1130 }
1131
1132 #[tokio::test]
1133 async fn list_includes_ahead_behind_for_tracking_branch() {
1134 let dir = tempfile::tempdir().unwrap();
1135 let _repo = diverging_repo(dir.path());
1136
1137 let svc = WorktreesService::new();
1138 svc.handle(
1139 "register",
1140 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
1141 )
1142 .await
1143 .unwrap();
1144 let payload = svc.handle("list", Value::Null).await.unwrap();
1145 let windows = windows_of(&payload);
1146 assert_eq!(
1148 windows[0].get("branch").and_then(Value::as_str),
1149 Some("main")
1150 );
1151 assert_eq!(windows[0].get("ahead").and_then(Value::as_u64), Some(1));
1152 assert_eq!(windows[0].get("behind").and_then(Value::as_u64), Some(1));
1153 }
1154
1155 #[test]
1156 fn window_label_includes_sync_for_tracking_branch() {
1157 let dir = tempfile::tempdir().unwrap();
1158 let _repo = diverging_repo(dir.path());
1159 let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
1160 let entry = WindowEntry {
1161 key: "k".to_string(),
1162 folders: vec![dir.path().to_path_buf()],
1163 repo: Some("companion-repo".to_string()),
1164 title: None,
1165 pid: None,
1166 last_seen: Utc::now(),
1167 };
1168 assert_eq!(window_label(&entry), format!("{repo_name} · main (+1 -1)"));
1170 }
1171
1172 fn add_worktree(repo: &Repository, base: git2::Oid, wt_path: &Path, branch: &str) {
1176 let commit = repo.find_commit(base).unwrap();
1177 repo.branch(branch, &commit, false).unwrap();
1178 let reference = repo
1179 .find_reference(&format!("refs/heads/{branch}"))
1180 .unwrap();
1181 let mut opts = git2::WorktreeAddOptions::new();
1182 opts.reference(Some(&reference));
1183 repo.worktree(branch, wt_path, Some(&opts)).unwrap();
1184 }
1185
1186 #[test]
1187 fn git_status_marks_linked_worktree_and_names_parent_repo() {
1188 let main_dir = tempfile::tempdir().unwrap();
1189 let repo = init_repo(main_dir.path());
1190 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
1191 repo.set_head("refs/heads/main").unwrap();
1192
1193 let wt_parent = tempfile::tempdir().unwrap();
1196 let wt_path = wt_parent.path().join("feature-wt");
1197 add_worktree(&repo, a, &wt_path, "feature");
1198
1199 let status = git_status(&wt_path);
1200 assert!(status.is_worktree);
1201 assert_eq!(status.branch.as_deref(), Some("feature"));
1202 assert_eq!(
1204 status.main_repo.as_deref(),
1205 main_dir.path().file_name().and_then(|n| n.to_str())
1206 );
1207
1208 let main_status = git_status(main_dir.path());
1210 assert!(!main_status.is_worktree);
1211 assert_eq!(main_status.main_repo, status.main_repo);
1212 }
1213
1214 #[test]
1215 fn window_label_marks_worktree_with_fork_glyph() {
1216 let main_dir = tempfile::tempdir().unwrap();
1217 let repo = init_repo(main_dir.path());
1218 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
1219 repo.set_head("refs/heads/main").unwrap();
1220 let wt_parent = tempfile::tempdir().unwrap();
1221 let wt_path = wt_parent.path().join("feature-wt");
1222 add_worktree(&repo, a, &wt_path, "feature");
1223
1224 let repo_name = main_dir.path().file_name().unwrap().to_str().unwrap();
1225 let entry = WindowEntry {
1226 key: "k".to_string(),
1227 folders: vec![wt_path],
1228 repo: Some("feature-wt".to_string()),
1229 title: None,
1230 pid: None,
1231 last_seen: Utc::now(),
1232 };
1233 assert_eq!(window_label(&entry), format!("{repo_name} ⑂ feature"));
1236 }
1237
1238 #[test]
1239 fn main_repo_name_derives_from_common_dir() {
1240 assert_eq!(
1242 main_repo_name(Path::new("/home/me/omni-dev/.git")).as_deref(),
1243 Some("omni-dev")
1244 );
1245 assert_eq!(
1247 main_repo_name(Path::new("/home/me/omni-dev/.git/")).as_deref(),
1248 Some("omni-dev")
1249 );
1250 assert_eq!(
1252 main_repo_name(Path::new("/srv/git/omni-dev.git")).as_deref(),
1253 Some("omni-dev")
1254 );
1255 assert_eq!(main_repo_name(Path::new("/.git")), None);
1257 }
1258}