1use std::collections::BTreeSet;
20use std::path::{Path, PathBuf};
21use std::process::{Command, Stdio};
22
23use anyhow::{anyhow, bail, Context, Result};
24use async_trait::async_trait;
25use git2::Repository;
26use serde::Serialize;
27use serde_json::{json, Value};
28
29use crate::daemon::service::{DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus};
30use crate::worktrees::{RegisterRequest, WindowEntry, WorktreesRegistry};
31
32pub const SERVICE_NAME: &str = "worktrees";
34
35const VSCODE_BIN_ENV: &str = "OMNI_DEV_VSCODE_BIN";
38
39pub struct WorktreesService {
41 registry: WorktreesRegistry,
43}
44
45impl WorktreesService {
46 #[must_use]
48 pub fn new() -> Self {
49 Self {
50 registry: WorktreesRegistry::new(),
51 }
52 }
53}
54
55impl Default for WorktreesService {
56 fn default() -> Self {
57 Self::new()
58 }
59}
60
61#[async_trait]
62impl DaemonService for WorktreesService {
63 fn name(&self) -> &'static str {
64 SERVICE_NAME
65 }
66
67 async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
68 match op {
69 "register" => {
70 let req: RegisterRequest =
71 serde_json::from_value(payload).context("invalid `register` payload")?;
72 if req.key.trim().is_empty() {
73 bail!("`register` requires a non-empty `key`");
74 }
75 self.registry.register(req);
76 Ok(json!({ "ok": true }))
77 }
78 "heartbeat" => {
79 let key = require_key(&payload, "heartbeat")?;
80 Ok(json!({ "known": self.registry.heartbeat(key) }))
81 }
82 "unregister" => {
83 let key = require_key(&payload, "unregister")?;
84 Ok(json!({ "removed": self.registry.unregister(key) }))
85 }
86 "list" => Ok(json!({ "windows": enriched_windows(self.registry.list()).await })),
87 other => bail!("unknown worktrees op: {other}"),
88 }
89 }
90
91 fn menu(&self) -> MenuSnapshot {
92 let entries = self.registry.list();
93 let items = if entries.is_empty() {
94 vec![MenuItem::Label("No open windows".to_string())]
95 } else {
96 window_menu_items(&entries)
97 };
98 MenuSnapshot {
99 title: "Worktrees".to_string(),
100 items,
101 }
102 }
103
104 async fn menu_action(&self, action_id: &str) -> Result<()> {
105 if let Some(key) = action_id.strip_prefix("focus:") {
106 let folder = self
109 .registry
110 .first_folder(key)
111 .ok_or_else(|| anyhow!("no open window with key {key} (it may have closed)"))?;
112 focus_window(&folder)?;
113 return Ok(());
114 }
115 bail!("unknown worktrees menu action: {action_id}")
116 }
117
118 async fn status(&self) -> ServiceStatus {
119 let entries = self.registry.list();
120 let repos: BTreeSet<&str> = entries.iter().filter_map(|e| e.repo.as_deref()).collect();
121 let summary = format!("{} window(s) across {} repo(s)", entries.len(), repos.len());
122 let windows = enriched_windows(entries).await;
123 ServiceStatus {
124 name: SERVICE_NAME.to_string(),
125 healthy: true,
126 summary,
127 detail: json!({ "windows": windows }),
128 }
129 }
130
131 async fn shutdown(&self) {
132 }
134}
135
136fn require_key<'a>(payload: &'a Value, op: &str) -> Result<&'a str> {
139 payload
140 .get("key")
141 .and_then(Value::as_str)
142 .ok_or_else(|| anyhow!("`{op}` requires `key`"))
143}
144
145#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
156struct GitStatus {
157 #[serde(skip_serializing_if = "Option::is_none")]
159 branch: Option<String>,
160 #[serde(skip_serializing_if = "Option::is_none")]
162 ahead: Option<usize>,
163 #[serde(skip_serializing_if = "Option::is_none")]
165 behind: Option<usize>,
166}
167
168fn git_status(folder: &Path) -> GitStatus {
173 let Ok(repo) = Repository::discover(folder) else {
174 return GitStatus::default();
175 };
176 let Ok(head) = repo.head() else {
177 return GitStatus::default();
179 };
180 let Some(name) = head
184 .shorthand()
185 .ok()
186 .filter(|_| head.is_branch())
187 .map(str::to_string)
188 else {
189 return GitStatus::default();
190 };
191 let branch = git2::Branch::wrap(head);
192 let (ahead, behind) = match upstream_ahead_behind(&repo, &branch) {
193 Some((ahead, behind)) => (Some(ahead), Some(behind)),
194 None => (None, None),
195 };
196 GitStatus {
197 branch: Some(name),
198 ahead,
199 behind,
200 }
201}
202
203fn upstream_ahead_behind(repo: &Repository, branch: &git2::Branch<'_>) -> Option<(usize, usize)> {
206 let upstream = branch.upstream().ok()?;
207 let local_oid = branch.get().target()?;
208 let upstream_oid = upstream.get().target()?;
209 repo.graph_ahead_behind(local_oid, upstream_oid).ok()
210}
211
212#[derive(Serialize)]
218struct EnrichedEntry<'a> {
219 #[serde(flatten)]
220 entry: &'a WindowEntry,
221 #[serde(flatten)]
222 git: GitStatus,
223}
224
225fn enriched_entry(entry: &WindowEntry) -> Value {
230 let git = entry
231 .folders
232 .first()
233 .map(|folder| git_status(folder))
234 .unwrap_or_default();
235 serde_json::to_value(EnrichedEntry { entry, git }).unwrap_or_else(|_| json!({}))
236}
237
238async fn enriched_windows(entries: Vec<WindowEntry>) -> Vec<Value> {
242 tokio::task::spawn_blocking(move || entries.iter().map(enriched_entry).collect())
243 .await
244 .unwrap_or_default()
245}
246
247fn display_name(entry: &WindowEntry) -> String {
250 if let Some(repo) = &entry.repo {
251 return repo.clone();
252 }
253 if let Some(folder) = entry.folders.first() {
254 return folder.file_name().map_or_else(
255 || folder.display().to_string(),
256 |n| n.to_string_lossy().into_owned(),
257 );
258 }
259 "(no folder)".to_string()
260}
261
262fn window_menu_items(entries: &[WindowEntry]) -> Vec<MenuItem> {
267 let mut items = Vec::new();
268 for entry in entries {
269 items.push(MenuItem::Label(window_label(entry)));
270 }
271 items.push(MenuItem::Separator);
272 for entry in entries {
273 if !entry.folders.is_empty() {
275 items.push(MenuItem::Action(MenuAction {
276 id: format!("focus:{}", entry.key),
277 label: format!("Focus {}", display_name(entry)),
278 enabled: true,
279 }));
280 }
281 }
282 items
283}
284
285fn window_label(entry: &WindowEntry) -> String {
289 let name = display_name(entry);
290 let status = entry
291 .folders
292 .first()
293 .map(|folder| git_status(folder))
294 .unwrap_or_default();
295 if let Some(branch) = &status.branch {
296 return match sync_indicator(status.ahead, status.behind) {
297 Some(sync) => format!("{name} · {branch} {sync}"),
298 None => format!("{name} · {branch}"),
299 };
300 }
301 match &entry.title {
303 Some(title) if title != &name => format!("{name} · {title}"),
304 _ => name,
305 }
306}
307
308fn sync_indicator(ahead: Option<usize>, behind: Option<usize>) -> Option<String> {
311 match (ahead, behind) {
312 (Some(ahead), Some(behind)) => Some(format!("(+{ahead} -{behind})")),
313 _ => None,
314 }
315}
316
317const CODE_BINARY_CANDIDATES: &[&str] = &[
320 "/usr/local/bin/code",
321 "/opt/homebrew/bin/code",
322 "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
323 "/usr/bin/code",
324];
325
326fn focus_window(folder: &Path) -> Result<()> {
329 focus_window_with(&resolve_code_binary(), folder)
330}
331
332fn focus_window_with(program: &Path, folder: &Path) -> Result<()> {
339 if !folder.is_absolute() {
342 bail!(
343 "refusing to focus a non-absolute folder path: {}",
344 folder.display()
345 );
346 }
347 if !folder.is_dir() {
348 bail!("worktree folder no longer exists: {}", folder.display());
349 }
350 let child = Command::new(program)
353 .arg(folder)
354 .stdin(Stdio::null())
355 .stdout(Stdio::null())
356 .stderr(Stdio::null())
357 .spawn()
358 .with_context(|| {
359 format!(
360 "failed to launch `{}` to focus {}",
361 program.display(),
362 folder.display()
363 )
364 })?;
365 std::thread::spawn(move || {
367 let mut child = child;
368 let _ = child.wait();
369 });
370 Ok(())
371}
372
373fn resolve_code_binary() -> PathBuf {
378 resolve_code_binary_from(std::env::var_os(VSCODE_BIN_ENV), CODE_BINARY_CANDIDATES)
379}
380
381fn resolve_code_binary_from(
384 env_override: Option<std::ffi::OsString>,
385 candidates: &[&str],
386) -> PathBuf {
387 if let Some(path) = env_override {
388 return PathBuf::from(path);
389 }
390 for candidate in candidates {
391 let path = Path::new(candidate);
392 if path.exists() {
393 return path.to_path_buf();
394 }
395 }
396 PathBuf::from("code")
397}
398
399#[cfg(test)]
400#[allow(clippy::unwrap_used, clippy::expect_used)]
401mod tests {
402 use super::*;
403 use chrono::Utc;
404
405 fn register_payload(key: &str, repo: Option<&str>, folder: &str) -> Value {
406 json!({
407 "key": key,
408 "folders": [folder],
409 "repo": repo,
410 "title": format!("{key}-title"),
411 "pid": 1234,
412 })
413 }
414
415 fn windows_of(payload: &Value) -> &Vec<Value> {
417 payload
418 .get("windows")
419 .and_then(Value::as_array)
420 .expect("windows array")
421 }
422
423 #[tokio::test]
424 async fn name_and_unknown_op() {
425 let svc = WorktreesService::new();
426 assert_eq!(svc.name(), "worktrees");
427 assert!(svc.handle("frobnicate", Value::Null).await.is_err());
428 }
429
430 #[tokio::test]
431 async fn handle_routes_ops_and_shapes_payloads() {
432 let svc = WorktreesService::new();
433 let payload = svc.handle("list", Value::Null).await.unwrap();
435 assert_eq!(payload, json!({ "windows": [] }));
436
437 let reply = svc
439 .handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
440 .await
441 .unwrap();
442 assert_eq!(reply, json!({ "ok": true }));
443 let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
444 assert_eq!(windows.len(), 1);
445 assert_eq!(windows[0].get("key").and_then(Value::as_str), Some("w1"));
446 assert!(windows[0].get("last_seen").is_some());
447
448 let known = svc
450 .handle("heartbeat", json!({ "key": "w1" }))
451 .await
452 .unwrap();
453 assert_eq!(known, json!({ "known": true }));
454 let unknown = svc
455 .handle("heartbeat", json!({ "key": "nope" }))
456 .await
457 .unwrap();
458 assert_eq!(unknown, json!({ "known": false }));
459
460 let gone = svc
462 .handle("unregister", json!({ "key": "w1" }))
463 .await
464 .unwrap();
465 assert_eq!(gone, json!({ "removed": true }));
466 let again = svc
467 .handle("unregister", json!({ "key": "w1" }))
468 .await
469 .unwrap();
470 assert_eq!(again, json!({ "removed": false }));
471 }
472
473 #[tokio::test]
474 async fn handle_rejects_missing_or_empty_key() {
475 let svc = WorktreesService::new();
476 assert!(svc.handle("register", json!({})).await.is_err());
478 assert!(svc
479 .handle("register", json!({ "key": " " }))
480 .await
481 .is_err());
482 assert!(svc.handle("heartbeat", json!({})).await.is_err());
484 assert!(svc.handle("unregister", json!({})).await.is_err());
485 }
486
487 #[test]
488 fn display_name_prefers_repo_then_folder_basename() {
489 let base = WindowEntry {
490 key: "k".to_string(),
491 folders: vec![PathBuf::from("/home/me/project")],
492 repo: Some("my-repo".to_string()),
493 title: None,
494 pid: None,
495 last_seen: Utc::now(),
496 };
497 assert_eq!(display_name(&base), "my-repo");
498
499 let no_repo = WindowEntry {
500 repo: None,
501 ..base.clone()
502 };
503 assert_eq!(display_name(&no_repo), "project");
504
505 let nothing = WindowEntry {
506 repo: None,
507 folders: vec![],
508 ..base.clone()
509 };
510 assert_eq!(display_name(¬hing), "(no folder)");
511
512 let rootish = WindowEntry {
515 repo: None,
516 folders: vec![PathBuf::from("/")],
517 ..base
518 };
519 assert_eq!(display_name(&rootish), "/");
520 }
521
522 #[test]
523 fn window_menu_items_label_omits_redundant_title_and_skips_folderless_actions() {
524 let now = Utc::now();
525 let entries = vec![
526 WindowEntry {
528 key: "k1".to_string(),
529 folders: vec![PathBuf::from("/tmp/a")],
530 repo: Some("repo".to_string()),
531 title: Some("a branch".to_string()),
532 pid: None,
533 last_seen: now,
534 },
535 WindowEntry {
538 key: "k2".to_string(),
539 folders: vec![],
540 repo: Some("solo".to_string()),
541 title: Some("solo".to_string()),
542 pid: None,
543 last_seen: now,
544 },
545 ];
546 let items = window_menu_items(&entries);
547 let labels: Vec<&str> = items
548 .iter()
549 .filter_map(|i| match i {
550 MenuItem::Label(t) => Some(t.as_str()),
551 _ => None,
552 })
553 .collect();
554 assert!(labels.contains(&"repo · a branch"));
555 assert!(labels.contains(&"solo")); let action_ids: Vec<&str> = items
558 .iter()
559 .filter_map(|i| match i {
560 MenuItem::Action(a) => Some(a.id.as_str()),
561 _ => None,
562 })
563 .collect();
564 assert_eq!(action_ids, vec!["focus:k1"]);
566 }
567
568 #[tokio::test]
569 async fn menu_and_status_shapes() {
570 let svc = WorktreesService::new();
571 let menu = svc.menu();
573 assert_eq!(menu.title, "Worktrees");
574 assert!(matches!(
575 menu.items.first(),
576 Some(MenuItem::Label(text)) if text == "No open windows"
577 ));
578 let status = svc.status().await;
579 assert_eq!(status.name, "worktrees");
580 assert!(status.healthy);
581 assert_eq!(status.summary, "0 window(s) across 0 repo(s)");
582
583 svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
585 .await
586 .unwrap();
587 svc.handle("register", register_payload("w2", Some("repo-a"), "/tmp/b"))
588 .await
589 .unwrap();
590 let status = svc.status().await;
591 assert_eq!(status.summary, "2 window(s) across 1 repo(s)");
592
593 let menu = svc.menu();
594 assert!(menu.items.iter().any(|i| matches!(i, MenuItem::Separator)));
596 let action_ids: Vec<&str> = menu
597 .items
598 .iter()
599 .filter_map(|i| match i {
600 MenuItem::Action(a) => Some(a.id.as_str()),
601 _ => None,
602 })
603 .collect();
604 assert!(action_ids.contains(&"focus:w1"));
605 assert!(action_ids.contains(&"focus:w2"));
606 }
607
608 #[tokio::test]
609 async fn default_constructs_an_empty_service() {
610 let svc = WorktreesService::default();
611 let payload = svc.handle("list", Value::Null).await.unwrap();
612 assert_eq!(payload, json!({ "windows": [] }));
613 }
614
615 #[tokio::test]
616 async fn menu_action_rejects_unknown_and_missing_window() {
617 let svc = WorktreesService::new();
618 assert!(svc.menu_action("bogus").await.is_err());
619 assert!(svc.menu_action("focus:nope").await.is_err());
621 svc.shutdown().await;
622 }
623
624 struct VscodeBinGuard(Option<std::ffi::OsString>);
628 impl Drop for VscodeBinGuard {
629 fn drop(&mut self) {
630 match self.0.take() {
631 Some(v) => std::env::set_var(VSCODE_BIN_ENV, v),
632 None => std::env::remove_var(VSCODE_BIN_ENV),
633 }
634 }
635 }
636
637 #[tokio::test]
638 async fn menu_action_focus_resolves_folder_and_spawns() {
639 let dir = tempfile::tempdir().unwrap();
640 let svc = WorktreesService::new();
641 svc.handle(
642 "register",
643 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
644 )
645 .await
646 .unwrap();
647
648 let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
651 std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
652 svc.menu_action("focus:w1").await.unwrap();
653 }
654
655 #[test]
656 fn focus_window_with_validates_folder_then_spawns() {
657 let dir = tempfile::tempdir().unwrap();
658 assert!(focus_window_with(Path::new("/bin/sh"), Path::new("relative/dir")).is_err());
660 assert!(
661 focus_window_with(Path::new("/bin/sh"), Path::new("/no/such/abs/dir/xyzzy")).is_err()
662 );
663 focus_window_with(Path::new("/bin/sh"), dir.path()).unwrap();
665 assert!(focus_window_with(Path::new("/no/such/launcher/xyzzy"), dir.path()).is_err());
667 }
668
669 #[test]
670 fn resolve_code_binary_from_prefers_env_then_candidate_then_fallback() {
671 assert_eq!(
673 resolve_code_binary_from(Some("/custom/code".into()), &["/usr/bin/code"]),
674 PathBuf::from("/custom/code")
675 );
676 let existing = tempfile::NamedTempFile::new().unwrap();
678 let existing_path = existing.path().to_str().unwrap();
679 assert_eq!(
680 resolve_code_binary_from(None, &["/no/such/candidate/xyzzy", existing_path]),
681 PathBuf::from(existing_path)
682 );
683 assert_eq!(
685 resolve_code_binary_from(None, &["/no/such/candidate/xyzzy"]),
686 PathBuf::from("code")
687 );
688 let _ = resolve_code_binary();
690 }
691
692 fn init_repo(dir: &Path) -> Repository {
697 let repo = Repository::init(dir).unwrap();
698 let mut cfg = repo.config().unwrap();
699 cfg.set_str("user.name", "Test").unwrap();
700 cfg.set_str("user.email", "test@example.com").unwrap();
701 repo
702 }
703
704 fn empty_commit(
707 repo: &Repository,
708 refname: Option<&str>,
709 parents: &[&git2::Commit<'_>],
710 msg: &str,
711 ) -> git2::Oid {
712 let sig = git2::Signature::now("Test", "test@example.com").unwrap();
713 let tree = repo
714 .find_tree(repo.treebuilder(None).unwrap().write().unwrap())
715 .unwrap();
716 repo.commit(refname, &sig, &sig, msg, &tree, parents)
717 .unwrap()
718 }
719
720 fn diverging_repo(dir: &Path) -> Repository {
723 let repo = init_repo(dir);
724 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
726 let a_commit = repo.find_commit(a).unwrap();
727 let c = empty_commit(&repo, None, &[&a_commit], "C");
729 repo.reference("refs/remotes/origin/main", c, true, "origin main")
730 .unwrap();
731 empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
733 drop(a_commit);
735 repo.set_head("refs/heads/main").unwrap();
736 let mut cfg = repo.config().unwrap();
738 cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
739 .unwrap();
740 cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
741 .unwrap();
742 cfg.set_str("branch.main.remote", "origin").unwrap();
743 cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
744 repo
745 }
746
747 #[test]
748 fn git_status_reads_branch_and_ahead_behind() {
749 let dir = tempfile::tempdir().unwrap();
750 let _repo = diverging_repo(dir.path());
751 let status = git_status(dir.path());
752 assert_eq!(status.branch.as_deref(), Some("main"));
753 assert_eq!(status.ahead, Some(1));
754 assert_eq!(status.behind, Some(1));
755 }
756
757 #[test]
758 fn git_status_empty_repo_is_unborn() {
759 let dir = tempfile::tempdir().unwrap();
762 init_repo(dir.path());
763 assert_eq!(git_status(dir.path()), GitStatus::default());
764 }
765
766 #[test]
767 fn git_status_no_upstream_reports_branch_only() {
768 let dir = tempfile::tempdir().unwrap();
769 let repo = init_repo(dir.path());
770 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
771 repo.set_head("refs/heads/main").unwrap();
772 let status = git_status(dir.path());
773 assert_eq!(status.branch.as_deref(), Some("main"));
774 assert_eq!(status.ahead, None);
776 assert_eq!(status.behind, None);
777 }
778
779 #[test]
780 fn git_status_non_repo_and_detached_are_empty() {
781 let plain = tempfile::tempdir().unwrap();
783 assert_eq!(git_status(plain.path()), GitStatus::default());
784
785 let dir = tempfile::tempdir().unwrap();
787 let repo = init_repo(dir.path());
788 let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
789 repo.set_head_detached(a).unwrap();
790 assert_eq!(git_status(dir.path()), GitStatus::default());
791 }
792
793 #[test]
794 fn sync_indicator_formats_only_with_upstream() {
795 assert_eq!(sync_indicator(Some(2), Some(1)).as_deref(), Some("(+2 -1)"));
796 assert_eq!(sync_indicator(Some(0), Some(0)).as_deref(), Some("(+0 -0)"));
797 assert_eq!(sync_indicator(None, None), None);
798 assert_eq!(sync_indicator(Some(1), None), None);
800 }
801
802 #[tokio::test]
803 async fn list_enriches_entries_with_git_status() {
804 let dir = tempfile::tempdir().unwrap();
805 let repo = init_repo(dir.path());
806 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
807 repo.set_head("refs/heads/main").unwrap();
808
809 let svc = WorktreesService::new();
810 svc.handle(
811 "register",
812 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
813 )
814 .await
815 .unwrap();
816 let payload = svc.handle("list", Value::Null).await.unwrap();
817 let windows = windows_of(&payload);
818 assert_eq!(windows.len(), 1);
819 assert_eq!(
820 windows[0].get("branch").and_then(Value::as_str),
821 Some("main")
822 );
823 assert!(windows[0].get("ahead").is_none());
825 assert!(windows[0].get("behind").is_none());
826
827 let plain = tempfile::tempdir().unwrap();
829 svc.handle(
830 "register",
831 json!({ "key": "w2", "folders": [plain.path()], "repo": "plain" }),
832 )
833 .await
834 .unwrap();
835 let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
836 let w2 = windows
837 .iter()
838 .find(|w| w.get("key").and_then(Value::as_str) == Some("w2"))
839 .unwrap();
840 assert!(w2.get("branch").is_none());
841 }
842
843 #[test]
844 fn window_label_prefers_git_branch_over_title() {
845 let dir = tempfile::tempdir().unwrap();
846 let repo = init_repo(dir.path());
847 empty_commit(&repo, Some("refs/heads/main"), &[], "A");
848 repo.set_head("refs/heads/main").unwrap();
849 let entry = WindowEntry {
850 key: "k".to_string(),
851 folders: vec![dir.path().to_path_buf()],
852 repo: Some("my-repo".to_string()),
853 title: Some("ignored title".to_string()),
854 pid: None,
855 last_seen: Utc::now(),
856 };
857 assert_eq!(window_label(&entry), "my-repo · main");
860 }
861
862 #[tokio::test]
863 async fn list_includes_ahead_behind_for_tracking_branch() {
864 let dir = tempfile::tempdir().unwrap();
865 let _repo = diverging_repo(dir.path());
866
867 let svc = WorktreesService::new();
868 svc.handle(
869 "register",
870 json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
871 )
872 .await
873 .unwrap();
874 let payload = svc.handle("list", Value::Null).await.unwrap();
875 let windows = windows_of(&payload);
876 assert_eq!(
878 windows[0].get("branch").and_then(Value::as_str),
879 Some("main")
880 );
881 assert_eq!(windows[0].get("ahead").and_then(Value::as_u64), Some(1));
882 assert_eq!(windows[0].get("behind").and_then(Value::as_u64), Some(1));
883 }
884
885 #[test]
886 fn window_label_includes_sync_for_tracking_branch() {
887 let dir = tempfile::tempdir().unwrap();
888 let _repo = diverging_repo(dir.path());
889 let entry = WindowEntry {
890 key: "k".to_string(),
891 folders: vec![dir.path().to_path_buf()],
892 repo: Some("my-repo".to_string()),
893 title: None,
894 pid: None,
895 last_seen: Utc::now(),
896 };
897 assert_eq!(window_label(&entry), "my-repo · main (+1 -1)");
899 }
900}