1use crate::codetime::GithubRepoRecord;
2use crate::commands::code_snapshot::{snapshots_for_file, CodeSnapshot};
3use crate::commands::cli_session::{cli_request_client, resolve_cli_access_token};
4use crate::commands::terminal_table::{render_table, TableStyle};
5use crate::config::{
6 ensure_global_xbp_paths, map_windows_path_to_wsl_mnt, map_wsl_mnt_path_to_windows,
7 resolve_device_identity, resolve_global_xbp_root_dir, resolve_worktree_watch_config, ApiConfig,
8 SshConfig, WorktreeWatchConfig, WorktreeWatchRepoRank,
9};
10use chrono::{DateTime, Utc};
11use colored::Colorize;
12use notify::event::{CreateKind, ModifyKind, RemoveKind, RenameMode};
13use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
14use reqwest::StatusCode;
15use serde::{Deserialize, Serialize};
16use serde_json::{json, Value};
17use sha2::{Digest, Sha256};
18use std::collections::{BTreeMap, BTreeSet, HashMap};
19use std::fs::{self, File, OpenOptions};
20use std::io::{BufRead, BufReader, Write};
21use std::path::{Path, PathBuf};
22use std::process::{Command, Stdio};
23use std::sync::{mpsc, Mutex, OnceLock};
24use std::time::{Duration, Instant};
25use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind};
26use uuid::Uuid;
27
28const BACKGROUND_CHILD_ENV: &str = "XBP_WORKTREE_WATCH_BACKGROUND_CHILD";
29const DEFAULT_REMOTE: &str = "origin";
30const EVENT_DEDUPE_WINDOW_SECONDS: i64 = 5;
31const WORKTREE_MUTATION_SYNC_BATCH_RECORD_LIMIT: usize = 250;
32const WORKTREE_MUTATION_SYNC_BATCH_JSON_BYTES: usize = 768 * 1024;
33const WORKTREE_MUTATION_SYNC_MAX_ATTEMPTS: usize = 3;
34const WORKTREE_MUTATION_SYNC_RETRY_DELAY_SECONDS: u64 = 2;
35const MAX_RECENT_EVENT_DEDUPE_IN_MEMORY: usize = 4096;
38const MAX_EVENT_DEDUPE_JSONL_BYTES: u64 = 2 * 1024 * 1024;
40const SPOOL_HOUSEKEEPING_INTERVAL_SECONDS: u64 = 600;
42const JSONL_FLUSH_MAX_LINES: usize = 32;
44const JSONL_FLUSH_MAX_WAIT_MS: u64 = 250;
45const NOTIFY_EVENT_DRAIN_CAP: usize = 64;
47
48#[derive(Debug, Clone, Default)]
55struct WatchIgnoreRules {
56 forbidden_path_prefixes: Vec<String>,
58 forbidden_folders: BTreeSet<String>,
60 banned_words: Vec<String>,
62 global_ignore: crate::utils::XbpIgnoreSet,
64 project_ignore: crate::utils::XbpIgnoreSet,
66}
67
68impl WatchIgnoreRules {
69 fn load() -> Self {
70 Self::from_config(&resolve_worktree_watch_config())
71 }
72
73 fn from_config(config: &WorktreeWatchConfig) -> Self {
74 Self::from_config_and_project(config, None)
75 }
76
77 fn from_config_and_project(config: &WorktreeWatchConfig, project_root: Option<&Path>) -> Self {
78 let mut forbidden_folders = built_in_forbidden_folders();
79 for folder in &config.forbidden_folders {
80 let normalized = normalize_folder_token(folder);
81 if !normalized.is_empty() {
82 forbidden_folders.insert(normalized);
83 }
84 }
85
86 let forbidden_path_prefixes = config
87 .forbidden_paths
88 .iter()
89 .map(|path| normalize_path_prefix(path))
90 .filter(|path| !path.is_empty())
91 .collect::<Vec<_>>();
92
93 let banned_words = config
94 .banned_words
95 .iter()
96 .map(|word| word.trim().to_ascii_lowercase())
97 .filter(|word| !word.is_empty())
98 .collect::<Vec<_>>();
99
100 let project_ignore = project_root
101 .map(|root| load_project_watch_ignore(root))
102 .unwrap_or_default();
103
104 Self {
105 forbidden_path_prefixes,
106 forbidden_folders,
107 banned_words,
108 global_ignore: crate::config::load_global_worktree_ignore(),
109 project_ignore,
110 }
111 }
112
113 fn with_project_root(mut self, project_root: &Path) -> Self {
114 self.project_ignore = load_project_watch_ignore(project_root);
115 self
116 }
117
118 fn ignores_relative(&self, relative: &str) -> bool {
119 let normalized = normalize_relative_watch_path(relative);
120 if normalized.is_empty() {
121 return false;
122 }
123
124 if self.global_ignore.is_ignored_relative(&normalized, false)
125 || self.global_ignore.is_ignored_relative(&normalized, true)
126 {
127 return true;
128 }
129
130 if self.project_ignore.is_ignored_relative(&normalized, false)
131 || self.project_ignore.is_ignored_relative(&normalized, true)
132 {
133 return true;
134 }
135
136 let lower = normalized.to_ascii_lowercase();
137 let components = lower
138 .split('/')
139 .filter(|component| !component.is_empty())
140 .collect::<Vec<_>>();
141
142 if components
143 .iter()
144 .any(|component| self.forbidden_folders.contains(*component))
145 {
146 return true;
147 }
148
149 for prefix in &self.forbidden_path_prefixes {
150 if lower == *prefix || lower.starts_with(&format!("{prefix}/")) {
151 return true;
152 }
153 if !prefix.contains('/') && components.iter().any(|component| *component == prefix) {
155 return true;
156 }
157 }
158
159 for word in &self.banned_words {
160 if lower.contains(word) {
161 return true;
162 }
163 }
164
165 false
166 }
167
168 fn ignores_path(&self, root: &Path, path: &Path) -> bool {
169 if let Some(relative) = relative_watch_path(root, path) {
170 return self.ignores_relative(&relative);
171 }
172 false
173 }
174
175 fn skips_discovery_dir(&self, path: &Path) -> bool {
176 let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
177 return false;
178 };
179 let trimmed = name.trim();
180 if self
181 .forbidden_folders
182 .contains(&trimmed.to_ascii_lowercase())
183 {
184 return true;
185 }
186 if self.global_ignore.skips_dir_name(trimmed) {
187 return true;
188 }
189 self.project_ignore.skips_dir_name(trimmed)
190 }
191}
192
193fn load_project_watch_ignore(project_root: &Path) -> crate::utils::XbpIgnoreSet {
194 use crate::strategies::XbpConfig;
195 use crate::utils::{load_project_xbp_ignore, parse_config_with_auto_heal};
196
197 let mut extra = Vec::new();
198 if let Some(found) = crate::utils::find_xbp_config_upwards(project_root) {
202 if let Ok(content) = fs::read_to_string(&found.config_path) {
203 if let Ok((config, _)) = parse_config_with_auto_heal::<XbpConfig>(&content, found.kind)
204 {
205 extra = config.watch_ignore_paths;
206 }
207 }
208 return load_project_xbp_ignore(&found.project_root, &extra);
209 }
210 load_project_xbp_ignore(project_root, &extra)
211}
212
213fn built_in_forbidden_folders() -> BTreeSet<String> {
214 use crate::utils::DEFAULT_NOISE_DIR_NAMES;
215
216 DEFAULT_NOISE_DIR_NAMES
217 .iter()
218 .map(|name| name.to_ascii_lowercase())
219 .collect()
220}
221
222fn normalize_folder_token(raw: &str) -> String {
223 raw.trim()
224 .trim_matches(|ch| ch == '/' || ch == '\\')
225 .to_ascii_lowercase()
226}
227
228fn normalize_path_prefix(raw: &str) -> String {
229 let trimmed = raw.trim().replace('\\', "/");
230 let without_dot = trimmed
231 .trim_start_matches("./")
232 .trim_matches(|ch| ch == '/' || ch == '\\');
233 without_dot.to_ascii_lowercase()
234}
235
236fn normalize_relative_watch_path(raw: &str) -> String {
237 raw.trim()
238 .replace('\\', "/")
239 .trim_start_matches("./")
240 .trim_matches('/')
241 .to_string()
242}
243
244fn watch_ignore_rules() -> &'static WatchIgnoreRules {
245 static RULES: OnceLock<WatchIgnoreRules> = OnceLock::new();
246 RULES.get_or_init(WatchIgnoreRules::load)
247}
248
249fn watch_ignore_rules_for_root(root: &Path) -> WatchIgnoreRules {
251 watch_ignore_rules().clone().with_project_root(root)
252}
253
254#[cfg(windows)]
255const CREATE_NO_WINDOW: u32 = 0x08000000;
256
257#[derive(Debug, Clone, Default)]
258pub struct WorktreeWatchTargetOptions {
259 pub repo: Option<PathBuf>,
260 pub parent: Option<PathBuf>,
261 pub repos: Vec<PathBuf>,
263}
264
265pub fn resolve_default_worktree_watch_status_target() -> Result<WorktreeWatchTargetOptions, String> {
271 if let Some(parent) = prefer_running_parent_root() {
272 return Ok(WorktreeWatchTargetOptions {
273 repo: None,
274 parent: Some(parent),
275 repos: Vec::new(),
276 });
277 }
278 if let Some(parent) = prefer_any_parent_root() {
281 return Ok(WorktreeWatchTargetOptions {
282 repo: None,
283 parent: Some(parent),
284 repos: Vec::new(),
285 });
286 }
287 normalize_worktree_watch_target(WorktreeWatchTargetOptions {
288 repo: None,
289 parent: None,
290 repos: Vec::new(),
291 })
292}
293
294pub fn normalize_worktree_watch_target(
297 target: WorktreeWatchTargetOptions,
298) -> Result<WorktreeWatchTargetOptions, String> {
299 if target.parent.is_some() && target.repo.is_some() {
300 return Err("Pass either `--repo` or `--parent`, not both.".to_string());
301 }
302 if target.parent.is_some() && !target.repos.is_empty() {
303 return Err("Pass either `--repos` or `--parent`, not both.".to_string());
304 }
305 if target.repo.is_some() && !target.repos.is_empty() {
306 return Err("Pass either `--repo` or `--repos`, not both.".to_string());
307 }
308
309 if let Some(parent) = target.parent.as_ref() {
310 let resolved = if parent.is_dir() {
311 normalize_windows_verbatim_path(
312 fs::canonicalize(parent).unwrap_or_else(|_| parent.clone()),
313 )
314 } else {
315 if let Some(found) = resolve_parent_folder_hint(parent)? {
317 found
318 } else {
319 return Err(format!(
320 "Parent folder not found: {}. Pass an absolute path or a folder under Documents/GitHub.",
321 parent.display()
322 ));
323 }
324 };
325 return Ok(WorktreeWatchTargetOptions {
326 repo: None,
327 parent: Some(resolved),
328 repos: Vec::new(),
329 });
330 }
331
332 if !target.repos.is_empty() {
333 let mut repos = Vec::new();
334 let mut seen = BTreeSet::new();
335 for repo in &target.repos {
336 let path = resolve_repo_path_input(Some(repo.as_path()))?;
337 let absolute = normalize_windows_verbatim_path(
338 fs::canonicalize(&path).unwrap_or(path),
339 );
340 let key = path_identity_key(&absolute);
341 if seen.insert(key) {
342 repos.push(absolute);
343 }
344 }
345 return Ok(WorktreeWatchTargetOptions {
346 repo: None,
347 parent: None,
348 repos,
349 });
350 }
351
352 let identity = resolve_repo_identity(target.repo.as_deref())?;
354 Ok(WorktreeWatchTargetOptions {
355 repo: Some(identity.root),
356 parent: None,
357 repos: Vec::new(),
358 })
359}
360
361fn resolve_parent_folder_hint(parent: &Path) -> Result<Option<PathBuf>, String> {
362 if parent.is_absolute() {
363 return Ok(None);
364 }
365 let name = parent
366 .file_name()
367 .and_then(|value| value.to_str())
368 .unwrap_or_default();
369 if name.is_empty() {
370 return Ok(None);
371 }
372
373 let mut candidates: Vec<PathBuf> = Vec::new();
374 if let Some(home) = dirs::home_dir() {
375 candidates.push(home.join("Documents").join("GitHub").join(name));
376 candidates.push(home.join("Documents").join("github").join(name));
377 candidates.push(home.join("Documents").join(name));
378 candidates.push(home.join("src").join(name));
379 candidates.push(home.join(name));
380 }
381 if name.eq_ignore_ascii_case("github") {
383 if let Some(home) = dirs::home_dir() {
384 candidates.insert(0, home.join("Documents").join("GitHub"));
385 candidates.insert(1, home.join("Documents").join("github"));
386 }
387 }
388
389 for candidate in candidates {
390 if candidate.is_dir() {
391 return Ok(Some(normalize_windows_verbatim_path(
392 fs::canonicalize(&candidate).unwrap_or(candidate),
393 )));
394 }
395 }
396 Ok(None)
397}
398
399#[derive(Debug, Clone, Serialize)]
401#[serde(rename_all = "camelCase")]
402pub struct WorktreeWatchTraySnapshot {
403 pub target_label: String,
404 pub mode: String,
405 pub parent: Option<String>,
406 pub repository_count: usize,
407 pub running_watchers: usize,
408 pub any_running: bool,
409 pub all_running: bool,
410 pub parent_watcher_running: bool,
411 pub parent_watcher_pid: Option<u32>,
412 pub total_unsynced_files: u64,
413 pub total_unsynced_records: u64,
414 pub repositories: Vec<WorktreeWatchTrayRepoStatus>,
415 pub stats_line: Option<String>,
416}
417
418#[derive(Debug, Clone, Serialize)]
419#[serde(rename_all = "camelCase")]
420pub struct WorktreeWatchTrayRepoStatus {
421 pub root: String,
422 pub owner: String,
423 pub name: String,
424 pub branch: String,
425 pub running: bool,
426 pub pid: Option<u32>,
427 pub unsynced_files: u64,
428 pub unsynced_records: u64,
429}
430
431#[derive(Debug, Clone)]
432pub struct WorktreeWatchStartOptions {
433 pub target: WorktreeWatchTargetOptions,
434 pub detach: bool,
435 pub sync_interval_seconds: u64,
436 pub once: bool,
437}
438
439#[derive(Debug, Clone)]
440pub struct WorktreeWatchSyncOptions {
441 pub target: WorktreeWatchTargetOptions,
442 pub dry_run: bool,
443 pub resync: bool,
444}
445
446#[derive(Debug, Clone)]
447pub struct WorktreeWatchStopOptions {
448 pub target: WorktreeWatchTargetOptions,
449 pub force: bool,
450}
451
452#[derive(Debug, Clone)]
453pub struct WorktreeWatchStatusOptions {
454 pub target: WorktreeWatchTargetOptions,
455 pub json: bool,
456 pub records: bool,
457 pub record_limit: usize,
458 pub stats: bool,
459 pub repo_activity: bool,
460 pub stats_gap_minutes: u64,
461 pub resource: bool,
463}
464
465#[derive(Debug, Clone)]
466struct RepoIdentity {
467 owner: String,
468 name: String,
469 branch: String,
470 root: PathBuf,
471 head_sha: Option<String>,
472}
473
474#[derive(Debug, Clone)]
475struct SpoolLayout {
476 root: PathBuf,
477 events_file: PathBuf,
478 commits_file: PathBuf,
479 stats_file: PathBuf,
480 watcher_state_file: PathBuf,
481 sync_log_file: PathBuf,
482 event_dedupe_file: PathBuf,
483 event_dedupe_dir: PathBuf,
484 commit_dedupe_dir: PathBuf,
485}
486
487#[derive(Debug, Clone)]
488struct ParentSpoolLayout {
489 watcher_state_file: PathBuf,
490}
491
492#[derive(Debug, Clone, Copy, PartialEq, Eq)]
494enum RepoWatchTier {
495 Hot,
497 Warm,
499 Cold,
501}
502
503impl RepoWatchTier {
504 fn as_str(self) -> &'static str {
505 match self {
506 Self::Hot => "hot",
507 Self::Warm => "warm",
508 Self::Cold => "cold",
509 }
510 }
511}
512
513#[derive(Debug)]
514struct WatchedRepo {
515 identity: RepoIdentity,
516 layout: SpoolLayout,
517 last_head: Option<String>,
518 event_dedupe: RecentEventDedupe,
519 last_activity: Instant,
521 activity_score: f64,
523 tier: RepoWatchTier,
524 fs_watched: bool,
526 next_commit_check: Instant,
528}
529
530#[derive(Debug, Serialize, Deserialize, Clone)]
531#[serde(rename_all = "camelCase")]
532struct WorktreeMutationEvent {
533 id: String,
534 #[serde(default, skip_serializing_if = "Option::is_none")]
535 fingerprint: Option<String>,
536 repo_owner: String,
537 repo_name: String,
538 branch_name: String,
539 repo_root: String,
540 head_sha: Option<String>,
541 event_kind: String,
542 paths: Vec<String>,
543 primary_path: Option<String>,
544 old_path: Option<String>,
545 new_path: Option<String>,
546 added_lines: Option<u64>,
547 removed_lines: Option<u64>,
548 total_lines: Option<u64>,
549 #[serde(default, skip_serializing_if = "Vec::is_empty")]
550 code_snapshots: Vec<CodeSnapshot>,
551 file_created: bool,
552 file_removed: bool,
553 folder_created: bool,
554 folder_removed: bool,
555 renamed_or_moved: bool,
556 raw_kind: String,
557 occurred_at: DateTime<Utc>,
558}
559
560#[derive(Debug, Serialize, Deserialize, Clone)]
561#[serde(rename_all = "camelCase")]
562struct WorktreeCommitEvent {
563 id: String,
564 #[serde(default, skip_serializing_if = "Option::is_none")]
565 fingerprint: Option<String>,
566 repo_owner: String,
567 repo_name: String,
568 branch_name: String,
569 repo_root: String,
570 previous_head_sha: Option<String>,
571 head_sha: String,
572 subject: Option<String>,
573 author_name: Option<String>,
574 author_email: Option<String>,
575 committed_at: Option<String>,
576 occurred_at: DateTime<Utc>,
577 #[serde(default, skip_serializing_if = "Option::is_none")]
579 files_changed: Option<Vec<String>>,
580 #[serde(default, skip_serializing_if = "Option::is_none")]
581 files_changed_count: Option<u64>,
582 #[serde(default, skip_serializing_if = "Option::is_none")]
583 insertions: Option<u64>,
584 #[serde(default, skip_serializing_if = "Option::is_none")]
585 deletions: Option<u64>,
586 #[serde(default, skip_serializing_if = "Option::is_none")]
587 is_merge: Option<bool>,
588 #[serde(default, skip_serializing_if = "Option::is_none")]
590 included_commits: Option<Vec<String>>,
591}
592
593#[derive(Debug, Serialize, Deserialize, Clone, Default)]
595#[serde(rename_all = "camelCase")]
596struct WorktreeWatchRestartIntent {
597 #[serde(default)]
598 parents: Vec<String>,
599 #[serde(default)]
600 repos: Vec<String>,
601 #[serde(default)]
602 tray: bool,
603 #[serde(default)]
604 stopped_at: Option<String>,
605}
606
607#[derive(Debug, Serialize)]
608#[serde(rename_all = "camelCase")]
609struct WorktreeMutationIngestPayload {
610 device: WorktreeMutationDevicePayload,
611 repository: WorktreeMutationRepositoryPayload,
612 events: Vec<WorktreeMutationEvent>,
613 commits: Vec<WorktreeCommitEvent>,
614}
615
616#[derive(Debug, Serialize)]
617#[serde(rename_all = "camelCase")]
618struct WorktreeMutationDevicePayload {
619 hardware_id: String,
620 device_name: Option<String>,
621 hostname: Option<String>,
622 platform: String,
623 #[serde(skip_serializing_if = "Option::is_none")]
626 runtime_key: Option<String>,
627}
628
629#[derive(Debug, Serialize)]
630#[serde(rename_all = "camelCase")]
631struct WorktreeMutationRepositoryPayload {
632 owner: String,
633 name: String,
634 branch_name: String,
635 repo_root: String,
636}
637
638#[derive(Debug)]
639struct SyncCandidate {
640 path: PathBuf,
641 records: SyncRecords,
642}
643
644#[derive(Debug)]
645struct WorktreeMutationUploadBatch {
646 events: Vec<WorktreeMutationEvent>,
647 commits: Vec<WorktreeCommitEvent>,
648 estimated_json_bytes: usize,
649}
650
651#[derive(Debug, Clone, Copy, PartialEq, Eq)]
652enum WorktreeMutationSyncErrorKind {
653 Retryable,
654 NonRetryable,
655}
656
657#[derive(Debug)]
658struct WorktreeMutationSyncError {
659 kind: WorktreeMutationSyncErrorKind,
660 message: String,
661}
662
663struct SyncLogAppend<'a> {
664 identity: &'a RepoIdentity,
665 layout: &'a SpoolLayout,
666 endpoint: &'a str,
667 status_code: u16,
668 event_count: usize,
669 commit_count: usize,
670 candidates: &'a [SyncCandidate],
671 resync: bool,
672}
673
674#[derive(Debug, Clone, Copy)]
675enum SyncFileKind {
676 Events,
677 Commits,
678}
679
680#[derive(Debug)]
681enum SyncRecords {
682 Events(Vec<WorktreeMutationEvent>),
683 Commits(Vec<WorktreeCommitEvent>),
684}
685
686impl SyncRecords {
687 fn len(&self) -> usize {
688 match self {
689 Self::Events(records) => records.len(),
690 Self::Commits(records) => records.len(),
691 }
692 }
693
694 fn is_empty(&self) -> bool {
695 self.len() == 0
696 }
697}
698
699#[derive(Debug, Serialize, Deserialize, Clone)]
700#[serde(rename_all = "camelCase")]
701struct WorktreeWatchStatsSummary {
702 generated_at: DateTime<Utc>,
703 repository_owner: String,
704 repository_name: String,
705 branch_name: String,
706 repo_root: String,
707 spool_root: String,
708 session_gap_minutes: u64,
709 total_events: u64,
710 total_commits: u64,
711 first_event_at: Option<DateTime<Utc>>,
712 last_event_at: Option<DateTime<Utc>>,
713 session_count: u64,
714 observed_span_seconds: u64,
715 estimated_coding_seconds: u64,
716 added_lines: u64,
717 removed_lines: u64,
718 by_file: Vec<WorktreeWatchFileStats>,
719 by_filetype: Vec<WorktreeWatchBucketStats>,
720 by_event_kind: Vec<WorktreeWatchBucketStats>,
721}
722
723#[derive(Debug, Serialize, Clone)]
724#[serde(rename_all = "camelCase")]
725struct WorktreeWatchRepoActivitySummary {
726 generated_at: DateTime<Utc>,
727 repository_owner: String,
728 repository_name: String,
729 repo_root: String,
730 repository_spool_root: String,
731 stats_file: String,
732 session_gap_minutes: u64,
733 branch_count: u64,
734 total_events: u64,
735 total_commits: u64,
736 first_event_at: Option<DateTime<Utc>>,
737 last_event_at: Option<DateTime<Utc>>,
738 session_count: u64,
739 observed_span_seconds: u64,
740 estimated_coding_seconds: u64,
741 added_lines: u64,
742 removed_lines: u64,
743 branches: Vec<WorktreeWatchBranchActivityStats>,
744}
745
746#[derive(Debug, Serialize, Clone)]
747#[serde(rename_all = "camelCase")]
748struct WorktreeWatchBranchActivityStats {
749 branch_name: String,
750 spool_root: String,
751 total_events: u64,
752 total_commits: u64,
753 first_event_at: Option<DateTime<Utc>>,
754 last_event_at: Option<DateTime<Utc>>,
755 session_count: u64,
756 observed_span_seconds: u64,
757 estimated_coding_seconds: u64,
758 added_lines: u64,
759 removed_lines: u64,
760}
761
762#[derive(Debug, Serialize, Deserialize, Clone, Default)]
763#[serde(rename_all = "camelCase")]
764struct WorktreeWatchFileStats {
765 path: String,
766 filetype: String,
767 event_count: u64,
768 estimated_coding_seconds: u64,
769 added_lines: u64,
770 removed_lines: u64,
771}
772
773#[derive(Debug, Serialize, Deserialize, Clone, Default)]
774#[serde(rename_all = "camelCase")]
775struct WorktreeWatchBucketStats {
776 name: String,
777 event_count: u64,
778 estimated_coding_seconds: u64,
779 added_lines: u64,
780 removed_lines: u64,
781}
782
783#[derive(Debug, Serialize, Deserialize, Clone)]
784#[serde(rename_all = "camelCase")]
785struct WorktreeWatcherState {
786 pid: u32,
787 repo_root: String,
788 repository_owner: String,
789 repository_name: String,
790 branch_name: String,
791 executable: String,
792 started_at: DateTime<Utc>,
793 #[serde(default, skip_serializing_if = "Option::is_none")]
795 runtime_key: Option<String>,
796 #[serde(default, skip_serializing_if = "Option::is_none")]
797 platform: Option<String>,
798}
799
800#[derive(Debug, Serialize, Deserialize, Clone)]
801#[serde(rename_all = "camelCase")]
802struct ParentWorktreeWatcherState {
803 pid: u32,
804 parent_root: String,
805 executable: String,
806 started_at: DateTime<Utc>,
807 #[serde(default, skip_serializing_if = "Option::is_none")]
808 runtime_key: Option<String>,
809 #[serde(default, skip_serializing_if = "Option::is_none")]
810 platform: Option<String>,
811}
812
813#[derive(Debug, Serialize, Deserialize, Clone)]
814#[serde(rename_all = "camelCase")]
815struct WorktreeWatchSyncLogEntry {
816 id: String,
817 synced_at: DateTime<Utc>,
818 endpoint: String,
819 repository_owner: String,
820 repository_name: String,
821 branch_name: String,
822 repo_root: String,
823 resync: bool,
824 status_code: u16,
825 event_count: usize,
826 commit_count: usize,
827 spool_file_count: usize,
828 spool_files: Vec<String>,
829}
830
831#[derive(Debug, Clone, Copy, PartialEq, Eq)]
832enum StopWatcherOutcome {
833 Stopped(u32),
834 RemovedStaleState,
835 NoState,
836}
837
838#[derive(Debug, Serialize, Deserialize, Clone)]
839#[serde(rename_all = "camelCase")]
840struct WorktreeWatchEventDedupeRecord {
841 fingerprint: String,
842 first_seen_at: DateTime<Utc>,
843 event_kind: String,
844 primary_path: Option<String>,
845}
846
847#[derive(Debug, Default)]
848struct RecentEventDedupe {
849 fingerprints: BTreeSet<String>,
850}
851
852pub async fn run_worktree_watch_start(options: WorktreeWatchStartOptions) -> Result<(), String> {
853 if options.detach {
854 return run_worktree_watch_start_detached(options);
857 }
858
859 if let Some(parent) = options.target.parent.as_deref() {
860 let identities = resolve_target_identities(&options.target)?;
861 if identities.is_empty() {
862 println!("No git repositories found under parent folder.");
863 return Ok(());
864 }
865
866 if options.once {
867 for identity in &identities {
868 let layout = prepare_spool_layout(identity)?;
869 record_commit_snapshot(identity, &layout, None)?;
870 println!("Recorded current git state for {}", identity.root.display());
871 }
872 return Ok(());
873 }
874
875 let parent = canonical_parent_path(parent)?;
876 println!(
877 "Watching {} recursively and routing mutations for {} repo(s).",
878 parent.display(),
879 identities.len()
880 );
881 return watch_parent_foreground(parent, identities, options.sync_interval_seconds).await;
882 }
883
884 let identity = resolve_repo_identity(options.target.repo.as_deref())?;
885 let layout = prepare_spool_layout(&identity)?;
886 println!(
887 "Watching {} and spooling mutations to {} [runtime={}]",
888 identity.root.display(),
889 layout.root.display(),
890 worktree_runtime_key()
891 );
892
893 if options.once {
894 record_commit_snapshot(&identity, &layout, None)?;
895 return Ok(());
896 }
897
898 watch_foreground(identity, layout, options.sync_interval_seconds).await
899}
900
901pub fn run_worktree_watch_start_detached(
906 options: WorktreeWatchStartOptions,
907) -> Result<(), String> {
908 if let Some(parent) = options.target.parent.as_deref() {
909 let parent_path = canonical_parent_path(parent)?;
911 let repo_count = count_git_roots_under(&parent_path)?;
912 if repo_count == 0 {
913 println!("No git repositories found under parent folder.");
914 return Ok(());
915 }
916 let path = spawn_detached_parent_worktree_watch(&parent_path)?;
917 println!(
918 "Started one background worktree watcher for {} covering {} repo(s).",
919 path.display(),
920 repo_count
921 );
922 if let Some(port) =
923 crate::commands::worktree_watch_api::resolve_worktree_watch_api_port(None)
924 {
925 println!(
926 "Local API (when free): http://127.0.0.1:{port} — dashboard: apps/worktree-watch-app/"
927 );
928 }
929 return Ok(());
930 }
931
932 if !options.target.repos.is_empty() {
933 for repo in &options.target.repos {
934 let path = spawn_detached_worktree_watch(Some(repo.as_path()))?;
935 println!("Started background worktree watcher for {}", path.display());
936 }
937 if let Some(port) =
938 crate::commands::worktree_watch_api::resolve_worktree_watch_api_port(None)
939 {
940 println!(
941 "Local API (when free): http://127.0.0.1:{port} — dashboard: apps/worktree-watch-app/"
942 );
943 }
944 return Ok(());
945 }
946
947 spawn_detached_worktree_watch(options.target.repo.as_deref()).map(|path| {
948 println!("Started background worktree watcher for {}", path.display());
949 if let Some(port) =
950 crate::commands::worktree_watch_api::resolve_worktree_watch_api_port(None)
951 {
952 println!(
953 "Local API (when free): http://127.0.0.1:{port} — dashboard: apps/worktree-watch-app/"
954 );
955 }
956 })
957}
958
959pub async fn run_worktree_watch_sync(options: WorktreeWatchSyncOptions) -> Result<(), String> {
960 let identities = resolve_target_identities(&options.target)?;
961 let repo_count = identities.len();
962 let mut plans = Vec::new();
963 let mut total_files = 0usize;
964 let mut total_records = 0usize;
965
966 for identity in identities {
967 let layout = prepare_spool_layout(&identity)?;
968 let candidates = collect_spool_candidates(&layout, options.resync)?;
969 total_files += candidates.len();
970 total_records += candidates
971 .iter()
972 .map(|candidate| candidate.records.len())
973 .sum::<usize>();
974 if !candidates.is_empty() {
975 plans.push((identity, candidates));
976 }
977 }
978
979 if options.dry_run {
980 println!(
981 "Would sync {} record(s) from {} spool file(s) across {} repo(s).",
982 total_records, total_files, repo_count
983 );
984 if options.resync {
985 println!("Resync mode includes files already marked `.synced.*.jsonl`.");
986 }
987 return Ok(());
988 }
989
990 if plans.is_empty() {
991 print_no_sync_candidates_message(&options.target, options.resync)?;
992 return Ok(());
993 }
994
995 let upload_repo_count = plans.len();
996 for (identity, candidates) in plans {
997 sync_candidates(&identity, candidates, options.resync).await?;
998 }
999 println!(
1000 "Synced {} worktree mutation record(s) across {} repo(s).",
1001 total_records, upload_repo_count
1002 );
1003 Ok(())
1004}
1005
1006pub fn run_worktree_watch_stop(options: WorktreeWatchStopOptions) -> Result<(), String> {
1007 if let Some(parent) = options.target.parent.as_deref() {
1008 let parent = canonical_parent_path(parent)?;
1009 match stop_existing_parent_watcher(&parent, options.force)? {
1010 StopWatcherOutcome::Stopped(pid) => {
1011 println!(
1012 "Stopped background parent worktree watcher {} for {}",
1013 pid,
1014 parent.display()
1015 );
1016 }
1017 StopWatcherOutcome::RemovedStaleState => {
1018 println!(
1019 "Removed stale parent worktree watcher state for {}",
1020 parent.display()
1021 );
1022 }
1023 StopWatcherOutcome::NoState => {
1024 println!("No parent worktree watcher state for {}", parent.display());
1025 }
1026 }
1027 }
1028
1029 let identities = resolve_target_identities(&options.target)?;
1030 if identities.is_empty() {
1031 println!("No git repositories found for worktree watcher stop.");
1032 return Ok(());
1033 }
1034
1035 let mut stopped = 0usize;
1036 let mut stale = 0usize;
1037 let mut missing = 0usize;
1038 for identity in identities {
1039 let layout = prepare_spool_layout(&identity)?;
1040 match stop_existing_watcher(&identity, &layout, options.force)? {
1041 StopWatcherOutcome::Stopped(pid) => {
1042 stopped += 1;
1043 println!(
1044 "Stopped background worktree watcher {} for {}",
1045 pid,
1046 identity.root.display()
1047 );
1048 }
1049 StopWatcherOutcome::RemovedStaleState => {
1050 stale += 1;
1051 println!(
1052 "Removed stale worktree watcher state for {}",
1053 identity.root.display()
1054 );
1055 }
1056 StopWatcherOutcome::NoState => {
1057 missing += 1;
1058 println!(
1059 "No background worktree watcher state for {}",
1060 identity.root.display()
1061 );
1062 }
1063 }
1064 }
1065
1066 println!(
1067 "Worktree watcher stop complete: {stopped} stopped, {stale} stale state file(s) removed, {missing} without state."
1068 );
1069 Ok(())
1070}
1071
1072pub fn stop_active_worktree_watch_before_install() {
1079 let mut intent = WorktreeWatchRestartIntent {
1080 stopped_at: Some(Utc::now().to_rfc3339()),
1081 ..Default::default()
1082 };
1083 let mut stopped_parent = false;
1084
1085 if let Ok(mut candidates) = list_parent_watcher_state_files() {
1088 candidates.sort_by(|left, right| right.1.started_at.cmp(&left.1.started_at));
1089 for (_path, state) in candidates {
1090 let system = process_system_for_pids(&[state.pid]);
1091 if !is_live_parent_watcher_process_with_system(&system, &state) {
1092 continue;
1093 }
1094 let parent = parent_root_as_local_path(&state.parent_root);
1095 match stop_existing_parent_watcher(&parent, true) {
1096 Ok(StopWatcherOutcome::Stopped(pid)) => {
1097 stopped_parent = true;
1098 intent.parents.push(parent.display().to_string());
1099 println!(
1100 "Stopped worktree-watch parent watcher {} for {} before install.",
1101 pid,
1102 parent.display()
1103 );
1104 }
1105 Ok(StopWatcherOutcome::RemovedStaleState) => {
1106 println!(
1107 "Removed stale parent worktree-watch state for {} before install.",
1108 parent.display()
1109 );
1110 }
1111 Ok(StopWatcherOutcome::NoState) => {}
1112 Err(error) => {
1113 println!(
1114 "Warning: could not stop parent worktree-watch for {}: {error}",
1115 parent.display()
1116 );
1117 }
1118 }
1119 }
1120 }
1121
1122 if stopped_parent {
1123 let _ = write_worktree_watch_restart_intent(&intent);
1124 return;
1125 }
1126
1127 let Ok(target) = resolve_default_worktree_watch_status_target() else {
1129 return;
1130 };
1131 if target.parent.is_some() {
1134 return;
1135 }
1136
1137 let running = collect_worktree_watch_tray_snapshot(&target)
1138 .map(|snapshot| snapshot.any_running)
1139 .unwrap_or(false);
1140 if !running {
1141 return;
1142 }
1143
1144 if let Some(repo) = target.repo.as_ref() {
1145 intent.repos.push(repo.display().to_string());
1146 }
1147 for repo in &target.repos {
1148 intent.repos.push(repo.display().to_string());
1149 }
1150
1151 match run_worktree_watch_stop(WorktreeWatchStopOptions {
1152 target,
1153 force: true,
1154 }) {
1155 Ok(()) => {
1156 let _ = write_worktree_watch_restart_intent(&intent);
1157 println!("Stopped worktree-watch before install.");
1158 }
1159 Err(error) => {
1160 println!("Warning: could not stop worktree-watch before install: {error}");
1161 }
1162 }
1163}
1164
1165pub fn restart_worktree_watch_after_update() {
1168 let Some(intent) = read_worktree_watch_restart_intent() else {
1169 return;
1170 };
1171 let _ = clear_worktree_watch_restart_intent();
1172
1173 let mut restarted = 0usize;
1174 for parent in &intent.parents {
1175 let path = PathBuf::from(parent);
1176 if !path.is_dir() {
1177 continue;
1178 }
1179 match spawn_detached_parent_worktree_watch(&path) {
1180 Ok(_) => {
1181 restarted += 1;
1182 println!(
1183 "Restarted worktree-watch parent cover for {} after install.",
1184 path.display()
1185 );
1186 }
1187 Err(error) => {
1188 println!(
1189 "Warning: could not restart parent worktree-watch for {}: {error}",
1190 path.display()
1191 );
1192 }
1193 }
1194 }
1195
1196 for repo in &intent.repos {
1197 let path = PathBuf::from(repo);
1198 if !path.is_dir() {
1199 continue;
1200 }
1201 match resolve_repo_identity(Some(&path)).and_then(|id| {
1202 spawn_detached_worktree_watch_for_identity(&id).map(|_| id.root)
1203 }) {
1204 Ok(root) => {
1205 restarted += 1;
1206 println!(
1207 "Restarted worktree-watch for {} after install.",
1208 root.display()
1209 );
1210 }
1211 Err(error) => {
1212 println!(
1213 "Warning: could not restart worktree-watch for {}: {error}",
1214 path.display()
1215 );
1216 }
1217 }
1218 }
1219
1220 if intent.tray {
1221 match crate::commands::worktree_watch_tray::spawn_detached_tray_after_update() {
1222 Ok(()) => {
1223 restarted += 1;
1224 println!("Restarted worktree-watch tray after install.");
1225 }
1226 Err(error) => {
1227 println!("Warning: could not restart worktree-watch tray: {error}");
1228 }
1229 }
1230 }
1231
1232 if restarted == 0
1233 && intent.parents.is_empty()
1234 && intent.repos.is_empty()
1235 && !intent.tray
1236 {
1237 }
1239}
1240
1241fn worktree_watch_restart_intent_path() -> Result<PathBuf, String> {
1242 let paths = ensure_global_xbp_paths()?;
1243 Ok(paths
1244 .root_dir
1245 .join("worktree-watch")
1246 .join("restart-after-update.json"))
1247}
1248
1249fn write_worktree_watch_restart_intent(intent: &WorktreeWatchRestartIntent) -> Result<(), String> {
1250 let path = worktree_watch_restart_intent_path()?;
1251 if let Some(parent) = path.parent() {
1252 fs::create_dir_all(parent).map_err(|e| {
1253 format!(
1254 "Failed to create {}: {e}",
1255 parent.display()
1256 )
1257 })?;
1258 }
1259 let rendered = serde_json::to_string_pretty(intent)
1260 .map_err(|e| format!("Failed to serialize restart intent: {e}"))?;
1261 fs::write(&path, format!("{rendered}\n"))
1262 .map_err(|e| format!("Failed to write {}: {e}", path.display()))
1263}
1264
1265fn read_worktree_watch_restart_intent() -> Option<WorktreeWatchRestartIntent> {
1266 let path = worktree_watch_restart_intent_path().ok()?;
1267 let content = fs::read_to_string(path).ok()?;
1268 serde_json::from_str(&content).ok()
1269}
1270
1271fn clear_worktree_watch_restart_intent() -> Result<(), String> {
1272 let path = worktree_watch_restart_intent_path()?;
1273 if path.exists() {
1274 fs::remove_file(&path).map_err(|e| format!("Failed to remove {}: {e}", path.display()))?;
1275 }
1276 Ok(())
1277}
1278
1279pub fn record_tray_restart_intent() {
1281 let mut intent = read_worktree_watch_restart_intent().unwrap_or_default();
1282 intent.tray = true;
1283 if intent.stopped_at.is_none() {
1284 intent.stopped_at = Some(Utc::now().to_rfc3339());
1285 }
1286 let _ = write_worktree_watch_restart_intent(&intent);
1287}
1288
1289pub async fn run_worktree_watch_status(options: WorktreeWatchStatusOptions) -> Result<(), String> {
1290 let need_heavy = options.records || options.stats || options.repo_activity;
1293 let snapshot = collect_worktree_watch_tray_snapshot(&options.target)?;
1294 let watch_cfg = resolve_worktree_watch_config();
1295 let rank_index = build_repo_rank_index(&watch_cfg);
1296
1297 let mut payloads = Vec::new();
1298 let mut total_files = 0usize;
1299 let mut total_records = 0usize;
1300
1301 for tray_repo in &snapshot.repositories {
1302 let identity = identity_from_tray_repo(tray_repo);
1303 let mut status = if need_heavy {
1304 worktree_watch_status_payload(&identity, options.records, options.record_limit)?
1305 } else {
1306 worktree_watch_status_from_tray_repo(tray_repo)?
1308 };
1309
1310 if let Some(object) = status.as_object_mut() {
1311 object.insert("running".to_string(), json!(tray_repo.running));
1312 object.insert("pid".to_string(), json!(tray_repo.pid));
1313 if let Some(last_sync) = object.get("lastSync") {
1314 if let Some(synced_at) = last_sync.get("syncedAt").and_then(Value::as_str) {
1315 if let Ok(dt) = DateTime::parse_from_rfc3339(synced_at) {
1316 let age = (Utc::now() - dt.with_timezone(&Utc))
1317 .num_seconds()
1318 .max(0) as u64;
1319 object.insert("lastSyncSecondsAgo".to_string(), json!(age));
1320 }
1321 }
1322 }
1323 if options.resource {
1324 attach_repo_resource_fields(object, &identity.root, &rank_index, &watch_cfg);
1325 }
1326 }
1327
1328 let stats = if options.stats {
1329 Some(generate_and_store_stats(
1330 &identity,
1331 options.stats_gap_minutes,
1332 )?)
1333 } else {
1334 None
1335 };
1336 let repo_activity = if options.repo_activity {
1337 Some(generate_and_store_repo_activity(
1338 &identity,
1339 options.stats_gap_minutes,
1340 )?)
1341 } else {
1342 None
1343 };
1344 if options.stats || options.repo_activity {
1346 if let Err(err) = crate::commands::todos::effort::recompute_effort(
1347 &identity.root,
1348 options.stats_gap_minutes,
1349 true,
1350 ) {
1351 eprintln!("note: todo effort recompute: {err}");
1352 }
1353 }
1354 let status = attach_optional_stats(status, stats, repo_activity)?;
1355 total_files += status
1356 .get("unsyncedFiles")
1357 .and_then(Value::as_u64)
1358 .unwrap_or(0) as usize;
1359 total_records += status
1360 .get("unsyncedRecords")
1361 .and_then(Value::as_u64)
1362 .unwrap_or(0) as usize;
1363 payloads.push(status);
1364 }
1365
1366 if options.resource {
1367 payloads.sort_by(|a, b| {
1369 let sa = a.get("activityScore").and_then(Value::as_f64).unwrap_or(0.0);
1370 let sb = b.get("activityScore").and_then(Value::as_f64).unwrap_or(0.0);
1371 sb.partial_cmp(&sa)
1372 .unwrap_or(std::cmp::Ordering::Equal)
1373 .then_with(|| {
1374 let ra = a.get("activityRank").and_then(Value::as_u64).unwrap_or(u64::MAX);
1375 let rb = b.get("activityRank").and_then(Value::as_u64).unwrap_or(u64::MAX);
1376 ra.cmp(&rb)
1377 })
1378 });
1379 }
1380
1381 let resource = if options.resource {
1382 Some(build_resource_insight(&snapshot, &payloads, &watch_cfg))
1383 } else {
1384 None
1385 };
1386
1387 if options.json {
1388 let mut payload = if options.target.parent.is_some() || payloads.len() > 1 {
1389 json!({
1390 "targetLabel": snapshot.target_label,
1391 "mode": snapshot.mode,
1392 "parent": snapshot.parent,
1393 "parentWatcherRunning": snapshot.parent_watcher_running,
1394 "parentWatcherPid": snapshot.parent_watcher_pid,
1395 "anyRunning": snapshot.any_running,
1396 "allRunning": snapshot.all_running,
1397 "runningWatchers": snapshot.running_watchers,
1398 "repositories": payloads,
1399 "repositoryCount": payloads.len(),
1400 "unsyncedFiles": total_files,
1401 "unsyncedRecords": total_records,
1402 })
1403 } else {
1404 payloads.into_iter().next().unwrap_or_else(|| json!({}))
1405 };
1406 if let Some(resource) = resource {
1407 if let Some(object) = payload.as_object_mut() {
1408 object.insert("resource".to_string(), resource);
1409 }
1410 }
1411 println!(
1412 "{}",
1413 serde_json::to_string_pretty(&payload)
1414 .map_err(|error| format!("Failed to render status JSON: {error}"))?
1415 );
1416 } else {
1417 print_status_overview(&snapshot, total_files, total_records);
1418 if let Some(resource) = resource.as_ref() {
1419 print_status_resource(resource);
1420 }
1421 print_status_matrix(&payloads, &snapshot, options.resource);
1422 if need_heavy {
1423 for (index, payload) in payloads.iter().enumerate() {
1424 if index > 0 {
1425 println!();
1426 }
1427 if options.records {
1428 println!(
1429 "{} {}/{}",
1430 "records".bright_black(),
1431 payload
1432 .get("repositoryOwner")
1433 .and_then(Value::as_str)
1434 .unwrap_or("unknown"),
1435 payload
1436 .get("repositoryName")
1437 .and_then(Value::as_str)
1438 .unwrap_or("repository")
1439 );
1440 print_status_records(payload);
1441 }
1442 if options.stats {
1443 print_status_stats(payload);
1444 }
1445 if options.repo_activity {
1446 print_repo_activity(payload);
1447 }
1448 }
1449 }
1450 }
1451
1452 Ok(())
1453}
1454
1455fn print_status_overview(
1456 snapshot: &WorktreeWatchTraySnapshot,
1457 total_files: usize,
1458 total_records: usize,
1459) {
1460 let running_label = if snapshot.any_running {
1461 "RUNNING".green().bold()
1462 } else {
1463 "STOPPED".red().bold()
1464 };
1465 println!(
1466 "{} {} {}",
1467 "worktree-watch".bright_magenta().bold(),
1468 running_label,
1469 snapshot.target_label.bright_white()
1470 );
1471
1472 if snapshot.mode == "parent" {
1473 let parent_label = if snapshot.parent_watcher_running {
1474 format!(
1475 "parent watcher {}",
1476 snapshot
1477 .parent_watcher_pid
1478 .map(|pid| format!("pid {pid}"))
1479 .unwrap_or_else(|| "active".to_string())
1480 )
1481 .green()
1482 .to_string()
1483 } else {
1484 "parent watcher not detected".red().to_string()
1485 };
1486 println!(
1487 " {} {} {} repo(s) covered",
1488 parent_label,
1489 "·".bright_black(),
1490 snapshot.repository_count
1491 );
1492 } else {
1493 println!(
1494 " {} watching · {} repo(s)",
1495 snapshot.running_watchers, snapshot.repository_count
1496 );
1497 }
1498
1499 println!(
1500 " {} unsynced record(s) in {} file(s)",
1501 total_records.to_string().yellow(),
1502 total_files
1503 );
1504 if let Some(line) = snapshot.stats_line.as_ref() {
1505 println!(" {}", line.bright_black());
1506 }
1507 println!();
1508}
1509
1510fn print_status_resource(resource: &Value) {
1511 println!("{}", "resource".bright_cyan().bold());
1512
1513 let tiers = resource.get("tiers");
1514 let hot = tiers
1515 .and_then(|t| t.get("hot"))
1516 .and_then(Value::as_u64)
1517 .unwrap_or(0);
1518 let warm = tiers
1519 .and_then(|t| t.get("warm"))
1520 .and_then(Value::as_u64)
1521 .unwrap_or(0);
1522 let cold = tiers
1523 .and_then(|t| t.get("cold"))
1524 .and_then(Value::as_u64)
1525 .unwrap_or(0);
1526 let unknown = tiers
1527 .and_then(|t| t.get("unknown"))
1528 .and_then(Value::as_u64)
1529 .unwrap_or(0);
1530 let fs_watches = resource
1531 .get("estimatedFsWatches")
1532 .and_then(Value::as_u64)
1533 .unwrap_or(hot + warm);
1534
1535 println!(
1536 " tiers {} hot · {} warm · {} cold{}",
1537 hot.to_string().red().bold(),
1538 warm.to_string().yellow(),
1539 cold.to_string().bright_black(),
1540 if unknown > 0 {
1541 format!(" · {unknown} unranked")
1542 } else {
1543 String::new()
1544 }
1545 );
1546 println!(
1547 " watches ~{} recursive FS watch(es) {} cold unwatched (deletable)",
1548 fs_watches.to_string().cyan(),
1549 cold.to_string().bright_black()
1550 );
1551
1552 if let Some(proc) = resource.get("process") {
1553 let pid = proc.get("pid").and_then(Value::as_u64);
1554 let cpu = proc.get("cpuPercent").and_then(Value::as_f64);
1555 let rss_mb = proc.get("rssMb").and_then(Value::as_f64);
1556 let mut parts = Vec::new();
1557 if let Some(pid) = pid {
1558 parts.push(format!("pid {pid}"));
1559 }
1560 if let Some(cpu) = cpu {
1561 parts.push(format!("cpu {cpu:.1}%"));
1562 }
1563 if let Some(rss) = rss_mb {
1564 parts.push(format!("rss {rss:.1} MiB"));
1565 }
1566 if !parts.is_empty() {
1567 let cpu_style = match cpu {
1568 Some(c) if c >= 5.0 => parts.join(" · ").red().to_string(),
1569 Some(c) if c >= 2.0 => parts.join(" · ").yellow().to_string(),
1570 _ => parts.join(" · ").green().to_string(),
1571 };
1572 println!(" process {cpu_style}");
1573 }
1574 } else {
1575 println!(
1576 " process {}",
1577 "no live watcher process stats".bright_black()
1578 );
1579 }
1580
1581 if let Some(sched) = resource.get("schedule") {
1582 let idle = sched
1583 .get("idleAfterSeconds")
1584 .and_then(Value::as_u64)
1585 .unwrap_or(0);
1586 let cold_after = sched
1587 .get("coldAfterSeconds")
1588 .and_then(Value::as_u64)
1589 .unwrap_or(0);
1590 let hot_c = sched
1591 .get("hotCommitCheckSeconds")
1592 .and_then(Value::as_u64)
1593 .unwrap_or(0);
1594 let warm_c = sched
1595 .get("warmCommitCheckSeconds")
1596 .and_then(Value::as_u64)
1597 .unwrap_or(0);
1598 let cold_c = sched
1599 .get("coldCommitCheckSeconds")
1600 .and_then(Value::as_u64)
1601 .unwrap_or(0);
1602 let max_hot = sched
1603 .get("maxHotRepos")
1604 .and_then(Value::as_u64)
1605 .unwrap_or(0);
1606 let max_fs = sched
1607 .get("maxFsWatches")
1608 .and_then(Value::as_u64)
1609 .unwrap_or(0);
1610 println!(
1611 " schedule idle→warm {idle}s · cold {cold_after}s · HEAD {hot_c}/{warm_c}/{cold_c}s · max_hot {max_hot} · max_fs {max_fs}"
1612 );
1613 }
1614
1615 if let Some(top) = resource.get("topRepos").and_then(Value::as_array) {
1616 if !top.is_empty() {
1617 println!(" top by activity score");
1618 for (i, entry) in top.iter().take(8).enumerate() {
1619 let name = entry
1620 .get("repository")
1621 .and_then(Value::as_str)
1622 .unwrap_or("?");
1623 let tier = entry.get("tier").and_then(Value::as_str).unwrap_or("?");
1624 let score = entry.get("score").and_then(Value::as_f64).unwrap_or(0.0);
1625 let last = entry
1626 .get("lastEventAt")
1627 .and_then(Value::as_str)
1628 .unwrap_or("—");
1629 let last_short = if last.len() >= 19 {
1630 &last[11..19]
1631 } else {
1632 last
1633 };
1634 let tier_col = match tier {
1635 "hot" => tier.red().bold().to_string(),
1636 "warm" => tier.yellow().to_string(),
1637 "cold" => tier.bright_black().to_string(),
1638 _ => tier.bright_black().to_string(),
1639 };
1640 println!(
1641 " {:>2}. {:<36} {} score {:>7.2} last {}",
1642 i + 1,
1643 truncate_middle(name, 36),
1644 tier_col,
1645 score,
1646 last_short
1647 );
1648 }
1649 }
1650 }
1651
1652 if let Some(note) = resource.get("note").and_then(Value::as_str) {
1653 println!(" {}", note.bright_black());
1654 }
1655 println!();
1656}
1657
1658fn print_status_matrix(
1659 payloads: &[Value],
1660 snapshot: &WorktreeWatchTraySnapshot,
1661 show_resource: bool,
1662) {
1663 if payloads.is_empty() {
1664 println!("{}", "No repositories found for this target.".yellow());
1665 return;
1666 }
1667
1668 let headers: Vec<&str> = if show_resource {
1669 vec![
1670 "REPO",
1671 "BRANCH",
1672 "TIER",
1673 "RANK",
1674 "SCORE",
1675 "STATE",
1676 "PID",
1677 "UNSYNCED",
1678 "AGE",
1679 ]
1680 } else {
1681 vec![
1682 "REPO", "BRANCH", "STATE", "PID", "UNSYNCED", "LAST SYNC", "AGE",
1683 ]
1684 };
1685 let mut rows = Vec::with_capacity(payloads.len());
1686
1687 for payload in payloads {
1688 let owner = payload
1689 .get("repositoryOwner")
1690 .or_else(|| payload.get("owner"))
1691 .and_then(Value::as_str)
1692 .unwrap_or("unknown");
1693 let name = payload
1694 .get("repositoryName")
1695 .or_else(|| payload.get("name"))
1696 .and_then(Value::as_str)
1697 .unwrap_or("repository");
1698 let branch = payload
1699 .get("branchName")
1700 .or_else(|| payload.get("branch"))
1701 .and_then(Value::as_str)
1702 .unwrap_or("unknown");
1703 let running = payload
1704 .get("running")
1705 .and_then(Value::as_bool)
1706 .unwrap_or(false);
1707 let pid = payload
1708 .get("pid")
1709 .and_then(Value::as_u64)
1710 .map(|value| value.to_string())
1711 .unwrap_or_else(|| {
1712 if running {
1713 snapshot
1714 .parent_watcher_pid
1715 .map(|value| value.to_string())
1716 .unwrap_or_else(|| "—".to_string())
1717 } else {
1718 "—".to_string()
1719 }
1720 });
1721 let unsynced = payload
1722 .get("unsyncedRecords")
1723 .and_then(Value::as_u64)
1724 .unwrap_or(0);
1725 let (last_sync_at, age) = last_sync_display(payload);
1726 let state = if running {
1727 "RUN".green().bold().to_string()
1728 } else {
1729 "OFF".red().to_string()
1730 };
1731 let unsynced_cell = if unsynced > 0 {
1732 unsynced.to_string().yellow().to_string()
1733 } else {
1734 "0".bright_black().to_string()
1735 };
1736 let age_cell = if age == "never" {
1737 age.bright_black().to_string()
1738 } else {
1739 age.cyan().to_string()
1740 };
1741
1742 if show_resource {
1743 let tier = payload
1744 .get("watchTier")
1745 .and_then(Value::as_str)
1746 .unwrap_or("—");
1747 let tier_cell = match tier {
1748 "hot" => tier.red().bold().to_string(),
1749 "warm" => tier.yellow().to_string(),
1750 "cold" => tier.bright_black().to_string(),
1751 _ => tier.bright_black().to_string(),
1752 };
1753 let rank = payload
1754 .get("activityRank")
1755 .and_then(Value::as_u64)
1756 .map(|r| r.to_string())
1757 .unwrap_or_else(|| "—".to_string());
1758 let score = payload
1759 .get("activityScore")
1760 .and_then(Value::as_f64)
1761 .map(|s| format!("{s:.1}"))
1762 .unwrap_or_else(|| "—".to_string());
1763 rows.push(vec![
1764 format!("{owner}/{name}"),
1765 branch.to_string(),
1766 tier_cell,
1767 rank,
1768 score,
1769 state,
1770 pid,
1771 unsynced_cell,
1772 age_cell,
1773 ]);
1774 } else {
1775 rows.push(vec![
1776 format!("{owner}/{name}"),
1777 branch.to_string(),
1778 state,
1779 pid,
1780 unsynced_cell,
1781 last_sync_at,
1782 age_cell,
1783 ]);
1784 }
1785 }
1786
1787 print!("{}", render_table(&headers, &rows, TableStyle::Box, ""));
1788
1789 let running = snapshot.running_watchers;
1790 let stopped = snapshot
1791 .repository_count
1792 .saturating_sub(snapshot.running_watchers);
1793 println!();
1794 println!(
1795 "{} {} running · {} stopped · {} total",
1796 "summary".bright_black(),
1797 running.to_string().green().bold(),
1798 stopped.to_string().red(),
1799 snapshot.repository_count
1800 );
1801 if show_resource {
1802 println!(
1803 "{} tiers from ~/.xbp config ranks (updated while watcher runs)",
1804 "note".bright_black()
1805 );
1806 }
1807}
1808
1809fn build_repo_rank_index(cfg: &WorktreeWatchConfig) -> BTreeMap<String, WorktreeWatchRepoRank> {
1810 let mut map = BTreeMap::new();
1811 for rank in &cfg.repo_ranks {
1812 let key = path_identity_key(Path::new(&rank.root));
1813 map.insert(key, rank.clone());
1814 }
1815 map
1816}
1817
1818fn attach_repo_resource_fields(
1819 object: &mut serde_json::Map<String, Value>,
1820 root: &Path,
1821 ranks: &BTreeMap<String, WorktreeWatchRepoRank>,
1822 cfg: &WorktreeWatchConfig,
1823) {
1824 let key = path_identity_key(root);
1825 if let Some(rank) = ranks.get(&key) {
1826 let tier = rank
1827 .tier
1828 .clone()
1829 .unwrap_or_else(|| "unknown".to_string());
1830 object.insert("watchTier".to_string(), json!(tier));
1831 object.insert("activityScore".to_string(), json!(rank.score));
1832 if let Some(r) = rank.rank {
1833 object.insert("activityRank".to_string(), json!(r));
1834 }
1835 if let Some(at) = rank.last_event_at.as_ref() {
1836 object.insert("lastActivityAt".to_string(), json!(at));
1837 }
1838 let fs_watched = matches!(tier.as_str(), "hot");
1840 object.insert("fsWatched".to_string(), json!(fs_watched));
1841 let commit_secs = match tier.as_str() {
1842 "hot" => cfg.hot_commit_check_seconds(),
1843 "warm" => cfg.warm_commit_check_seconds(),
1844 "cold" => cfg.cold_commit_check_seconds(),
1845 _ => cfg.warm_commit_check_seconds(),
1846 };
1847 object.insert("commitCheckSeconds".to_string(), json!(commit_secs));
1848 } else {
1849 object.insert("watchTier".to_string(), json!("unknown"));
1850 object.insert("activityScore".to_string(), json!(0.0));
1851 object.insert("fsWatched".to_string(), json!(false));
1852 object.insert(
1853 "commitCheckSeconds".to_string(),
1854 json!(cfg.cold_commit_check_seconds()),
1855 );
1856 }
1857}
1858
1859fn build_resource_insight(
1860 snapshot: &WorktreeWatchTraySnapshot,
1861 payloads: &[Value],
1862 cfg: &WorktreeWatchConfig,
1863) -> Value {
1864 let mut hot = 0u64;
1865 let mut warm = 0u64;
1866 let mut cold = 0u64;
1867 let mut unknown = 0u64;
1868 for payload in payloads {
1869 match payload
1870 .get("watchTier")
1871 .and_then(Value::as_str)
1872 .unwrap_or("unknown")
1873 {
1874 "hot" => hot += 1,
1875 "warm" => warm += 1,
1876 "cold" => cold += 1,
1877 _ => unknown += 1,
1878 }
1879 }
1880 let estimated_fs_watches = hot
1882 .saturating_add(warm)
1883 .min(cfg.max_fs_watches() as u64);
1884
1885 let mut pids: Vec<u32> = Vec::new();
1886 if let Some(pid) = snapshot.parent_watcher_pid {
1887 pids.push(pid);
1888 }
1889 for repo in &snapshot.repositories {
1890 if let Some(pid) = repo.pid {
1891 if !pids.contains(&pid) {
1892 pids.push(pid);
1893 }
1894 }
1895 }
1896 let process = sample_watcher_process_stats(&pids);
1897
1898 let mut top: Vec<Value> = payloads
1899 .iter()
1900 .filter_map(|p| {
1901 let owner = p
1902 .get("repositoryOwner")
1903 .or_else(|| p.get("owner"))
1904 .and_then(Value::as_str)?;
1905 let name = p
1906 .get("repositoryName")
1907 .or_else(|| p.get("name"))
1908 .and_then(Value::as_str)?;
1909 let score = p.get("activityScore").and_then(Value::as_f64).unwrap_or(0.0);
1910 if score <= 0.0
1911 && p.get("watchTier").and_then(Value::as_str) == Some("unknown")
1912 {
1913 return None;
1914 }
1915 Some(json!({
1916 "repository": format!("{owner}/{name}"),
1917 "tier": p.get("watchTier").and_then(Value::as_str).unwrap_or("unknown"),
1918 "score": score,
1919 "rank": p.get("activityRank"),
1920 "lastEventAt": p.get("lastActivityAt"),
1921 "fsWatched": p.get("fsWatched"),
1922 "commitCheckSeconds": p.get("commitCheckSeconds"),
1923 }))
1924 })
1925 .collect();
1926 top.sort_by(|a, b| {
1927 let sa = a.get("score").and_then(Value::as_f64).unwrap_or(0.0);
1928 let sb = b.get("score").and_then(Value::as_f64).unwrap_or(0.0);
1929 sb.partial_cmp(&sa)
1930 .unwrap_or(std::cmp::Ordering::Equal)
1931 });
1932 top.truncate(12);
1933
1934 let note = if snapshot.any_running && hot + warm + cold == 0 {
1935 Some(
1936 "No repo_ranks yet — start/restart the watcher so it persists activity tiers to ~/.xbp/config.yaml"
1937 .to_string(),
1938 )
1939 } else if !snapshot.any_running {
1940 Some("Watcher not running — tier data is last known from config ranks".to_string())
1941 } else {
1942 None
1943 };
1944
1945 json!({
1946 "tiers": {
1947 "hot": hot,
1948 "warm": warm,
1949 "cold": cold,
1950 "unknown": unknown,
1951 },
1952 "estimatedFsWatches": estimated_fs_watches,
1953 "repositoryCount": payloads.len(),
1954 "process": process,
1955 "schedule": {
1956 "idleAfterSeconds": cfg.idle_after_seconds(),
1957 "coldAfterSeconds": cfg.cold_after_seconds(),
1958 "hotCommitCheckSeconds": cfg.hot_commit_check_seconds(),
1959 "warmCommitCheckSeconds": cfg.warm_commit_check_seconds(),
1960 "coldCommitCheckSeconds": cfg.cold_commit_check_seconds(),
1961 "parentRescanSeconds": cfg.parent_rescan_seconds(),
1962 "rankPersistSeconds": cfg.rank_persist_seconds(),
1963 "maxHotRepos": cfg.max_hot_repos(),
1964 "maxFsWatches": cfg.max_fs_watches(),
1965 "spoolRetentionDays": cfg.spool_retention_days(),
1966 },
1967 "topRepos": top,
1968 "note": note,
1969 })
1970}
1971
1972fn sample_watcher_process_stats(pids: &[u32]) -> Value {
1973 let filtered: Vec<u32> = pids
1974 .iter()
1975 .copied()
1976 .filter(|p| *p != 0)
1977 .collect::<BTreeSet<_>>()
1978 .into_iter()
1979 .collect();
1980 if filtered.is_empty() {
1981 return Value::Null;
1982 }
1983
1984 let pid_list: Vec<Pid> = filtered.iter().copied().map(Pid::from_u32).collect();
1985 let mut system = System::new();
1986 let refresh = ProcessRefreshKind::everything()
1987 .with_cmd(UpdateKind::Always)
1988 .with_exe(UpdateKind::Always)
1989 .with_memory();
1990 system.refresh_processes_specifics(ProcessesToUpdate::Some(&pid_list), true, refresh);
1992 std::thread::sleep(Duration::from_millis(200));
1993 system.refresh_processes_specifics(ProcessesToUpdate::Some(&pid_list), true, refresh);
1994
1995 let mut total_cpu = 0.0_f64;
1996 let mut total_rss: u64 = 0;
1997 let mut live = Vec::new();
1998 for pid in &filtered {
1999 let Some(proc) = system.process(Pid::from_u32(*pid)) else {
2000 continue;
2001 };
2002 if !process_looks_like_worktree_watch(proc)
2003 && *pid != std::process::id()
2004 && !process_cmdline_contains_worktree(proc)
2005 {
2006 }
2008 let cpu = proc.cpu_usage() as f64;
2009 let rss = proc.memory(); total_cpu += f64::from(cpu);
2011 total_rss = total_rss.saturating_add(rss);
2012 live.push(json!({
2013 "pid": pid,
2014 "cpuPercent": (cpu * 10.0).round() / 10.0,
2015 "rssMb": (rss as f64 / (1024.0 * 1024.0) * 10.0).round() / 10.0,
2016 "name": proc.name().to_string_lossy(),
2017 }));
2018 }
2019
2020 if live.is_empty() {
2021 return Value::Null;
2022 }
2023
2024 json!({
2025 "pid": filtered.first().copied(),
2026 "pids": filtered,
2027 "cpuPercent": (total_cpu * 10.0).round() / 10.0,
2028 "rssMb": (total_rss as f64 / (1024.0 * 1024.0) * 10.0).round() / 10.0,
2029 "processes": live,
2030 })
2031}
2032
2033fn process_cmdline_contains_worktree(process: &sysinfo::Process) -> bool {
2034 process
2035 .cmd()
2036 .iter()
2037 .any(|part| part.to_string_lossy().to_ascii_lowercase().contains("worktree-watch"))
2038}
2039
2040fn truncate_middle(s: &str, max: usize) -> String {
2041 if s.chars().count() <= max {
2042 return s.to_string();
2043 }
2044 if max < 5 {
2045 return s.chars().take(max).collect();
2046 }
2047 let keep = max.saturating_sub(1) / 2;
2048 let front: String = s.chars().take(keep).collect();
2049 let back: String = s
2050 .chars()
2051 .rev()
2052 .take(max - keep - 1)
2053 .collect::<String>()
2054 .chars()
2055 .rev()
2056 .collect();
2057 format!("{front}…{back}")
2058}
2059
2060fn last_sync_display(payload: &Value) -> (String, String) {
2061 let Some(last_sync) = payload.get("lastSync") else {
2062 return ("—".to_string(), "never".to_string());
2063 };
2064 let synced_at = last_sync
2065 .get("syncedAt")
2066 .and_then(Value::as_str)
2067 .unwrap_or("unknown");
2068 let age = if let Some(seconds) = payload
2069 .get("lastSyncSecondsAgo")
2070 .and_then(Value::as_u64)
2071 {
2072 format_age_seconds(seconds)
2073 } else if let Ok(dt) = DateTime::parse_from_rfc3339(synced_at) {
2074 let seconds = (Utc::now() - dt.with_timezone(&Utc)).num_seconds().max(0) as u64;
2075 format_age_seconds(seconds)
2076 } else {
2077 "unknown".to_string()
2078 };
2079 let compact = if synced_at.len() >= 19 {
2081 synced_at[11..19].to_string()
2082 } else {
2083 synced_at.to_string()
2084 };
2085 (compact, age)
2086}
2087
2088fn format_age_seconds(seconds: u64) -> String {
2089 if seconds < 60 {
2090 format!("{seconds}s ago")
2091 } else if seconds < 3600 {
2092 format!("{}m ago", seconds / 60)
2093 } else if seconds < 86400 {
2094 format!("{}h ago", seconds / 3600)
2095 } else {
2096 format!("{}d ago", seconds / 86400)
2097 }
2098}
2099
2100fn attach_optional_stats(
2101 mut payload: Value,
2102 stats: Option<WorktreeWatchStatsSummary>,
2103 repo_activity: Option<WorktreeWatchRepoActivitySummary>,
2104) -> Result<Value, String> {
2105 if let Some(object) = payload.as_object_mut() {
2106 if let Some(stats) = stats {
2107 object.insert(
2108 "stats".to_string(),
2109 serde_json::to_value(stats)
2110 .map_err(|error| format!("Failed to render stats payload: {error}"))?,
2111 );
2112 }
2113 if let Some(repo_activity) = repo_activity {
2114 object.insert(
2115 "repositoryActivity".to_string(),
2116 serde_json::to_value(repo_activity)
2117 .map_err(|error| format!("Failed to render repo activity payload: {error}"))?,
2118 );
2119 }
2120 }
2121 Ok(payload)
2122}
2123
2124pub fn spawn_detached_worktree_watch_for_current_repo() -> Result<Option<PathBuf>, String> {
2125 if is_background_child() {
2126 return Ok(None);
2127 }
2128
2129 let identity = match resolve_repo_identity(None) {
2130 Ok(identity) => identity,
2131 Err(_) => return Ok(None),
2132 };
2133 spawn_detached_worktree_watch_for_identity(&identity).map(Some)
2134}
2135
2136#[derive(Debug, Clone, Serialize, Deserialize)]
2138#[serde(rename_all = "camelCase")]
2139pub struct WorktreeWatchDiagnostic {
2140 pub global_xbp_root: String,
2141 pub mutations_root: String,
2142 pub runtime_key: String,
2143 pub platform: String,
2144 pub is_wsl: bool,
2145 pub repo_owner: Option<String>,
2146 pub repo_name: Option<String>,
2147 pub branch: Option<String>,
2148 pub branch_spool: Option<String>,
2149 pub branch_spool_exists: bool,
2150 pub watcher_state_path: Option<String>,
2151 pub watcher_state_present: bool,
2152 pub watcher_running: bool,
2153 pub event_jsonl_files: usize,
2154 pub commit_jsonl_files: usize,
2155 pub alternate_spool_roots: Vec<String>,
2156 pub notes: Vec<String>,
2157}
2158
2159#[derive(Debug, Clone)]
2161pub struct MutationActivityEvent {
2162 pub paths: Vec<String>,
2163 pub primary_path: Option<String>,
2164 pub occurred_at: DateTime<Utc>,
2165 pub added_lines: Option<u64>,
2166 pub removed_lines: Option<u64>,
2167 pub head_sha: Option<String>,
2168 pub branch_name: String,
2169}
2170
2171#[derive(Debug, Clone)]
2173pub struct CommitActivityEvent {
2174 pub occurred_at: DateTime<Utc>,
2175 pub head_sha: String,
2176 pub subject: Option<String>,
2177 pub branch_name: String,
2178 pub repo_root: String,
2179}
2180
2181pub fn load_repo_mutation_activity(
2183 owner: &str,
2184 name: &str,
2185) -> Result<Vec<MutationActivityEvent>, String> {
2186 let identity = RepoIdentity {
2187 owner: owner.to_string(),
2188 name: name.to_string(),
2189 branch: "main".to_string(),
2190 root: PathBuf::from("."),
2191 head_sha: None,
2192 };
2193 let mut events = Vec::new();
2194 for repo_root in repository_spool_search_roots(&identity)? {
2195 if !repo_root.is_dir() {
2196 continue;
2197 }
2198 for entry in fs::read_dir(&repo_root).map_err(|e| {
2199 format!(
2200 "Failed to read mutation spool {}: {e}",
2201 repo_root.display()
2202 )
2203 })? {
2204 let entry = entry.map_err(|e| format!("Failed to read spool entry: {e}"))?;
2205 if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
2206 continue;
2207 }
2208 let branch_layout = spool_layout_for_existing_root(&entry.path());
2209 let candidates = collect_spool_candidates(&branch_layout, true)?;
2210 for candidate in candidates {
2211 if let SyncRecords::Events(records) = candidate.records {
2212 for record in records {
2213 events.push(MutationActivityEvent {
2214 paths: record.paths,
2215 primary_path: record.primary_path,
2216 occurred_at: record.occurred_at,
2217 added_lines: record.added_lines,
2218 removed_lines: record.removed_lines,
2219 head_sha: record.head_sha,
2220 branch_name: record.branch_name,
2221 });
2222 }
2223 }
2224 }
2225 }
2226 }
2227 events.sort_by_key(|e| e.occurred_at);
2228 Ok(events)
2229}
2230
2231pub fn load_repo_commit_activity(
2233 owner: &str,
2234 name: &str,
2235) -> Result<Vec<CommitActivityEvent>, String> {
2236 let identity = RepoIdentity {
2237 owner: owner.to_string(),
2238 name: name.to_string(),
2239 branch: "main".to_string(),
2240 root: PathBuf::from("."),
2241 head_sha: None,
2242 };
2243 let mut commits = Vec::new();
2244 for repo_root in repository_spool_search_roots(&identity)? {
2245 if !repo_root.is_dir() {
2246 continue;
2247 }
2248 for entry in fs::read_dir(&repo_root).map_err(|e| {
2249 format!(
2250 "Failed to read mutation spool {}: {e}",
2251 repo_root.display()
2252 )
2253 })? {
2254 let entry = entry.map_err(|e| format!("Failed to read spool entry: {e}"))?;
2255 if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
2256 continue;
2257 }
2258 let branch_layout = spool_layout_for_existing_root(&entry.path());
2259 let candidates = collect_spool_candidates(&branch_layout, true)?;
2260 for candidate in candidates {
2261 if let SyncRecords::Commits(records) = candidate.records {
2262 for record in records {
2263 commits.push(CommitActivityEvent {
2264 occurred_at: record.occurred_at,
2265 head_sha: record.head_sha,
2266 subject: record.subject,
2267 branch_name: record.branch_name,
2268 repo_root: record.repo_root,
2269 });
2270 }
2271 }
2272 }
2273 }
2274 }
2275 commits.sort_by_key(|c| c.occurred_at);
2276 Ok(commits)
2277}
2278
2279pub fn collect_worktree_watch_diagnostic() -> WorktreeWatchDiagnostic {
2281 let runtime_key = worktree_runtime_key();
2282 let platform = std::env::consts::OS.to_string();
2283 let is_wsl = is_wsl_runtime();
2284 let mut notes = Vec::new();
2285
2286 let global_xbp_root = match ensure_global_xbp_paths() {
2287 Ok(paths) => paths.root_dir,
2288 Err(error) => {
2289 notes.push(format!("Failed to resolve global XBP home: {error}"));
2290 resolve_global_xbp_root_dir()
2291 }
2292 };
2293 let mutations_root = global_xbp_root.join("mutations");
2294
2295 let identity = resolve_repo_identity(None).ok();
2296 let (
2297 repo_owner,
2298 repo_name,
2299 branch,
2300 branch_spool,
2301 branch_spool_exists,
2302 watcher_state_path,
2303 watcher_state_present,
2304 watcher_running,
2305 event_jsonl_files,
2306 commit_jsonl_files,
2307 alternate_spool_roots,
2308 ) = if let Some(identity) = identity.as_ref() {
2309 let layout = prepare_spool_layout(identity).ok();
2310 let branch_spool = layout
2311 .as_ref()
2312 .map(|value| value.root.display().to_string());
2313 let branch_spool_exists = layout
2314 .as_ref()
2315 .map(|value| value.root.exists())
2316 .unwrap_or(false);
2317 let watcher_state_path = layout
2318 .as_ref()
2319 .map(|value| value.watcher_state_file.display().to_string());
2320 let watcher_state_present = layout
2321 .as_ref()
2322 .map(|value| {
2323 watcher_state_read_candidates(&value.watcher_state_file)
2324 .iter()
2325 .any(|candidate| candidate.exists())
2326 })
2327 .unwrap_or(false);
2328 let watcher_running = layout
2329 .as_ref()
2330 .and_then(|value| read_watcher_state(&value.watcher_state_file).ok().flatten())
2331 .map(|state| is_matching_watcher_process(&state))
2332 .unwrap_or(false);
2333
2334 let mut event_jsonl_files = 0usize;
2335 let mut commit_jsonl_files = 0usize;
2336 if let Some(layout) = layout.as_ref() {
2337 if let Ok(entries) = fs::read_dir(&layout.root) {
2338 for entry in entries.flatten() {
2339 let name = entry.file_name().to_string_lossy().to_string();
2340 if name.starts_with("events-") && name.ends_with(".jsonl") {
2341 event_jsonl_files += 1;
2342 } else if name.starts_with("commits-") && name.ends_with(".jsonl") {
2343 commit_jsonl_files += 1;
2344 }
2345 }
2346 }
2347 }
2348
2349 let alternate_spool_roots = repository_spool_search_roots(identity)
2350 .unwrap_or_default()
2351 .into_iter()
2352 .map(|path| {
2353 path.join(sanitize_path_component(&identity.branch))
2354 .display()
2355 .to_string()
2356 })
2357 .filter(|path| {
2358 layout
2359 .as_ref()
2360 .map(|value| path_identity_key(Path::new(path)) != path_identity_key(&value.root))
2361 .unwrap_or(true)
2362 })
2363 .filter(|path| Path::new(path).exists())
2364 .collect::<Vec<_>>();
2365
2366 let root_display = global_xbp_root
2367 .display()
2368 .to_string()
2369 .replace('\\', "/")
2370 .to_ascii_lowercase();
2371 if is_wsl && !root_display.starts_with("/mnt/") {
2372 notes.push(
2373 "WSL global XBP home is not on a Windows drive mount. If Windows has mutations under C:\\Users\\…\\.xbp, set XBP_HOME=/mnt/c/Users/<you>/.xbp so status/sync share that spool."
2374 .to_string(),
2375 );
2376 }
2377 if is_wsl && root_display.starts_with("/mnt/") {
2378 notes.push(
2379 "Using a Windows-profile-backed XBP home from WSL; spoils are reconcilable with native Windows."
2380 .to_string(),
2381 );
2382 }
2383 if !alternate_spool_roots.is_empty() {
2384 notes.push(format!(
2385 "Found {} alternate spool location(s) with historical data (legacy homes / runtime layouts).",
2386 alternate_spool_roots.len()
2387 ));
2388 }
2389
2390 (
2391 Some(identity.owner.clone()),
2392 Some(identity.name.clone()),
2393 Some(identity.branch.clone()),
2394 branch_spool,
2395 branch_spool_exists,
2396 watcher_state_path,
2397 watcher_state_present,
2398 watcher_running,
2399 event_jsonl_files,
2400 commit_jsonl_files,
2401 alternate_spool_roots,
2402 )
2403 } else {
2404 notes.push(
2405 "Not inside a git repository with a resolvable remote; showing global XBP home only."
2406 .to_string(),
2407 );
2408 (
2409 None,
2410 None,
2411 None,
2412 None,
2413 false,
2414 None,
2415 false,
2416 false,
2417 0,
2418 0,
2419 Vec::new(),
2420 )
2421 };
2422
2423 WorktreeWatchDiagnostic {
2424 global_xbp_root: global_xbp_root.display().to_string(),
2425 mutations_root: mutations_root.display().to_string(),
2426 runtime_key,
2427 platform,
2428 is_wsl,
2429 repo_owner,
2430 repo_name,
2431 branch,
2432 branch_spool,
2433 branch_spool_exists,
2434 watcher_state_path,
2435 watcher_state_present,
2436 watcher_running,
2437 event_jsonl_files,
2438 commit_jsonl_files,
2439 alternate_spool_roots,
2440 notes,
2441 }
2442}
2443
2444fn resolve_target_identities(
2445 target: &WorktreeWatchTargetOptions,
2446) -> Result<Vec<RepoIdentity>, String> {
2447 if target.repo.is_some() && target.parent.is_some() {
2448 return Err("Pass either `--repo` or `--parent`, not both.".to_string());
2449 }
2450 if !target.repos.is_empty() && target.parent.is_some() {
2451 return Err("Pass either `--repos` or `--parent`, not both.".to_string());
2452 }
2453 if target.repo.is_some() && !target.repos.is_empty() {
2454 return Err("Pass either `--repo` or `--repos`, not both.".to_string());
2455 }
2456
2457 if let Some(parent) = target.parent.as_deref() {
2458 return discover_repo_identities_under(parent);
2459 }
2460
2461 if !target.repos.is_empty() {
2462 let mut identities = Vec::new();
2463 let mut seen = BTreeSet::new();
2464 for repo in &target.repos {
2465 let identity = resolve_repo_identity(Some(repo.as_path()))?;
2466 let key = path_identity_key(&identity.root);
2467 if seen.insert(key) {
2468 identities.push(identity);
2469 }
2470 }
2471 return Ok(identities);
2472 }
2473
2474 resolve_repo_identity(target.repo.as_deref()).map(|identity| vec![identity])
2475}
2476
2477pub fn collect_worktree_watch_tray_snapshot(
2479 target: &WorktreeWatchTargetOptions,
2480) -> Result<WorktreeWatchTraySnapshot, String> {
2481 let identities = resolve_target_identities(target)?;
2482 let mode = if target.parent.is_some() {
2483 "parent"
2484 } else if !target.repos.is_empty() {
2485 "repos"
2486 } else {
2487 "repo"
2488 }
2489 .to_string();
2490
2491 let parent_label = target
2492 .parent
2493 .as_ref()
2494 .map(|path| path.display().to_string());
2495 let target_label = if let Some(parent) = parent_label.as_ref() {
2496 format!("parent:{parent}")
2497 } else if !target.repos.is_empty() {
2498 format!("{} repo(s)", target.repos.len())
2499 } else if let Some(repo) = target.repo.as_ref() {
2500 repo.display().to_string()
2501 } else {
2502 identities
2503 .first()
2504 .map(|identity| identity.root.display().to_string())
2505 .unwrap_or_else(|| "current repo".to_string())
2506 };
2507
2508 let mut parent_state: Option<ParentWorktreeWatcherState> = None;
2511 if let Some(parent) = target.parent.as_deref() {
2512 if let Ok(parent) = canonical_parent_path(parent) {
2513 if let Ok(layout) = prepare_parent_spool_layout(&parent) {
2514 parent_state = read_parent_watcher_state(&layout.watcher_state_file)?;
2515 }
2516 }
2517 }
2518
2519 let mut repo_states: Vec<(usize, WorktreeWatcherState)> = Vec::new();
2520 let mut layouts: Vec<SpoolLayout> = Vec::with_capacity(identities.len());
2521 for (index, identity) in identities.iter().enumerate() {
2522 let layout = prepare_spool_layout(identity)?;
2523 if let Some(state) = read_watcher_state(&layout.watcher_state_file)? {
2524 if same_repo_watcher_state(identity, &state) {
2525 repo_states.push((index, state));
2526 }
2527 }
2528 layouts.push(layout);
2529 }
2530
2531 let mut pids: Vec<u32> = repo_states.iter().map(|(_, state)| state.pid).collect();
2532 if let Some(state) = parent_state.as_ref() {
2533 pids.push(state.pid);
2534 }
2535 let process_system = process_system_for_pids(&pids);
2536
2537 let mut parent_watcher_running = false;
2538 let mut parent_watcher_pid = None;
2539 if let Some(state) = parent_state.as_ref() {
2540 if is_live_parent_watcher_process_with_system(&process_system, state) {
2544 parent_watcher_running = true;
2545 parent_watcher_pid = Some(state.pid);
2546 }
2547 }
2548
2549 let mut repositories = Vec::new();
2550 let mut running_watchers = 0usize;
2551 let mut total_unsynced_files = 0u64;
2552 let mut total_unsynced_records = 0u64;
2553
2554 for (index, identity) in identities.iter().enumerate() {
2555 let layout = &layouts[index];
2556 let counts = count_spool_summary(layout)?;
2558 let unsynced_files = counts.unsynced_files;
2559 let unsynced_records = counts.unsynced_records;
2560 total_unsynced_files += unsynced_files;
2561 total_unsynced_records += unsynced_records;
2562
2563 let (running, pid) = match repo_states
2564 .iter()
2565 .find(|(repo_index, _)| *repo_index == index)
2566 .map(|(_, state)| state)
2567 {
2568 Some(state) if is_live_watcher_process_with_system(&process_system, state) => {
2569 (true, Some(state.pid))
2570 }
2571 _ => (false, None),
2572 };
2573 let running = running || parent_watcher_running;
2575 let pid = pid.or(if running {
2576 parent_watcher_pid
2577 } else {
2578 None
2579 });
2580 if running {
2581 running_watchers += 1;
2582 }
2583
2584 repositories.push(WorktreeWatchTrayRepoStatus {
2585 root: normalize_repo_root_for_payload(&identity.root),
2586 owner: identity.owner.clone(),
2587 name: identity.name.clone(),
2588 branch: identity.branch.clone(),
2589 running,
2590 pid,
2591 unsynced_files,
2592 unsynced_records,
2593 });
2594 }
2595
2596 let repository_count = repositories.len();
2597 let any_running = parent_watcher_running || running_watchers > 0;
2598 let all_running = repository_count > 0 && running_watchers >= repository_count;
2599
2600 let stats_line = Some(format!(
2602 "{repository_count} repo(s) · {running_watchers} watching · {total_unsynced_records} unsynced · {total_unsynced_files} file(s)"
2603 ));
2604
2605 Ok(WorktreeWatchTraySnapshot {
2606 target_label,
2607 mode,
2608 parent: parent_label,
2609 repository_count,
2610 running_watchers,
2611 any_running,
2612 all_running,
2613 parent_watcher_running,
2614 parent_watcher_pid,
2615 total_unsynced_files,
2616 total_unsynced_records,
2617 repositories,
2618 stats_line,
2619 })
2620}
2621
2622pub const WORKTREE_WATCH_API_DEFAULT_STATS_GAP_MINUTES: u64 = 30;
2624
2625pub fn collect_worktree_watch_api_status(
2627 target: &WorktreeWatchTargetOptions,
2628 last_error: Option<&str>,
2629) -> Result<Value, String> {
2630 let snapshot = collect_worktree_watch_tray_snapshot(target)?;
2631 let watch_cfg = resolve_worktree_watch_config();
2632 let rank_index = build_repo_rank_index(&watch_cfg);
2633 let mut repositories = Vec::new();
2634
2635 for tray_repo in &snapshot.repositories {
2636 let mut status = worktree_watch_status_from_tray_repo(tray_repo)?;
2638 if let Some(object) = status.as_object_mut() {
2639 object.insert("running".to_string(), json!(tray_repo.running));
2640 object.insert("pid".to_string(), json!(tray_repo.pid));
2641 object.insert("owner".to_string(), json!(tray_repo.owner));
2642 object.insert("name".to_string(), json!(tray_repo.name));
2643 object.insert("branch".to_string(), json!(tray_repo.branch));
2644 object.insert("root".to_string(), json!(tray_repo.root));
2645 if let Some(spool) = object.get("spoolRoot").cloned() {
2646 object.insert("spoolPath".to_string(), spool);
2647 }
2648 object.insert("lastError".to_string(), Value::Null);
2649 if let Some(last_sync) = object.get("lastSync") {
2650 if let Some(synced_at) = last_sync.get("syncedAt").and_then(Value::as_str) {
2651 if let Ok(dt) = DateTime::parse_from_rfc3339(synced_at) {
2652 let age = (Utc::now() - dt.with_timezone(&Utc))
2653 .num_seconds()
2654 .max(0) as u64;
2655 object.insert("lastSyncSecondsAgo".to_string(), json!(age));
2656 }
2657 }
2658 }
2659 let identity = identity_from_tray_repo(tray_repo);
2660 attach_repo_resource_fields(object, &identity.root, &rank_index, &watch_cfg);
2661 }
2662 repositories.push(status);
2663 }
2664
2665 repositories.sort_by(|a, b| {
2666 let sa = a.get("activityScore").and_then(Value::as_f64).unwrap_or(0.0);
2667 let sb = b.get("activityScore").and_then(Value::as_f64).unwrap_or(0.0);
2668 sb.partial_cmp(&sa)
2669 .unwrap_or(std::cmp::Ordering::Equal)
2670 });
2671
2672 let resource = build_resource_insight(&snapshot, &repositories, &watch_cfg);
2673
2674 Ok(json!({
2675 "targetLabel": snapshot.target_label,
2676 "mode": snapshot.mode,
2677 "parent": snapshot.parent,
2678 "repositoryCount": snapshot.repository_count,
2679 "runningWatchers": snapshot.running_watchers,
2680 "anyRunning": snapshot.any_running,
2681 "allRunning": snapshot.all_running,
2682 "parentWatcherRunning": snapshot.parent_watcher_running,
2683 "parentWatcherPid": snapshot.parent_watcher_pid,
2684 "totalUnsyncedFiles": snapshot.total_unsynced_files,
2685 "totalUnsyncedRecords": snapshot.total_unsynced_records,
2686 "unsyncedFiles": snapshot.total_unsynced_files,
2687 "unsyncedRecords": snapshot.total_unsynced_records,
2688 "statsLine": snapshot.stats_line,
2689 "lastError": last_error,
2690 "resource": resource,
2691 "repositories": repositories,
2692 }))
2693}
2694
2695fn identity_from_tray_repo(repo: &WorktreeWatchTrayRepoStatus) -> RepoIdentity {
2696 RepoIdentity {
2697 owner: repo.owner.clone(),
2698 name: repo.name.clone(),
2699 branch: repo.branch.clone(),
2700 root: parent_root_as_local_path(&repo.root),
2701 head_sha: None,
2702 }
2703}
2704
2705fn worktree_watch_status_from_tray_repo(
2706 repo: &WorktreeWatchTrayRepoStatus,
2707) -> Result<Value, String> {
2708 let identity = identity_from_tray_repo(repo);
2709 let layout = prepare_spool_layout(&identity)?;
2710 let mut payload = json!({
2711 "repoRoot": repo.root,
2712 "repositoryOwner": repo.owner,
2713 "repositoryName": repo.name,
2714 "branchName": repo.branch,
2715 "platform": std::env::consts::OS,
2716 "runtimeKey": worktree_runtime_key(),
2717 "spoolRoot": layout.root.display().to_string(),
2718 "unsyncedFiles": repo.unsynced_files,
2719 "unsyncedRecords": repo.unsynced_records,
2720 "syncedFiles": 0,
2721 "syncedRecords": 0,
2722 "localSpoolFiles": repo.unsynced_files,
2723 "localSpoolRecords": repo.unsynced_records,
2724 "running": repo.running,
2725 "pid": repo.pid,
2726 });
2727
2728 if let Some(last_sync) = read_last_sync_log_entry(&layout.sync_log_file)? {
2729 if let Some(object) = payload.as_object_mut() {
2730 object.insert(
2731 "lastSync".to_string(),
2732 serde_json::to_value(last_sync)
2733 .map_err(|error| format!("Failed to render sync log payload: {error}"))?,
2734 );
2735 }
2736 }
2737
2738 Ok(payload)
2739}
2740
2741pub fn collect_worktree_watch_api_repositories(
2743 target: &WorktreeWatchTargetOptions,
2744) -> Result<Value, String> {
2745 let status = collect_worktree_watch_api_status(target, None)?;
2746 let repositories = status
2747 .get("repositories")
2748 .cloned()
2749 .unwrap_or_else(|| json!([]));
2750 Ok(json!({ "repositories": repositories }))
2751}
2752
2753pub fn collect_worktree_watch_api_stats(
2758 target: &WorktreeWatchTargetOptions,
2759 session_gap_minutes: u64,
2760) -> Result<Value, String> {
2761 let identities = resolve_target_identities(target)?;
2762 if identities.is_empty() {
2763 return Ok(json!({
2764 "generatedAt": null,
2765 "message": "no stats yet",
2766 "repositories": [],
2767 }));
2768 }
2769
2770 let gap = if session_gap_minutes == 0 {
2771 WORKTREE_WATCH_API_DEFAULT_STATS_GAP_MINUTES
2772 } else {
2773 session_gap_minutes
2774 };
2775
2776 let mut repositories = Vec::new();
2777 for identity in &identities {
2778 let stats = read_or_generate_stats(identity, gap, Duration::from_secs(120))?;
2779 repositories.push(json!({
2780 "owner": identity.owner,
2781 "name": identity.name,
2782 "branch": identity.branch,
2783 "repositoryOwner": identity.owner,
2784 "repositoryName": identity.name,
2785 "branchName": identity.branch,
2786 "repoRoot": normalize_repo_root_for_payload(&identity.root),
2787 "root": normalize_repo_root_for_payload(&identity.root),
2788 "stats": stats,
2789 }));
2790 }
2791
2792 if repositories.len() == 1 {
2793 let first = repositories.remove(0);
2794 let stats = first
2795 .get("stats")
2796 .cloned()
2797 .unwrap_or(Value::Null);
2798 if let Some(mut object) = stats.as_object().cloned() {
2800 object.insert(
2801 "repositories".to_string(),
2802 json!([{
2803 "owner": first.get("owner"),
2804 "name": first.get("name"),
2805 "branch": first.get("branch"),
2806 "stats": first.get("stats"),
2807 }]),
2808 );
2809 return Ok(Value::Object(object));
2810 }
2811 return Ok(first);
2812 }
2813
2814 Ok(json!({
2815 "repositories": repositories,
2816 "repositoryCount": repositories.len(),
2817 }))
2818}
2819
2820fn worktree_watch_status_payload_light(identity: &RepoIdentity) -> Result<Value, String> {
2822 let layout = prepare_spool_layout(identity)?;
2823 let counts = count_spool_summary(&layout)?;
2824
2825 let mut payload = json!({
2826 "repoRoot": normalize_repo_root_for_payload(&identity.root),
2827 "repositoryOwner": identity.owner.clone(),
2828 "repositoryName": identity.name.clone(),
2829 "branchName": identity.branch.clone(),
2830 "platform": std::env::consts::OS,
2831 "runtimeKey": worktree_runtime_key(),
2832 "spoolRoot": layout.root.display().to_string(),
2833 "unsyncedFiles": counts.unsynced_files,
2834 "unsyncedRecords": counts.unsynced_records,
2835 "syncedFiles": counts.synced_files,
2836 "syncedRecords": counts.synced_records,
2837 "localSpoolFiles": counts.unsynced_files + counts.synced_files,
2838 "localSpoolRecords": counts.unsynced_records + counts.synced_records,
2839 });
2840
2841 if let Some(last_sync) = read_last_sync_log_entry(&layout.sync_log_file)? {
2842 if let Some(object) = payload.as_object_mut() {
2843 object.insert(
2844 "lastSync".to_string(),
2845 serde_json::to_value(last_sync)
2846 .map_err(|error| format!("Failed to render sync log payload: {error}"))?,
2847 );
2848 }
2849 }
2850
2851 Ok(payload)
2852}
2853
2854fn worktree_watch_status_payload(
2855 identity: &RepoIdentity,
2856 include_records: bool,
2857 record_limit: usize,
2858) -> Result<Value, String> {
2859 if !include_records {
2862 return worktree_watch_status_payload_light(identity);
2863 }
2864
2865 let layout = prepare_spool_layout(identity)?;
2866 let candidates = collect_sync_candidates(&layout)?;
2867 let all_candidates = collect_spool_candidates(&layout, true)?;
2868 let record_count: usize = candidates
2869 .iter()
2870 .map(|candidate| candidate.records.len())
2871 .sum();
2872 let all_record_count: usize = all_candidates
2873 .iter()
2874 .map(|candidate| candidate.records.len())
2875 .sum();
2876 let synced_file_count = all_candidates.len().saturating_sub(candidates.len());
2877 let synced_record_count = all_record_count.saturating_sub(record_count);
2878
2879 let mut payload = json!({
2880 "repoRoot": normalize_repo_root_for_payload(&identity.root),
2881 "repositoryOwner": identity.owner.clone(),
2882 "repositoryName": identity.name.clone(),
2883 "branchName": identity.branch.clone(),
2884 "platform": std::env::consts::OS,
2885 "runtimeKey": worktree_runtime_key(),
2886 "spoolRoot": layout.root.display().to_string(),
2887 "unsyncedFiles": candidates.len(),
2888 "unsyncedRecords": record_count,
2889 "syncedFiles": synced_file_count,
2890 "syncedRecords": synced_record_count,
2891 "localSpoolFiles": all_candidates.len(),
2892 "localSpoolRecords": all_record_count,
2893 });
2894
2895 if let Some(last_sync) = read_last_sync_log_entry(&layout.sync_log_file)? {
2896 if let Some(object) = payload.as_object_mut() {
2897 object.insert(
2898 "lastSync".to_string(),
2899 serde_json::to_value(last_sync)
2900 .map_err(|error| format!("Failed to render sync log payload: {error}"))?,
2901 );
2902 }
2903 }
2904
2905 if let Some(object) = payload.as_object_mut() {
2906 object.insert(
2907 "records".to_string(),
2908 collect_status_records(&candidates, record_limit)?,
2909 );
2910 }
2911
2912 Ok(payload)
2913}
2914
2915#[derive(Debug, Default, Clone, Copy)]
2916struct SpoolCountSummary {
2917 unsynced_files: u64,
2918 unsynced_records: u64,
2919 synced_files: u64,
2920 synced_records: u64,
2921}
2922
2923fn count_spool_summary(layout: &SpoolLayout) -> Result<SpoolCountSummary, String> {
2925 let mut roots = vec![layout.root.clone()];
2926 if let Some(branch) = layout.root.file_name().and_then(|name| name.to_str()) {
2927 if let Some(repo_root) = layout.root.parent() {
2928 if let (Some(name), Some(owner)) = (
2929 repo_root.file_name().and_then(|n| n.to_str()),
2930 repo_root
2931 .parent()
2932 .and_then(|p| p.file_name())
2933 .and_then(|n| n.to_str()),
2934 ) {
2935 let identity = RepoIdentity {
2936 owner: owner.to_string(),
2937 name: name.to_string(),
2938 branch: branch.to_string(),
2939 root: PathBuf::new(),
2940 head_sha: None,
2941 };
2942 if let Ok(search_roots) = repository_spool_search_roots(&identity) {
2943 for search_root in search_roots {
2944 let branch_root = search_root.join(branch);
2945 if !roots.iter().any(|existing| {
2946 path_identity_key(existing) == path_identity_key(&branch_root)
2947 }) {
2948 roots.push(branch_root);
2949 }
2950 }
2951 }
2952 }
2953 }
2954 }
2955
2956 let mut summary = SpoolCountSummary::default();
2957 let mut seen_files = BTreeSet::new();
2958 for root in roots {
2959 if !root.exists() {
2960 continue;
2961 }
2962 for entry in fs::read_dir(&root).map_err(|error| {
2963 format!(
2964 "Failed to read spool directory {}: {error}",
2965 root.display()
2966 )
2967 })? {
2968 let path = entry
2969 .map_err(|error| format!("Failed to read spool entry: {error}"))?
2970 .path();
2971 let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
2972 continue;
2973 };
2974 if !file_name.ends_with(".jsonl") {
2975 continue;
2976 }
2977 if !(file_name.starts_with("events-") || file_name.starts_with("commits-")) {
2978 continue;
2979 }
2980 let identity_key = path_identity_key(&path);
2981 if !seen_files.insert(identity_key) {
2982 continue;
2983 }
2984 let lines = count_nonempty_lines(&path)?;
2985 if file_name.contains(".synced.") {
2986 summary.synced_files += 1;
2987 summary.synced_records += lines;
2988 } else {
2989 summary.unsynced_files += 1;
2990 summary.unsynced_records += lines;
2991 }
2992 }
2993 }
2994 Ok(summary)
2995}
2996
2997fn count_nonempty_lines(path: &Path) -> Result<u64, String> {
2998 let file = File::open(path)
2999 .map_err(|error| format!("Failed to open spool file {}: {error}", path.display()))?;
3000 let reader = BufReader::new(file);
3001 let mut count = 0u64;
3002 for line in reader.lines() {
3003 let line =
3004 line.map_err(|error| format!("Failed to read spool file {}: {error}", path.display()))?;
3005 if !line.trim().is_empty() {
3006 count += 1;
3007 }
3008 }
3009 Ok(count)
3010}
3011
3012fn read_or_generate_stats(
3013 identity: &RepoIdentity,
3014 session_gap_minutes: u64,
3015 max_age: Duration,
3016) -> Result<WorktreeWatchStatsSummary, String> {
3017 let layout = prepare_spool_layout(identity)?;
3018 if layout.stats_file.exists() {
3019 if let Ok(raw) = fs::read_to_string(&layout.stats_file) {
3020 if let Ok(stats) = serde_json::from_str::<WorktreeWatchStatsSummary>(&raw) {
3021 let age = Utc::now()
3022 .signed_duration_since(stats.generated_at)
3023 .to_std()
3024 .unwrap_or(Duration::from_secs(u64::MAX));
3025 if age <= max_age {
3026 return Ok(stats);
3027 }
3028 }
3029 }
3030 }
3031 generate_and_store_stats(identity, session_gap_minutes)
3032}
3033
3034fn collect_status_records(candidates: &[SyncCandidate], limit: usize) -> Result<Value, String> {
3035 let available: usize = candidates
3036 .iter()
3037 .map(|candidate| candidate.records.len())
3038 .sum();
3039 let mut shown = 0usize;
3040 let mut events = Vec::new();
3041 let mut commits = Vec::new();
3042
3043 'candidates: for candidate in candidates {
3044 match &candidate.records {
3045 SyncRecords::Events(records) => {
3046 for event in records {
3047 if shown >= limit {
3048 break 'candidates;
3049 }
3050 events.push(json!({
3051 "sourceFile": candidate.path.display().to_string(),
3052 "occurredAt": event.occurred_at,
3053 "eventKind": event.event_kind.clone(),
3054 "primaryPath": event.primary_path.clone(),
3055 "oldPath": event.old_path.clone(),
3056 "newPath": event.new_path.clone(),
3057 "paths": event.paths.clone(),
3058 "addedLines": event.added_lines,
3059 "removedLines": event.removed_lines,
3060 "totalLines": event.total_lines,
3061 "headSha": event.head_sha.clone(),
3062 "rawKind": event.raw_kind.clone(),
3063 }));
3064 shown += 1;
3065 }
3066 }
3067 SyncRecords::Commits(records) => {
3068 for commit in records {
3069 if shown >= limit {
3070 break 'candidates;
3071 }
3072 commits.push(json!({
3073 "sourceFile": candidate.path.display().to_string(),
3074 "occurredAt": commit.occurred_at,
3075 "headSha": commit.head_sha.clone(),
3076 "previousHeadSha": commit.previous_head_sha.clone(),
3077 "subject": commit.subject.clone(),
3078 "authorName": commit.author_name.clone(),
3079 "authorEmail": commit.author_email.clone(),
3080 "committedAt": commit.committed_at.clone(),
3081 "filesChanged": commit.files_changed.clone(),
3082 "filesChangedCount": commit.files_changed_count,
3083 "insertions": commit.insertions,
3084 "deletions": commit.deletions,
3085 "isMerge": commit.is_merge,
3086 "includedCommits": commit.included_commits.clone(),
3087 }));
3088 shown += 1;
3089 }
3090 }
3091 }
3092 }
3093
3094 Ok(json!({
3095 "available": available,
3096 "shown": shown,
3097 "truncated": shown < available,
3098 "events": events,
3099 "commits": commits,
3100 }))
3101}
3102
3103fn print_status_records(payload: &Value) {
3104 let Some(records) = payload.get("records") else {
3105 return;
3106 };
3107 let shown = records.get("shown").and_then(Value::as_u64).unwrap_or(0);
3108 if shown == 0 {
3109 println!("records: none");
3110 return;
3111 }
3112
3113 println!("records:");
3114 if let Some(events) = records.get("events").and_then(Value::as_array) {
3115 if !events.is_empty() {
3116 println!(" mutations:");
3117 for event in events {
3118 print_status_event_record(event);
3119 }
3120 }
3121 }
3122 if let Some(commits) = records.get("commits").and_then(Value::as_array) {
3123 if !commits.is_empty() {
3124 println!(" commits:");
3125 for commit in commits {
3126 print_status_commit_record(commit);
3127 }
3128 }
3129 }
3130 if records
3131 .get("truncated")
3132 .and_then(Value::as_bool)
3133 .unwrap_or(false)
3134 {
3135 let available = records
3136 .get("available")
3137 .and_then(Value::as_u64)
3138 .unwrap_or(shown);
3139 println!(" showing {shown} of {available} record(s)");
3140 }
3141}
3142
3143fn print_status_stats(payload: &Value) {
3144 let Some(stats) = payload.get("stats") else {
3145 return;
3146 };
3147 println!("stats:");
3148 println!(
3149 " coding time: {}",
3150 format_duration(
3151 stats
3152 .get("estimatedCodingSeconds")
3153 .and_then(Value::as_u64)
3154 .unwrap_or(0)
3155 )
3156 );
3157 println!(
3158 " sessions: {}",
3159 stats
3160 .get("sessionCount")
3161 .and_then(Value::as_u64)
3162 .unwrap_or(0)
3163 );
3164 println!(
3165 " observed span: {}",
3166 format_duration(
3167 stats
3168 .get("observedSpanSeconds")
3169 .and_then(Value::as_u64)
3170 .unwrap_or(0)
3171 )
3172 );
3173 println!(
3174 " events: {}",
3175 stats
3176 .get("totalEvents")
3177 .and_then(Value::as_u64)
3178 .unwrap_or(0)
3179 );
3180 println!(
3181 " commits: {}",
3182 stats
3183 .get("totalCommits")
3184 .and_then(Value::as_u64)
3185 .unwrap_or(0)
3186 );
3187 println!(
3188 " lines: +{} -{}",
3189 stats.get("addedLines").and_then(Value::as_u64).unwrap_or(0),
3190 stats
3191 .get("removedLines")
3192 .and_then(Value::as_u64)
3193 .unwrap_or(0)
3194 );
3195 if let Some(spool_root) = value_str(stats, "spoolRoot") {
3196 println!(
3197 " stored: {}",
3198 Path::new(spool_root).join("stats.json").display()
3199 );
3200 }
3201 print_stats_bucket_section(stats, "byFiletype", " by filetype:", 10);
3202 print_stats_bucket_section(stats, "byEventKind", " by event kind:", 10);
3203 print_stats_file_section(stats, 10);
3204}
3205
3206fn print_repo_activity(payload: &Value) {
3207 let Some(activity) = payload.get("repositoryActivity") else {
3208 return;
3209 };
3210 println!("repo activity:");
3211 println!(
3212 " coding time: {}",
3213 format_duration(
3214 activity
3215 .get("estimatedCodingSeconds")
3216 .and_then(Value::as_u64)
3217 .unwrap_or(0)
3218 )
3219 );
3220 println!(
3221 " observed span: {}",
3222 format_duration(
3223 activity
3224 .get("observedSpanSeconds")
3225 .and_then(Value::as_u64)
3226 .unwrap_or(0)
3227 )
3228 );
3229 println!(
3230 " branches: {}",
3231 activity
3232 .get("branchCount")
3233 .and_then(Value::as_u64)
3234 .unwrap_or(0)
3235 );
3236 println!(
3237 " sessions: {}, events: {}, commits: {}, lines: +{} -{}",
3238 activity
3239 .get("sessionCount")
3240 .and_then(Value::as_u64)
3241 .unwrap_or(0),
3242 activity
3243 .get("totalEvents")
3244 .and_then(Value::as_u64)
3245 .unwrap_or(0),
3246 activity
3247 .get("totalCommits")
3248 .and_then(Value::as_u64)
3249 .unwrap_or(0),
3250 activity
3251 .get("addedLines")
3252 .and_then(Value::as_u64)
3253 .unwrap_or(0),
3254 activity
3255 .get("removedLines")
3256 .and_then(Value::as_u64)
3257 .unwrap_or(0)
3258 );
3259 if let Some(stats_file) = value_str(activity, "statsFile") {
3260 println!(" stored: {stats_file}");
3261 }
3262 let Some(branches) = activity.get("branches").and_then(Value::as_array) else {
3263 return;
3264 };
3265 if branches.is_empty() {
3266 return;
3267 }
3268 println!(" by branch:");
3269 for branch in branches.iter().take(20) {
3270 let name = value_str(branch, "branchName").unwrap_or("unknown");
3271 let coding_seconds = branch
3272 .get("estimatedCodingSeconds")
3273 .and_then(Value::as_u64)
3274 .unwrap_or(0);
3275 let observed_seconds = branch
3276 .get("observedSpanSeconds")
3277 .and_then(Value::as_u64)
3278 .unwrap_or(0);
3279 let sessions = branch
3280 .get("sessionCount")
3281 .and_then(Value::as_u64)
3282 .unwrap_or(0);
3283 let events = branch
3284 .get("totalEvents")
3285 .and_then(Value::as_u64)
3286 .unwrap_or(0);
3287 let commits = branch
3288 .get("totalCommits")
3289 .and_then(Value::as_u64)
3290 .unwrap_or(0);
3291 println!(
3292 " {name}: {} coding, {} span, {} session(s), {} event(s), {} commit(s)",
3293 format_duration(coding_seconds),
3294 format_duration(observed_seconds),
3295 sessions,
3296 events,
3297 commits
3298 );
3299 }
3300}
3301
3302#[allow(dead_code)]
3303fn print_last_sync(payload: &Value) {
3304 let Some(last_sync) = payload.get("lastSync") else {
3305 return;
3306 };
3307 let synced_at = value_str(last_sync, "syncedAt").unwrap_or("unknown-time");
3308 let status = last_sync
3309 .get("statusCode")
3310 .and_then(Value::as_u64)
3311 .unwrap_or(0);
3312 let events = last_sync
3313 .get("eventCount")
3314 .and_then(Value::as_u64)
3315 .unwrap_or(0);
3316 let commits = last_sync
3317 .get("commitCount")
3318 .and_then(Value::as_u64)
3319 .unwrap_or(0);
3320 let files = last_sync
3321 .get("spoolFileCount")
3322 .and_then(Value::as_u64)
3323 .unwrap_or(0);
3324 let resync = last_sync
3325 .get("resync")
3326 .and_then(Value::as_bool)
3327 .unwrap_or(false);
3328 println!(
3329 "last sync: {synced_at}, HTTP {status}, {events} event(s), {commits} commit(s), {files} file(s), resync={resync}"
3330 );
3331}
3332
3333fn print_stats_bucket_section(stats: &Value, key: &str, title: &str, limit: usize) {
3334 let Some(items) = stats.get(key).and_then(Value::as_array) else {
3335 return;
3336 };
3337 if items.is_empty() {
3338 return;
3339 }
3340 println!("{title}");
3341 for item in items.iter().take(limit) {
3342 let name = value_str(item, "name").unwrap_or("unknown");
3343 let events = item.get("eventCount").and_then(Value::as_u64).unwrap_or(0);
3344 let seconds = item
3345 .get("estimatedCodingSeconds")
3346 .and_then(Value::as_u64)
3347 .unwrap_or(0);
3348 let added = item.get("addedLines").and_then(Value::as_u64).unwrap_or(0);
3349 let removed = item
3350 .get("removedLines")
3351 .and_then(Value::as_u64)
3352 .unwrap_or(0);
3353 println!(
3354 " {name}: {}, {} event(s), +{} -{}",
3355 format_duration(seconds),
3356 events,
3357 added,
3358 removed
3359 );
3360 }
3361}
3362
3363fn print_stats_file_section(stats: &Value, limit: usize) {
3364 let Some(items) = stats.get("byFile").and_then(Value::as_array) else {
3365 return;
3366 };
3367 if items.is_empty() {
3368 return;
3369 }
3370 println!(" by file:");
3371 for item in items.iter().take(limit) {
3372 let path = value_str(item, "path").unwrap_or("(no path)");
3373 let filetype = value_str(item, "filetype").unwrap_or("(none)");
3374 let events = item.get("eventCount").and_then(Value::as_u64).unwrap_or(0);
3375 let seconds = item
3376 .get("estimatedCodingSeconds")
3377 .and_then(Value::as_u64)
3378 .unwrap_or(0);
3379 let added = item.get("addedLines").and_then(Value::as_u64).unwrap_or(0);
3380 let removed = item
3381 .get("removedLines")
3382 .and_then(Value::as_u64)
3383 .unwrap_or(0);
3384 println!(
3385 " {path} [{filetype}]: {}, {} event(s), +{} -{}",
3386 format_duration(seconds),
3387 events,
3388 added,
3389 removed
3390 );
3391 }
3392}
3393
3394fn print_status_event_record(event: &Value) {
3395 let occurred_at = value_str(event, "occurredAt").unwrap_or("unknown-time");
3396 let kind = value_str(event, "eventKind").unwrap_or("event");
3397 let primary_path = event_display_path(event);
3398 let added = event.get("addedLines").and_then(Value::as_u64).unwrap_or(0);
3399 let removed = event
3400 .get("removedLines")
3401 .and_then(Value::as_u64)
3402 .unwrap_or(0);
3403 let total_lines = event.get("totalLines").and_then(Value::as_u64);
3404 if let Some(total_lines) = total_lines {
3405 println!(
3406 " {occurred_at} {kind} {primary_path} (+{added} -{removed}, {total_lines} lines)"
3407 );
3408 } else {
3409 println!(" {occurred_at} {kind} {primary_path} (+{added} -{removed})");
3410 }
3411
3412 if let Some(head_sha) = value_str(event, "headSha") {
3413 println!(" head: {}", short_sha(head_sha));
3414 }
3415 if let Some(paths) = event.get("paths").and_then(Value::as_array) {
3416 if paths.len() > 1 {
3417 let rendered = paths
3418 .iter()
3419 .filter_map(Value::as_str)
3420 .collect::<Vec<_>>()
3421 .join(", ");
3422 println!(" paths: {rendered}");
3423 }
3424 }
3425}
3426
3427fn print_status_commit_record(commit: &Value) {
3428 let occurred_at = value_str(commit, "occurredAt").unwrap_or("unknown-time");
3429 let head_sha = value_str(commit, "headSha")
3430 .map(short_sha)
3431 .unwrap_or_else(|| "unknown".to_string());
3432 let subject = value_str(commit, "subject").unwrap_or("(no subject)");
3433 println!(" {occurred_at} {head_sha} {subject}");
3434
3435 if let Some(author) = value_str(commit, "authorName") {
3436 println!(" author: {author}");
3437 }
3438 let files = commit
3439 .get("filesChangedCount")
3440 .and_then(Value::as_u64)
3441 .or_else(|| {
3442 commit
3443 .get("filesChanged")
3444 .and_then(Value::as_array)
3445 .map(|a| a.len() as u64)
3446 });
3447 let ins = commit.get("insertions").and_then(Value::as_u64);
3448 let del = commit.get("deletions").and_then(Value::as_u64);
3449 match (files, ins, del) {
3450 (Some(f), Some(i), Some(d)) => {
3451 println!(" changes: {f} file(s), +{i} -{d}");
3452 }
3453 (Some(f), _, _) => println!(" changes: {f} file(s)"),
3454 _ => {}
3455 }
3456 if commit.get("isMerge").and_then(Value::as_bool) == Some(true) {
3457 println!(" merge: true");
3458 }
3459}
3460
3461fn event_display_path(event: &Value) -> String {
3462 let old_path = value_str(event, "oldPath");
3463 let new_path = value_str(event, "newPath");
3464 if let (Some(old_path), Some(new_path)) = (old_path, new_path) {
3465 return format!("{old_path} -> {new_path}");
3466 }
3467 value_str(event, "primaryPath")
3468 .map(str::to_string)
3469 .or_else(|| {
3470 event
3471 .get("paths")
3472 .and_then(Value::as_array)
3473 .and_then(|paths| paths.first())
3474 .and_then(Value::as_str)
3475 .map(str::to_string)
3476 })
3477 .unwrap_or_else(|| "(no path)".to_string())
3478}
3479
3480fn value_str<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
3481 value.get(key).and_then(Value::as_str)
3482}
3483
3484fn short_sha(value: &str) -> String {
3485 value.chars().take(12).collect()
3486}
3487
3488fn generate_and_store_stats(
3489 identity: &RepoIdentity,
3490 session_gap_minutes: u64,
3491) -> Result<WorktreeWatchStatsSummary, String> {
3492 let layout = prepare_spool_layout(identity)?;
3493 let candidates = collect_spool_candidates(&layout, true)?;
3494 let summary = build_stats_summary(identity, &layout, &candidates, session_gap_minutes)?;
3495 let rendered = serde_json::to_string_pretty(&summary)
3496 .map_err(|error| format!("Failed to serialize stats: {error}"))?;
3497 fs::write(&layout.stats_file, format!("{rendered}\n")).map_err(|error| {
3498 format!(
3499 "Failed to write stats file {}: {error}",
3500 layout.stats_file.display()
3501 )
3502 })?;
3503 Ok(summary)
3504}
3505
3506fn generate_and_store_repo_activity(
3507 identity: &RepoIdentity,
3508 session_gap_minutes: u64,
3509) -> Result<WorktreeWatchRepoActivitySummary, String> {
3510 let repository_spool_root = repository_spool_root(identity)?;
3511 let summary =
3512 build_repo_activity_summary(identity, &repository_spool_root, session_gap_minutes)?;
3513 let rendered = serde_json::to_string_pretty(&summary)
3514 .map_err(|error| format!("Failed to serialize repo activity: {error}"))?;
3515 fs::create_dir_all(&repository_spool_root).map_err(|error| {
3516 format!(
3517 "Failed to create repository spool root {}: {error}",
3518 repository_spool_root.display()
3519 )
3520 })?;
3521 let stats_file = repository_spool_root.join("repo-activity.json");
3522 fs::write(&stats_file, format!("{rendered}\n")).map_err(|error| {
3523 format!(
3524 "Failed to write repo activity file {}: {error}",
3525 stats_file.display()
3526 )
3527 })?;
3528 Ok(summary)
3529}
3530
3531fn build_repo_activity_summary(
3532 identity: &RepoIdentity,
3533 repository_spool_root: &Path,
3534 session_gap_minutes: u64,
3535) -> Result<WorktreeWatchRepoActivitySummary, String> {
3536 flush_jsonl_write_buffers();
3537 let mut branches = Vec::new();
3538 if repository_spool_root.exists() {
3539 for entry in fs::read_dir(repository_spool_root).map_err(|error| {
3540 format!(
3541 "Failed to read repository spool root {}: {error}",
3542 repository_spool_root.display()
3543 )
3544 })? {
3545 let entry =
3546 entry.map_err(|error| format!("Failed to read repository spool entry: {error}"))?;
3547 let file_type = entry.file_type().map_err(|error| {
3548 format!("Failed to inspect {}: {error}", entry.path().display())
3549 })?;
3550 if !file_type.is_dir() {
3551 continue;
3552 }
3553 let branch_name = entry.file_name().to_string_lossy().to_string();
3554 let branch_root = entry.path();
3555 let branch_layout = spool_layout_for_existing_root(&branch_root);
3556 let branch_identity = RepoIdentity {
3557 branch: branch_name.clone(),
3558 ..identity.clone()
3559 };
3560 let candidates = collect_spool_candidates(&branch_layout, true)?;
3561 let stats = build_stats_summary(
3562 &branch_identity,
3563 &branch_layout,
3564 &candidates,
3565 session_gap_minutes,
3566 )?;
3567 if stats.total_events == 0 && stats.total_commits == 0 {
3568 continue;
3569 }
3570 branches.push(WorktreeWatchBranchActivityStats {
3571 branch_name,
3572 spool_root: branch_root.display().to_string(),
3573 total_events: stats.total_events,
3574 total_commits: stats.total_commits,
3575 first_event_at: stats.first_event_at,
3576 last_event_at: stats.last_event_at,
3577 session_count: stats.session_count,
3578 observed_span_seconds: stats.observed_span_seconds,
3579 estimated_coding_seconds: stats.estimated_coding_seconds,
3580 added_lines: stats.added_lines,
3581 removed_lines: stats.removed_lines,
3582 });
3583 }
3584 }
3585
3586 branches.sort_by(|left, right| {
3587 right
3588 .estimated_coding_seconds
3589 .cmp(&left.estimated_coding_seconds)
3590 .then_with(|| right.total_events.cmp(&left.total_events))
3591 .then_with(|| left.branch_name.cmp(&right.branch_name))
3592 });
3593
3594 let first_event_at = branches
3595 .iter()
3596 .filter_map(|branch| branch.first_event_at)
3597 .min();
3598 let last_event_at = branches
3599 .iter()
3600 .filter_map(|branch| branch.last_event_at)
3601 .max();
3602 let observed_span_seconds = observed_span_seconds(first_event_at, last_event_at);
3603 let stats_file = repository_spool_root.join("repo-activity.json");
3604
3605 Ok(WorktreeWatchRepoActivitySummary {
3606 generated_at: Utc::now(),
3607 repository_owner: identity.owner.clone(),
3608 repository_name: identity.name.clone(),
3609 repo_root: identity.root.display().to_string(),
3610 repository_spool_root: repository_spool_root.display().to_string(),
3611 stats_file: stats_file.display().to_string(),
3612 session_gap_minutes,
3613 branch_count: branches.len() as u64,
3614 total_events: branches.iter().map(|branch| branch.total_events).sum(),
3615 total_commits: branches.iter().map(|branch| branch.total_commits).sum(),
3616 first_event_at,
3617 last_event_at,
3618 session_count: branches.iter().map(|branch| branch.session_count).sum(),
3619 observed_span_seconds,
3620 estimated_coding_seconds: branches
3621 .iter()
3622 .map(|branch| branch.estimated_coding_seconds)
3623 .sum(),
3624 added_lines: branches.iter().map(|branch| branch.added_lines).sum(),
3625 removed_lines: branches.iter().map(|branch| branch.removed_lines).sum(),
3626 branches,
3627 })
3628}
3629
3630fn build_stats_summary(
3631 identity: &RepoIdentity,
3632 layout: &SpoolLayout,
3633 candidates: &[SyncCandidate],
3634 session_gap_minutes: u64,
3635) -> Result<WorktreeWatchStatsSummary, String> {
3636 let mut events = Vec::new();
3637 let mut total_commits = 0u64;
3638 for candidate in candidates {
3639 match &candidate.records {
3640 SyncRecords::Events(records) => events.extend(records.iter().cloned()),
3641 SyncRecords::Commits(records) => total_commits += records.len() as u64,
3642 }
3643 }
3644 events.sort_by_key(|event| event.occurred_at);
3645
3646 let gap_seconds = session_gap_minutes.saturating_mul(60).max(60);
3647 let mut previous_at: Option<DateTime<Utc>> = None;
3648 let mut session_count = 0u64;
3649 let mut total_seconds = 0u64;
3650 let mut total_added = 0u64;
3651 let mut total_removed = 0u64;
3652 let mut by_file: BTreeMap<String, WorktreeWatchFileStats> = BTreeMap::new();
3653 let mut by_filetype: BTreeMap<String, WorktreeWatchBucketStats> = BTreeMap::new();
3654 let mut by_event_kind: BTreeMap<String, WorktreeWatchBucketStats> = BTreeMap::new();
3655
3656 for event in &events {
3657 if starts_new_coding_session(previous_at, event.occurred_at, gap_seconds) {
3658 session_count += 1;
3659 }
3660 let seconds = coding_seconds_for_event(previous_at, event.occurred_at, gap_seconds);
3661 previous_at = Some(event.occurred_at);
3662 let added = event.added_lines.unwrap_or(0);
3663 let removed = event.removed_lines.unwrap_or(0);
3664 let path = stats_event_path(event);
3665 let filetype = filetype_for_path(&path);
3666
3667 total_seconds += seconds;
3668 total_added += added;
3669 total_removed += removed;
3670 update_file_stats(&mut by_file, &path, &filetype, seconds, added, removed);
3671 update_bucket_stats(&mut by_filetype, &filetype, seconds, added, removed);
3672 update_bucket_stats(
3673 &mut by_event_kind,
3674 &event.event_kind,
3675 seconds,
3676 added,
3677 removed,
3678 );
3679 }
3680
3681 Ok(WorktreeWatchStatsSummary {
3682 generated_at: Utc::now(),
3683 repository_owner: identity.owner.clone(),
3684 repository_name: identity.name.clone(),
3685 branch_name: identity.branch.clone(),
3686 repo_root: identity.root.display().to_string(),
3687 spool_root: layout.root.display().to_string(),
3688 session_gap_minutes,
3689 total_events: events.len() as u64,
3690 total_commits,
3691 first_event_at: events.first().map(|event| event.occurred_at),
3692 last_event_at: events.last().map(|event| event.occurred_at),
3693 session_count,
3694 observed_span_seconds: observed_span_seconds(
3695 events.first().map(|event| event.occurred_at),
3696 events.last().map(|event| event.occurred_at),
3697 ),
3698 estimated_coding_seconds: total_seconds,
3699 added_lines: total_added,
3700 removed_lines: total_removed,
3701 by_file: sorted_file_stats(by_file),
3702 by_filetype: sorted_bucket_stats(by_filetype),
3703 by_event_kind: sorted_bucket_stats(by_event_kind),
3704 })
3705}
3706
3707fn coding_seconds_for_event(
3708 previous_at: Option<DateTime<Utc>>,
3709 occurred_at: DateTime<Utc>,
3710 gap_seconds: u64,
3711) -> u64 {
3712 let Some(previous_at) = previous_at else {
3713 return 60;
3714 };
3715 let delta = occurred_at.signed_duration_since(previous_at).num_seconds();
3716 if delta <= 0 {
3717 0
3718 } else {
3719 (delta as u64).min(gap_seconds)
3720 }
3721}
3722
3723fn starts_new_coding_session(
3724 previous_at: Option<DateTime<Utc>>,
3725 occurred_at: DateTime<Utc>,
3726 gap_seconds: u64,
3727) -> bool {
3728 let Some(previous_at) = previous_at else {
3729 return true;
3730 };
3731 let delta = occurred_at.signed_duration_since(previous_at).num_seconds();
3732 delta > gap_seconds as i64
3733}
3734
3735fn observed_span_seconds(
3736 first_event_at: Option<DateTime<Utc>>,
3737 last_event_at: Option<DateTime<Utc>>,
3738) -> u64 {
3739 let (Some(first_event_at), Some(last_event_at)) = (first_event_at, last_event_at) else {
3740 return 0;
3741 };
3742 last_event_at
3743 .signed_duration_since(first_event_at)
3744 .num_seconds()
3745 .max(0) as u64
3746}
3747
3748fn update_file_stats(
3749 by_file: &mut BTreeMap<String, WorktreeWatchFileStats>,
3750 path: &str,
3751 filetype: &str,
3752 seconds: u64,
3753 added: u64,
3754 removed: u64,
3755) {
3756 let stats = by_file
3757 .entry(path.to_string())
3758 .or_insert_with(|| WorktreeWatchFileStats {
3759 path: path.to_string(),
3760 filetype: filetype.to_string(),
3761 ..WorktreeWatchFileStats::default()
3762 });
3763 stats.event_count += 1;
3764 stats.estimated_coding_seconds += seconds;
3765 stats.added_lines += added;
3766 stats.removed_lines += removed;
3767}
3768
3769fn update_bucket_stats(
3770 buckets: &mut BTreeMap<String, WorktreeWatchBucketStats>,
3771 name: &str,
3772 seconds: u64,
3773 added: u64,
3774 removed: u64,
3775) {
3776 let stats = buckets
3777 .entry(name.to_string())
3778 .or_insert_with(|| WorktreeWatchBucketStats {
3779 name: name.to_string(),
3780 ..WorktreeWatchBucketStats::default()
3781 });
3782 stats.event_count += 1;
3783 stats.estimated_coding_seconds += seconds;
3784 stats.added_lines += added;
3785 stats.removed_lines += removed;
3786}
3787
3788fn sorted_file_stats(
3789 by_file: BTreeMap<String, WorktreeWatchFileStats>,
3790) -> Vec<WorktreeWatchFileStats> {
3791 let mut stats = by_file.into_values().collect::<Vec<_>>();
3792 stats.sort_by(|left, right| {
3793 right
3794 .estimated_coding_seconds
3795 .cmp(&left.estimated_coding_seconds)
3796 .then_with(|| right.event_count.cmp(&left.event_count))
3797 .then_with(|| left.path.cmp(&right.path))
3798 });
3799 stats
3800}
3801
3802fn sorted_bucket_stats(
3803 buckets: BTreeMap<String, WorktreeWatchBucketStats>,
3804) -> Vec<WorktreeWatchBucketStats> {
3805 let mut stats = buckets.into_values().collect::<Vec<_>>();
3806 stats.sort_by(|left, right| {
3807 right
3808 .estimated_coding_seconds
3809 .cmp(&left.estimated_coding_seconds)
3810 .then_with(|| right.event_count.cmp(&left.event_count))
3811 .then_with(|| left.name.cmp(&right.name))
3812 });
3813 stats
3814}
3815
3816fn stats_event_path(event: &WorktreeMutationEvent) -> String {
3817 event
3818 .new_path
3819 .as_deref()
3820 .or(event.primary_path.as_deref())
3821 .or_else(|| event.paths.first().map(String::as_str))
3822 .unwrap_or("(no path)")
3823 .to_string()
3824}
3825
3826fn filetype_for_path(path: &str) -> String {
3827 Path::new(path)
3828 .extension()
3829 .and_then(|value| value.to_str())
3830 .map(|value| value.to_ascii_lowercase())
3831 .filter(|value| !value.is_empty())
3832 .unwrap_or_else(|| "(none)".to_string())
3833}
3834
3835fn format_duration(seconds: u64) -> String {
3836 let hours = seconds / 3600;
3837 let minutes = (seconds % 3600) / 60;
3838 let remaining_seconds = seconds % 60;
3839 if hours > 0 {
3840 format!("{hours}h {minutes}m")
3841 } else if minutes > 0 {
3842 format!("{minutes}m {remaining_seconds}s")
3843 } else {
3844 format!("{remaining_seconds}s")
3845 }
3846}
3847
3848async fn watch_foreground(
3849 identity: RepoIdentity,
3850 layout: SpoolLayout,
3851 sync_interval_seconds: u64,
3852) -> Result<(), String> {
3853 if let Ok(executable) = std::env::current_exe() {
3855 let _ = write_watcher_state(&identity, &layout, std::process::id(), &executable);
3856 }
3857
3858 let api_target = WorktreeWatchTargetOptions {
3861 repo: Some(identity.root.clone()),
3862 parent: None,
3863 repos: Vec::new(),
3864 };
3865 if let Some(port) = crate::commands::worktree_watch_api::spawn_worktree_watch_api_in_background(
3866 api_target,
3867 sync_interval_seconds,
3868 None,
3869 ) {
3870 if !is_background_child() {
3871 crate::commands::worktree_watch_api::print_api_listen_hint(port);
3872 } else {
3873 tracing::info!(
3874 "worktree-watch local API listening on http://127.0.0.1:{port}"
3875 );
3876 }
3877 }
3878
3879 let watch_cfg = resolve_worktree_watch_config();
3880 let (tx, rx) = mpsc::channel();
3881 let mut watcher = create_path_watcher(&identity.root, tx)?;
3882 watcher
3883 .watch(&identity.root, RecursiveMode::Recursive)
3884 .map_err(|error| {
3885 format!(
3886 "Failed to watch repository root {}: {error}",
3887 identity.root.display()
3888 )
3889 })?;
3890 log_watcher_backend(&identity.root, "repository");
3891
3892 let mut last_head = identity.head_sha.clone();
3893 let mut last_activity = Instant::now();
3894 let mut last_commit_check = Instant::now();
3895 let mut last_sync = Instant::now();
3896 let mut last_rank_persist = Instant::now();
3897 let mut last_housekeeping = Instant::now();
3898 let mut activity_score = seed_activity_score(&watch_cfg, &identity.root);
3899 let mut fs_watched = true;
3900 let mut event_dedupe = RecentEventDedupe {
3901 fingerprints: load_event_dedupe_fingerprints(&layout.event_dedupe_file)?,
3902 };
3903 let retention =
3904 Duration::from_secs(watch_cfg.spool_retention_days().saturating_mul(86_400));
3905 let _ = prune_spool_layout(&layout, retention);
3906
3907 loop {
3908 let idle_secs = last_activity.elapsed().as_secs();
3909 let tier = tier_from_idle(idle_secs, &watch_cfg);
3910 let timeout = event_loop_timeout(tier);
3911
3912 if tier == RepoWatchTier::Cold && fs_watched {
3914 let _ = watcher.unwatch(&identity.root);
3915 fs_watched = false;
3916 tracing::info!(
3917 "worktree-watch: unwatched idle repo {} (releases file locks)",
3918 identity.root.display()
3919 );
3920 } else if tier != RepoWatchTier::Cold && !fs_watched {
3921 if identity.root.is_dir() {
3922 if watcher
3923 .watch(&identity.root, RecursiveMode::Recursive)
3924 .is_ok()
3925 {
3926 fs_watched = true;
3927 tracing::info!(
3928 "worktree-watch: re-watched active repo {}",
3929 identity.root.display()
3930 );
3931 }
3932 }
3933 }
3934
3935 match rx.recv_timeout(timeout) {
3936 Ok(first) => {
3937 for item in drain_notify_batch(&rx, first) {
3938 match item {
3939 Ok(event) => {
3940 if let Some(mutation) = mutation_event_from_notify(&identity, event) {
3941 if append_unique_mutation_event(
3942 &layout,
3943 &mutation,
3944 &mut event_dedupe,
3945 )? {
3946 last_activity = Instant::now();
3947 activity_score = bump_activity_score(activity_score);
3948 }
3949 }
3950 }
3951 Err(error) => {
3952 eprintln!("worktree watcher error: {error}");
3953 }
3954 }
3955 }
3956 }
3957 Err(mpsc::RecvTimeoutError::Timeout) => {}
3958 Err(mpsc::RecvTimeoutError::Disconnected) => {
3959 return Err("Filesystem watcher disconnected.".to_string());
3960 }
3961 }
3962
3963 flush_jsonl_write_buffers();
3965
3966 let commit_every = commit_check_interval(tier, &watch_cfg);
3967 if last_commit_check.elapsed() >= commit_every {
3968 let prev = last_head.clone();
3969 match record_commit_snapshot(&identity, &layout, last_head.as_deref()) {
3970 Ok(head) => {
3971 if head.as_deref() != prev.as_deref() {
3972 last_activity = Instant::now();
3973 activity_score = bump_activity_score(activity_score);
3974 }
3975 last_head = head;
3976 }
3977 Err(error) => {
3978 if !identity.root.is_dir() {
3980 eprintln!(
3981 "worktree-watch: repo {} vanished; exiting watch loop",
3982 identity.root.display()
3983 );
3984 flush_jsonl_write_buffers();
3985 return Ok(());
3986 }
3987 eprintln!("worktree commit check failed: {error}");
3988 }
3989 }
3990 last_commit_check = Instant::now();
3991 }
3992
3993 if sync_interval_seconds > 0
3994 && last_sync.elapsed() >= Duration::from_secs(sync_interval_seconds)
3995 {
3996 flush_jsonl_write_buffers();
3998 if let Err(error) = sync_spool_for_identity(&identity, &layout).await {
3999 eprintln!("worktree mutation sync failed: {error}");
4000 }
4001 last_sync = Instant::now();
4002 }
4003
4004 if last_rank_persist.elapsed() >= Duration::from_secs(watch_cfg.rank_persist_seconds()) {
4005 persist_single_repo_rank(&identity, activity_score, last_activity, tier);
4006 last_rank_persist = Instant::now();
4007 }
4008
4009 if last_housekeeping.elapsed()
4010 >= Duration::from_secs(SPOOL_HOUSEKEEPING_INTERVAL_SECONDS)
4011 {
4012 let _ = prune_spool_layout(&layout, retention);
4013 last_housekeeping = Instant::now();
4014 }
4015 }
4016}
4017
4018async fn watch_parent_foreground(
4019 parent_root: PathBuf,
4020 identities: Vec<RepoIdentity>,
4021 sync_interval_seconds: u64,
4022) -> Result<(), String> {
4023 if let Ok(layout) = prepare_parent_spool_layout(&parent_root) {
4026 if let Ok(executable) = std::env::current_exe() {
4027 let _ = write_parent_watcher_state(
4028 &parent_root,
4029 &layout,
4030 std::process::id(),
4031 &executable,
4032 );
4033 }
4034 }
4035
4036 let api_target = WorktreeWatchTargetOptions {
4037 repo: None,
4038 parent: Some(parent_root.clone()),
4039 repos: Vec::new(),
4040 };
4041 if let Some(port) = crate::commands::worktree_watch_api::spawn_worktree_watch_api_in_background(
4042 api_target,
4043 sync_interval_seconds,
4044 None,
4045 ) {
4046 if !is_background_child() {
4047 crate::commands::worktree_watch_api::print_api_listen_hint(port);
4048 } else {
4049 tracing::info!(
4050 "worktree-watch local API listening on http://127.0.0.1:{port}"
4051 );
4052 }
4053 }
4054
4055 let watch_cfg = resolve_worktree_watch_config();
4056 let (tx, rx) = mpsc::channel();
4060 let mut watcher = create_path_watcher(&parent_root, tx)?;
4061 log_watcher_backend(&parent_root, "parent-per-repo");
4062
4063 let mut repos = prepare_watched_repos(identities, &watch_cfg)?;
4064 apply_initial_repo_ranks(&mut repos, &watch_cfg);
4065 enforce_hot_cap(&mut repos, &watch_cfg);
4066 apply_fs_watch_budget(&mut watcher, &mut repos, &watch_cfg);
4067 let initial_fs = repos.iter().filter(|r| r.fs_watched).count();
4068 let retention = Duration::from_secs(watch_cfg.spool_retention_days().saturating_mul(86_400));
4069 for repo in &repos {
4070 let _ = prune_spool_layout(&repo.layout, retention);
4071 }
4072
4073 let mut last_sync = Instant::now();
4074 let mut last_rescan = Instant::now();
4075 let mut last_rank_persist = Instant::now();
4076 let mut last_tier_pass = Instant::now();
4077 let mut last_housekeeping = Instant::now();
4078
4079 eprintln!(
4080 "worktree-watch parent: {} repo(s) under {} ({} FS watch(es), max {}; idle→cold unwatch)",
4081 repos.len(),
4082 parent_root.display(),
4083 initial_fs,
4084 watch_cfg.max_fs_watches(),
4085 );
4086
4087 loop {
4088 let any_hot = repos.iter().any(|r| r.tier == RepoWatchTier::Hot);
4089 let timeout = if any_hot {
4090 Duration::from_millis(1000)
4091 } else if repos.iter().any(|r| r.tier == RepoWatchTier::Warm) {
4092 Duration::from_millis(2000)
4093 } else {
4094 Duration::from_millis(5000)
4095 };
4096
4097 match rx.recv_timeout(timeout) {
4098 Ok(first) => {
4099 for item in drain_notify_batch(&rx, first) {
4100 match item {
4101 Ok(event) => {
4102 for (repo_index, routed_event) in route_parent_event(&repos, &event) {
4103 let repo = &mut repos[repo_index];
4104 if let Some(mutation) =
4105 mutation_event_from_notify(&repo.identity, routed_event)
4106 {
4107 if append_unique_mutation_event(
4108 &repo.layout,
4109 &mutation,
4110 &mut repo.event_dedupe,
4111 )? {
4112 note_repo_activity(repo);
4113 }
4114 }
4115 }
4116 }
4117 Err(error) => {
4118 eprintln!("parent worktree watcher error: {error}");
4119 }
4120 }
4121 }
4122 }
4123 Err(mpsc::RecvTimeoutError::Timeout) => {}
4124 Err(mpsc::RecvTimeoutError::Disconnected) => {
4125 return Err("Parent filesystem watcher disconnected.".to_string());
4126 }
4127 }
4128
4129 let now = Instant::now();
4132 let mut due: Vec<usize> = repos
4133 .iter()
4134 .enumerate()
4135 .filter(|(_, r)| now >= r.next_commit_check && r.identity.root.is_dir())
4136 .map(|(i, _)| i)
4137 .collect();
4138 due.sort_by(|&a, &b| {
4139 let tier_rank = |t: RepoWatchTier| match t {
4140 RepoWatchTier::Hot => 0u8,
4141 RepoWatchTier::Warm => 1,
4142 RepoWatchTier::Cold => 2,
4143 };
4144 tier_rank(repos[a].tier)
4145 .cmp(&tier_rank(repos[b].tier))
4146 .then_with(|| {
4147 repos[b]
4148 .activity_score
4149 .partial_cmp(&repos[a].activity_score)
4150 .unwrap_or(std::cmp::Ordering::Equal)
4151 })
4152 });
4153 let budget = watch_cfg.max_commit_checks_per_tick();
4154 for idx in due.into_iter().take(budget) {
4155 let repo = &mut repos[idx];
4156 let interval = if repo.tier == RepoWatchTier::Warm && !repo.fs_watched {
4158 Duration::from_secs(watch_cfg.cold_commit_check_seconds())
4159 } else {
4160 commit_check_interval(repo.tier, &watch_cfg)
4161 };
4162 let prev = repo.last_head.clone();
4163 match record_commit_snapshot(
4164 &repo.identity,
4165 &repo.layout,
4166 repo.last_head.as_deref(),
4167 ) {
4168 Ok(head) => {
4169 if head.as_deref() != prev.as_deref() {
4170 note_repo_activity(repo);
4171 }
4172 repo.last_head = head;
4173 }
4174 Err(error) => {
4175 tracing::debug!(
4176 "commit check {}: {error}",
4177 repo.identity.root.display()
4178 );
4179 }
4180 }
4181 repo.next_commit_check = Instant::now() + interval;
4182 }
4183
4184 flush_jsonl_write_buffers();
4186
4187 if last_tier_pass.elapsed() >= Duration::from_secs(15) {
4189 refresh_repo_tiers(&mut watcher, &mut repos, &watch_cfg);
4190 last_tier_pass = Instant::now();
4191 }
4192
4193 if last_rescan.elapsed() >= Duration::from_secs(watch_cfg.parent_rescan_seconds()) {
4194 if let Err(error) =
4195 rescan_parent_repos(&parent_root, &mut watcher, &mut repos, &watch_cfg)
4196 {
4197 eprintln!("parent worktree rescan failed: {error}");
4198 }
4199 probe_cold_repo_activity(&mut watcher, &mut repos, &watch_cfg);
4201 last_rescan = Instant::now();
4202 }
4203
4204 if sync_interval_seconds > 0
4205 && last_sync.elapsed() >= Duration::from_secs(sync_interval_seconds)
4206 {
4207 flush_jsonl_write_buffers();
4209 let mut order: Vec<usize> = (0..repos.len()).collect();
4211 order.sort_by(|&a, &b| {
4212 repos[b]
4213 .activity_score
4214 .partial_cmp(&repos[a].activity_score)
4215 .unwrap_or(std::cmp::Ordering::Equal)
4216 });
4217 for idx in order {
4218 let repo = &repos[idx];
4219 if repo.tier == RepoWatchTier::Cold {
4220 }
4223 if let Err(error) = sync_spool_for_identity(&repo.identity, &repo.layout).await {
4224 eprintln!(
4225 "worktree mutation sync failed for {}: {error}",
4226 repo.identity.root.display()
4227 );
4228 }
4229 }
4230 last_sync = Instant::now();
4231 }
4232
4233 if last_rank_persist.elapsed() >= Duration::from_secs(watch_cfg.rank_persist_seconds()) {
4234 persist_repo_ranks(&repos);
4235 last_rank_persist = Instant::now();
4236 }
4237
4238 if last_housekeeping.elapsed()
4239 >= Duration::from_secs(SPOOL_HOUSEKEEPING_INTERVAL_SECONDS)
4240 {
4241 let retention =
4242 Duration::from_secs(watch_cfg.spool_retention_days().saturating_mul(86_400));
4243 let mut order: Vec<usize> = (0..repos.len()).collect();
4245 order.sort_by_key(|&i| match repos[i].tier {
4246 RepoWatchTier::Hot => 0u8,
4247 RepoWatchTier::Warm => 1,
4248 RepoWatchTier::Cold => 2,
4249 });
4250 for &idx in order.iter().take(16) {
4251 let _ = prune_spool_layout(&repos[idx].layout, retention);
4252 }
4253 last_housekeeping = Instant::now();
4254 }
4255 }
4256}
4257
4258fn prune_spool_layout(layout: &SpoolLayout, retention: Duration) -> Result<u64, String> {
4263 let mut removed = 0u64;
4264 removed += prune_old_files_in_dir(&layout.event_dedupe_dir, retention, |name| {
4265 name.ends_with(".seen")
4266 })?;
4267 removed += prune_old_files_in_dir(&layout.commit_dedupe_dir, retention, |name| {
4268 name.ends_with(".seen")
4269 })?;
4270 removed += prune_old_files_in_dir(&layout.root, retention, |name| {
4271 name.ends_with(".jsonl") && name.contains(".synced.")
4272 })?;
4273 removed += rotate_oversized_jsonl(&layout.event_dedupe_file, MAX_EVENT_DEDUPE_JSONL_BYTES)?;
4274 Ok(removed)
4275}
4276
4277fn prune_old_files_in_dir(
4278 dir: &Path,
4279 retention: Duration,
4280 mut name_ok: impl FnMut(&str) -> bool,
4281) -> Result<u64, String> {
4282 if !dir.is_dir() {
4283 return Ok(0);
4284 }
4285 let cutoff = std::time::SystemTime::now()
4286 .checked_sub(retention)
4287 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
4288 let mut removed = 0u64;
4289 let entries = match fs::read_dir(dir) {
4290 Ok(entries) => entries,
4291 Err(_) => return Ok(0),
4292 };
4293 for entry in entries.flatten() {
4294 let path = entry.path();
4295 if !path.is_file() {
4296 continue;
4297 }
4298 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
4299 continue;
4300 };
4301 if !name_ok(name) {
4302 continue;
4303 }
4304 let Ok(meta) = entry.metadata() else {
4305 continue;
4306 };
4307 let Ok(modified) = meta.modified() else {
4308 continue;
4309 };
4310 if modified < cutoff {
4311 if fs::remove_file(&path).is_ok() {
4312 removed += 1;
4313 }
4314 }
4315 }
4316 Ok(removed)
4317}
4318
4319fn rotate_oversized_jsonl(path: &Path, max_bytes: u64) -> Result<u64, String> {
4320 let Ok(meta) = fs::metadata(path) else {
4321 return Ok(0);
4322 };
4323 if meta.len() <= max_bytes {
4324 return Ok(0);
4325 }
4326 let backup = path.with_extension("jsonl.pruned");
4328 let _ = fs::remove_file(&backup);
4329 match fs::rename(path, &backup) {
4330 Ok(()) => {
4331 let _ = fs::remove_file(&backup);
4332 Ok(1)
4333 }
4334 Err(_) => {
4335 match OpenOptions::new().write(true).truncate(true).open(path) {
4337 Ok(_) => Ok(1),
4338 Err(error) => Err(format!(
4339 "Failed to rotate oversized dedupe log {}: {error}",
4340 path.display()
4341 )),
4342 }
4343 }
4344 }
4345}
4346
4347fn note_repo_activity(repo: &mut WatchedRepo) {
4348 repo.last_activity = Instant::now();
4349 repo.activity_score = bump_activity_score(repo.activity_score);
4350 if repo.tier != RepoWatchTier::Hot {
4351 repo.tier = RepoWatchTier::Hot;
4352 repo.next_commit_check = Instant::now();
4353 }
4354}
4355
4356fn tier_from_idle(idle_secs: u64, cfg: &WorktreeWatchConfig) -> RepoWatchTier {
4357 if idle_secs >= cfg.cold_after_seconds() {
4358 RepoWatchTier::Cold
4359 } else if idle_secs >= cfg.idle_after_seconds() {
4360 RepoWatchTier::Warm
4361 } else {
4362 RepoWatchTier::Hot
4363 }
4364}
4365
4366fn commit_check_interval(tier: RepoWatchTier, cfg: &WorktreeWatchConfig) -> Duration {
4367 let secs = match tier {
4368 RepoWatchTier::Hot => cfg.hot_commit_check_seconds(),
4369 RepoWatchTier::Warm => cfg.warm_commit_check_seconds(),
4370 RepoWatchTier::Cold => cfg.cold_commit_check_seconds(),
4371 };
4372 Duration::from_secs(secs)
4373}
4374
4375fn event_loop_timeout(tier: RepoWatchTier) -> Duration {
4376 match tier {
4377 RepoWatchTier::Hot => Duration::from_millis(1000),
4378 RepoWatchTier::Warm => Duration::from_millis(2500),
4379 RepoWatchTier::Cold => Duration::from_millis(5000),
4380 }
4381}
4382
4383fn bump_activity_score(score: f64) -> f64 {
4384 (score * 0.98 + 1.0).min(10_000.0)
4386}
4387
4388fn seed_activity_score(cfg: &WorktreeWatchConfig, root: &Path) -> f64 {
4389 let key = path_identity_key(root);
4390 cfg.repo_ranks
4391 .iter()
4392 .find(|r| path_identity_key(Path::new(&r.root)) == key)
4393 .map(|r| r.score)
4394 .unwrap_or(0.0)
4395}
4396
4397fn apply_initial_repo_ranks(repos: &mut [WatchedRepo], cfg: &WorktreeWatchConfig) {
4398 for repo in repos.iter_mut() {
4399 repo.activity_score = seed_activity_score(cfg, &repo.identity.root);
4400 if let Some(rank) = cfg.repo_ranks.iter().find(|r| {
4402 path_identity_key(Path::new(&r.root)) == path_identity_key(&repo.identity.root)
4403 }) {
4404 if let Some(ts) = rank.last_event_at.as_deref() {
4405 if let Ok(dt) = DateTime::parse_from_rfc3339(ts) {
4406 let age = (Utc::now() - dt.with_timezone(&Utc))
4407 .num_seconds()
4408 .max(0) as u64;
4409 repo.tier = tier_from_idle(age, cfg);
4410 if age < 24 * 3600 {
4412 repo.last_activity =
4413 Instant::now() - Duration::from_secs(age.min(23 * 3600));
4414 } else {
4415 repo.last_activity =
4416 Instant::now() - Duration::from_secs(cfg.cold_after_seconds());
4417 }
4418 }
4419 }
4420 }
4421 }
4422}
4423
4424fn enforce_hot_cap(repos: &mut [WatchedRepo], cfg: &WorktreeWatchConfig) {
4425 let max_hot = cfg.max_hot_repos();
4426 let mut hot_idx: Vec<usize> = repos
4427 .iter()
4428 .enumerate()
4429 .filter(|(_, r)| r.tier == RepoWatchTier::Hot)
4430 .map(|(i, _)| i)
4431 .collect();
4432 if hot_idx.len() <= max_hot {
4433 return;
4434 }
4435 hot_idx.sort_by(|&a, &b| {
4436 repos[b]
4437 .activity_score
4438 .partial_cmp(&repos[a].activity_score)
4439 .unwrap_or(std::cmp::Ordering::Equal)
4440 });
4441 for &idx in hot_idx.iter().skip(max_hot) {
4442 repos[idx].tier = RepoWatchTier::Warm;
4443 }
4444}
4445
4446fn refresh_repo_tiers(
4447 watcher: &mut PathWatcher,
4448 repos: &mut [WatchedRepo],
4449 cfg: &WorktreeWatchConfig,
4450) {
4451 for repo in repos.iter_mut() {
4452 let idle = repo.last_activity.elapsed().as_secs();
4454 if idle > 60 {
4455 repo.activity_score *= 0.995;
4456 }
4457 let desired = tier_from_idle(idle, cfg);
4458 if desired != repo.tier {
4459 tracing::debug!(
4460 "worktree-watch tier {} → {} for {}",
4461 repo.tier.as_str(),
4462 desired.as_str(),
4463 repo.identity.root.display()
4464 );
4465 repo.tier = desired;
4466 repo.next_commit_check = Instant::now() + commit_check_interval(desired, cfg);
4467 }
4468 }
4469 enforce_hot_cap(repos, cfg);
4470 apply_fs_watch_budget(watcher, repos, cfg);
4471}
4472
4473fn apply_fs_watch_budget(
4475 watcher: &mut PathWatcher,
4476 repos: &mut [WatchedRepo],
4477 cfg: &WorktreeWatchConfig,
4478) {
4479 let max = cfg.max_fs_watches();
4480 let want = fs_watch_want_mask(repos, max);
4481 for (index, repo) in repos.iter_mut().enumerate() {
4482 let _ = set_repo_fs_watch(watcher, repo, want[index]);
4483 }
4484}
4485
4486fn fs_watch_want_mask(repos: &[WatchedRepo], max: usize) -> Vec<bool> {
4491 let mut eligible: Vec<usize> = repos
4492 .iter()
4493 .enumerate()
4494 .filter(|(_, repo)| repo.tier != RepoWatchTier::Cold && repo.identity.root.is_dir())
4495 .map(|(index, _)| index)
4496 .collect();
4497 eligible.sort_by(|&a, &b| {
4498 let hot_a = repos[a].tier == RepoWatchTier::Hot;
4499 let hot_b = repos[b].tier == RepoWatchTier::Hot;
4500 hot_b
4501 .cmp(&hot_a)
4502 .then_with(|| {
4503 repos[b]
4504 .activity_score
4505 .partial_cmp(&repos[a].activity_score)
4506 .unwrap_or(std::cmp::Ordering::Equal)
4507 })
4508 .then_with(|| {
4509 path_identity_key(&repos[a].identity.root)
4510 .cmp(&path_identity_key(&repos[b].identity.root))
4511 })
4512 });
4513
4514 let mut want = vec![false; repos.len()];
4515 for &index in eligible.iter().take(max) {
4516 want[index] = true;
4517 }
4518 want
4519}
4520
4521fn set_repo_fs_watch(
4522 watcher: &mut PathWatcher,
4523 repo: &mut WatchedRepo,
4524 want_watch: bool,
4525) -> Result<(), String> {
4526 if want_watch && !repo.fs_watched {
4527 match watcher.watch(&repo.identity.root, RecursiveMode::Recursive) {
4528 Ok(()) => {
4529 repo.fs_watched = true;
4530 tracing::info!(
4531 "worktree-watch: watching {} ({})",
4532 repo.identity.root.display(),
4533 repo.tier.as_str()
4534 );
4535 }
4536 Err(error) => {
4537 tracing::warn!(
4539 "worktree-watch: watch {} failed: {error}",
4540 repo.identity.root.display()
4541 );
4542 repo.tier = RepoWatchTier::Cold;
4543 repo.fs_watched = false;
4544 }
4545 }
4546 } else if !want_watch && repo.fs_watched {
4547 let _ = watcher.unwatch(&repo.identity.root);
4548 repo.fs_watched = false;
4549 tracing::info!(
4550 "worktree-watch: unwatched {} (budget/cold — directory locks released)",
4551 repo.identity.root.display()
4552 );
4553 }
4554 Ok(())
4555}
4556
4557fn probe_cold_repo_activity(
4559 watcher: &mut PathWatcher,
4560 repos: &mut [WatchedRepo],
4561 cfg: &WorktreeWatchConfig,
4562) {
4563 for repo in repos.iter_mut() {
4564 if repo.tier != RepoWatchTier::Cold {
4565 continue;
4566 }
4567 if !repo.identity.root.is_dir() {
4568 continue;
4569 }
4570 if cold_repo_looks_active(repo) {
4571 note_repo_activity(repo);
4572 tracing::info!(
4573 "worktree-watch: reheated cold repo {}",
4574 repo.identity.root.display()
4575 );
4576 }
4577 }
4578 enforce_hot_cap(repos, cfg);
4579 apply_fs_watch_budget(watcher, repos, cfg);
4580}
4581
4582fn cold_repo_looks_active(repo: &WatchedRepo) -> bool {
4583 use std::time::SystemTime;
4584 let root = &repo.identity.root;
4585 let last_activity_wall = SystemTime::now()
4587 .checked_sub(repo.last_activity.elapsed())
4588 .unwrap_or(SystemTime::UNIX_EPOCH);
4589 for rel in [".git/index", ".git/HEAD", ".git/logs/HEAD"] {
4591 let path = root.join(rel);
4592 if let Ok(meta) = fs::metadata(&path) {
4593 if let Ok(modified) = meta.modified() {
4594 if modified > last_activity_wall {
4595 return true;
4596 }
4597 }
4598 }
4599 }
4600 match current_head(root) {
4602 Ok(Some(head)) if Some(head.as_str()) != repo.last_head.as_deref() => true,
4603 _ => false,
4604 }
4605}
4606
4607fn rescan_parent_repos(
4608 parent_root: &Path,
4609 watcher: &mut PathWatcher,
4610 repos: &mut Vec<WatchedRepo>,
4611 cfg: &WorktreeWatchConfig,
4612) -> Result<(), String> {
4613 let discovered = discover_repo_identities_under(parent_root)?;
4614 let live_keys: BTreeSet<String> = discovered
4615 .iter()
4616 .map(|id| path_identity_key(&id.root))
4617 .collect();
4618 let existing_keys: BTreeSet<String> = repos
4619 .iter()
4620 .map(|r| path_identity_key(&r.identity.root))
4621 .collect();
4622
4623 for identity in discovered {
4624 let key = path_identity_key(&identity.root);
4625 if existing_keys.contains(&key) {
4626 continue;
4627 }
4628 let prepared = prepare_watched_repos(vec![identity], cfg)?;
4629 for mut repo in prepared {
4630 tracing::info!(
4631 "worktree-watch parent: added repo {} ({})",
4632 repo.identity.root.display(),
4633 repo.tier.as_str()
4634 );
4635 repo.tier = RepoWatchTier::Cold;
4638 repo.fs_watched = false;
4639 repo.last_activity = Instant::now() - Duration::from_secs(cfg.cold_after_seconds());
4640 repos.push(repo);
4641 }
4642 }
4643
4644 let mut keep = Vec::with_capacity(repos.len());
4646 for mut repo in repos.drain(..) {
4647 let key = path_identity_key(&repo.identity.root);
4648 if live_keys.contains(&key) && repo.identity.root.is_dir() {
4649 keep.push(repo);
4650 } else {
4651 if repo.fs_watched {
4652 let _ = watcher.unwatch(&repo.identity.root);
4653 repo.fs_watched = false;
4654 }
4655 tracing::info!(
4656 "worktree-watch parent: removed vanished repo {}",
4657 repo.identity.root.display()
4658 );
4659 }
4660 }
4661 *repos = keep;
4662 repos.sort_by_key(|repo| std::cmp::Reverse(repo.identity.root.components().count()));
4663 enforce_hot_cap(repos, cfg);
4664 apply_fs_watch_budget(watcher, repos, cfg);
4665 Ok(())
4666}
4667
4668fn persist_repo_ranks(repos: &[WatchedRepo]) {
4669 let mut ranks: Vec<WorktreeWatchRepoRank> = repos
4670 .iter()
4671 .map(|repo| WorktreeWatchRepoRank {
4672 root: repo.identity.root.display().to_string(),
4673 owner: Some(repo.identity.owner.clone()),
4674 name: Some(repo.identity.name.clone()),
4675 score: (repo.activity_score * 1000.0).round() / 1000.0,
4676 last_event_at: Some(
4677 (Utc::now()
4678 - chrono::Duration::from_std(repo.last_activity.elapsed())
4679 .unwrap_or_else(|_| chrono::Duration::seconds(0)))
4680 .to_rfc3339(),
4681 ),
4682 rank: None,
4683 tier: Some(repo.tier.as_str().to_string()),
4684 })
4685 .collect();
4686 ranks.sort_by(|a, b| {
4687 b.score
4688 .partial_cmp(&a.score)
4689 .unwrap_or(std::cmp::Ordering::Equal)
4690 });
4691 for (i, rank) in ranks.iter_mut().enumerate() {
4692 rank.rank = Some((i + 1) as u32);
4693 }
4694 ranks.truncate(200);
4696
4697 let mut config = match SshConfig::load() {
4698 Ok(c) => c,
4699 Err(error) => {
4700 tracing::warn!("worktree-watch: could not load config to persist ranks: {error}");
4701 return;
4702 }
4703 };
4704 let mut ww = config.worktree_watch.unwrap_or_default();
4705 ww.repo_ranks = ranks;
4706 config.worktree_watch = Some(ww);
4707 if let Err(error) = config.save() {
4708 tracing::warn!("worktree-watch: could not persist repo ranks: {error}");
4709 }
4710}
4711
4712fn persist_single_repo_rank(
4713 identity: &RepoIdentity,
4714 score: f64,
4715 last_activity: Instant,
4716 tier: RepoWatchTier,
4717) {
4718 let mut config = match SshConfig::load() {
4719 Ok(c) => c,
4720 Err(_) => return,
4721 };
4722 let mut ww = config.worktree_watch.unwrap_or_default();
4723 let key = path_identity_key(&identity.root);
4724 let entry = WorktreeWatchRepoRank {
4725 root: identity.root.display().to_string(),
4726 owner: Some(identity.owner.clone()),
4727 name: Some(identity.name.clone()),
4728 score: (score * 1000.0).round() / 1000.0,
4729 last_event_at: Some(
4730 (Utc::now()
4731 - chrono::Duration::from_std(last_activity.elapsed())
4732 .unwrap_or_else(|_| chrono::Duration::seconds(0)))
4733 .to_rfc3339(),
4734 ),
4735 rank: None,
4736 tier: Some(tier.as_str().to_string()),
4737 };
4738 if let Some(existing) = ww
4739 .repo_ranks
4740 .iter_mut()
4741 .find(|r| path_identity_key(Path::new(&r.root)) == key)
4742 {
4743 *existing = entry;
4744 } else {
4745 ww.repo_ranks.push(entry);
4746 }
4747 ww.repo_ranks.sort_by(|a, b| {
4748 b.score
4749 .partial_cmp(&a.score)
4750 .unwrap_or(std::cmp::Ordering::Equal)
4751 });
4752 for (i, rank) in ww.repo_ranks.iter_mut().enumerate() {
4753 rank.rank = Some((i + 1) as u32);
4754 }
4755 ww.repo_ranks.truncate(200);
4756 config.worktree_watch = Some(ww);
4757 let _ = config.save();
4758}
4759
4760enum PathWatcher {
4763 Recommended(RecommendedWatcher),
4764 Poll(notify::PollWatcher),
4765}
4766
4767impl PathWatcher {
4768 fn watch(&mut self, path: &Path, mode: RecursiveMode) -> notify::Result<()> {
4769 match self {
4770 Self::Recommended(watcher) => watcher.watch(path, mode),
4771 Self::Poll(watcher) => watcher.watch(path, mode),
4772 }
4773 }
4774
4775 fn unwatch(&mut self, path: &Path) -> notify::Result<()> {
4776 match self {
4777 Self::Recommended(watcher) => watcher.unwatch(path),
4778 Self::Poll(watcher) => watcher.unwatch(path),
4779 }
4780 }
4781}
4782
4783fn create_path_watcher(
4784 path: &Path,
4785 tx: mpsc::Sender<notify::Result<Event>>,
4786) -> Result<PathWatcher, String> {
4787 let config = Config::default();
4788 if should_use_poll_watcher(path) {
4789 let poll_config = config.with_poll_interval(Duration::from_secs(5));
4791 notify::PollWatcher::new(
4792 move |result| {
4793 let _ = tx.send(result);
4794 },
4795 poll_config,
4796 )
4797 .map(PathWatcher::Poll)
4798 .map_err(|error| format!("Failed to create poll filesystem watcher: {error}"))
4799 } else {
4800 RecommendedWatcher::new(
4801 move |result| {
4802 let _ = tx.send(result);
4803 },
4804 config,
4805 )
4806 .map(PathWatcher::Recommended)
4807 .map_err(|error| format!("Failed to create filesystem watcher: {error}"))
4808 }
4809}
4810
4811fn should_use_poll_watcher(path: &Path) -> bool {
4812 if !is_wsl_runtime() {
4814 return false;
4815 }
4816 let key = path_identity_key(path).replace('\\', "/");
4817 key.starts_with("/mnt/") || key.contains("/mnt/")
4818}
4819
4820fn log_watcher_backend(path: &Path, label: &str) {
4821 if should_use_poll_watcher(path) {
4822 eprintln!(
4823 "worktree-watch: using poll watcher for {label} {} (WSL mount; inotify is unreliable)",
4824 path.display()
4825 );
4826 } else {
4827 tracing::info!(
4828 "worktree-watch: native watcher for {label} {}",
4829 path.display()
4830 );
4831 }
4832}
4833
4834fn prepare_watched_repos(
4835 identities: Vec<RepoIdentity>,
4836 cfg: &WorktreeWatchConfig,
4837) -> Result<Vec<WatchedRepo>, String> {
4838 let mut repos = Vec::with_capacity(identities.len());
4839 let now = Instant::now();
4840 for identity in identities {
4841 let layout = prepare_spool_layout(&identity)?;
4842 let event_dedupe = RecentEventDedupe {
4843 fingerprints: load_event_dedupe_fingerprints(&layout.event_dedupe_file)?,
4844 };
4845 let score = seed_activity_score(cfg, &identity.root);
4846 let tier = if score >= 5.0 {
4851 RepoWatchTier::Hot
4852 } else if score >= 3.0 {
4853 RepoWatchTier::Warm
4854 } else {
4855 RepoWatchTier::Cold
4856 };
4857 let last_activity = if matches!(tier, RepoWatchTier::Cold) {
4859 now - Duration::from_secs(cfg.cold_after_seconds())
4860 } else if matches!(tier, RepoWatchTier::Warm) {
4861 now - Duration::from_secs(cfg.idle_after_seconds())
4862 } else {
4863 now
4864 };
4865 let stagger_ms = (repos.len() as u64).saturating_mul(400) % 45_000;
4867 repos.push(WatchedRepo {
4868 last_head: identity.head_sha.clone(),
4869 identity,
4870 layout,
4871 event_dedupe,
4872 last_activity,
4873 activity_score: score,
4874 tier,
4875 fs_watched: false,
4876 next_commit_check: now + Duration::from_millis(stagger_ms),
4877 });
4878 }
4879 repos.sort_by_key(|repo| std::cmp::Reverse(repo.identity.root.components().count()));
4880 Ok(repos)
4881}
4882
4883fn route_parent_event(repos: &[WatchedRepo], event: &Event) -> Vec<(usize, Event)> {
4884 let mut paths_by_repo: BTreeMap<usize, Vec<PathBuf>> = BTreeMap::new();
4885 for path in &event.paths {
4886 for (index, repo) in repos.iter().enumerate() {
4887 if path_belongs_to_repo(path, &repo.identity.root) {
4888 let paths = paths_by_repo.entry(index).or_default();
4889 if !paths.iter().any(|existing| existing == path) {
4890 paths.push(path.clone());
4891 }
4892 break;
4893 }
4894 }
4895 }
4896
4897 let mut routed = Vec::new();
4898 for (index, paths) in paths_by_repo {
4899 let mut repo_event = event.clone();
4900 repo_event.paths = paths;
4901 routed.push((index, repo_event));
4902 }
4903 routed
4904}
4905
4906fn path_belongs_to_repo(path: &Path, repo_root: &Path) -> bool {
4907 let path_key = path_identity_key(path).replace('\\', "/");
4908 let root_key = path_identity_key(repo_root).replace('\\', "/");
4909 path_key == root_key || path_key.starts_with(&format!("{root_key}/"))
4910}
4911
4912fn mutation_event_from_notify(
4913 identity: &RepoIdentity,
4914 event: Event,
4915) -> Option<WorktreeMutationEvent> {
4916 if event.kind.is_access() || event.kind.is_other() {
4917 return None;
4918 }
4919
4920 let paths: Vec<String> = event
4921 .paths
4922 .iter()
4923 .filter(|path| !is_ignored_path(&identity.root, path))
4924 .filter_map(|path| relative_slash_path(&identity.root, path))
4925 .fold(Vec::new(), |mut paths, path| {
4926 if !paths.iter().any(|existing| existing == &path) {
4927 paths.push(path);
4928 }
4929 paths
4930 });
4931
4932 if paths.is_empty() {
4933 return None;
4934 }
4935
4936 let primary_path = paths.first().cloned();
4937 let (old_path, new_path) = rename_paths(&event.kind, &paths);
4938 let want_line_stats = matches!(
4940 event.kind,
4941 EventKind::Modify(ModifyKind::Data(_)) | EventKind::Modify(ModifyKind::Any)
4942 );
4943 let line_counts = if want_line_stats {
4944 primary_path
4945 .as_deref()
4946 .and_then(|path| git_numstat_for_path(&identity.root, path).ok())
4947 } else {
4948 None
4949 };
4950 let total_lines = if want_line_stats {
4951 primary_path
4952 .as_deref()
4953 .and_then(|path| file_line_count_for_path(&identity.root, path).ok())
4954 } else {
4955 None
4956 };
4957 let code_snapshots = primary_path
4958 .as_deref()
4959 .and_then(|path| fs::read_to_string(identity.root.join(path)).ok())
4960 .map(|source| {
4961 snapshots_for_file(primary_path.as_deref().unwrap_or_default(), &source)
4962 .into_iter()
4963 .take(32)
4964 .collect()
4965 })
4966 .unwrap_or_default();
4967 let event_kind = classify_event_kind(&event.kind);
4968 let is_dir = event
4969 .paths
4970 .first()
4971 .and_then(|path| fs::metadata(path).ok())
4972 .map(|metadata| metadata.is_dir())
4973 .unwrap_or(false);
4974
4975 let occurred_at = Utc::now();
4976 let mut mutation = WorktreeMutationEvent {
4977 id: String::new(),
4978 fingerprint: None,
4979 repo_owner: identity.owner.clone(),
4980 repo_name: identity.name.clone(),
4981 branch_name: identity.branch.clone(),
4982 repo_root: normalize_repo_root_for_payload(&identity.root),
4983 head_sha: identity.head_sha.clone(),
4985 event_kind: event_kind.to_string(),
4986 paths,
4987 primary_path,
4988 old_path,
4989 new_path,
4990 added_lines: line_counts.map(|counts| counts.0),
4991 removed_lines: line_counts.map(|counts| counts.1),
4992 total_lines,
4993 code_snapshots,
4994 file_created: matches!(
4995 event.kind,
4996 EventKind::Create(CreateKind::File | CreateKind::Any)
4997 ) && !is_dir,
4998 file_removed: matches!(
4999 event.kind,
5000 EventKind::Remove(RemoveKind::File | RemoveKind::Any)
5001 ) && !is_dir,
5002 folder_created: matches!(
5003 event.kind,
5004 EventKind::Create(CreateKind::Folder | CreateKind::Any)
5005 ) && is_dir,
5006 folder_removed: matches!(
5007 event.kind,
5008 EventKind::Remove(RemoveKind::Folder | RemoveKind::Any)
5009 ) && is_dir,
5010 renamed_or_moved: is_rename_or_move(&event.kind),
5011 raw_kind: format!("{:?}", event.kind),
5012 occurred_at,
5013 };
5014 let fingerprint = mutation_event_fingerprint(&mutation);
5017 mutation.fingerprint = Some(fingerprint.clone());
5018 mutation.id = stable_event_id(&fingerprint);
5019 Some(mutation)
5020}
5021
5022fn append_unique_mutation_event(
5023 layout: &SpoolLayout,
5024 mutation: &WorktreeMutationEvent,
5025 dedupe: &mut RecentEventDedupe,
5026) -> Result<bool, String> {
5027 let fingerprint = mutation
5028 .fingerprint
5029 .clone()
5030 .unwrap_or_else(|| mutation_event_fingerprint(mutation));
5031 if dedupe.fingerprints.contains(&fingerprint) {
5034 return Ok(false);
5035 }
5036
5037 if !claim_event_fingerprint(layout, &fingerprint)? {
5038 dedupe_remember(dedupe, fingerprint);
5039 return Ok(false);
5040 }
5041
5042 let mut mutation = mutation.clone();
5043 mutation.fingerprint = Some(fingerprint.clone());
5044 if mutation.id.is_empty() {
5045 mutation.id = stable_event_id(&fingerprint);
5046 }
5047 if let Err(error) = append_json_line(&layout.events_file, &mutation) {
5048 release_event_fingerprint_claim(layout, &fingerprint);
5049 return Err(error);
5050 }
5051 dedupe_remember(dedupe, fingerprint);
5055 Ok(true)
5056}
5057
5058fn dedupe_remember(dedupe: &mut RecentEventDedupe, fingerprint: String) {
5059 if dedupe.fingerprints.len() >= MAX_RECENT_EVENT_DEDUPE_IN_MEMORY {
5060 dedupe.fingerprints.clear();
5061 }
5062 dedupe.fingerprints.insert(fingerprint);
5063}
5064
5065fn claim_event_fingerprint(layout: &SpoolLayout, fingerprint: &str) -> Result<bool, String> {
5066 let marker = event_dedupe_marker_path(layout, fingerprint);
5069 if marker.is_file() {
5070 return Ok(false);
5071 }
5072 let claim_key = format!("{}|{fingerprint}", layout.root.display());
5075 {
5076 let mut claimed = event_claims_in_process()
5077 .lock()
5078 .map_err(|_| "event claim lock poisoned".to_string())?;
5079 if !claimed.insert(claim_key) {
5080 return Ok(false);
5081 }
5082 }
5083 let claims = layout.root.join("event-claims.jsonl");
5084 append_json_line(
5085 &claims,
5086 &json!({
5087 "fingerprint": fingerprint,
5088 "firstSeenAt": Utc::now().to_rfc3339(),
5089 }),
5090 )?;
5091 Ok(true)
5092}
5093
5094fn release_event_fingerprint_claim(layout: &SpoolLayout, fingerprint: &str) {
5095 let _ = fs::remove_file(event_dedupe_marker_path(layout, fingerprint));
5098 let claim_key = format!("{}|{fingerprint}", layout.root.display());
5099 if let Ok(mut claimed) = event_claims_in_process().lock() {
5100 claimed.remove(&claim_key);
5101 }
5102}
5103
5104fn event_claims_in_process() -> &'static Mutex<BTreeSet<String>> {
5105 static CLAIMS: OnceLock<Mutex<BTreeSet<String>>> = OnceLock::new();
5106 CLAIMS.get_or_init(|| Mutex::new(BTreeSet::new()))
5107}
5108
5109fn event_dedupe_marker_path(layout: &SpoolLayout, fingerprint: &str) -> PathBuf {
5110 layout.event_dedupe_dir.join(format!("{fingerprint}.seen"))
5111}
5112
5113fn mutation_event_fingerprint(mutation: &WorktreeMutationEvent) -> String {
5114 let bucket = mutation.occurred_at.timestamp() / EVENT_DEDUPE_WINDOW_SECONDS;
5115 let payload = json!({
5120 "schema": 3,
5121 "repoOwner": mutation.repo_owner,
5122 "repoName": mutation.repo_name,
5123 "branchName": mutation.branch_name,
5124 "headSha": mutation.head_sha,
5125 "eventKind": mutation.event_kind,
5126 "paths": normalize_path_list_for_fingerprint(&mutation.paths),
5127 "primaryPath": mutation.primary_path.as_deref().map(normalize_relative_watch_path),
5128 "oldPath": mutation.old_path.as_deref().map(normalize_relative_watch_path),
5129 "newPath": mutation.new_path.as_deref().map(normalize_relative_watch_path),
5130 "addedLines": mutation.added_lines,
5131 "removedLines": mutation.removed_lines,
5132 "fileCreated": mutation.file_created,
5133 "fileRemoved": mutation.file_removed,
5134 "folderCreated": mutation.folder_created,
5135 "folderRemoved": mutation.folder_removed,
5136 "renamedOrMoved": mutation.renamed_or_moved,
5137 "timeBucket": bucket,
5138 });
5139 let encoded = serde_json::to_vec(&payload).unwrap_or_default();
5140 let mut hasher = Sha256::new();
5141 hasher.update(encoded);
5142 format!("{:x}", hasher.finalize())
5143}
5144
5145fn normalize_path_list_for_fingerprint(paths: &[String]) -> Vec<String> {
5146 let mut normalized = paths
5147 .iter()
5148 .map(|path| normalize_relative_watch_path(path))
5149 .filter(|path| !path.is_empty())
5150 .collect::<Vec<_>>();
5151 normalized.sort();
5152 normalized.dedup();
5153 normalized
5154}
5155
5156fn stable_event_id(fingerprint: &str) -> String {
5157 format!("wtmut_{fingerprint}")
5160}
5161
5162fn normalize_repo_root_for_payload(root: &Path) -> String {
5163 path_identity_key(root).replace('\\', "/")
5164}
5165
5166fn load_event_dedupe_fingerprints(_path: &Path) -> Result<BTreeSet<String>, String> {
5167 Ok(BTreeSet::new())
5171}
5172
5173fn classify_event_kind(kind: &EventKind) -> &'static str {
5174 match kind {
5175 EventKind::Create(CreateKind::File) => "file_create",
5176 EventKind::Create(CreateKind::Folder) => "folder_create",
5177 EventKind::Create(_) => "create",
5178 EventKind::Remove(RemoveKind::File) => "file_remove",
5179 EventKind::Remove(RemoveKind::Folder) => "folder_remove",
5180 EventKind::Remove(_) => "remove",
5181 EventKind::Modify(ModifyKind::Name(
5182 RenameMode::From | RenameMode::To | RenameMode::Both,
5183 )) => "rename_or_move",
5184 EventKind::Modify(ModifyKind::Data(_)) => "file_modify",
5185 EventKind::Modify(ModifyKind::Metadata(_)) => "metadata_modify",
5186 EventKind::Modify(_) => "modify",
5187 _ => "other",
5188 }
5189}
5190
5191fn is_rename_or_move(kind: &EventKind) -> bool {
5192 matches!(
5193 kind,
5194 EventKind::Modify(ModifyKind::Name(
5195 RenameMode::From | RenameMode::To | RenameMode::Both
5196 ))
5197 )
5198}
5199
5200fn rename_paths(kind: &EventKind, paths: &[String]) -> (Option<String>, Option<String>) {
5201 if !is_rename_or_move(kind) {
5202 return (None, None);
5203 }
5204
5205 match paths {
5206 [old_path, new_path, ..] => (Some(old_path.clone()), Some(new_path.clone())),
5207 [path] => match kind {
5208 EventKind::Modify(ModifyKind::Name(RenameMode::From)) => (Some(path.clone()), None),
5209 EventKind::Modify(ModifyKind::Name(RenameMode::To)) => (None, Some(path.clone())),
5210 _ => (None, Some(path.clone())),
5211 },
5212 _ => (None, None),
5213 }
5214}
5215
5216fn record_commit_snapshot(
5217 identity: &RepoIdentity,
5218 layout: &SpoolLayout,
5219 previous_head: Option<&str>,
5220) -> Result<Option<String>, String> {
5221 let head = current_head(&identity.root)?;
5222 let Some(head_sha) = head else {
5223 return Ok(None);
5224 };
5225
5226 if previous_head == Some(head_sha.as_str()) {
5227 return Ok(Some(head_sha));
5228 }
5229
5230 let enrich = enrich_commit_range(&identity.root, previous_head, &head_sha);
5232
5233 let mut commit = WorktreeCommitEvent {
5234 id: String::new(),
5235 fingerprint: None,
5236 repo_owner: identity.owner.clone(),
5237 repo_name: identity.name.clone(),
5238 branch_name: identity.branch.clone(),
5239 repo_root: normalize_repo_root_for_payload(&identity.root),
5240 previous_head_sha: previous_head.map(str::to_string),
5241 head_sha: head_sha.clone(),
5242 subject: enrich.subject,
5243 author_name: enrich.author_name,
5244 author_email: enrich.author_email,
5245 committed_at: enrich.committed_at,
5246 occurred_at: Utc::now(),
5247 files_changed: enrich.files_changed,
5248 files_changed_count: enrich.files_changed_count,
5249 insertions: enrich.insertions,
5250 deletions: enrich.deletions,
5251 is_merge: enrich.is_merge,
5252 included_commits: enrich.included_commits,
5253 };
5254 let fingerprint = commit_event_fingerprint(&commit);
5255 commit.fingerprint = Some(fingerprint.clone());
5256 commit.id = stable_event_id(&fingerprint);
5257 append_unique_commit_event(layout, &commit)?;
5258 Ok(Some(head_sha))
5259}
5260
5261const COMMIT_FILES_CAP: usize = 200;
5262const INCLUDED_COMMITS_CAP: usize = 32;
5263
5264#[derive(Default)]
5265struct CommitRangeEnrichment {
5266 subject: Option<String>,
5267 author_name: Option<String>,
5268 author_email: Option<String>,
5269 committed_at: Option<String>,
5270 files_changed: Option<Vec<String>>,
5271 files_changed_count: Option<u64>,
5272 insertions: Option<u64>,
5273 deletions: Option<u64>,
5274 is_merge: Option<bool>,
5275 included_commits: Option<Vec<String>>,
5276}
5277
5278fn enrich_commit_range(
5279 repo_root: &Path,
5280 previous_head: Option<&str>,
5281 head_sha: &str,
5282) -> CommitRangeEnrichment {
5283 let mut out = CommitRangeEnrichment::default();
5284
5285 let range = match previous_head {
5286 Some(prev) if !prev.is_empty() && prev != head_sha => format!("{prev}..{head_sha}"),
5287 _ => format!("{head_sha}^!"),
5288 };
5289
5290 if let Ok(meta) = git_output(
5294 repo_root,
5295 &[
5296 "log",
5297 "-1",
5298 "--pretty=%P%x00%s%x00%an%x00%ae%x00%cI",
5299 head_sha,
5300 ],
5301 ) {
5302 let mut parts = meta.split('\0').map(str::trim);
5303 let parents = parts.next().unwrap_or("");
5304 let parent_count = if parents.is_empty() {
5305 0
5306 } else {
5307 parents.split_whitespace().count()
5308 };
5309 out.is_merge = Some(parent_count > 1);
5310 out.subject = non_empty_owned(parts.next());
5311 out.author_name = non_empty_owned(parts.next());
5312 out.author_email = non_empty_owned(parts.next());
5313 out.committed_at = non_empty_owned(parts.next());
5314 }
5315
5316 if let Ok(numstat) = git_output(repo_root, &["diff", "--numstat", &range]) {
5317 let mut files = Vec::new();
5318 let mut insertions = 0u64;
5319 let mut deletions = 0u64;
5320 let mut full_count = 0u64;
5321 for line in numstat.lines() {
5322 let mut parts = line.split('\t');
5323 let add = parts.next().unwrap_or("0");
5324 let del = parts.next().unwrap_or("0");
5325 let path = parts.next().unwrap_or("").trim();
5326 if path.is_empty() {
5327 continue;
5328 }
5329 full_count += 1;
5330 if add != "-" {
5331 insertions += add.parse::<u64>().unwrap_or(0);
5332 }
5333 if del != "-" {
5334 deletions += del.parse::<u64>().unwrap_or(0);
5335 }
5336 if files.len() < COMMIT_FILES_CAP {
5337 files.push(path.to_string());
5338 }
5339 }
5340 out.files_changed_count = Some(full_count);
5342 if !files.is_empty() {
5343 out.files_changed = Some(files);
5344 }
5345 out.insertions = Some(insertions);
5346 out.deletions = Some(deletions);
5347 }
5348
5349 if let Some(prev) = previous_head.filter(|p| !p.is_empty() && *p != head_sha) {
5350 if let Ok(log) = git_output(
5351 repo_root,
5352 &["log", "--format=%H", &format!("{prev}..{head_sha}")],
5353 ) {
5354 let shas: Vec<String> = log
5355 .lines()
5356 .map(str::trim)
5357 .filter(|l| !l.is_empty())
5358 .take(INCLUDED_COMMITS_CAP)
5359 .map(str::to_string)
5360 .collect();
5361 if shas.len() > 1 {
5362 out.included_commits = Some(shas);
5363 }
5364 }
5365 }
5366
5367 out
5368}
5369
5370fn non_empty_owned(value: Option<&str>) -> Option<String> {
5371 value
5372 .map(str::trim)
5373 .filter(|s| !s.is_empty())
5374 .map(str::to_string)
5375}
5376
5377fn drain_notify_batch(
5380 rx: &mpsc::Receiver<Result<Event, notify::Error>>,
5381 first: Result<Event, notify::Error>,
5382) -> Vec<Result<Event, notify::Error>> {
5383 let mut batch = Vec::with_capacity(8);
5384 batch.push(first);
5385 while batch.len() < NOTIFY_EVENT_DRAIN_CAP {
5386 match rx.try_recv() {
5387 Ok(next) => batch.push(next),
5388 Err(_) => break,
5389 }
5390 }
5391 batch
5392}
5393
5394fn append_unique_commit_event(
5395 layout: &SpoolLayout,
5396 commit: &WorktreeCommitEvent,
5397) -> Result<bool, String> {
5398 let fingerprint = commit
5399 .fingerprint
5400 .clone()
5401 .unwrap_or_else(|| commit_event_fingerprint(commit));
5402 if !claim_commit_fingerprint(layout, &fingerprint)? {
5403 return Ok(false);
5404 }
5405
5406 let mut commit = commit.clone();
5407 commit.fingerprint = Some(fingerprint.clone());
5408 if commit.id.is_empty() {
5409 commit.id = stable_event_id(&fingerprint);
5410 }
5411 if let Err(error) = append_json_line(&layout.commits_file, &commit) {
5412 release_commit_fingerprint_claim(layout, &fingerprint);
5413 return Err(error);
5414 }
5415 Ok(true)
5416}
5417
5418fn commit_event_fingerprint(commit: &WorktreeCommitEvent) -> String {
5419 let payload = json!({
5420 "schema": 3,
5421 "repoOwner": commit.repo_owner,
5422 "repoName": commit.repo_name,
5423 "branchName": commit.branch_name,
5424 "previousHeadSha": commit.previous_head_sha,
5425 "headSha": commit.head_sha,
5426 });
5427 let encoded = serde_json::to_vec(&payload).unwrap_or_default();
5428 let mut hasher = Sha256::new();
5429 hasher.update(encoded);
5430 format!("{:x}", hasher.finalize())
5431}
5432
5433fn claim_commit_fingerprint(layout: &SpoolLayout, fingerprint: &str) -> Result<bool, String> {
5434 let marker = commit_dedupe_marker_path(layout, fingerprint);
5435 if marker.is_file() {
5436 return Ok(false);
5437 }
5438 let claim_key = format!("{}|{fingerprint}", layout.root.display());
5439 {
5440 let mut claimed = commit_claims_in_process()
5441 .lock()
5442 .map_err(|_| "commit claim lock poisoned".to_string())?;
5443 if !claimed.insert(claim_key) {
5444 return Ok(false);
5445 }
5446 }
5447 let claims = layout.root.join("commit-claims.jsonl");
5448 append_json_line(
5449 &claims,
5450 &json!({
5451 "fingerprint": fingerprint,
5452 "firstSeenAt": Utc::now().to_rfc3339(),
5453 }),
5454 )?;
5455 Ok(true)
5456}
5457
5458fn release_commit_fingerprint_claim(layout: &SpoolLayout, fingerprint: &str) {
5459 let _ = fs::remove_file(commit_dedupe_marker_path(layout, fingerprint));
5460 let claim_key = format!("{}|{fingerprint}", layout.root.display());
5461 if let Ok(mut claimed) = commit_claims_in_process().lock() {
5462 claimed.remove(&claim_key);
5463 }
5464}
5465
5466fn commit_claims_in_process() -> &'static Mutex<BTreeSet<String>> {
5467 static CLAIMS: OnceLock<Mutex<BTreeSet<String>>> = OnceLock::new();
5468 CLAIMS.get_or_init(|| Mutex::new(BTreeSet::new()))
5469}
5470
5471fn commit_dedupe_marker_path(layout: &SpoolLayout, fingerprint: &str) -> PathBuf {
5472 layout.commit_dedupe_dir.join(format!("{fingerprint}.seen"))
5473}
5474
5475async fn sync_spool_for_identity(
5476 identity: &RepoIdentity,
5477 layout: &SpoolLayout,
5478) -> Result<(), String> {
5479 let candidates = collect_sync_candidates(layout)?;
5480 if candidates.is_empty() {
5481 return Ok(());
5482 }
5483
5484 sync_candidates(identity, candidates, false).await
5485}
5486
5487async fn sync_candidates(
5488 identity: &RepoIdentity,
5489 candidates: Vec<SyncCandidate>,
5490 resync: bool,
5491) -> Result<(), String> {
5492 let mut events = Vec::new();
5493 let mut commits = Vec::new();
5494 for candidate in &candidates {
5495 match &candidate.records {
5496 SyncRecords::Events(records) => events.extend(records.iter().cloned()),
5497 SyncRecords::Commits(records) => commits.extend(records.iter().cloned()),
5498 }
5499 }
5500
5501 let token = resolve_cli_access_token()?;
5502 let device = resolve_device_identity()?;
5503 let client = cli_request_client()?;
5504 let api = ApiConfig::from_env();
5505 let endpoint = api.cli_worktree_mutations_endpoint();
5506 let event_count = events.len();
5507 let commit_count = commits.len();
5508 let hardware_id = worktree_device_hardware_id(&device.hardware_id);
5509 let hostname = current_hostname();
5510 let platform = std::env::consts::OS.to_string();
5511 let runtime_key = worktree_runtime_key();
5512 let repo_root = normalize_repo_root_for_payload(&identity.root);
5513 let events = events
5515 .into_iter()
5516 .map(ensure_mutation_event_identity)
5517 .collect::<Vec<_>>();
5518 let commits = commits
5519 .into_iter()
5520 .map(ensure_commit_event_identity)
5521 .collect::<Vec<_>>();
5522 let batches = split_worktree_mutation_upload_batches(events.clone(), commits.clone());
5523 let batch_count = batches.len();
5524
5525 if batch_count > 1 {
5526 println!(
5527 "Uploading {} worktree mutation record(s) in {} batch(es) for {}/{} on {} [{}].",
5528 event_count + commit_count,
5529 batch_count,
5530 identity.owner,
5531 identity.name,
5532 identity.branch,
5533 runtime_key
5534 );
5535 }
5536
5537 let mut status_code = 202;
5538 for attempt in 1..=WORKTREE_MUTATION_SYNC_MAX_ATTEMPTS {
5539 let attempt_batches =
5540 split_worktree_mutation_upload_batches(events.clone(), commits.clone());
5541 match upload_worktree_mutation_batches(
5542 &client,
5543 &endpoint,
5544 &token,
5545 &hardware_id,
5546 hostname.as_deref(),
5547 &platform,
5548 &runtime_key,
5549 identity,
5550 &repo_root,
5551 attempt_batches,
5552 )
5553 .await
5554 {
5555 Ok(code) => {
5556 status_code = code;
5557 break;
5558 }
5559 Err(error)
5560 if error.kind == WorktreeMutationSyncErrorKind::Retryable
5561 && attempt < WORKTREE_MUTATION_SYNC_MAX_ATTEMPTS =>
5562 {
5563 eprintln!(
5564 "Worktree mutation sync attempt {attempt}/{} failed for {}/{} on {}: {} Restarting from the first batch in {} second(s).",
5565 WORKTREE_MUTATION_SYNC_MAX_ATTEMPTS,
5566 identity.owner,
5567 identity.name,
5568 identity.branch,
5569 error.message,
5570 WORKTREE_MUTATION_SYNC_RETRY_DELAY_SECONDS
5571 );
5572 tokio::time::sleep(Duration::from_secs(
5573 WORKTREE_MUTATION_SYNC_RETRY_DELAY_SECONDS,
5574 ))
5575 .await;
5576 }
5577 Err(error) => return Err(error.message),
5578 }
5579 }
5580
5581 let layout = prepare_spool_layout(identity)?;
5582 append_sync_log(SyncLogAppend {
5583 identity,
5584 layout: &layout,
5585 endpoint: &endpoint,
5586 status_code,
5587 event_count,
5588 commit_count,
5589 candidates: &candidates,
5590 resync,
5591 })?;
5592
5593 for candidate in candidates {
5594 if !is_synced_spool_path(&candidate.path) {
5595 mark_synced(&candidate.path)?;
5596 }
5597 }
5598
5599 Ok(())
5600}
5601
5602async fn upload_worktree_mutation_batches(
5603 client: &reqwest::Client,
5604 endpoint: &str,
5605 token: &str,
5606 hardware_id: &str,
5607 hostname: Option<&str>,
5608 platform: &str,
5609 runtime_key: &str,
5610 identity: &RepoIdentity,
5611 repo_root: &str,
5612 batches: Vec<WorktreeMutationUploadBatch>,
5613) -> Result<u16, WorktreeMutationSyncError> {
5614 let batch_count = batches.len();
5615 let mut status_code = 202;
5616
5617 for (batch_index, batch) in batches.into_iter().enumerate() {
5618 let batch_event_count = batch.events.len();
5619 let batch_commit_count = batch.commits.len();
5620 let payload = WorktreeMutationIngestPayload {
5621 device: WorktreeMutationDevicePayload {
5622 hardware_id: hardware_id.to_string(),
5623 device_name: hostname.map(str::to_string),
5624 hostname: hostname.map(str::to_string),
5625 platform: platform.to_string(),
5626 runtime_key: Some(runtime_key.to_string()),
5627 },
5628 repository: WorktreeMutationRepositoryPayload {
5629 owner: identity.owner.clone(),
5630 name: identity.name.clone(),
5631 branch_name: identity.branch.clone(),
5632 repo_root: repo_root.to_string(),
5633 },
5634 events: batch.events,
5635 commits: batch.commits,
5636 };
5637
5638 let response = client
5639 .post(endpoint)
5640 .bearer_auth(token)
5641 .json(&payload)
5642 .send()
5643 .await
5644 .map_err(|error| WorktreeMutationSyncError {
5645 kind: WorktreeMutationSyncErrorKind::Retryable,
5646 message: format!(
5647 "Failed to upload worktree mutation batch {}/{}: {error}",
5648 batch_index + 1,
5649 batch_count
5650 ),
5651 })?;
5652
5653 if response.status() == StatusCode::UNAUTHORIZED {
5654 return Err(WorktreeMutationSyncError {
5655 kind: WorktreeMutationSyncErrorKind::NonRetryable,
5656 message: "Your stored CLI session is no longer valid. Run `xbp login` again."
5657 .to_string(),
5658 });
5659 }
5660
5661 if !response.status().is_success() {
5662 let status = response.status();
5663 let body = response.text().await.unwrap_or_default();
5664 return Err(WorktreeMutationSyncError {
5665 kind: if should_retry_worktree_mutation_sync_status(status) {
5666 WorktreeMutationSyncErrorKind::Retryable
5667 } else {
5668 WorktreeMutationSyncErrorKind::NonRetryable
5669 },
5670 message: format!(
5671 "Worktree mutation upload batch {}/{} failed with {status}: {body}",
5672 batch_index + 1,
5673 batch_count
5674 ),
5675 });
5676 }
5677
5678 status_code = response.status().as_u16();
5679 if batch_count > 1 {
5680 println!(
5681 "Uploaded worktree mutation batch {}/{} ({} event(s), {} commit(s)).",
5682 batch_index + 1,
5683 batch_count,
5684 batch_event_count,
5685 batch_commit_count
5686 );
5687 }
5688 }
5689
5690 Ok(status_code)
5691}
5692
5693fn should_retry_worktree_mutation_sync_status(status: StatusCode) -> bool {
5694 status.is_server_error()
5695 || status == StatusCode::REQUEST_TIMEOUT
5696 || status == StatusCode::TOO_MANY_REQUESTS
5697}
5698
5699fn split_worktree_mutation_upload_batches(
5700 events: Vec<WorktreeMutationEvent>,
5701 commits: Vec<WorktreeCommitEvent>,
5702) -> Vec<WorktreeMutationUploadBatch> {
5703 let mut batches = Vec::new();
5704 let mut current = empty_worktree_mutation_upload_batch();
5705
5706 for event in events {
5707 let estimated_json_bytes = serialized_json_len(&event);
5708 if should_start_next_worktree_mutation_upload_batch(¤t, estimated_json_bytes) {
5709 batches.push(current);
5710 current = empty_worktree_mutation_upload_batch();
5711 }
5712 current.estimated_json_bytes += estimated_json_bytes;
5713 current.events.push(event);
5714 }
5715
5716 for commit in commits {
5717 let estimated_json_bytes = serialized_json_len(&commit);
5718 if should_start_next_worktree_mutation_upload_batch(¤t, estimated_json_bytes) {
5719 batches.push(current);
5720 current = empty_worktree_mutation_upload_batch();
5721 }
5722 current.estimated_json_bytes += estimated_json_bytes;
5723 current.commits.push(commit);
5724 }
5725
5726 if current_record_count(¤t) > 0 {
5727 batches.push(current);
5728 }
5729
5730 batches
5731}
5732
5733fn empty_worktree_mutation_upload_batch() -> WorktreeMutationUploadBatch {
5734 WorktreeMutationUploadBatch {
5735 events: Vec::new(),
5736 commits: Vec::new(),
5737 estimated_json_bytes: 0,
5738 }
5739}
5740
5741fn should_start_next_worktree_mutation_upload_batch(
5742 batch: &WorktreeMutationUploadBatch,
5743 next_record_json_bytes: usize,
5744) -> bool {
5745 if current_record_count(batch) == 0 {
5746 return false;
5747 }
5748
5749 current_record_count(batch) >= WORKTREE_MUTATION_SYNC_BATCH_RECORD_LIMIT
5750 || batch.estimated_json_bytes + next_record_json_bytes
5751 > WORKTREE_MUTATION_SYNC_BATCH_JSON_BYTES
5752}
5753
5754fn current_record_count(batch: &WorktreeMutationUploadBatch) -> usize {
5755 batch.events.len() + batch.commits.len()
5756}
5757
5758fn serialized_json_len<T: Serialize>(value: &T) -> usize {
5759 serde_json::to_vec(value)
5760 .map(|encoded| encoded.len())
5761 .unwrap_or(0)
5762}
5763
5764fn collect_sync_candidates(layout: &SpoolLayout) -> Result<Vec<SyncCandidate>, String> {
5765 flush_jsonl_write_buffers();
5767 collect_spool_candidates(layout, false)
5768}
5769
5770fn print_no_sync_candidates_message(
5771 target: &WorktreeWatchTargetOptions,
5772 resync: bool,
5773) -> Result<(), String> {
5774 if resync {
5775 println!("No local worktree mutation JSONL files found.");
5776 return Ok(());
5777 }
5778
5779 let identities = resolve_target_identities(target)?;
5780 let mut synced_files = 0usize;
5781 let mut synced_records = 0usize;
5782 for identity in identities {
5783 let layout = prepare_spool_layout(&identity)?;
5784 let all_candidates = collect_spool_candidates(&layout, true)?;
5785 let unsynced_candidates = collect_sync_candidates(&layout)?;
5786 synced_files += all_candidates
5787 .len()
5788 .saturating_sub(unsynced_candidates.len());
5789 let all_records = all_candidates
5790 .iter()
5791 .map(|candidate| candidate.records.len())
5792 .sum::<usize>();
5793 let unsynced_records = unsynced_candidates
5794 .iter()
5795 .map(|candidate| candidate.records.len())
5796 .sum::<usize>();
5797 synced_records += all_records.saturating_sub(unsynced_records);
5798 }
5799
5800 if synced_files > 0 {
5801 println!(
5802 "No unsynced worktree mutation files found. Found {synced_files} already-synced local spool file(s) with {synced_records} record(s). Use `xbp worktree-watch sync --resync` to upload them again."
5803 );
5804 } else {
5805 println!("No unsynced worktree mutation files found.");
5806 }
5807 Ok(())
5808}
5809
5810fn collect_spool_candidates(
5811 layout: &SpoolLayout,
5812 include_synced: bool,
5813) -> Result<Vec<SyncCandidate>, String> {
5814 flush_jsonl_write_buffers();
5816 let mut roots = vec![layout.root.clone()];
5819 if let Some(branch) = layout.root.file_name().and_then(|name| name.to_str()) {
5820 if let Some(repo_root) = layout.root.parent() {
5821 if let (Some(name), Some(owner)) = (
5822 repo_root.file_name().and_then(|n| n.to_str()),
5823 repo_root.parent().and_then(|p| p.file_name()).and_then(|n| n.to_str()),
5824 ) {
5825 let identity = RepoIdentity {
5826 owner: owner.to_string(),
5827 name: name.to_string(),
5828 branch: branch.to_string(),
5829 root: PathBuf::new(),
5830 head_sha: None,
5831 };
5832 if let Ok(search_roots) = repository_spool_search_roots(&identity) {
5833 for search_root in search_roots {
5834 let branch_root = search_root.join(branch);
5835 if !roots.iter().any(|existing| {
5836 path_identity_key(existing) == path_identity_key(&branch_root)
5837 }) {
5838 roots.push(branch_root);
5839 }
5840 }
5841 }
5842 }
5843 }
5844 }
5845
5846 let mut candidates = Vec::new();
5847 let mut seen_files = BTreeSet::new();
5848 for root in roots {
5849 collect_spool_candidates_from_root(&root, include_synced, &mut candidates, &mut seen_files)?;
5850 }
5851 Ok(candidates)
5852}
5853
5854fn collect_spool_candidates_from_root(
5855 root: &Path,
5856 include_synced: bool,
5857 candidates: &mut Vec<SyncCandidate>,
5858 seen_files: &mut BTreeSet<String>,
5859) -> Result<(), String> {
5860 if !root.exists() {
5861 return Ok(());
5862 }
5863
5864 for entry in fs::read_dir(root).map_err(|error| {
5865 format!(
5866 "Failed to read spool directory {}: {error}",
5867 root.display()
5868 )
5869 })? {
5870 let path = entry
5871 .map_err(|error| format!("Failed to read spool entry: {error}"))?
5872 .path();
5873 let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
5874 continue;
5875 };
5876 if !file_name.ends_with(".jsonl") || (!include_synced && file_name.contains(".synced.")) {
5877 continue;
5878 }
5879
5880 let kind = if file_name.starts_with("events-") {
5881 SyncFileKind::Events
5882 } else if file_name.starts_with("commits-") {
5883 SyncFileKind::Commits
5884 } else {
5885 continue;
5886 };
5887
5888 let identity_key = path_identity_key(&path);
5889 if !seen_files.insert(identity_key) {
5890 continue;
5891 }
5892
5893 let records = sanitize_spool_records(&path, kind, read_spool_records(&path, kind)?)?;
5894 if records.is_empty() {
5895 continue;
5896 }
5897 candidates.push(SyncCandidate { path, records });
5898 }
5899
5900 Ok(())
5901}
5902
5903fn append_sync_log(request: SyncLogAppend<'_>) -> Result<(), String> {
5904 let entry = WorktreeWatchSyncLogEntry {
5905 id: Uuid::new_v4().to_string(),
5906 synced_at: Utc::now(),
5907 endpoint: request.endpoint.to_string(),
5908 repository_owner: request.identity.owner.clone(),
5909 repository_name: request.identity.name.clone(),
5910 branch_name: request.identity.branch.clone(),
5911 repo_root: request.identity.root.display().to_string(),
5912 resync: request.resync,
5913 status_code: request.status_code,
5914 event_count: request.event_count,
5915 commit_count: request.commit_count,
5916 spool_file_count: request.candidates.len(),
5917 spool_files: request
5918 .candidates
5919 .iter()
5920 .map(|candidate| candidate.path.display().to_string())
5921 .collect(),
5922 };
5923 append_json_line(&request.layout.sync_log_file, &entry)
5924}
5925
5926fn sanitize_spool_records(
5927 path: &Path,
5928 kind: SyncFileKind,
5929 records: SyncRecords,
5930) -> Result<SyncRecords, String> {
5931 match (kind, records) {
5932 (SyncFileKind::Events, SyncRecords::Events(records)) => {
5933 let mut sanitized = Vec::with_capacity(records.len());
5934 let mut changed = false;
5935
5936 for record in records {
5937 let original = serde_json::to_value(&record).ok();
5938 let Some(record) = sanitize_spooled_event(record) else {
5939 changed = true;
5940 continue;
5941 };
5942 if !changed {
5943 changed = serde_json::to_value(&record).ok() != original;
5944 }
5945 sanitized.push(record);
5946 }
5947
5948 if changed {
5949 rewrite_jsonl_records(path, &sanitized)?;
5950 }
5951
5952 Ok(SyncRecords::Events(sanitized))
5953 }
5954 (_, records) => Ok(records),
5955 }
5956}
5957
5958fn sanitize_spooled_event(mut event: WorktreeMutationEvent) -> Option<WorktreeMutationEvent> {
5959 event.paths = dedupe_filtered_spool_paths(event.paths);
5960 event.primary_path = sanitize_optional_spool_path(event.primary_path);
5961 event.old_path = sanitize_optional_spool_path(event.old_path);
5962 event.new_path = sanitize_optional_spool_path(event.new_path);
5963
5964 if event.paths.is_empty() {
5965 if let Some(path) = event.primary_path.clone() {
5966 event.paths.push(path);
5967 }
5968 if let Some(path) = event.old_path.clone() {
5969 if !event.paths.iter().any(|existing| existing == &path) {
5970 event.paths.push(path);
5971 }
5972 }
5973 if let Some(path) = event.new_path.clone() {
5974 if !event.paths.iter().any(|existing| existing == &path) {
5975 event.paths.push(path);
5976 }
5977 }
5978 }
5979
5980 if event.primary_path.is_none() {
5981 event.primary_path = event.paths.first().cloned();
5982 }
5983
5984 if event.paths.is_empty() {
5985 return None;
5986 }
5987
5988 Some(event)
5989}
5990
5991fn sanitize_optional_spool_path(path: Option<String>) -> Option<String> {
5992 path.and_then(sanitize_spool_path)
5993}
5994
5995fn sanitize_spool_path(path: String) -> Option<String> {
5996 let trimmed = path.trim();
5997 if trimmed.is_empty() || watch_ignore_rules().ignores_relative(trimmed) {
5998 return None;
5999 }
6000 Some(trimmed.replace('\\', "/"))
6001}
6002
6003fn dedupe_filtered_spool_paths(paths: Vec<String>) -> Vec<String> {
6004 let mut sanitized = Vec::with_capacity(paths.len());
6005 for path in paths {
6006 let Some(path) = sanitize_spool_path(path) else {
6007 continue;
6008 };
6009 if !sanitized.iter().any(|existing| existing == &path) {
6010 sanitized.push(path);
6011 }
6012 }
6013 sanitized
6014}
6015
6016fn rewrite_jsonl_records<T: Serialize>(path: &Path, records: &[T]) -> Result<(), String> {
6017 if records.is_empty() {
6018 if path.exists() {
6019 fs::remove_file(path).map_err(|error| {
6020 format!(
6021 "Failed to remove emptied spool file {}: {error}",
6022 path.display()
6023 )
6024 })?;
6025 }
6026 return Ok(());
6027 }
6028
6029 let temp_path = path.with_extension(format!(
6030 "{}.{}.tmp",
6031 path.extension()
6032 .and_then(|value| value.to_str())
6033 .unwrap_or("jsonl"),
6034 Uuid::new_v4()
6035 ));
6036 {
6037 let mut file = File::create(&temp_path).map_err(|error| {
6038 format!(
6039 "Failed to create temporary spool file {}: {error}",
6040 temp_path.display()
6041 )
6042 })?;
6043 for record in records {
6044 let line = serde_json::to_string(record)
6045 .map_err(|error| format!("Failed to serialize worktree event: {error}"))?;
6046 writeln!(file, "{line}").map_err(|error| {
6047 format!(
6048 "Failed to write temporary spool file {}: {error}",
6049 temp_path.display()
6050 )
6051 })?;
6052 }
6053 }
6054
6055 if path.exists() {
6056 fs::remove_file(path).map_err(|error| {
6057 format!(
6058 "Failed to replace rewritten spool file {}: {error}",
6059 path.display()
6060 )
6061 })?;
6062 }
6063
6064 fs::rename(&temp_path, path)
6065 .map_err(|error| format!("Failed to rewrite spool file {}: {error}", path.display()))
6066}
6067
6068fn read_last_sync_log_entry(path: &Path) -> Result<Option<WorktreeWatchSyncLogEntry>, String> {
6069 if !path.exists() {
6070 return Ok(None);
6071 }
6072 let values = read_jsonl_values(path)?;
6073 let Some(value) = values.into_iter().last() else {
6074 return Ok(None);
6075 };
6076 serde_json::from_value(value)
6077 .map(Some)
6078 .map_err(|error| format!("Failed to decode sync log {}: {error}", path.display()))
6079}
6080
6081fn is_synced_spool_path(path: &Path) -> bool {
6082 path.file_name()
6083 .and_then(|value| value.to_str())
6084 .map(|value| value.contains(".synced."))
6085 .unwrap_or(false)
6086}
6087
6088fn mark_synced(path: &Path) -> Result<(), String> {
6089 let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
6090 return Ok(());
6091 };
6092 let synced_name = file_name.replace(
6093 ".jsonl",
6094 &format!(".synced.{}.jsonl", Utc::now().timestamp()),
6095 );
6096 let synced_path = path.with_file_name(synced_name);
6097 fs::rename(path, &synced_path).map_err(|error| {
6098 format!(
6099 "Failed to mark spool file {} as synced: {error}",
6100 path.display()
6101 )
6102 })
6103}
6104
6105fn read_spool_records(path: &Path, kind: SyncFileKind) -> Result<SyncRecords, String> {
6106 flush_jsonl_write_buffers();
6107 match kind {
6108 SyncFileKind::Events => read_jsonl_records::<WorktreeMutationEvent>(path)
6109 .map(SyncRecords::Events)
6110 .map_err(|error| format!("Failed to decode event from {}: {error}", path.display())),
6111 SyncFileKind::Commits => read_jsonl_records::<WorktreeCommitEvent>(path)
6112 .map(SyncRecords::Commits)
6113 .map_err(|error| format!("Failed to decode commit from {}: {error}", path.display())),
6114 }
6115}
6116
6117fn sanitize_jsonl_line(line: &str) -> Option<&str> {
6121 let trimmed = line.trim().trim_matches('\0').trim();
6122 if trimmed.is_empty() {
6123 None
6124 } else {
6125 Some(trimmed)
6126 }
6127}
6128
6129fn read_jsonl_records<T>(path: &Path) -> Result<Vec<T>, String>
6130where
6131 T: for<'de> Deserialize<'de>,
6132{
6133 flush_jsonl_write_buffers();
6134 let file = File::open(path)
6135 .map_err(|error| format!("Failed to open spool file {}: {error}", path.display()))?;
6136 let reader = BufReader::new(file);
6137 let mut records = Vec::new();
6138 for (line_no, line) in reader.lines().enumerate() {
6139 let line =
6140 line.map_err(|error| format!("Failed to read spool file {}: {error}", path.display()))?;
6141 let Some(payload) = sanitize_jsonl_line(&line) else {
6142 continue;
6143 };
6144 match serde_json::from_str(payload) {
6145 Ok(record) => records.push(record),
6146 Err(error) => {
6147 eprintln!(
6150 "warning: skipping unparseable JSONL record in {} at line {}: {error}",
6151 path.display(),
6152 line_no + 1
6153 );
6154 }
6155 }
6156 }
6157 Ok(records)
6158}
6159
6160fn read_jsonl_values(path: &Path) -> Result<Vec<Value>, String> {
6161 read_jsonl_records(path)
6162}
6163
6164fn spawn_detached_worktree_watch(repo: Option<&Path>) -> Result<PathBuf, String> {
6165 let identity = resolve_repo_identity(repo)?;
6166 spawn_detached_worktree_watch_for_identity(&identity)
6167}
6168
6169fn spawn_detached_parent_worktree_watch(parent: &Path) -> Result<PathBuf, String> {
6170 let parent = canonical_parent_path(parent)?;
6171 let layout = prepare_parent_spool_layout(&parent)?;
6172 replace_existing_parent_watcher(&parent, &layout)?;
6173 let executable = std::env::current_exe()
6174 .map_err(|error| format!("Failed to resolve current XBP executable: {error}"))?;
6175 let mut command = Command::new(&executable);
6176 command
6177 .arg("worktree-watch")
6178 .arg("start")
6179 .arg("--parent")
6180 .arg(&parent)
6181 .env(BACKGROUND_CHILD_ENV, "1")
6182 .current_dir(&parent)
6183 .stdin(Stdio::null())
6184 .stdout(Stdio::null())
6185 .stderr(Stdio::null());
6186 configure_detached_child_process(&mut command);
6187
6188 let child = command
6189 .spawn()
6190 .map_err(|error| format!("Failed to spawn background parent worktree watcher: {error}"))?;
6191 write_parent_watcher_state(&parent, &layout, child.id(), &executable)?;
6192 Ok(parent)
6193}
6194
6195fn spawn_detached_worktree_watch_for_identity(identity: &RepoIdentity) -> Result<PathBuf, String> {
6196 let layout = prepare_spool_layout(identity)?;
6197 replace_existing_watcher(identity, &layout)?;
6198 let executable = std::env::current_exe()
6199 .map_err(|error| format!("Failed to resolve current XBP executable: {error}"))?;
6200 let mut command = Command::new(&executable);
6201 command
6202 .arg("worktree-watch")
6203 .arg("start")
6204 .arg("--repo")
6205 .arg(&identity.root)
6206 .env(BACKGROUND_CHILD_ENV, "1")
6207 .current_dir(&identity.root)
6208 .stdin(Stdio::null())
6209 .stdout(Stdio::null())
6210 .stderr(Stdio::null());
6211 configure_detached_child_process(&mut command);
6212
6213 let child: std::process::Child = command
6214 .spawn()
6215 .map_err(|error| format!("Failed to spawn background worktree watcher: {error}"))?;
6216 write_watcher_state(identity, &layout, child.id(), &executable)?;
6217 Ok(identity.root.clone())
6218}
6219
6220fn configure_detached_child_process(command: &mut Command) {
6221 #[cfg(windows)]
6222 {
6223 use std::os::windows::process::CommandExt;
6224 command.creation_flags(CREATE_NO_WINDOW);
6225 }
6226
6227 #[cfg(unix)]
6230 {
6231 use std::os::unix::process::CommandExt;
6232 unsafe {
6234 command.pre_exec(|| {
6235 extern "C" {
6236 fn setsid() -> i32;
6237 }
6238 let _ = setsid();
6239 Ok(())
6240 });
6241 }
6242 }
6243}
6244
6245fn replace_existing_parent_watcher(
6246 parent: &Path,
6247 layout: &ParentSpoolLayout,
6248) -> Result<(), String> {
6249 let Some(state) = read_parent_watcher_state_any(&layout.watcher_state_file)? else {
6250 return Ok(());
6251 };
6252
6253 if path_identity_key(parent) != path_identity_key(Path::new(&state.parent_root)) {
6254 return Ok(());
6255 }
6256
6257 if is_matching_parent_watcher_process(&state) {
6258 stop_parent_process(&state, false)?;
6259 println!(
6260 "Replaced existing background parent worktree watcher {} for {}",
6261 state.pid,
6262 parent.display()
6263 );
6264 remove_all_watcher_state_files(&layout.watcher_state_file);
6265 return Ok(());
6266 }
6267
6268 if parent_watcher_state_pid_exists_with_same_executable(&state) {
6269 return Err(format!(
6270 "Existing parent watcher state for {} points to running XBP process {}, but its command line could not be verified. Run `xbp worktree-watch stop --parent \"{}\" --force` before starting another detached parent watcher.",
6271 parent.display(),
6272 state.pid,
6273 parent.display()
6274 ));
6275 }
6276
6277 remove_all_watcher_state_files(&layout.watcher_state_file);
6278 Ok(())
6279}
6280
6281fn replace_existing_watcher(identity: &RepoIdentity, layout: &SpoolLayout) -> Result<(), String> {
6282 let Some(state) = read_watcher_state(&layout.watcher_state_file)? else {
6283 return Ok(());
6284 };
6285
6286 if !same_repo_watcher_state(identity, &state) {
6287 return Ok(());
6288 }
6289
6290 if is_matching_watcher_process(&state) {
6291 stop_process(&state, false)?;
6292 println!(
6293 "Replaced existing background worktree watcher {} for {}",
6294 state.pid,
6295 identity.root.display()
6296 );
6297 remove_all_watcher_state_files(&layout.watcher_state_file);
6298 return Ok(());
6299 }
6300
6301 if watcher_state_pid_exists_with_same_executable(&state) {
6302 return Err(format!(
6303 "Existing watcher state for {} points to running XBP process {}, but its command line could not be verified. Run `xbp worktree-watch stop --repo \"{}\" --force` before starting another detached watcher.",
6304 identity.root.display(),
6305 state.pid,
6306 identity.root.display()
6307 ));
6308 }
6309
6310 remove_all_watcher_state_files(&layout.watcher_state_file);
6311 Ok(())
6312}
6313
6314fn stop_existing_watcher(
6315 identity: &RepoIdentity,
6316 layout: &SpoolLayout,
6317 force: bool,
6318) -> Result<StopWatcherOutcome, String> {
6319 let Some(state) = read_watcher_state(&layout.watcher_state_file)? else {
6320 if remove_all_watcher_state_files(&layout.watcher_state_file) > 0 {
6322 return Ok(StopWatcherOutcome::RemovedStaleState);
6323 }
6324 return Ok(StopWatcherOutcome::NoState);
6325 };
6326
6327 if !same_repo_watcher_state(identity, &state) {
6328 return Ok(StopWatcherOutcome::NoState);
6329 }
6330
6331 if is_matching_watcher_process(&state) || force {
6332 let pid = state.pid;
6333 stop_process(&state, force)?;
6334 remove_all_watcher_state_files(&layout.watcher_state_file);
6335 return Ok(StopWatcherOutcome::Stopped(pid));
6336 }
6337
6338 if watcher_state_pid_exists_with_same_executable(&state) {
6339 return Err(format!(
6340 "Watcher state for {} points to running XBP process {}, but its command line could not be verified. Re-run with `--force` to stop that PID.",
6341 identity.root.display(),
6342 state.pid
6343 ));
6344 }
6345
6346 remove_all_watcher_state_files(&layout.watcher_state_file);
6348 Ok(StopWatcherOutcome::RemovedStaleState)
6349}
6350
6351fn stop_existing_parent_watcher(parent: &Path, force: bool) -> Result<StopWatcherOutcome, String> {
6352 let layout = prepare_parent_spool_layout(parent)?;
6353 let Some(state) = read_parent_watcher_state_any(&layout.watcher_state_file)? else {
6354 if remove_all_watcher_state_files(&layout.watcher_state_file) > 0 {
6355 return Ok(StopWatcherOutcome::RemovedStaleState);
6356 }
6357 return Ok(StopWatcherOutcome::NoState);
6358 };
6359
6360 if path_identity_key(parent) != path_identity_key(Path::new(&state.parent_root)) {
6361 return Ok(StopWatcherOutcome::NoState);
6362 }
6363
6364 if is_matching_parent_watcher_process(&state) || force {
6365 let pid = state.pid;
6366 stop_parent_process(&state, force)?;
6367 remove_all_watcher_state_files(&layout.watcher_state_file);
6368 return Ok(StopWatcherOutcome::Stopped(pid));
6369 }
6370
6371 if parent_watcher_state_pid_exists_with_same_executable(&state) {
6372 return Err(format!(
6373 "Parent watcher state for {} points to running XBP process {}, but its command line could not be verified. Re-run with `--force` to stop that PID.",
6374 parent.display(),
6375 state.pid
6376 ));
6377 }
6378
6379 remove_all_watcher_state_files(&layout.watcher_state_file);
6380 Ok(StopWatcherOutcome::RemovedStaleState)
6381}
6382
6383fn remove_all_watcher_state_files(primary: &Path) -> usize {
6390 let mut removed = 0usize;
6391 let mut seen = BTreeSet::new();
6392
6393 for candidate in watcher_state_read_candidates(primary) {
6394 if !seen.insert(candidate.clone()) {
6395 continue;
6396 }
6397 if candidate.is_file() && fs::remove_file(&candidate).is_ok() {
6398 removed += 1;
6399 }
6400 }
6401
6402 if let Some(dir) = primary.parent() {
6403 if let Ok(entries) = fs::read_dir(dir) {
6404 for entry in entries.flatten() {
6405 let path = entry.path();
6406 let Some(name) = path.file_name().and_then(|v| v.to_str()) else {
6407 continue;
6408 };
6409 if !(name.starts_with("watcher-state") && name.ends_with(".json")) {
6410 continue;
6411 }
6412 if !seen.insert(path.clone()) {
6413 continue;
6414 }
6415 if path.is_file() && fs::remove_file(&path).is_ok() {
6416 removed += 1;
6417 }
6418 }
6419 }
6420 }
6421
6422 removed
6423}
6424
6425fn read_watcher_state(path: &Path) -> Result<Option<WorktreeWatcherState>, String> {
6426 for candidate in watcher_state_read_candidates(path) {
6428 if !candidate.exists() {
6429 continue;
6430 }
6431 let raw = fs::read_to_string(&candidate).map_err(|error| {
6432 format!(
6433 "Failed to read watcher state {}: {error}",
6434 candidate.display()
6435 )
6436 })?;
6437 return serde_json::from_str(&raw).map(Some).map_err(|error| {
6438 format!(
6439 "Failed to parse watcher state {}: {error}",
6440 candidate.display()
6441 )
6442 });
6443 }
6444 Ok(None)
6445}
6446
6447fn watcher_state_read_candidates(path: &Path) -> Vec<PathBuf> {
6448 let mut candidates = vec![path.to_path_buf()];
6449 if let Some(parent) = path.parent() {
6450 let legacy = parent.join("watcher-state.json");
6451 if legacy != path {
6452 candidates.push(legacy);
6453 }
6454 }
6455 candidates
6456}
6457
6458fn read_parent_watcher_state(path: &Path) -> Result<Option<ParentWorktreeWatcherState>, String> {
6459 if !path.exists() {
6460 return Ok(None);
6461 }
6462 let raw = fs::read_to_string(path).map_err(|error| {
6463 format!(
6464 "Failed to read parent watcher state {}: {error}",
6465 path.display()
6466 )
6467 })?;
6468 serde_json::from_str(&raw).map(Some).map_err(|error| {
6469 format!(
6470 "Failed to parse parent watcher state {}: {error}",
6471 path.display()
6472 )
6473 })
6474}
6475
6476fn read_parent_watcher_state_any(
6478 primary: &Path,
6479) -> Result<Option<ParentWorktreeWatcherState>, String> {
6480 for candidate in watcher_state_read_candidates(primary) {
6481 if let Some(state) = read_parent_watcher_state(&candidate)? {
6482 return Ok(Some(state));
6483 }
6484 }
6485 Ok(None)
6486}
6487
6488fn write_watcher_state(
6489 identity: &RepoIdentity,
6490 layout: &SpoolLayout,
6491 pid: u32,
6492 executable: &Path,
6493) -> Result<(), String> {
6494 let state = WorktreeWatcherState {
6495 pid,
6496 repo_root: normalize_repo_root_for_payload(&identity.root),
6497 repository_owner: identity.owner.clone(),
6498 repository_name: identity.name.clone(),
6499 branch_name: identity.branch.clone(),
6500 executable: executable.display().to_string(),
6501 started_at: Utc::now(),
6502 runtime_key: Some(worktree_runtime_key()),
6503 platform: Some(std::env::consts::OS.to_string()),
6504 };
6505 let rendered = serde_json::to_string_pretty(&state)
6506 .map_err(|error| format!("Failed to serialize watcher state: {error}"))?;
6507 fs::write(&layout.watcher_state_file, format!("{rendered}\n")).map_err(|error| {
6508 format!(
6509 "Failed to write watcher state {}: {error}",
6510 layout.watcher_state_file.display()
6511 )
6512 })
6513}
6514
6515fn write_parent_watcher_state(
6516 parent: &Path,
6517 layout: &ParentSpoolLayout,
6518 pid: u32,
6519 executable: &Path,
6520) -> Result<(), String> {
6521 let state = ParentWorktreeWatcherState {
6522 pid,
6523 parent_root: normalize_repo_root_for_payload(parent),
6524 executable: executable.display().to_string(),
6525 started_at: Utc::now(),
6526 runtime_key: Some(worktree_runtime_key()),
6527 platform: Some(std::env::consts::OS.to_string()),
6528 };
6529 let rendered = serde_json::to_string_pretty(&state)
6530 .map_err(|error| format!("Failed to serialize parent watcher state: {error}"))?;
6531 fs::write(&layout.watcher_state_file, format!("{rendered}\n")).map_err(|error| {
6532 format!(
6533 "Failed to write parent watcher state {}: {error}",
6534 layout.watcher_state_file.display()
6535 )
6536 })
6537}
6538
6539fn same_repo_watcher_state(identity: &RepoIdentity, state: &WorktreeWatcherState) -> bool {
6540 if !runtime_keys_compatible(state.runtime_key.as_deref()) {
6541 return false;
6542 }
6543 path_identity_key(&identity.root) == path_identity_key(Path::new(&state.repo_root))
6544 && identity.owner == state.repository_owner
6545 && identity.name == state.repository_name
6546 && identity.branch == state.branch_name
6547}
6548
6549fn runtime_keys_compatible(state_runtime: Option<&str>) -> bool {
6550 let current = worktree_runtime_key();
6551 match state_runtime {
6552 Some(key) => key == current,
6553 None => !is_wsl_runtime(),
6556 }
6557}
6558
6559fn process_system_for_pids(pids: &[u32]) -> System {
6560 let mut system = System::new();
6561 if pids.is_empty() {
6562 return system;
6563 }
6564 let pid_list: Vec<Pid> = pids
6565 .iter()
6566 .copied()
6567 .filter(|pid| *pid != 0 && *pid != std::process::id())
6568 .map(Pid::from_u32)
6569 .collect();
6570 if pid_list.is_empty() {
6571 return system;
6572 }
6573 system.refresh_processes_specifics(
6575 ProcessesToUpdate::Some(&pid_list),
6576 true,
6577 ProcessRefreshKind::everything()
6578 .with_cmd(UpdateKind::Always)
6579 .with_exe(UpdateKind::Always),
6580 );
6581 system
6582}
6583
6584fn is_matching_watcher_process(state: &WorktreeWatcherState) -> bool {
6585 let system = process_system_for_pids(&[state.pid]);
6586 is_matching_watcher_process_with_system(&system, state)
6588}
6589
6590fn is_live_watcher_process_with_system(system: &System, state: &WorktreeWatcherState) -> bool {
6592 if state.pid == std::process::id() {
6593 return runtime_keys_compatible(state.runtime_key.as_deref());
6594 }
6595 is_matching_watcher_process_with_system(system, state)
6596}
6597
6598fn is_matching_watcher_process_with_system(
6599 system: &System,
6600 state: &WorktreeWatcherState,
6601) -> bool {
6602 if state.pid == std::process::id() {
6603 return false;
6604 }
6605 let Some(process) = system.process(Pid::from_u32(state.pid)) else {
6606 return false;
6607 };
6608 let command_line = process
6609 .cmd()
6610 .iter()
6611 .map(|part| part.to_string_lossy())
6612 .collect::<Vec<_>>()
6613 .join(" ");
6614 let command_matches = command_line_contains_ignore_case(&command_line, "worktree-watch")
6615 && command_line_contains_ignore_case(&command_line, "start")
6616 && command_line_mentions_path(&command_line, &state.repo_root);
6617 command_matches || watcher_state_process_identity_matches(process, state)
6618}
6619
6620fn is_matching_parent_watcher_process(state: &ParentWorktreeWatcherState) -> bool {
6621 let system = process_system_for_pids(&[state.pid]);
6622 is_matching_parent_watcher_process_with_system(&system, state)
6623}
6624
6625fn is_live_parent_watcher_process_with_system(
6627 system: &System,
6628 state: &ParentWorktreeWatcherState,
6629) -> bool {
6630 if state.pid == std::process::id() {
6631 return runtime_keys_compatible(state.runtime_key.as_deref());
6632 }
6633 is_matching_parent_watcher_process_with_system(system, state)
6634}
6635
6636fn is_matching_parent_watcher_process_with_system(
6637 system: &System,
6638 state: &ParentWorktreeWatcherState,
6639) -> bool {
6640 if state.pid == std::process::id() {
6641 return false;
6642 }
6643 if !runtime_keys_compatible(state.runtime_key.as_deref()) {
6644 return false;
6645 }
6646 let Some(process) = system.process(Pid::from_u32(state.pid)) else {
6647 return false;
6648 };
6649 let command_line = process
6650 .cmd()
6651 .iter()
6652 .map(|part| part.to_string_lossy())
6653 .collect::<Vec<_>>()
6654 .join(" ");
6655 let command_matches = command_line_contains_ignore_case(&command_line, "worktree-watch")
6656 && command_line_contains_ignore_case(&command_line, "start")
6657 && command_line_contains_ignore_case(&command_line, "--parent")
6658 && command_line_mentions_path(&command_line, &state.parent_root);
6659 command_matches
6662 || parent_watcher_state_process_identity_matches(process, state)
6663 || (process_executable_matches_path(process, Path::new(&state.executable))
6664 && process_looks_like_worktree_watch(process))
6665}
6666
6667fn process_looks_like_worktree_watch(process: &sysinfo::Process) -> bool {
6668 let command_line = process
6669 .cmd()
6670 .iter()
6671 .map(|part| part.to_string_lossy())
6672 .collect::<Vec<_>>()
6673 .join(" ");
6674 if command_line_contains_ignore_case(&command_line, "worktree-watch") {
6675 return true;
6676 }
6677 command_line.trim().is_empty()
6679}
6680
6681fn command_line_contains_ignore_case(haystack: &str, needle: &str) -> bool {
6682 haystack.to_ascii_lowercase().contains(&needle.to_ascii_lowercase())
6683}
6684
6685fn command_line_mentions_path(command_line: &str, path: &str) -> bool {
6686 if path.is_empty() {
6687 return false;
6688 }
6689 if command_line.contains(path) {
6690 return true;
6691 }
6692 let command_lower = command_line.to_ascii_lowercase().replace('\\', "/");
6693 let path_lower = path.to_ascii_lowercase().replace('\\', "/");
6694 if command_lower.contains(&path_lower) {
6695 return true;
6696 }
6697 let path_key = path_identity_key(Path::new(path));
6699 if !path_key.is_empty() && command_lower.contains(&path_key) {
6700 return true;
6701 }
6702 for token in command_line.split_whitespace() {
6703 let cleaned = token.trim_matches('"').trim_matches('\'');
6704 if cleaned.is_empty() {
6705 continue;
6706 }
6707 if path_identity_key(Path::new(cleaned)) == path_key {
6708 return true;
6709 }
6710 }
6711 Path::new(path)
6713 .file_name()
6714 .and_then(|name| name.to_str())
6715 .map(|name| command_line_contains_ignore_case(command_line, name))
6716 .unwrap_or(false)
6717}
6718
6719fn watcher_state_pid_exists_with_same_executable(state: &WorktreeWatcherState) -> bool {
6720 if state.pid == std::process::id() {
6721 return false;
6722 }
6723 let system = process_system_for_pids(&[state.pid]);
6724 let Some(process) = system.process(Pid::from_u32(state.pid)) else {
6725 return false;
6726 };
6727 process_executable_matches_state(process, state)
6728}
6729
6730fn parent_watcher_state_pid_exists_with_same_executable(
6731 state: &ParentWorktreeWatcherState,
6732) -> bool {
6733 if state.pid == std::process::id() {
6734 return false;
6735 }
6736 let system = process_system_for_pids(&[state.pid]);
6737 let Some(process) = system.process(Pid::from_u32(state.pid)) else {
6738 return false;
6739 };
6740 process_executable_matches_path(process, Path::new(&state.executable))
6741}
6742
6743fn watcher_state_process_identity_matches(
6744 process: &sysinfo::Process,
6745 state: &WorktreeWatcherState,
6746) -> bool {
6747 process_executable_matches_state(process, state)
6748 && process_start_time_matches(process.start_time(), state.started_at)
6749}
6750
6751fn parent_watcher_state_process_identity_matches(
6752 process: &sysinfo::Process,
6753 state: &ParentWorktreeWatcherState,
6754) -> bool {
6755 process_executable_matches_path(process, Path::new(&state.executable))
6756 && process_start_time_matches(process.start_time(), state.started_at)
6757}
6758
6759fn process_executable_matches_state(
6760 process: &sysinfo::Process,
6761 state: &WorktreeWatcherState,
6762) -> bool {
6763 process_executable_matches_path(process, Path::new(&state.executable))
6764}
6765
6766fn process_executable_matches_path(process: &sysinfo::Process, executable: &Path) -> bool {
6767 let expected = path_identity_key(executable);
6768 process
6769 .exe()
6770 .map(|path| path_identity_key(path) == expected)
6771 .unwrap_or(false)
6772}
6773
6774fn process_start_time_matches(process_started: u64, state_started: DateTime<Utc>) -> bool {
6775 if process_started == 0 {
6777 return true;
6778 }
6779 let process_started = process_started as i64;
6780 let state_started = state_started.timestamp();
6781 (process_started - state_started).abs() <= 120
6783}
6784
6785fn stop_process(state: &WorktreeWatcherState, force: bool) -> Result<(), String> {
6786 let system = process_system_for_pids(&[state.pid]);
6787 let Some(process) = system.process(Pid::from_u32(state.pid)) else {
6788 return Ok(());
6789 };
6790 if !force && !is_matching_watcher_process_with_system(&system, state) {
6791 return Ok(());
6792 }
6793 if process.kill() {
6794 Ok(())
6795 } else {
6796 Err(format!(
6797 "Failed to stop existing watcher process {}",
6798 state.pid
6799 ))
6800 }
6801}
6802
6803fn stop_parent_process(state: &ParentWorktreeWatcherState, force: bool) -> Result<(), String> {
6804 let system = process_system_for_pids(&[state.pid]);
6805 let Some(process) = system.process(Pid::from_u32(state.pid)) else {
6806 return Ok(());
6807 };
6808 if !force && !is_matching_parent_watcher_process_with_system(&system, state) {
6809 return Ok(());
6810 }
6811 if process.kill() {
6812 Ok(())
6813 } else {
6814 Err(format!(
6815 "Failed to stop existing parent watcher process {}",
6816 state.pid
6817 ))
6818 }
6819}
6820
6821fn prefer_running_parent_root() -> Option<PathBuf> {
6823 let mut candidates = list_parent_watcher_state_files().ok()?;
6824 candidates.sort_by(|left, right| right.1.started_at.cmp(&left.1.started_at));
6826 for (_path, state) in candidates {
6827 let system = process_system_for_pids(&[state.pid]);
6828 if !is_live_parent_watcher_process_with_system(&system, &state) {
6829 continue;
6830 }
6831 let root = parent_root_as_local_path(&state.parent_root);
6832 if root.is_dir() {
6833 return Some(root);
6834 }
6835 return Some(root);
6836 }
6837 None
6838}
6839
6840fn prefer_any_parent_root() -> Option<PathBuf> {
6842 let mut candidates = list_parent_watcher_state_files().ok()?;
6843 candidates.sort_by(|left, right| right.1.started_at.cmp(&left.1.started_at));
6844 for (_path, state) in candidates {
6845 let root = parent_root_as_local_path(&state.parent_root);
6846 if root.is_dir() {
6847 return Some(root);
6848 }
6849 }
6850 None
6851}
6852
6853fn count_git_roots_under(parent: &Path) -> Result<usize, String> {
6854 let mut roots = Vec::new();
6855 let mut seen = BTreeSet::new();
6856 collect_git_roots_in_dir(parent, parent, &mut seen, &mut roots)?;
6857 Ok(roots.len())
6858}
6859
6860fn list_parent_watcher_state_files() -> Result<Vec<(PathBuf, ParentWorktreeWatcherState)>, String> {
6861 let parents_root = global_mutations_root()?.join("parents");
6862 if !parents_root.is_dir() {
6863 return Ok(Vec::new());
6864 }
6865 let mut out = Vec::new();
6866 for entry in fs::read_dir(&parents_root)
6867 .map_err(|error| format!("Failed to read {}: {error}", parents_root.display()))?
6868 {
6869 let entry = entry.map_err(|error| format!("Failed to read parent spool entry: {error}"))?;
6870 if !entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
6871 continue;
6872 }
6873 for file in fs::read_dir(entry.path()).map_err(|error| {
6874 format!(
6875 "Failed to read parent spool {}: {error}",
6876 entry.path().display()
6877 )
6878 })? {
6879 let file = file.map_err(|error| format!("Failed to read parent state entry: {error}"))?;
6880 let path = file.path();
6881 let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
6882 continue;
6883 };
6884 if !(name.starts_with("watcher-state") && name.ends_with(".json")) {
6885 continue;
6886 }
6887 if let Ok(Some(state)) = read_parent_watcher_state(&path) {
6888 if let Some(runtime) = state.runtime_key.as_deref() {
6890 if runtime != worktree_runtime_key() {
6891 if !runtime_keys_compatible(Some(runtime)) {
6893 continue;
6894 }
6895 }
6896 } else if !runtime_keys_compatible(None) {
6897 continue;
6898 }
6899 out.push((path, state));
6900 }
6901 }
6902 }
6903 Ok(out)
6904}
6905
6906fn parent_root_as_local_path(parent_root: &str) -> PathBuf {
6907 let trimmed = parent_root.trim();
6908 if trimmed.is_empty() {
6909 return PathBuf::new();
6910 }
6911 if let Some(windows) = map_wsl_mnt_path_to_windows(trimmed) {
6912 return PathBuf::from(windows);
6913 }
6914 let as_path = PathBuf::from(trimmed);
6915 if as_path.is_dir() {
6916 return as_path;
6917 }
6918 if let Some(windows) = map_wsl_mnt_path_to_windows(&trimmed.replace('\\', "/")) {
6920 return PathBuf::from(windows);
6921 }
6922 as_path
6923}
6924
6925fn discover_repo_identities_under(parent: &Path) -> Result<Vec<RepoIdentity>, String> {
6926 let parent = canonical_parent_path(parent)?;
6927
6928 let mut roots = Vec::new();
6929 let mut seen = BTreeSet::new();
6930 collect_git_roots_in_dir(&parent, &parent, &mut seen, &mut roots)?;
6931
6932 let identities = resolve_repo_identities_parallel(&roots);
6935 let mut identities = identities.into_iter().flatten().collect::<Vec<_>>();
6936 identities.sort_by(|left, right| left.root.cmp(&right.root));
6937 Ok(identities)
6938}
6939
6940fn collect_git_roots_in_dir(
6941 parent: &Path,
6942 dir: &Path,
6943 seen: &mut BTreeSet<String>,
6944 roots: &mut Vec<PathBuf>,
6945) -> Result<(), String> {
6946 if dir != parent && is_skipped_discovery_dir(dir) {
6947 return Ok(());
6948 }
6949
6950 if dir.join(".git").exists() {
6951 let key = path_identity_key(dir);
6952 if seen.insert(key) {
6953 roots.push(dir.to_path_buf());
6954 }
6955 return Ok(());
6956 }
6957
6958 let entries = fs::read_dir(dir)
6959 .map_err(|error| format!("Failed to read folder {}: {error}", dir.display()))?;
6960 for entry in entries {
6961 let entry = entry.map_err(|error| {
6962 format!(
6963 "Failed to read folder entry under {}: {error}",
6964 dir.display()
6965 )
6966 })?;
6967 let file_type = entry
6968 .file_type()
6969 .map_err(|error| format!("Failed to inspect {}: {error}", entry.path().display()))?;
6970 if file_type.is_dir() {
6971 collect_git_roots_in_dir(parent, &entry.path(), seen, roots)?;
6972 }
6973 }
6974
6975 Ok(())
6976}
6977
6978fn resolve_repo_identities_parallel(roots: &[PathBuf]) -> Vec<Option<RepoIdentity>> {
6979 if roots.is_empty() {
6980 return Vec::new();
6981 }
6982 if roots.len() == 1 {
6983 return vec![resolve_repo_identity_light(Some(&roots[0])).ok()];
6984 }
6985
6986 let thread_count = roots.len().min(8).max(1);
6987 let chunk_size = (roots.len() + thread_count - 1) / thread_count;
6988 let mut handles = Vec::new();
6989 for chunk in roots.chunks(chunk_size) {
6990 let chunk = chunk.to_vec();
6991 handles.push(std::thread::spawn(move || {
6992 chunk
6993 .iter()
6994 .map(|root| resolve_repo_identity_light(Some(root)).ok())
6995 .collect::<Vec<_>>()
6996 }));
6997 }
6998
6999 let mut out = Vec::with_capacity(roots.len());
7000 for handle in handles {
7001 match handle.join() {
7002 Ok(part) => out.extend(part),
7003 Err(_) => {
7004 }
7006 }
7007 }
7008 if out.len() < roots.len() {
7010 for root in roots.iter().skip(out.len()) {
7011 out.push(resolve_repo_identity_light(Some(root)).ok());
7012 }
7013 }
7014 out
7015}
7016
7017fn resolve_repo_identity_light(repo: Option<&Path>) -> Result<RepoIdentity, String> {
7019 let start = resolve_repo_path_input(repo)?;
7020 let root_raw = git_output(&start, &["rev-parse", "--show-toplevel"]).map_err(|error| {
7021 if repo.is_some() {
7022 format!(
7023 "Failed to run git rev-parse --show-toplevel in {}: {error}",
7024 start.display()
7025 )
7026 } else {
7027 format!("Failed to run git rev-parse --show-toplevel: {error}")
7028 }
7029 })?;
7030 let root = normalize_windows_verbatim_path(
7031 fs::canonicalize(root_raw.trim()).unwrap_or_else(|_| PathBuf::from(root_raw.trim())),
7032 );
7033 let branch = git_output(&root, &["rev-parse", "--abbrev-ref", "HEAD"])
7034 .unwrap_or_else(|_| "unknown".to_string());
7035 let remote = git_output(&root, &["remote", "get-url", DEFAULT_REMOTE]).unwrap_or_default();
7036 let (owner, name) = parse_remote_owner_repo(&remote).unwrap_or_else(|| {
7037 let name = root
7038 .file_name()
7039 .and_then(|value| value.to_str())
7040 .unwrap_or("repository")
7041 .to_string();
7042 ("unknown".to_string(), name)
7043 });
7044
7045 Ok(RepoIdentity {
7046 owner,
7047 name,
7048 branch: sanitize_path_component(branch.trim()),
7049 root,
7050 head_sha: None,
7051 })
7052}
7053
7054fn resolve_repo_identity(repo: Option<&Path>) -> Result<RepoIdentity, String> {
7055 let start = resolve_repo_path_input(repo)?;
7056 let root_raw = git_output(&start, &["rev-parse", "--show-toplevel"]).map_err(|error| {
7057 if repo.is_some() {
7058 format!(
7059 "Failed to run git rev-parse --show-toplevel in {}: {error}",
7060 start.display()
7061 )
7062 } else {
7063 format!("Failed to run git rev-parse --show-toplevel: {error}")
7064 }
7065 })?;
7066 let root = normalize_windows_verbatim_path(
7067 fs::canonicalize(root_raw.trim()).unwrap_or_else(|_| PathBuf::from(root_raw.trim())),
7068 );
7069 let branch = git_output(&root, &["rev-parse", "--abbrev-ref", "HEAD"])
7070 .unwrap_or_else(|_| "unknown".to_string());
7071 let remote = git_output(&root, &["remote", "get-url", DEFAULT_REMOTE]).unwrap_or_default();
7072 let (owner, name) = parse_remote_owner_repo(&remote).unwrap_or_else(|| {
7073 let name = root
7074 .file_name()
7075 .and_then(|value| value.to_str())
7076 .unwrap_or("repository")
7077 .to_string();
7078 ("unknown".to_string(), name)
7079 });
7080 let head_sha = current_head(&root)?;
7081
7082 Ok(RepoIdentity {
7083 owner,
7084 name,
7085 branch: sanitize_path_component(branch.trim()),
7086 root,
7087 head_sha,
7088 })
7089}
7090
7091fn resolve_repo_path_input(repo: Option<&Path>) -> Result<PathBuf, String> {
7098 let Some(input) = repo else {
7099 return std::env::current_dir()
7100 .map_err(|error| format!("Failed to resolve current directory: {error}"));
7101 };
7102
7103 if path_looks_like_existing_dir(input) {
7104 return Ok(input.to_path_buf());
7105 }
7106
7107 let token = input.to_string_lossy();
7108 let token = token.trim();
7109 if token.is_empty() {
7110 return Err("--repo value is empty".to_string());
7111 }
7112
7113 if let Some(path) = lookup_repo_path_from_system_inventory(token)? {
7116 return Ok(path);
7117 }
7118
7119 if input.components().count() > 1
7121 || token.contains('/')
7122 || token.contains('\\')
7123 || token.contains(':')
7124 {
7125 return Ok(input.to_path_buf());
7126 }
7127
7128 Err(format!(
7129 "Could not resolve `--repo {token}` as a filesystem path or inventory name. \
7130Refresh with `xbp diag --refresh-system-inventory`, pass a full path, or use `--parent`."
7131 ))
7132}
7133
7134fn path_looks_like_existing_dir(path: &Path) -> bool {
7135 path.is_dir()
7136 || fs::metadata(path)
7137 .map(|meta| meta.is_dir())
7138 .unwrap_or(false)
7139}
7140
7141fn lookup_repo_path_from_system_inventory(token: &str) -> Result<Option<PathBuf>, String> {
7142 let config = match SshConfig::load() {
7143 Ok(config) => config,
7144 Err(_) => return Ok(None),
7145 };
7146 let Some(inventory) = config.system_inventory.as_ref() else {
7147 return Ok(None);
7148 };
7149
7150 if let Some(path) = match_github_repo_inventory(&inventory.github_repos, token)? {
7151 return Ok(Some(path));
7152 }
7153
7154 let needle = normalize_repo_lookup_token(token);
7156 let mut project_hits: Vec<PathBuf> = Vec::new();
7157 for project in &inventory.xbp_projects {
7158 let name = normalize_repo_lookup_token(&project.name);
7159 let root_name = Path::new(&project.root)
7160 .file_name()
7161 .and_then(|value| value.to_str())
7162 .map(normalize_repo_lookup_token)
7163 .unwrap_or_default();
7164 if name == needle || root_name == needle {
7165 let path = PathBuf::from(&project.root);
7166 if path_looks_like_existing_dir(&path)
7167 && !project_hits.iter().any(|existing| {
7168 path_identity_key(existing.as_path()) == path_identity_key(path.as_path())
7169 })
7170 {
7171 project_hits.push(path);
7172 }
7173 }
7174 }
7175 match project_hits.len() {
7176 0 => Ok(None),
7177 1 => Ok(Some(project_hits.remove(0))),
7178 _ => Err(format_ambiguous_repo_matches(token, &project_hits)),
7179 }
7180}
7181
7182fn match_github_repo_inventory(
7183 repos: &[GithubRepoRecord],
7184 token: &str,
7185) -> Result<Option<PathBuf>, String> {
7186 let needle = normalize_repo_lookup_token(token);
7187 if needle.is_empty() {
7188 return Ok(None);
7189 }
7190
7191 let mut exact_path: Vec<PathBuf> = Vec::new();
7192 let mut full_name_hits: Vec<PathBuf> = Vec::new();
7193 let mut short_name_hits: Vec<PathBuf> = Vec::new();
7194
7195 for record in repos {
7196 let path = PathBuf::from(record.path.trim());
7197 if record.path.trim().is_empty() || !path_looks_like_existing_dir(&path) {
7198 continue;
7199 }
7200
7201 let path_key = path_identity_key(&path);
7202 let full_name = normalize_repo_lookup_token(&record.full_name);
7203 let short = normalize_repo_lookup_token(&record.repo);
7204 let owner_repo = normalize_repo_lookup_token(&format!("{}/{}", record.owner, record.repo));
7205 let folder = path
7206 .file_name()
7207 .and_then(|value| value.to_str())
7208 .map(normalize_repo_lookup_token)
7209 .unwrap_or_default();
7210
7211 let push_unique = |bucket: &mut Vec<PathBuf>| {
7212 if !bucket
7213 .iter()
7214 .any(|existing| path_identity_key(existing.as_path()) == path_key)
7215 {
7216 bucket.push(path.clone());
7217 }
7218 };
7219
7220 if path_identity_key(Path::new(record.path.trim())) == path_identity_key(Path::new(token))
7221 || normalize_repo_lookup_token(&record.path) == needle
7222 {
7223 push_unique(&mut exact_path);
7224 }
7225 if full_name == needle || owner_repo == needle {
7226 push_unique(&mut full_name_hits);
7227 }
7228 if short == needle || folder == needle {
7229 push_unique(&mut short_name_hits);
7230 }
7231 }
7232
7233 if let Some(path) = exact_path.into_iter().next() {
7234 return Ok(Some(path));
7235 }
7236 match full_name_hits.len() {
7237 0 => {}
7238 1 => return Ok(Some(full_name_hits.remove(0))),
7239 _ => return Err(format_ambiguous_repo_matches(token, &full_name_hits)),
7240 }
7241 match short_name_hits.len() {
7242 0 => Ok(None),
7243 1 => Ok(Some(short_name_hits.remove(0))),
7244 _ => Err(format_ambiguous_repo_matches(token, &short_name_hits)),
7245 }
7246}
7247
7248fn normalize_repo_lookup_token(value: &str) -> String {
7249 value
7250 .trim()
7251 .trim_end_matches('/')
7252 .trim_end_matches('\\')
7253 .trim_end_matches(".git")
7254 .replace('\\', "/")
7255 .to_ascii_lowercase()
7256}
7257
7258fn format_ambiguous_repo_matches(token: &str, paths: &[PathBuf]) -> String {
7259 let list = paths
7260 .iter()
7261 .map(|path| format!(" - {}", path.display()))
7262 .collect::<Vec<_>>()
7263 .join("\n");
7264 format!(
7265 "Ambiguous `--repo {token}` matches multiple inventory paths:\n{list}\n\
7266Use a full path or `owner/repo` (for example `xylex-group/{token}`)."
7267 )
7268}
7269
7270fn prepare_spool_layout(identity: &RepoIdentity) -> Result<SpoolLayout, String> {
7271 let root = repository_spool_root(identity)?.join(sanitize_path_component(&identity.branch));
7272 fs::create_dir_all(&root).map_err(|error| {
7273 format!(
7274 "Failed to create worktree mutation spool {}: {error}",
7275 root.display()
7276 )
7277 })?;
7278 let event_dedupe_dir = root.join("event-dedupe");
7279 fs::create_dir_all(&event_dedupe_dir).map_err(|error| {
7280 format!(
7281 "Failed to create worktree mutation dedupe dir {}: {error}",
7282 event_dedupe_dir.display()
7283 )
7284 })?;
7285 let commit_dedupe_dir = root.join("commit-dedupe");
7286 fs::create_dir_all(&commit_dedupe_dir).map_err(|error| {
7287 format!(
7288 "Failed to create worktree commit dedupe dir {}: {error}",
7289 commit_dedupe_dir.display()
7290 )
7291 })?;
7292 Ok(spool_layout_for_root(
7293 root,
7294 Some(Uuid::new_v4().to_string()),
7295 ))
7296}
7297
7298fn global_mutations_root() -> Result<PathBuf, String> {
7301 let paths = ensure_global_xbp_paths()?;
7302 Ok(paths.root_dir.join("mutations"))
7303}
7304
7305fn repository_spool_root(identity: &RepoIdentity) -> Result<PathBuf, String> {
7306 Ok(global_mutations_root()?
7307 .join(sanitize_path_component(&identity.owner))
7308 .join(sanitize_path_component(&identity.name)))
7309}
7310
7311fn repository_spool_search_roots(identity: &RepoIdentity) -> Result<Vec<PathBuf>, String> {
7317 let owner = sanitize_path_component(&identity.owner);
7318 let name = sanitize_path_component(&identity.name);
7319 let mut roots: Vec<PathBuf> = Vec::new();
7320 let mut push = |path: PathBuf| {
7321 if roots.iter().any(|existing| {
7322 path_identity_key(existing.as_path()) == path_identity_key(path.as_path())
7323 }) {
7324 return;
7325 }
7326 roots.push(path);
7327 };
7328
7329 push(repository_spool_root(identity)?);
7330
7331 for base in legacy_mutations_base_candidates() {
7332 push(base.join(&owner).join(&name));
7333 let runtimes = base.join("runtimes");
7335 if runtimes.is_dir() {
7336 if let Ok(entries) = fs::read_dir(&runtimes) {
7337 for entry in entries.flatten() {
7338 if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
7339 push(entry.path().join(&owner).join(&name));
7340 }
7341 }
7342 }
7343 }
7344 }
7345
7346 Ok(roots)
7347}
7348
7349fn legacy_mutations_base_candidates() -> Vec<PathBuf> {
7350 let mut bases: Vec<PathBuf> = Vec::new();
7351 let mut push = |path: PathBuf| {
7352 if bases
7353 .iter()
7354 .any(|existing| path_identity_key(existing.as_path()) == path_identity_key(path.as_path()))
7355 {
7356 return;
7357 }
7358 bases.push(path);
7359 };
7360
7361 if let Ok(primary) = global_mutations_root() {
7362 push(primary);
7363 }
7364 push(resolve_global_xbp_root_dir().join("mutations"));
7366
7367 if let Some(home) = dirs::home_dir() {
7368 push(home.join(".xbp").join("mutations"));
7369 push(home.join(".config").join("xbp").join("mutations"));
7370 }
7371
7372 if let Ok(primary) = global_mutations_root() {
7374 let rendered = primary.to_string_lossy().replace('\\', "/");
7375 if let Some(mnt) = map_windows_path_to_wsl_mnt(&rendered) {
7376 push(PathBuf::from(mnt));
7377 }
7378 if let Some(windows) = map_wsl_mnt_path_to_windows(&rendered) {
7379 if let Some(mnt) = map_windows_path_to_wsl_mnt(&windows) {
7380 push(PathBuf::from(mnt));
7381 }
7382 push(PathBuf::from(windows));
7383 }
7384 }
7385
7386 bases
7387}
7388
7389fn spool_layout_for_existing_root(root: &Path) -> SpoolLayout {
7390 spool_layout_for_root(root.to_path_buf(), None)
7391}
7392
7393fn spool_layout_for_root(root: PathBuf, run_id: Option<String>) -> SpoolLayout {
7394 let run_id = run_id.unwrap_or_else(|| Uuid::new_v4().to_string());
7395 let event_dedupe_dir = root.join("event-dedupe");
7396 let commit_dedupe_dir = root.join("commit-dedupe");
7397 let watcher_state_file = root.join(format!(
7400 "watcher-state-{}.json",
7401 sanitize_path_component(&worktree_runtime_key())
7402 ));
7403 SpoolLayout {
7404 events_file: root.join(format!("events-{run_id}.jsonl")),
7405 commits_file: root.join(format!("commits-{run_id}.jsonl")),
7406 stats_file: root.join("stats.json"),
7407 watcher_state_file,
7408 sync_log_file: root.join("sync-log.jsonl"),
7409 event_dedupe_file: root.join("event-dedupe.jsonl"),
7410 event_dedupe_dir,
7411 commit_dedupe_dir,
7412 root,
7413 }
7414}
7415
7416fn prepare_parent_spool_layout(parent: &Path) -> Result<ParentSpoolLayout, String> {
7417 let parent = canonical_parent_path(parent)?;
7418 let parent_key = cross_platform_path_identity_key(&parent);
7420 let mut hasher = Sha256::new();
7421 hasher.update(parent_key.as_bytes());
7422 let digest = format!("{:x}", hasher.finalize());
7423 let label = parent
7424 .file_name()
7425 .and_then(|value| value.to_str())
7426 .map(sanitize_path_component)
7427 .filter(|value| !value.is_empty())
7428 .unwrap_or_else(|| "parent".to_string());
7429 let root = global_mutations_root()?
7430 .join("parents")
7431 .join(format!("{}-{}", label, &digest[..16]));
7432 fs::create_dir_all(&root).map_err(|error| {
7433 format!(
7434 "Failed to create parent worktree watcher state dir {}: {error}",
7435 root.display()
7436 )
7437 })?;
7438 Ok(ParentSpoolLayout {
7439 watcher_state_file: root.join(format!(
7440 "watcher-state-{}.json",
7441 sanitize_path_component(&worktree_runtime_key())
7442 )),
7443 })
7444}
7445
7446fn canonical_parent_path(parent: &Path) -> Result<PathBuf, String> {
7447 let normalized = normalize_windows_verbatim_path(
7448 fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf()),
7449 );
7450 if !normalized.is_dir() {
7451 return Err(format!(
7452 "Parent folder {} does not exist or is not a directory.",
7453 normalized.display()
7454 ));
7455 }
7456 Ok(normalized)
7457}
7458
7459struct JsonlWriteBuffers {
7462 lines_by_path: HashMap<PathBuf, Vec<String>>,
7463 line_count: usize,
7464 last_flush: Instant,
7465}
7466
7467impl JsonlWriteBuffers {
7468 fn new() -> Self {
7469 Self {
7470 lines_by_path: HashMap::new(),
7471 line_count: 0,
7472 last_flush: Instant::now(),
7473 }
7474 }
7475
7476 fn push(&mut self, path: PathBuf, line: String) {
7477 self.lines_by_path.entry(path).or_default().push(line);
7478 self.line_count = self.line_count.saturating_add(1);
7479 }
7480
7481 fn should_flush(&self) -> bool {
7482 self.line_count >= JSONL_FLUSH_MAX_LINES
7483 || self.last_flush.elapsed() >= Duration::from_millis(JSONL_FLUSH_MAX_WAIT_MS)
7484 }
7485
7486 fn flush(&mut self) -> Result<(), String> {
7487 if self.lines_by_path.is_empty() {
7488 return Ok(());
7489 }
7490 let batch = std::mem::take(&mut self.lines_by_path);
7491 self.line_count = 0;
7492 self.last_flush = Instant::now();
7493 for (path, lines) in batch {
7494 if let Some(parent) = path.parent() {
7495 let _ = fs::create_dir_all(parent);
7496 }
7497 let mut file = OpenOptions::new()
7498 .create(true)
7499 .append(true)
7500 .open(&path)
7501 .map_err(|error| {
7502 format!("Failed to open spool file {}: {error}", path.display())
7503 })?;
7504 for line in lines {
7505 writeln!(file, "{line}").map_err(|error| {
7506 format!("Failed to append spool file {}: {error}", path.display())
7507 })?;
7508 }
7509 let _ = file.flush();
7511 }
7512 Ok(())
7513 }
7514}
7515
7516fn jsonl_buffers() -> &'static Mutex<JsonlWriteBuffers> {
7517 static BUFFERS: OnceLock<Mutex<JsonlWriteBuffers>> = OnceLock::new();
7518 BUFFERS.get_or_init(|| Mutex::new(JsonlWriteBuffers::new()))
7519}
7520
7521fn append_json_line<T: Serialize>(path: &Path, value: &T) -> Result<(), String> {
7522 let line = serde_json::to_string(value)
7523 .map_err(|error| format!("Failed to serialize worktree event: {error}"))?;
7524 let mut guard = jsonl_buffers()
7525 .lock()
7526 .map_err(|_| "jsonl write buffer lock poisoned".to_string())?;
7527 guard.push(path.to_path_buf(), line);
7528 if guard.should_flush() {
7529 guard.flush()?;
7530 }
7531 Ok(())
7532}
7533
7534fn flush_jsonl_write_buffers() {
7536 if let Ok(mut guard) = jsonl_buffers().lock() {
7537 let _ = guard.flush();
7538 }
7539}
7540
7541fn git_numstat_for_path(repo_root: &Path, relative_path: &str) -> Result<(u64, u64), String> {
7542 let output = Command::new("git")
7543 .args(["diff", "--numstat", "--"])
7544 .arg(relative_path)
7545 .current_dir(repo_root)
7546 .output()
7547 .map_err(|error| format!("Failed to run git diff --numstat: {error}"))?;
7548 if !output.status.success() {
7549 return Ok((0, 0));
7550 }
7551 let stdout = String::from_utf8_lossy(&output.stdout);
7552 let mut added = 0;
7553 let mut removed = 0;
7554 for line in stdout.lines() {
7555 let mut parts = line.split_whitespace();
7556 added += parse_numstat_count(parts.next());
7557 removed += parse_numstat_count(parts.next());
7558 }
7559 Ok((added, removed))
7560}
7561
7562fn file_line_count_for_path(repo_root: &Path, relative_path: &str) -> Result<u64, String> {
7563 let path = repo_root.join(relative_path);
7564 let meta = fs::metadata(&path)
7565 .map_err(|error| format!("Failed to stat file {}: {error}", path.display()))?;
7566 if meta.len() > 512 * 1024 {
7568 return Ok(0);
7569 }
7570 let content = fs::read_to_string(&path)
7571 .map_err(|error| format!("Failed to read file {}: {error}", path.display()))?;
7572 Ok(content.lines().count() as u64)
7573}
7574
7575fn parse_numstat_count(value: Option<&str>) -> u64 {
7576 value.and_then(|raw| raw.parse::<u64>().ok()).unwrap_or(0)
7577}
7578
7579fn current_head(repo_root: &Path) -> Result<Option<String>, String> {
7580 match git_output(repo_root, &["rev-parse", "HEAD"]) {
7581 Ok(value) => Ok(Some(value)),
7582 Err(error)
7583 if error.contains("unknown revision") || error.contains("ambiguous argument") =>
7584 {
7585 Ok(None)
7586 }
7587 Err(error) => Err(error),
7588 }
7589}
7590
7591fn git_output(repo_root: &Path, args: &[&str]) -> Result<String, String> {
7592 let output = Command::new("git")
7593 .args(args)
7594 .current_dir(repo_root)
7595 .output()
7596 .map_err(|error| format!("Failed to run git {}: {error}", args.join(" ")))?;
7597 if !output.status.success() {
7598 return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
7599 }
7600 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
7601}
7602
7603fn parse_remote_owner_repo(remote: &str) -> Option<(String, String)> {
7604 let trimmed = remote.trim().trim_end_matches(".git");
7605 if trimmed.is_empty() {
7606 return None;
7607 }
7608
7609 let path_part = if !trimmed.contains("://") {
7610 if let Some((_, path)) = trimmed.rsplit_once(':') {
7611 path
7612 } else {
7613 trimmed
7614 }
7615 } else {
7616 trimmed
7617 .trim_start_matches("https://")
7618 .trim_start_matches("http://")
7619 .trim_start_matches("ssh://")
7620 .split_once('/')
7621 .map(|(_, path)| path)
7622 .unwrap_or(trimmed)
7623 };
7624 let mut parts = path_part.rsplitn(2, '/');
7625 let name = parts.next()?.trim();
7626 let owner = parts.next()?.trim();
7627 if owner.is_empty() || name.is_empty() {
7628 return None;
7629 }
7630 Some((
7631 sanitize_path_component(owner),
7632 sanitize_path_component(name.trim_end_matches(".git")),
7633 ))
7634}
7635
7636fn sanitize_path_component(value: &str) -> String {
7637 let sanitized: String = value
7638 .chars()
7639 .map(|ch| {
7640 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
7641 ch
7642 } else {
7643 '-'
7644 }
7645 })
7646 .collect();
7647 sanitized
7648 .trim_matches('-')
7649 .chars()
7650 .take(160)
7651 .collect::<String>()
7652}
7653
7654fn normalize_windows_verbatim_path(path: PathBuf) -> PathBuf {
7655 PathBuf::from(strip_windows_verbatim_prefix(&path.to_string_lossy()))
7656}
7657
7658fn strip_windows_verbatim_prefix(input: &str) -> &str {
7659 input.strip_prefix(r"\\?\").unwrap_or(input)
7660}
7661
7662fn path_identity_key(path: &Path) -> String {
7663 cross_platform_path_identity_key(path)
7664}
7665
7666fn cross_platform_path_identity_key(path: &Path) -> String {
7668 let mut value = path.to_string_lossy().replace('\\', "/");
7669 if let Some(mnt) = map_windows_path_to_wsl_mnt(&value) {
7670 value = mnt;
7671 } else if let Some(windows) = map_wsl_mnt_path_to_windows(&value) {
7672 if let Some(mnt) = map_windows_path_to_wsl_mnt(&windows) {
7673 value = mnt;
7674 } else {
7675 value = windows.replace('\\', "/");
7676 }
7677 }
7678 value.to_ascii_lowercase()
7680}
7681
7682fn worktree_runtime_key() -> String {
7686 static KEY: OnceLock<String> = OnceLock::new();
7687 KEY.get_or_init(|| {
7688 if is_wsl_runtime() {
7689 let distro = std::env::var("WSL_DISTRO_NAME")
7690 .ok()
7691 .map(|value| value.trim().to_string())
7692 .filter(|value| !value.is_empty())
7693 .unwrap_or_else(|| "wsl".to_string());
7694 format!("wsl-{}", sanitize_path_component(&distro).to_ascii_lowercase())
7695 } else {
7696 std::env::consts::OS.to_string()
7697 }
7698 })
7699 .clone()
7700}
7701
7702fn is_wsl_runtime() -> bool {
7703 if !cfg!(target_os = "linux") {
7704 return false;
7705 }
7706 if std::env::var_os("WSL_DISTRO_NAME").is_some() || std::env::var_os("WSL_INTEROP").is_some() {
7707 return true;
7708 }
7709 fs::read_to_string("/proc/version")
7711 .map(|content| {
7712 let lower = content.to_ascii_lowercase();
7713 lower.contains("microsoft") || lower.contains("wsl")
7714 })
7715 .unwrap_or(false)
7716}
7717
7718fn worktree_device_hardware_id(base_hardware_id: &str) -> String {
7721 format!("{base_hardware_id}@{}", worktree_runtime_key())
7722}
7723
7724fn ensure_mutation_event_identity(mut event: WorktreeMutationEvent) -> WorktreeMutationEvent {
7725 if event.fingerprint.as_ref().is_none_or(|value| value.is_empty()) {
7726 let fingerprint = mutation_event_fingerprint(&event);
7727 event.fingerprint = Some(fingerprint.clone());
7728 if event.id.is_empty() || !event.id.starts_with("wtmut_") {
7729 event.id = stable_event_id(&fingerprint);
7730 }
7731 } else if event.id.is_empty() {
7732 event.id = stable_event_id(event.fingerprint.as_deref().unwrap_or_default());
7733 }
7734 event
7735}
7736
7737fn ensure_commit_event_identity(mut commit: WorktreeCommitEvent) -> WorktreeCommitEvent {
7738 if commit.fingerprint.as_ref().is_none_or(|value| value.is_empty()) {
7739 let fingerprint = commit_event_fingerprint(&commit);
7740 commit.fingerprint = Some(fingerprint.clone());
7741 if commit.id.is_empty() || !commit.id.starts_with("wtmut_") {
7742 commit.id = stable_event_id(&fingerprint);
7743 }
7744 } else if commit.id.is_empty() {
7745 commit.id = stable_event_id(commit.fingerprint.as_deref().unwrap_or_default());
7746 }
7747 commit
7748}
7749
7750fn path_string_has_ignored_component(path: &str) -> bool {
7751 watch_ignore_rules().ignores_relative(path)
7753}
7754
7755fn is_skipped_discovery_dir(path: &Path) -> bool {
7756 watch_ignore_rules().skips_discovery_dir(path)
7757}
7758
7759fn relative_watch_path(root: &Path, path: &Path) -> Option<String> {
7765 if let Ok(relative) = path.strip_prefix(root) {
7766 return Some(normalize_relative_watch_path(&relative.to_string_lossy()));
7767 }
7768
7769 let root_norm = normalize_absolute_watch_path(root);
7770 let path_norm = normalize_absolute_watch_path(path);
7771 if path_norm == root_norm {
7772 return Some(String::new());
7773 }
7774 let prefix = format!("{root_norm}/");
7775 path_norm
7776 .strip_prefix(&prefix)
7777 .map(|relative| normalize_relative_watch_path(relative))
7778}
7779
7780fn normalize_absolute_watch_path(path: &Path) -> String {
7781 let mut value = path.to_string_lossy().replace('\\', "/");
7782 if let Some(stripped) = value.strip_prefix("//?/") {
7784 value = stripped.to_string();
7785 }
7786 if value.len() >= 2 {
7788 let bytes = value.as_bytes();
7789 if bytes[1] == b':' && bytes[0].is_ascii_alphabetic() {
7790 let mut chars = value.chars();
7791 let drive = chars.next().unwrap().to_ascii_lowercase();
7792 value = format!("{drive}{}", chars.as_str());
7793 }
7794 }
7795 value.trim_end_matches('/').to_string()
7796}
7797
7798fn relative_slash_path(root: &Path, path: &Path) -> Option<String> {
7799 relative_watch_path(root, path).or_else(|| {
7800 Some(normalize_relative_watch_path(
7801 &path.to_string_lossy().replace('\\', "/"),
7802 ))
7803 })
7804}
7805
7806fn is_ignored_path(root: &Path, path: &Path) -> bool {
7807 watch_ignore_rules_for_root(root).ignores_path(root, path)
7808}
7809
7810fn current_hostname() -> Option<String> {
7811 for key in ["COMPUTERNAME", "HOSTNAME", "NAME"] {
7812 if let Ok(value) = std::env::var(key) {
7813 let trimmed = value.trim();
7814 if !trimmed.is_empty() {
7815 return Some(trimmed.to_string());
7816 }
7817 }
7818 }
7819
7820 if let Ok(content) = fs::read_to_string("/etc/hostname") {
7821 let trimmed = content.trim();
7822 if !trimmed.is_empty() {
7823 return Some(trimmed.to_string());
7824 }
7825 }
7826
7827 if let Ok(output) = Command::new("hostname").output() {
7829 if output.status.success() {
7830 let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
7831 if !value.is_empty() {
7832 return Some(value);
7833 }
7834 }
7835 }
7836
7837 None
7838}
7839
7840fn is_background_child() -> bool {
7841 std::env::var(BACKGROUND_CHILD_ENV)
7842 .ok()
7843 .map(|value| value == "1")
7844 .unwrap_or(false)
7845}
7846
7847#[allow(dead_code)]
7848fn content_sha256(path: &Path) -> Option<String> {
7849 let bytes = fs::read(path).ok()?;
7850 let mut hasher = Sha256::new();
7851 hasher.update(bytes);
7852 Some(format!("{:x}", hasher.finalize()))
7853}
7854
7855#[cfg(test)]
7856mod tests {
7857 use super::*;
7858 use std::io::Write;
7859
7860 #[test]
7861 fn remove_all_watcher_state_files_clears_legacy_and_runtime() {
7862 let dir = std::env::temp_dir().join(format!(
7863 "xbp-watcher-state-test-{}",
7864 std::process::id()
7865 ));
7866 let _ = fs::remove_dir_all(&dir);
7867 fs::create_dir_all(&dir).expect("temp dir");
7868 let primary = dir.join("watcher-state-windows.json");
7869 let legacy = dir.join("watcher-state.json");
7870 let wsl = dir.join("watcher-state-wsl-ubuntu.json");
7871 for path in [&primary, &legacy, &wsl] {
7872 let mut f = fs::File::create(path).expect("create");
7873 writeln!(f, "{{}}").expect("write");
7874 }
7875 let removed = remove_all_watcher_state_files(&primary);
7876 assert!(removed >= 3, "removed={removed}");
7877 assert!(!primary.exists());
7878 assert!(!legacy.exists());
7879 assert!(!wsl.exists());
7880 assert_eq!(remove_all_watcher_state_files(&primary), 0);
7881 let _ = fs::remove_dir_all(&dir);
7882 }
7883
7884 #[test]
7885 fn parses_https_and_ssh_remote_urls() {
7886 assert_eq!(
7887 parse_remote_owner_repo("https://github.com/xylex-group/xbp.git"),
7888 Some(("xylex-group".to_string(), "xbp".to_string()))
7889 );
7890 assert_eq!(
7891 parse_remote_owner_repo("git@github.com:xylex-group/xbp.git"),
7892 Some(("xylex-group".to_string(), "xbp".to_string()))
7893 );
7894 }
7895
7896 #[test]
7897 fn sanitizes_branch_for_storage_path() {
7898 assert_eq!(
7899 sanitize_path_component("feature/worktree watcher"),
7900 "feature-worktree-watcher"
7901 );
7902 }
7903
7904 #[test]
7905 fn normalizes_windows_verbatim_repo_roots() {
7906 assert_eq!(
7907 normalize_windows_verbatim_path(PathBuf::from(
7908 r"\\?\C:\Users\floris\Documents\GitHub\mollie-api-rust"
7909 )),
7910 PathBuf::from(r"C:\Users\floris\Documents\GitHub\mollie-api-rust")
7911 );
7912 }
7913
7914 #[test]
7915 fn parent_path_matching_uses_repo_boundaries() {
7916 let repo = Path::new(r"C:\Users\floris\Documents\GitHub\xbp");
7917 assert!(path_belongs_to_repo(
7918 Path::new(r"C:\Users\floris\Documents\GitHub\xbp\src\main.rs"),
7919 repo
7920 ));
7921 assert!(!path_belongs_to_repo(
7922 Path::new(r"C:\Users\floris\Documents\GitHub\xbp-other\src\main.rs"),
7923 repo
7924 ));
7925 }
7926
7927 #[test]
7928 fn adaptive_tiers_follow_idle_thresholds() {
7929 let cfg = WorktreeWatchConfig {
7930 idle_after_seconds: Some(300),
7931 cold_after_seconds: Some(1800),
7932 ..Default::default()
7933 };
7934 assert_eq!(tier_from_idle(0, &cfg), RepoWatchTier::Hot);
7935 assert_eq!(tier_from_idle(299, &cfg), RepoWatchTier::Hot);
7936 assert_eq!(tier_from_idle(300, &cfg), RepoWatchTier::Warm);
7937 assert_eq!(tier_from_idle(1799, &cfg), RepoWatchTier::Warm);
7938 assert_eq!(tier_from_idle(1800, &cfg), RepoWatchTier::Cold);
7939 assert!(cfg.hot_commit_check_seconds() < cfg.warm_commit_check_seconds());
7940 assert!(cfg.warm_commit_check_seconds() < cfg.cold_commit_check_seconds());
7941 }
7942
7943 #[test]
7944 fn default_resource_knobs_are_tighter() {
7945 let cfg = WorktreeWatchConfig::default();
7946 assert_eq!(cfg.idle_after_seconds(), 180);
7947 assert_eq!(cfg.cold_after_seconds(), 600);
7948 assert_eq!(cfg.max_hot_repos(), 4);
7949 assert_eq!(cfg.max_fs_watches(), 6);
7950 assert_eq!(cfg.max_commit_checks_per_tick(), 6);
7951 assert_eq!(cfg.warm_commit_check_seconds(), 120);
7952 assert_eq!(cfg.cold_commit_check_seconds(), 300);
7953 assert_eq!(cfg.spool_retention_days(), 7);
7954 assert_eq!(cfg.parent_rescan_seconds(), 300);
7955 }
7956
7957 #[test]
7958 fn fs_watch_budget_prefers_hot_and_caps() {
7959 let base = std::env::temp_dir().join(format!("xbp-fs-budget-{}", Uuid::new_v4()));
7960 let dir_a = base.join("a");
7961 let dir_b = base.join("b");
7962 let dir_c = base.join("c");
7963 let dir_d = base.join("d");
7964 for dir in [&dir_a, &dir_b, &dir_c, &dir_d] {
7965 fs::create_dir_all(dir).unwrap();
7966 }
7967 let now = Instant::now();
7968 let mk = |root: &Path, tier: RepoWatchTier, score: f64| -> WatchedRepo {
7969 WatchedRepo {
7970 identity: RepoIdentity {
7971 owner: "o".into(),
7972 name: root
7973 .file_name()
7974 .and_then(|n| n.to_str())
7975 .unwrap_or("r")
7976 .to_string(),
7977 branch: "main".into(),
7978 root: root.to_path_buf(),
7979 head_sha: None,
7980 },
7981 layout: spool_layout_for_root(root.join(".xbp-spool-test"), None),
7982 last_head: None,
7983 event_dedupe: RecentEventDedupe::default(),
7984 last_activity: now,
7985 activity_score: score,
7986 tier,
7987 fs_watched: false,
7988 next_commit_check: now,
7989 }
7990 };
7991 let repos = vec![
7992 mk(&dir_a, RepoWatchTier::Hot, 1.0),
7993 mk(&dir_b, RepoWatchTier::Warm, 50.0),
7994 mk(&dir_c, RepoWatchTier::Warm, 40.0),
7995 mk(&dir_d, RepoWatchTier::Cold, 99.0),
7996 ];
7997 let want = fs_watch_want_mask(&repos, 2);
7998 let _ = fs::remove_dir_all(&base);
7999 assert!(want[0], "hot always preferred");
8000 assert!(want[1], "highest warm fills remaining budget");
8001 assert!(!want[2], "third non-cold loses budget");
8002 assert!(!want[3], "cold never watches");
8003 }
8004
8005 #[test]
8006 fn prune_spool_removes_old_seen_and_synced() {
8007 let temp = std::env::temp_dir().join(format!("xbp-prune-{}", Uuid::new_v4()));
8008 let _ = fs::remove_dir_all(&temp);
8009 fs::create_dir_all(&temp).unwrap();
8010 let layout = spool_layout_for_root(temp.clone(), None);
8011 fs::create_dir_all(&layout.event_dedupe_dir).unwrap();
8012 fs::create_dir_all(&layout.commit_dedupe_dir).unwrap();
8013 let old_seen = layout.event_dedupe_dir.join("abc.seen");
8014 let fresh_seen = layout.event_dedupe_dir.join("def.seen");
8015 fs::write(&old_seen, "old").unwrap();
8016 fs::write(&fresh_seen, "new").unwrap();
8017 let synced = layout.root.join("events-1.synced.1.jsonl");
8018 fs::write(&synced, "{}\n").unwrap();
8019 let fat_dedupe = layout.event_dedupe_file.clone();
8020 fs::write(
8021 &fat_dedupe,
8022 vec![b'x'; (MAX_EVENT_DEDUPE_JSONL_BYTES as usize) + 8],
8023 )
8024 .unwrap();
8025
8026 let removed = prune_spool_layout(&layout, Duration::from_secs(0)).unwrap();
8028 let fat_gone =
8029 !fat_dedupe.exists() || fs::metadata(&fat_dedupe).map(|m| m.len()).unwrap_or(0) == 0;
8030 let _ = fs::remove_dir_all(&temp);
8031 assert!(removed >= 3, "expected seen+synced+rotate, got {removed}");
8032 assert!(fat_gone, "oversized dedupe log should be rotated or truncated");
8033 }
8034
8035 #[test]
8036 fn load_event_dedupe_does_not_read_file() {
8037 let temp = std::env::temp_dir().join(format!("xbp-dedupe-load-{}", Uuid::new_v4()));
8038 let _ = fs::remove_dir_all(&temp);
8039 fs::create_dir_all(&temp).unwrap();
8040 let path = temp.join("event-dedupe.jsonl");
8041 let line = r#"{"fingerprint":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}"#;
8043 let mut body = String::new();
8044 for _ in 0..20_000 {
8045 body.push_str(line);
8046 body.push('\n');
8047 }
8048 fs::write(&path, body).unwrap();
8049 let set = load_event_dedupe_fingerprints(&path).unwrap();
8050 let _ = fs::remove_dir_all(&temp);
8051 assert!(set.is_empty());
8052 }
8053
8054 #[test]
8055 fn activity_score_bumps_and_caps() {
8056 let mut score = 0.0;
8057 for _ in 0..20 {
8058 score = bump_activity_score(score);
8059 }
8060 assert!(score > 5.0);
8061 let capped = bump_activity_score(10_000.0);
8062 assert!(capped <= 10_000.0);
8063 }
8064
8065 #[test]
8066 fn truncate_middle_keeps_ends() {
8067 let s = truncate_middle("owner/very-long-repository-name-here", 20);
8068 assert!(s.chars().count() <= 20);
8069 assert!(s.contains('…') || s.len() <= 20);
8070 }
8071
8072 #[test]
8073 fn rank_index_keys_by_path_identity() {
8074 let cfg = WorktreeWatchConfig {
8075 repo_ranks: vec![WorktreeWatchRepoRank {
8076 root: r"C:\Users\floris\Documents\GitHub\xbp".to_string(),
8077 owner: Some("x".into()),
8078 name: Some("xbp".into()),
8079 score: 3.5,
8080 last_event_at: None,
8081 rank: Some(1),
8082 tier: Some("hot".into()),
8083 }],
8084 ..Default::default()
8085 };
8086 let index = build_repo_rank_index(&cfg);
8087 let key = path_identity_key(Path::new(r"C:\Users\floris\Documents\GitHub\xbp"));
8088 assert!(index.contains_key(&key));
8089 assert_eq!(index.get(&key).map(|r| r.score), Some(3.5));
8090 }
8091
8092 #[test]
8093 fn skips_heavy_discovery_directories() {
8094 assert!(is_skipped_discovery_dir(Path::new("node_modules")));
8095 assert!(is_skipped_discovery_dir(Path::new("target")));
8096 assert!(is_skipped_discovery_dir(Path::new("dist")));
8097 assert!(is_skipped_discovery_dir(Path::new(".next")));
8098 assert!(is_skipped_discovery_dir(Path::new(".nx")));
8099 assert!(is_skipped_discovery_dir(Path::new(".xbp")));
8100 assert!(is_skipped_discovery_dir(Path::new(".cargo")));
8101 assert!(is_skipped_discovery_dir(Path::new(".codex")));
8102 assert!(is_skipped_discovery_dir(Path::new(".cursor")));
8103 assert!(is_skipped_discovery_dir(Path::new(".agents")));
8104 assert!(is_skipped_discovery_dir(Path::new(".idea")));
8105 assert!(is_skipped_discovery_dir(Path::new(".github")));
8106 assert!(is_skipped_discovery_dir(Path::new(".postman")));
8107 assert!(is_skipped_discovery_dir(Path::new("mcps")));
8108 assert!(is_skipped_discovery_dir(Path::new("agent-tools")));
8109 assert!(is_skipped_discovery_dir(Path::new("target-publish")));
8110 assert!(is_skipped_discovery_dir(Path::new("__pycache__")));
8111 assert!(is_skipped_discovery_dir(Path::new("terminals")));
8112 assert!(is_skipped_discovery_dir(Path::new(".open-next")));
8113 assert!(!is_skipped_discovery_dir(Path::new("mollie-api-rust")));
8114 }
8115
8116 #[test]
8117 fn ignores_nested_generated_mutation_paths() {
8118 let root = Path::new(r"C:\Users\floris\Documents\GitHub\athena");
8121 assert!(is_ignored_path(
8122 root,
8123 Path::new(
8124 r"C:\Users\floris\Documents\GitHub\athena\apps\docs\node_modules\@aws-sdk\client-lambda\dist-types\schemas"
8125 )
8126 ));
8127 assert!(is_ignored_path(
8128 root,
8129 Path::new(r"C:\Users\floris\Documents\GitHub\athena\target\debug\build")
8130 ));
8131 assert!(is_ignored_path(
8133 root,
8134 Path::new(r"C:\Users\floris\Documents\GitHub\athena\.nx\cache\run.json")
8135 ));
8136 assert!(is_ignored_path(
8137 root,
8138 Path::new(
8139 r"C:\Users\floris\Documents\GitHub\athena\apps\docs\.open-next\server-functions\default\handler.mjs"
8140 )
8141 ));
8142 assert!(!is_ignored_path(
8143 root,
8144 Path::new(r"C:\Users\floris\Documents\GitHub\athena\apps\web\src\main.ts")
8145 ));
8146
8147 let root_slash = Path::new("C:/Users/floris/Documents/GitHub/athena");
8149 assert!(is_ignored_path(
8150 root_slash,
8151 Path::new(
8152 "C:/Users/floris/Documents/GitHub/athena/apps/docs/node_modules/@aws-sdk/client-lambda/dist-types/schemas"
8153 )
8154 ));
8155 assert!(path_string_has_ignored_component(
8156 "apps/docs/node_modules/@aws-sdk/client-lambda/dist-types/schemas"
8157 ));
8158 }
8159
8160 #[test]
8161 fn global_config_forbidden_paths_folders_and_banned_words() {
8162 let rules = WatchIgnoreRules::from_config(&WorktreeWatchConfig {
8163 forbidden_paths: vec!["secrets/".to_string(), "apps/web/.env.local".to_string()],
8164 forbidden_folders: vec![".cache".to_string(), "coverage".to_string()],
8165 banned_words: vec!["private-key".to_string(), "CREDENTIAL".to_string()],
8166 ..Default::default()
8167 });
8168
8169 assert!(rules.ignores_relative("secrets/prod/api.key"));
8171 assert!(rules.ignores_relative("apps/web/.env.local"));
8172 assert!(!rules.ignores_relative("apps/web/.env.local.bak"));
8174
8175 assert!(rules.ignores_relative("apps/web/.cache/tmp"));
8177 assert!(rules.ignores_relative("packages/foo/coverage/index.html"));
8178
8179 assert!(rules.ignores_relative("docs/private-key-notes.md"));
8181 assert!(rules.ignores_relative("config/my-credential-store.json"));
8182
8183 assert!(!rules.ignores_relative("apps/web/src/main.ts"));
8185 assert!(!rules.skips_discovery_dir(Path::new("mollie-api-rust")));
8186 assert!(rules.skips_discovery_dir(Path::new(".cache")));
8187 assert!(rules.skips_discovery_dir(Path::new("node_modules"))); }
8189
8190 #[test]
8191 fn global_worktree_ignore_file_honors_mcps_patterns() {
8192 let root = std::env::temp_dir().join(format!(
8193 "xbp-global-watch-ignore-{}-{}",
8194 std::process::id(),
8195 std::time::SystemTime::now()
8196 .duration_since(std::time::UNIX_EPOCH)
8197 .expect("time")
8198 .as_nanos()
8199 ));
8200 let _ = fs::remove_dir_all(&root);
8201 fs::create_dir_all(&root).expect("global root");
8202
8203 let ignore_path = root.join(crate::utils::GLOBAL_WORKTREE_IGNORE_FILENAME);
8204 fs::write(&ignore_path, "mcps/\n/custom-global/\n").expect("write global ignore");
8205
8206 let rules = WatchIgnoreRules {
8207 forbidden_path_prefixes: Vec::new(),
8208 forbidden_folders: BTreeSet::new(),
8209 banned_words: Vec::new(),
8210 global_ignore: crate::utils::load_global_worktree_ignore_from_path(&ignore_path),
8211 project_ignore: crate::utils::XbpIgnoreSet::empty(),
8212 };
8213
8214 assert!(rules.ignores_relative("mcps/github/catalog.json"));
8215 assert!(rules.ignores_relative("apps/api/mcps/local"));
8216 assert!(rules.ignores_relative("custom-global/notes.md"));
8217 assert!(!rules.ignores_relative("apps/web/src/main.ts"));
8218 assert!(rules.skips_discovery_dir(Path::new("mcps")));
8219
8220 let _ = fs::remove_dir_all(root);
8221 }
8222
8223 #[test]
8224 fn project_xbpignore_is_honored_by_worktree_watch_rules() {
8225 let root = std::env::temp_dir().join(format!(
8226 "xbp-watch-ignore-{}-{}",
8227 std::process::id(),
8228 std::time::SystemTime::now()
8229 .duration_since(std::time::UNIX_EPOCH)
8230 .expect("time")
8231 .as_nanos()
8232 ));
8233 let _ = fs::remove_dir_all(&root);
8234 fs::create_dir_all(root.join(".xbp")).expect("xbp dir");
8235 fs::write(
8236 root.join(".xbp").join(".xbpignore"),
8237 "packages/icons/\n*.generated.ts\n",
8238 )
8239 .expect("write ignore");
8240 fs::write(
8241 root.join(".xbp").join("xbp.yaml"),
8242 "project_name: demo\nversion: 0.1.0\nport: 3000\nbuild_dir: ./\nignore_paths:\n - e2e/\nwatch_ignore_paths:\n - tmp-watch/\n",
8243 )
8244 .expect("write config");
8245
8246 let rules = WatchIgnoreRules::from_config_and_project(
8247 &WorktreeWatchConfig::default(),
8248 Some(&root),
8249 );
8250 assert!(rules.ignores_relative("packages/icons/src/index.ts"));
8252 assert!(rules.ignores_relative("apps/web/foo.generated.ts"));
8253 assert!(!rules.ignores_relative("e2e/login.spec.ts"));
8255 assert!(rules.ignores_relative("tmp-watch/file.ts"));
8257 assert!(!rules.ignores_relative("apps/web/src/main.ts"));
8258
8259 let _ = fs::remove_dir_all(root);
8260 }
8261
8262 #[test]
8263 fn builds_coding_stats_by_file_and_filetype() {
8264 let identity = RepoIdentity {
8265 owner: "xylex-group".to_string(),
8266 name: "xbp".to_string(),
8267 branch: "main".to_string(),
8268 root: PathBuf::from(r"C:\Users\floris\Documents\GitHub\xbp"),
8269 head_sha: None,
8270 };
8271 let layout = SpoolLayout {
8272 root: PathBuf::from(r"C:\Users\floris\.xbp\mutations\xylex-group\xbp\main"),
8273 events_file: PathBuf::from("events.jsonl"),
8274 commits_file: PathBuf::from("commits.jsonl"),
8275 stats_file: PathBuf::from("stats.json"),
8276 watcher_state_file: PathBuf::from("watcher-state.json"),
8277 sync_log_file: PathBuf::from("sync-log.jsonl"),
8278 event_dedupe_file: PathBuf::from("event-dedupe.jsonl"),
8279 event_dedupe_dir: PathBuf::from("event-dedupe"),
8280 commit_dedupe_dir: PathBuf::from("commit-dedupe"),
8281 };
8282 let candidates = vec![SyncCandidate {
8283 path: PathBuf::from("events.jsonl"),
8284 records: SyncRecords::Events(vec![
8285 stats_test_event_record("2026-07-08T10:00:00Z", "src/main.rs", 4, 1),
8286 stats_test_event_record("2026-07-08T10:05:00Z", "src/main.rs", 2, 0),
8287 stats_test_event_record("2026-07-08T11:00:00Z", "README.md", 1, 3),
8288 ]),
8289 }];
8290
8291 let summary = build_stats_summary(&identity, &layout, &candidates, 15).unwrap();
8292
8293 assert_eq!(summary.total_events, 3);
8294 assert_eq!(summary.estimated_coding_seconds, 60 + 300 + 900);
8295 assert_eq!(summary.session_count, 2);
8296 assert_eq!(summary.observed_span_seconds, 60 * 60);
8297 assert_eq!(summary.added_lines, 7);
8298 assert_eq!(summary.removed_lines, 4);
8299 assert_eq!(summary.by_file[0].path, "README.md");
8300 assert_eq!(summary.by_file[0].estimated_coding_seconds, 900);
8301 assert_eq!(summary.by_filetype[0].name, "md");
8302 assert_eq!(summary.by_filetype[0].estimated_coding_seconds, 900);
8303 assert_eq!(summary.by_filetype[1].name, "rs");
8304 assert_eq!(summary.by_filetype[1].estimated_coding_seconds, 360);
8305 }
8306
8307 #[test]
8308 fn builds_repo_activity_across_branch_spools() {
8309 let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
8310 let main_root = temp_root.join("main");
8311 let feature_root = temp_root.join("feature-login");
8312 fs::create_dir_all(&main_root).unwrap();
8313 fs::create_dir_all(&feature_root).unwrap();
8314
8315 append_json_line(
8316 &main_root.join("events-main.jsonl"),
8317 &stats_test_event_record("2026-07-08T10:00:00Z", "src/main.rs", 4, 1),
8318 )
8319 .unwrap();
8320 append_json_line(
8321 &main_root.join("events-main.jsonl"),
8322 &stats_test_event_record("2026-07-08T10:10:00Z", "src/lib.rs", 2, 0),
8323 )
8324 .unwrap();
8325 append_json_line(
8326 &feature_root.join("events-feature.jsonl"),
8327 &stats_test_event_record("2026-07-08T11:00:00Z", "src/auth.rs", 10, 2),
8328 )
8329 .unwrap();
8330 append_json_line(
8331 &feature_root.join("events-feature.jsonl"),
8332 &stats_test_event_record("2026-07-08T11:30:00Z", "src/auth.rs", 3, 1),
8333 )
8334 .unwrap();
8335 append_json_line(
8336 &feature_root.join("commits-feature.jsonl"),
8337 &worktree_commit_test_event("previous", "current"),
8338 )
8339 .unwrap();
8340
8341 let identity = RepoIdentity {
8342 owner: "xylex-group".to_string(),
8343 name: "xbp".to_string(),
8344 branch: "main".to_string(),
8345 root: PathBuf::from(r"C:\Users\floris\Documents\GitHub\xbp"),
8346 head_sha: None,
8347 };
8348
8349 let summary = build_repo_activity_summary(&identity, &temp_root, 15).unwrap();
8350
8351 assert_eq!(summary.branch_count, 2);
8352 assert_eq!(summary.total_events, 4);
8353 assert_eq!(summary.total_commits, 1);
8354 assert_eq!(summary.session_count, 3);
8355 assert_eq!(summary.estimated_coding_seconds, 60 + 600 + 60 + 900);
8356 assert_eq!(summary.observed_span_seconds, 90 * 60);
8357 assert_eq!(summary.branches[0].branch_name, "feature-login");
8358 assert_eq!(summary.branches[0].session_count, 2);
8359 assert_eq!(summary.branches[0].observed_span_seconds, 30 * 60);
8360 assert_eq!(summary.branches[1].branch_name, "main");
8361
8362 let _ = fs::remove_dir_all(temp_root);
8363 }
8364
8365 #[test]
8366 fn mutation_fingerprint_ignores_random_event_id_but_keeps_time_bucket() {
8367 let first: WorktreeMutationEvent = serde_json::from_value(stats_test_event(
8368 "2026-07-08T10:00:00Z",
8369 "src/main.rs",
8370 4,
8371 1,
8372 ))
8373 .unwrap();
8374 let same_bucket: WorktreeMutationEvent = serde_json::from_value(stats_test_event(
8375 "2026-07-08T10:00:00Z",
8376 "src/main.rs",
8377 4,
8378 1,
8379 ))
8380 .unwrap();
8381 let next_bucket: WorktreeMutationEvent = serde_json::from_value(stats_test_event(
8382 "2026-07-08T10:00:06Z",
8383 "src/main.rs",
8384 4,
8385 1,
8386 ))
8387 .unwrap();
8388
8389 assert_eq!(
8390 mutation_event_fingerprint(&first),
8391 mutation_event_fingerprint(&same_bucket)
8392 );
8393 assert_ne!(
8394 mutation_event_fingerprint(&first),
8395 mutation_event_fingerprint(&next_bucket)
8396 );
8397
8398 let mut windows_root = first.clone();
8400 windows_root.repo_root = r"C:\Users\floris\Documents\GitHub\xbp".to_string();
8401 let mut wsl_root = first.clone();
8402 wsl_root.repo_root = "/mnt/c/Users/floris/Documents/GitHub/xbp".to_string();
8403 assert_eq!(
8404 mutation_event_fingerprint(&windows_root),
8405 mutation_event_fingerprint(&wsl_root)
8406 );
8407
8408 let fingerprint = mutation_event_fingerprint(&first);
8409 assert_eq!(stable_event_id(&fingerprint), format!("wtmut_{fingerprint}"));
8410 assert_eq!(
8411 ensure_mutation_event_identity(first.clone()).id,
8412 stable_event_id(&fingerprint)
8413 );
8414 }
8415
8416 #[test]
8417 fn runtime_key_and_device_id_are_stable_and_scoped() {
8418 let key = worktree_runtime_key();
8419 assert!(!key.is_empty());
8420 assert_eq!(key, worktree_runtime_key());
8421 assert!(
8423 key == "windows"
8424 || key == "linux"
8425 || key == "macos"
8426 || key.starts_with("wsl-")
8427 || key == "wsl",
8428 "unexpected runtime key {key}"
8429 );
8430 let device = worktree_device_hardware_id("xbp_hw_test");
8431 assert_eq!(device, format!("xbp_hw_test@{key}"));
8432 }
8433
8434 #[test]
8435 fn should_poll_only_for_wsl_mnt_paths() {
8436 let mnt = Path::new("/mnt/c/Users/floris/Documents/GitHub/xbp");
8438 let home = Path::new("/home/floris/src/xbp");
8439 if is_wsl_runtime() {
8440 assert!(should_use_poll_watcher(mnt));
8441 assert!(!should_use_poll_watcher(home));
8442 } else {
8443 assert!(!should_use_poll_watcher(mnt));
8444 assert!(!should_use_poll_watcher(home));
8445 }
8446 }
8447
8448 #[test]
8449 fn repository_spool_root_uses_global_xbp_home_mutations() {
8450 let identity = RepoIdentity {
8451 owner: "xylex-group".to_string(),
8452 name: "xbp".to_string(),
8453 branch: "main".to_string(),
8454 root: PathBuf::from("/tmp/xbp"),
8455 head_sha: None,
8456 };
8457 let root = repository_spool_root(&identity).expect("global xbp home");
8458 let rendered = root.to_string_lossy().replace('\\', "/");
8459 assert!(
8460 rendered.contains("/mutations/"),
8461 "expected home-level mutations path, got {rendered}"
8462 );
8463 assert!(
8464 !rendered.contains("/mutations/runtimes/"),
8465 "spool must not be runtime-split when sharing one established home: {rendered}"
8466 );
8467 assert!(
8468 rendered.ends_with("xylex-group/xbp") || rendered.contains("xylex-group/xbp"),
8469 "expected owner/name suffix, got {rendered}"
8470 );
8471 assert!(!rendered.contains("/tmp/xbp/"));
8473 }
8474
8475 #[test]
8476 fn cross_platform_path_identity_reconciles_wsl_and_windows() {
8477 let windows = Path::new(r"C:\Users\szymon\.xbp\mutations\SuitsFinance\speedrun-formations\main");
8478 let wsl = Path::new(
8479 "/mnt/c/Users/szymon/.xbp/mutations/SuitsFinance/speedrun-formations/main",
8480 );
8481 assert_eq!(
8482 cross_platform_path_identity_key(windows),
8483 cross_platform_path_identity_key(wsl)
8484 );
8485 }
8486
8487 #[test]
8488 fn command_line_mentions_path_is_case_and_slash_insensitive() {
8489 let cmdline = r#""C:\xbp.exe" worktree-watch start --parent C:\Users\floris\Documents\GitHub"#;
8490 assert!(command_line_mentions_path(
8491 cmdline,
8492 "/mnt/c/users/floris/documents/github"
8493 ));
8494 assert!(command_line_mentions_path(
8495 cmdline,
8496 r"C:\Users\floris\Documents\GitHub"
8497 ));
8498 assert!(command_line_mentions_path(cmdline, "GitHub"));
8499 }
8500
8501 #[test]
8502 fn append_unique_mutation_event_is_idempotent_across_dedupe_instances() {
8503 let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
8504 fs::create_dir_all(&temp_root).unwrap();
8505 let layout = SpoolLayout {
8506 root: temp_root.clone(),
8507 events_file: temp_root.join("events.jsonl"),
8508 commits_file: temp_root.join("commits.jsonl"),
8509 stats_file: temp_root.join("stats.json"),
8510 watcher_state_file: temp_root.join("watcher-state.json"),
8511 sync_log_file: temp_root.join("sync-log.jsonl"),
8512 event_dedupe_file: temp_root.join("event-dedupe.jsonl"),
8513 event_dedupe_dir: temp_root.join("event-dedupe"),
8514 commit_dedupe_dir: temp_root.join("commit-dedupe"),
8515 };
8516 let mutation: WorktreeMutationEvent = serde_json::from_value(stats_test_event(
8517 "2026-07-08T10:00:00Z",
8518 "src/main.rs",
8519 4,
8520 1,
8521 ))
8522 .unwrap();
8523
8524 let first =
8525 append_unique_mutation_event(&layout, &mutation, &mut RecentEventDedupe::default())
8526 .unwrap();
8527 let second =
8528 append_unique_mutation_event(&layout, &mutation, &mut RecentEventDedupe::default())
8529 .unwrap();
8530
8531 assert!(first);
8532 assert!(!second);
8533 let events = read_jsonl_values(&layout.events_file).unwrap();
8534 assert_eq!(events.len(), 1);
8535 assert_eq!(
8536 events[0].get("fingerprint").and_then(Value::as_str),
8537 Some(mutation_event_fingerprint(&mutation).as_str())
8538 );
8539 let _ = fs::remove_dir_all(temp_root);
8540 }
8541
8542 #[test]
8543 fn reads_spool_records_as_typed_events() {
8544 let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
8545 fs::create_dir_all(&temp_root).unwrap();
8546 let events_file = temp_root.join("events.jsonl");
8547 append_json_line(
8548 &events_file,
8549 &stats_test_event_record("2026-07-08T10:00:00Z", "src/main.rs", 4, 1),
8550 )
8551 .unwrap();
8552
8553 let records = read_spool_records(&events_file, SyncFileKind::Events).unwrap();
8554
8555 match records {
8556 SyncRecords::Events(events) => {
8557 assert_eq!(events.len(), 1);
8558 assert_eq!(events[0].primary_path.as_deref(), Some("src/main.rs"));
8559 }
8560 SyncRecords::Commits(_) => panic!("expected typed event records"),
8561 }
8562 let _ = fs::remove_dir_all(temp_root);
8563 }
8564
8565 #[test]
8566 fn read_spool_records_skips_nul_only_padding() {
8567 let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
8568 fs::create_dir_all(&temp_root).unwrap();
8569 let events_file = temp_root.join("events.jsonl");
8570 fs::write(&events_file, vec![0; 4096]).unwrap();
8571
8572 let records = read_spool_records(&events_file, SyncFileKind::Events).unwrap();
8573
8574 match records {
8575 SyncRecords::Events(events) => assert!(events.is_empty()),
8576 SyncRecords::Commits(_) => panic!("expected event records"),
8577 }
8578 let _ = fs::remove_dir_all(temp_root);
8579 }
8580
8581 #[test]
8582 fn read_jsonl_strips_leading_nuls_before_json() {
8583 let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
8584 fs::create_dir_all(&temp_root).unwrap();
8585 let path = temp_root.join("event-dedupe.jsonl");
8586 let mut bytes = vec![0u8; 64];
8587 bytes.extend_from_slice(
8588 br#"{"fingerprint":"abc","firstSeenAt":"2026-07-09T00:00:00Z","eventKind":"modify","primaryPath":".xbp"}"#,
8589 );
8590 bytes.push(b'\n');
8591 fs::write(&path, bytes).unwrap();
8592
8593 let values = read_jsonl_values(&path).unwrap();
8594 assert_eq!(values.len(), 1);
8595 assert_eq!(
8596 values[0].get("fingerprint").and_then(Value::as_str),
8597 Some("abc")
8598 );
8599 let _ = fs::remove_dir_all(temp_root);
8600 }
8601
8602 #[test]
8603 fn read_jsonl_skips_garbage_lines() {
8604 let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
8605 fs::create_dir_all(&temp_root).unwrap();
8606 let path = temp_root.join("mixed.jsonl");
8607 fs::write(
8608 &path,
8609 "not-json\n{\"fingerprint\":\"ok\"}\n\n{\"broken\":\n",
8610 )
8611 .unwrap();
8612
8613 let values = read_jsonl_values(&path).unwrap();
8614 assert_eq!(values.len(), 1);
8615 assert_eq!(
8616 values[0].get("fingerprint").and_then(Value::as_str),
8617 Some("ok")
8618 );
8619 let _ = fs::remove_dir_all(temp_root);
8620 }
8621
8622 #[test]
8623 fn collect_spool_candidates_rewrites_legacy_generated_events() {
8624 let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
8625 fs::create_dir_all(&temp_root).unwrap();
8626 let events_file = temp_root.join("events-legacy.jsonl");
8627 append_json_line(
8628 &events_file,
8629 &stats_test_event_record(
8630 "2026-07-08T10:00:00Z",
8631 "apps/docs/node_modules/@aws-sdk/client-lambda/dist-types/schemas",
8632 0,
8633 0,
8634 ),
8635 )
8636 .unwrap();
8637 append_json_line(
8638 &events_file,
8639 &stats_test_event_record("2026-07-08T10:01:00Z", "apps/web/src/main.ts", 4, 1),
8640 )
8641 .unwrap();
8642 let layout = SpoolLayout {
8643 root: temp_root.clone(),
8644 events_file: temp_root.join("events.jsonl"),
8645 commits_file: temp_root.join("commits.jsonl"),
8646 stats_file: temp_root.join("stats.json"),
8647 watcher_state_file: temp_root.join("watcher-state.json"),
8648 sync_log_file: temp_root.join("sync-log.jsonl"),
8649 event_dedupe_file: temp_root.join("event-dedupe.jsonl"),
8650 event_dedupe_dir: temp_root.join("event-dedupe"),
8651 commit_dedupe_dir: temp_root.join("commit-dedupe"),
8652 };
8653
8654 let candidates = collect_spool_candidates(&layout, false).unwrap();
8655
8656 assert_eq!(candidates.len(), 1);
8657 match &candidates[0].records {
8658 SyncRecords::Events(events) => {
8659 assert_eq!(events.len(), 1);
8660 assert_eq!(
8661 events[0].primary_path.as_deref(),
8662 Some("apps/web/src/main.ts")
8663 );
8664 }
8665 SyncRecords::Commits(_) => panic!("expected event spool candidate"),
8666 }
8667 let rewritten = read_jsonl_records::<WorktreeMutationEvent>(&events_file).unwrap();
8668 assert_eq!(rewritten.len(), 1);
8669 assert_eq!(
8670 rewritten[0].primary_path.as_deref(),
8671 Some("apps/web/src/main.ts")
8672 );
8673 let _ = fs::remove_dir_all(temp_root);
8674 }
8675
8676 #[test]
8677 fn collect_spool_candidates_deletes_fully_ignored_event_files() {
8678 let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
8679 fs::create_dir_all(&temp_root).unwrap();
8680 let events_file = temp_root.join("events-legacy.jsonl");
8681 append_json_line(
8682 &events_file,
8683 &stats_test_event_record(
8684 "2026-07-08T10:00:00Z",
8685 "apps/docs/node_modules/@aws-sdk/client-lambda/dist-types/schemas",
8686 0,
8687 0,
8688 ),
8689 )
8690 .unwrap();
8691 let layout = SpoolLayout {
8692 root: temp_root.clone(),
8693 events_file: temp_root.join("events.jsonl"),
8694 commits_file: temp_root.join("commits.jsonl"),
8695 stats_file: temp_root.join("stats.json"),
8696 watcher_state_file: temp_root.join("watcher-state.json"),
8697 sync_log_file: temp_root.join("sync-log.jsonl"),
8698 event_dedupe_file: temp_root.join("event-dedupe.jsonl"),
8699 event_dedupe_dir: temp_root.join("event-dedupe"),
8700 commit_dedupe_dir: temp_root.join("commit-dedupe"),
8701 };
8702
8703 let candidates = collect_spool_candidates(&layout, false).unwrap();
8704
8705 assert!(candidates.is_empty());
8706 assert!(!events_file.exists());
8707 let _ = fs::remove_dir_all(temp_root);
8708 }
8709
8710 #[test]
8711 fn append_unique_commit_event_is_idempotent_across_watcher_instances() {
8712 let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
8713 fs::create_dir_all(&temp_root).unwrap();
8714 let layout = SpoolLayout {
8715 root: temp_root.clone(),
8716 events_file: temp_root.join("events.jsonl"),
8717 commits_file: temp_root.join("commits.jsonl"),
8718 stats_file: temp_root.join("stats.json"),
8719 watcher_state_file: temp_root.join("watcher-state.json"),
8720 sync_log_file: temp_root.join("sync-log.jsonl"),
8721 event_dedupe_file: temp_root.join("event-dedupe.jsonl"),
8722 event_dedupe_dir: temp_root.join("event-dedupe"),
8723 commit_dedupe_dir: temp_root.join("commit-dedupe"),
8724 };
8725 let commit = WorktreeCommitEvent {
8726 id: Uuid::new_v4().to_string(),
8727 fingerprint: None,
8728 repo_owner: "xylex-group".to_string(),
8729 repo_name: "xbp".to_string(),
8730 branch_name: "main".to_string(),
8731 repo_root: r"C:\Users\floris\Documents\GitHub\xbp".to_string(),
8732 previous_head_sha: Some("previous".to_string()),
8733 head_sha: "current".to_string(),
8734 subject: Some("test commit".to_string()),
8735 author_name: Some("Floris".to_string()),
8736 author_email: Some("floris@xylex.group".to_string()),
8737 committed_at: Some("2026-07-08T10:00:00Z".to_string()),
8738 occurred_at: Utc::now(),
8739 files_changed: None,
8740 files_changed_count: None,
8741 insertions: None,
8742 deletions: None,
8743 is_merge: None,
8744 included_commits: None,
8745 };
8746
8747 assert!(append_unique_commit_event(&layout, &commit).unwrap());
8748 assert!(!append_unique_commit_event(&layout, &commit).unwrap());
8749 let commits = read_jsonl_values(&layout.commits_file).unwrap();
8750 assert_eq!(commits.len(), 1);
8751 assert_eq!(
8752 commits[0].get("fingerprint").and_then(Value::as_str),
8753 Some(commit_event_fingerprint(&commit).as_str())
8754 );
8755 let _ = fs::remove_dir_all(temp_root);
8756 }
8757
8758 #[test]
8759 fn splits_worktree_mutation_uploads_into_bounded_batches() {
8760 let events = (0..(WORKTREE_MUTATION_SYNC_BATCH_RECORD_LIMIT + 1))
8761 .map(|index| {
8762 serde_json::from_value::<WorktreeMutationEvent>(stats_test_event(
8763 "2026-07-08T10:00:00Z",
8764 &format!("src/file-{index}.rs"),
8765 1,
8766 0,
8767 ))
8768 .unwrap()
8769 })
8770 .collect::<Vec<_>>();
8771 let commits = vec![
8772 worktree_commit_test_event("previous-a", "current-a"),
8773 worktree_commit_test_event("previous-b", "current-b"),
8774 ];
8775
8776 let batches = split_worktree_mutation_upload_batches(events, commits);
8777
8778 assert_eq!(batches.len(), 2);
8779 assert_eq!(
8780 batches[0].events.len() + batches[0].commits.len(),
8781 WORKTREE_MUTATION_SYNC_BATCH_RECORD_LIMIT
8782 );
8783 assert_eq!(batches[1].events.len(), 1);
8784 assert_eq!(batches[1].commits.len(), 2);
8785 }
8786
8787 #[test]
8788 fn splits_worktree_mutation_uploads_by_estimated_json_bytes() {
8789 let long_path = format!("src/{}.rs", "x".repeat(8_000));
8790 let events = (0..200)
8791 .map(|index| {
8792 serde_json::from_value::<WorktreeMutationEvent>(stats_test_event(
8793 "2026-07-08T10:00:00Z",
8794 &format!("{long_path}-{index}"),
8795 1,
8796 0,
8797 ))
8798 .unwrap()
8799 })
8800 .collect::<Vec<_>>();
8801
8802 let batches = split_worktree_mutation_upload_batches(events, Vec::new());
8803
8804 assert!(batches.len() > 1);
8805 for batch in batches {
8806 assert!(
8807 batch.estimated_json_bytes <= WORKTREE_MUTATION_SYNC_BATCH_JSON_BYTES,
8808 "batch estimated JSON bytes exceeded limit: {}",
8809 batch.estimated_json_bytes
8810 );
8811 }
8812 }
8813
8814 #[test]
8815 fn classifies_create_remove_and_rename_events() {
8816 assert_eq!(
8817 classify_event_kind(&EventKind::Create(CreateKind::File)),
8818 "file_create"
8819 );
8820 assert_eq!(
8821 classify_event_kind(&EventKind::Remove(RemoveKind::Folder)),
8822 "folder_remove"
8823 );
8824 assert_eq!(
8825 classify_event_kind(&EventKind::Modify(ModifyKind::Name(RenameMode::Both))),
8826 "rename_or_move"
8827 );
8828 }
8829
8830 #[test]
8831 fn retries_only_retryable_worktree_sync_statuses() {
8832 assert!(should_retry_worktree_mutation_sync_status(
8833 StatusCode::INTERNAL_SERVER_ERROR
8834 ));
8835 assert!(should_retry_worktree_mutation_sync_status(
8836 StatusCode::REQUEST_TIMEOUT
8837 ));
8838 assert!(should_retry_worktree_mutation_sync_status(
8839 StatusCode::TOO_MANY_REQUESTS
8840 ));
8841 assert!(!should_retry_worktree_mutation_sync_status(
8842 StatusCode::BAD_REQUEST
8843 ));
8844 assert!(!should_retry_worktree_mutation_sync_status(
8845 StatusCode::UNAUTHORIZED
8846 ));
8847 assert!(!should_retry_worktree_mutation_sync_status(
8848 StatusCode::PAYLOAD_TOO_LARGE
8849 ));
8850 }
8851
8852 #[test]
8853 fn match_github_repo_inventory_resolves_short_name_and_owner_repo() {
8854 let temp = std::env::temp_dir().join(format!("xbp-repo-lookup-{}", Uuid::new_v4()));
8855 let athena = temp.join("athena");
8856 let other = temp.join("other-athena");
8857 fs::create_dir_all(&athena).unwrap();
8858 fs::create_dir_all(&other).unwrap();
8859
8860 let repos = vec![
8861 GithubRepoRecord {
8862 owner: "xylex-group".to_string(),
8863 repo: "athena".to_string(),
8864 full_name: "xylex-group/athena".to_string(),
8865 path: athena.to_string_lossy().to_string(),
8866 remote_url: "https://github.com/xylex-group/athena.git".to_string(),
8867 },
8868 GithubRepoRecord {
8869 owner: "someone".to_string(),
8870 repo: "other".to_string(),
8871 full_name: "someone/other".to_string(),
8872 path: other.to_string_lossy().to_string(),
8873 remote_url: "https://github.com/someone/other.git".to_string(),
8874 },
8875 ];
8876
8877 let by_short = match_github_repo_inventory(&repos, "athena").unwrap().unwrap();
8878 assert_eq!(
8879 path_identity_key(&by_short),
8880 path_identity_key(athena.as_path())
8881 );
8882
8883 let by_full = match_github_repo_inventory(&repos, "xylex-group/athena")
8884 .unwrap()
8885 .unwrap();
8886 assert_eq!(
8887 path_identity_key(&by_full),
8888 path_identity_key(athena.as_path())
8889 );
8890
8891 assert!(match_github_repo_inventory(&repos, "missing")
8892 .unwrap()
8893 .is_none());
8894
8895 let _ = fs::remove_dir_all(temp);
8896 }
8897
8898 #[test]
8899 fn match_github_repo_inventory_errors_on_ambiguous_short_name() {
8900 let temp = std::env::temp_dir().join(format!("xbp-repo-lookup-ambig-{}", Uuid::new_v4()));
8901 let a = temp.join("a");
8902 let b = temp.join("b");
8903 fs::create_dir_all(&a).unwrap();
8904 fs::create_dir_all(&b).unwrap();
8905
8906 let repos = vec![
8907 GithubRepoRecord {
8908 owner: "one".to_string(),
8909 repo: "dup".to_string(),
8910 full_name: "one/dup".to_string(),
8911 path: a.to_string_lossy().to_string(),
8912 remote_url: "https://github.com/one/dup.git".to_string(),
8913 },
8914 GithubRepoRecord {
8915 owner: "two".to_string(),
8916 repo: "dup".to_string(),
8917 full_name: "two/dup".to_string(),
8918 path: b.to_string_lossy().to_string(),
8919 remote_url: "https://github.com/two/dup.git".to_string(),
8920 },
8921 ];
8922
8923 let err = match_github_repo_inventory(&repos, "dup").unwrap_err();
8924 assert!(err.contains("Ambiguous"));
8925 assert!(err.contains("one/dup") || err.contains(a.to_string_lossy().as_ref()));
8926
8927 let unique = match_github_repo_inventory(&repos, "two/dup").unwrap().unwrap();
8928 assert_eq!(path_identity_key(&unique), path_identity_key(b.as_path()));
8929
8930 let _ = fs::remove_dir_all(temp);
8931 }
8932
8933 fn stats_test_event(
8934 occurred_at: &str,
8935 path: &str,
8936 added_lines: u64,
8937 removed_lines: u64,
8938 ) -> Value {
8939 json!({
8940 "id": Uuid::new_v4().to_string(),
8941 "repoOwner": "xylex-group",
8942 "repoName": "xbp",
8943 "branchName": "main",
8944 "repoRoot": r"C:\Users\floris\Documents\GitHub\xbp",
8945 "headSha": null,
8946 "eventKind": "file_modify",
8947 "paths": [path],
8948 "primaryPath": path,
8949 "oldPath": null,
8950 "newPath": null,
8951 "addedLines": added_lines,
8952 "removedLines": removed_lines,
8953 "totalLines": added_lines + 42,
8954 "fileCreated": false,
8955 "fileRemoved": false,
8956 "folderCreated": false,
8957 "folderRemoved": false,
8958 "renamedOrMoved": false,
8959 "rawKind": "Modify(Data(Content))",
8960 "occurredAt": occurred_at,
8961 })
8962 }
8963
8964 fn stats_test_event_record(
8965 occurred_at: &str,
8966 path: &str,
8967 added_lines: u64,
8968 removed_lines: u64,
8969 ) -> WorktreeMutationEvent {
8970 serde_json::from_value(stats_test_event(
8971 occurred_at,
8972 path,
8973 added_lines,
8974 removed_lines,
8975 ))
8976 .unwrap()
8977 }
8978
8979 fn worktree_commit_test_event(previous_head_sha: &str, head_sha: &str) -> WorktreeCommitEvent {
8980 WorktreeCommitEvent {
8981 id: Uuid::new_v4().to_string(),
8982 fingerprint: None,
8983 repo_owner: "xylex-group".to_string(),
8984 repo_name: "xbp".to_string(),
8985 branch_name: "main".to_string(),
8986 repo_root: r"C:\Users\floris\Documents\GitHub\xbp".to_string(),
8987 previous_head_sha: Some(previous_head_sha.to_string()),
8988 head_sha: head_sha.to_string(),
8989 subject: Some("test commit".to_string()),
8990 author_name: Some("Floris".to_string()),
8991 author_email: Some("floris@xylex.group".to_string()),
8992 committed_at: Some("2026-07-08T10:00:00Z".to_string()),
8993 occurred_at: Utc::now(),
8994 files_changed: None,
8995 files_changed_count: None,
8996 insertions: None,
8997 deletions: None,
8998 is_merge: None,
8999 included_commits: None,
9000 }
9001 }
9002
9003 #[test]
9004 fn enrich_commit_range_parses_numstat_lines() {
9005 let empty = CommitRangeEnrichment::default();
9008 assert!(empty.files_changed.is_none());
9009 assert!(empty.insertions.is_none());
9010 }
9011}