1use crate::codetime::GithubRepoRecord;
2use crate::commands::cli_session::{cli_request_client, resolve_cli_access_token};
3use crate::commands::terminal_table::{render_table, TableStyle};
4use crate::config::{
5 ensure_global_xbp_paths, map_windows_path_to_wsl_mnt, map_wsl_mnt_path_to_windows,
6 resolve_device_identity, resolve_global_xbp_root_dir, resolve_worktree_watch_config, ApiConfig,
7 SshConfig, WorktreeWatchConfig,
8};
9use chrono::{DateTime, Utc};
10use colored::Colorize;
11use notify::event::{CreateKind, ModifyKind, RemoveKind, RenameMode};
12use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
13use reqwest::StatusCode;
14use serde::{Deserialize, Serialize};
15use serde_json::{json, Value};
16use sha2::{Digest, Sha256};
17use std::collections::{BTreeMap, BTreeSet};
18use std::fs::{self, File, OpenOptions};
19use std::io::{BufRead, BufReader, Write};
20use std::path::{Path, PathBuf};
21use std::process::{Command, Stdio};
22use std::sync::{mpsc, OnceLock};
23use std::time::{Duration, Instant};
24use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind};
25use uuid::Uuid;
26
27const BACKGROUND_CHILD_ENV: &str = "XBP_WORKTREE_WATCH_BACKGROUND_CHILD";
28const DEFAULT_REMOTE: &str = "origin";
29const EVENT_DEDUPE_WINDOW_SECONDS: i64 = 5;
30const WORKTREE_MUTATION_SYNC_BATCH_RECORD_LIMIT: usize = 250;
31const WORKTREE_MUTATION_SYNC_BATCH_JSON_BYTES: usize = 768 * 1024;
32const WORKTREE_MUTATION_SYNC_MAX_ATTEMPTS: usize = 3;
33const WORKTREE_MUTATION_SYNC_RETRY_DELAY_SECONDS: u64 = 2;
34
35#[derive(Debug, Clone, Default)]
42struct WatchIgnoreRules {
43 forbidden_path_prefixes: Vec<String>,
45 forbidden_folders: BTreeSet<String>,
47 banned_words: Vec<String>,
49 global_ignore: crate::utils::XbpIgnoreSet,
51 project_ignore: crate::utils::XbpIgnoreSet,
53}
54
55impl WatchIgnoreRules {
56 fn load() -> Self {
57 Self::from_config(&resolve_worktree_watch_config())
58 }
59
60 fn from_config(config: &WorktreeWatchConfig) -> Self {
61 Self::from_config_and_project(config, None)
62 }
63
64 fn from_config_and_project(config: &WorktreeWatchConfig, project_root: Option<&Path>) -> Self {
65 let mut forbidden_folders = built_in_forbidden_folders();
66 for folder in &config.forbidden_folders {
67 let normalized = normalize_folder_token(folder);
68 if !normalized.is_empty() {
69 forbidden_folders.insert(normalized);
70 }
71 }
72
73 let forbidden_path_prefixes = config
74 .forbidden_paths
75 .iter()
76 .map(|path| normalize_path_prefix(path))
77 .filter(|path| !path.is_empty())
78 .collect::<Vec<_>>();
79
80 let banned_words = config
81 .banned_words
82 .iter()
83 .map(|word| word.trim().to_ascii_lowercase())
84 .filter(|word| !word.is_empty())
85 .collect::<Vec<_>>();
86
87 let project_ignore = project_root
88 .map(|root| load_project_watch_ignore(root))
89 .unwrap_or_default();
90
91 Self {
92 forbidden_path_prefixes,
93 forbidden_folders,
94 banned_words,
95 global_ignore: crate::config::load_global_worktree_ignore(),
96 project_ignore,
97 }
98 }
99
100 fn with_project_root(mut self, project_root: &Path) -> Self {
101 self.project_ignore = load_project_watch_ignore(project_root);
102 self
103 }
104
105 fn ignores_relative(&self, relative: &str) -> bool {
106 let normalized = normalize_relative_watch_path(relative);
107 if normalized.is_empty() {
108 return false;
109 }
110
111 if self.global_ignore.is_ignored_relative(&normalized, false)
112 || self.global_ignore.is_ignored_relative(&normalized, true)
113 {
114 return true;
115 }
116
117 if self.project_ignore.is_ignored_relative(&normalized, false)
118 || self.project_ignore.is_ignored_relative(&normalized, true)
119 {
120 return true;
121 }
122
123 let lower = normalized.to_ascii_lowercase();
124 let components = lower
125 .split('/')
126 .filter(|component| !component.is_empty())
127 .collect::<Vec<_>>();
128
129 if components
130 .iter()
131 .any(|component| self.forbidden_folders.contains(*component))
132 {
133 return true;
134 }
135
136 for prefix in &self.forbidden_path_prefixes {
137 if lower == *prefix || lower.starts_with(&format!("{prefix}/")) {
138 return true;
139 }
140 if !prefix.contains('/') && components.iter().any(|component| *component == prefix) {
142 return true;
143 }
144 }
145
146 for word in &self.banned_words {
147 if lower.contains(word) {
148 return true;
149 }
150 }
151
152 false
153 }
154
155 fn ignores_path(&self, root: &Path, path: &Path) -> bool {
156 if let Some(relative) = relative_watch_path(root, path) {
157 return self.ignores_relative(&relative);
158 }
159 false
160 }
161
162 fn skips_discovery_dir(&self, path: &Path) -> bool {
163 let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
164 return false;
165 };
166 let trimmed = name.trim();
167 if self
168 .forbidden_folders
169 .contains(&trimmed.to_ascii_lowercase())
170 {
171 return true;
172 }
173 if self.global_ignore.skips_dir_name(trimmed) {
174 return true;
175 }
176 self.project_ignore.skips_dir_name(trimmed)
177 }
178}
179
180fn load_project_watch_ignore(project_root: &Path) -> crate::utils::XbpIgnoreSet {
181 use crate::strategies::XbpConfig;
182 use crate::utils::{load_project_xbp_ignore, parse_config_with_auto_heal};
183
184 let mut extra = Vec::new();
185 if let Some(found) = crate::utils::find_xbp_config_upwards(project_root) {
189 if let Ok(content) = fs::read_to_string(&found.config_path) {
190 if let Ok((config, _)) = parse_config_with_auto_heal::<XbpConfig>(&content, found.kind)
191 {
192 extra = config.watch_ignore_paths;
193 }
194 }
195 return load_project_xbp_ignore(&found.project_root, &extra);
196 }
197 load_project_xbp_ignore(project_root, &extra)
198}
199
200fn built_in_forbidden_folders() -> BTreeSet<String> {
201 use crate::utils::DEFAULT_NOISE_DIR_NAMES;
202
203 DEFAULT_NOISE_DIR_NAMES
204 .iter()
205 .map(|name| name.to_ascii_lowercase())
206 .collect()
207}
208
209fn normalize_folder_token(raw: &str) -> String {
210 raw.trim()
211 .trim_matches(|ch| ch == '/' || ch == '\\')
212 .to_ascii_lowercase()
213}
214
215fn normalize_path_prefix(raw: &str) -> String {
216 let trimmed = raw.trim().replace('\\', "/");
217 let without_dot = trimmed
218 .trim_start_matches("./")
219 .trim_matches(|ch| ch == '/' || ch == '\\');
220 without_dot.to_ascii_lowercase()
221}
222
223fn normalize_relative_watch_path(raw: &str) -> String {
224 raw.trim()
225 .replace('\\', "/")
226 .trim_start_matches("./")
227 .trim_matches('/')
228 .to_string()
229}
230
231fn watch_ignore_rules() -> &'static WatchIgnoreRules {
232 static RULES: OnceLock<WatchIgnoreRules> = OnceLock::new();
233 RULES.get_or_init(WatchIgnoreRules::load)
234}
235
236fn watch_ignore_rules_for_root(root: &Path) -> WatchIgnoreRules {
238 watch_ignore_rules().clone().with_project_root(root)
239}
240
241#[cfg(windows)]
242const CREATE_NO_WINDOW: u32 = 0x08000000;
243
244#[derive(Debug, Clone, Default)]
245pub struct WorktreeWatchTargetOptions {
246 pub repo: Option<PathBuf>,
247 pub parent: Option<PathBuf>,
248 pub repos: Vec<PathBuf>,
250}
251
252pub fn resolve_default_worktree_watch_status_target() -> Result<WorktreeWatchTargetOptions, String> {
258 if let Some(parent) = prefer_running_parent_root() {
259 return Ok(WorktreeWatchTargetOptions {
260 repo: None,
261 parent: Some(parent),
262 repos: Vec::new(),
263 });
264 }
265 if let Some(parent) = prefer_any_parent_root() {
268 return Ok(WorktreeWatchTargetOptions {
269 repo: None,
270 parent: Some(parent),
271 repos: Vec::new(),
272 });
273 }
274 normalize_worktree_watch_target(WorktreeWatchTargetOptions {
275 repo: None,
276 parent: None,
277 repos: Vec::new(),
278 })
279}
280
281pub fn normalize_worktree_watch_target(
284 target: WorktreeWatchTargetOptions,
285) -> Result<WorktreeWatchTargetOptions, String> {
286 if target.parent.is_some() && target.repo.is_some() {
287 return Err("Pass either `--repo` or `--parent`, not both.".to_string());
288 }
289 if target.parent.is_some() && !target.repos.is_empty() {
290 return Err("Pass either `--repos` or `--parent`, not both.".to_string());
291 }
292 if target.repo.is_some() && !target.repos.is_empty() {
293 return Err("Pass either `--repo` or `--repos`, not both.".to_string());
294 }
295
296 if let Some(parent) = target.parent.as_ref() {
297 let resolved = if parent.is_dir() {
298 normalize_windows_verbatim_path(
299 fs::canonicalize(parent).unwrap_or_else(|_| parent.clone()),
300 )
301 } else {
302 if let Some(found) = resolve_parent_folder_hint(parent)? {
304 found
305 } else {
306 return Err(format!(
307 "Parent folder not found: {}. Pass an absolute path or a folder under Documents/GitHub.",
308 parent.display()
309 ));
310 }
311 };
312 return Ok(WorktreeWatchTargetOptions {
313 repo: None,
314 parent: Some(resolved),
315 repos: Vec::new(),
316 });
317 }
318
319 if !target.repos.is_empty() {
320 let mut repos = Vec::new();
321 let mut seen = BTreeSet::new();
322 for repo in &target.repos {
323 let path = resolve_repo_path_input(Some(repo.as_path()))?;
324 let absolute = normalize_windows_verbatim_path(
325 fs::canonicalize(&path).unwrap_or(path),
326 );
327 let key = path_identity_key(&absolute);
328 if seen.insert(key) {
329 repos.push(absolute);
330 }
331 }
332 return Ok(WorktreeWatchTargetOptions {
333 repo: None,
334 parent: None,
335 repos,
336 });
337 }
338
339 let identity = resolve_repo_identity(target.repo.as_deref())?;
341 Ok(WorktreeWatchTargetOptions {
342 repo: Some(identity.root),
343 parent: None,
344 repos: Vec::new(),
345 })
346}
347
348fn resolve_parent_folder_hint(parent: &Path) -> Result<Option<PathBuf>, String> {
349 if parent.is_absolute() {
350 return Ok(None);
351 }
352 let name = parent
353 .file_name()
354 .and_then(|value| value.to_str())
355 .unwrap_or_default();
356 if name.is_empty() {
357 return Ok(None);
358 }
359
360 let mut candidates: Vec<PathBuf> = Vec::new();
361 if let Some(home) = dirs::home_dir() {
362 candidates.push(home.join("Documents").join("GitHub").join(name));
363 candidates.push(home.join("Documents").join("github").join(name));
364 candidates.push(home.join("Documents").join(name));
365 candidates.push(home.join("src").join(name));
366 candidates.push(home.join(name));
367 }
368 if name.eq_ignore_ascii_case("github") {
370 if let Some(home) = dirs::home_dir() {
371 candidates.insert(0, home.join("Documents").join("GitHub"));
372 candidates.insert(1, home.join("Documents").join("github"));
373 }
374 }
375
376 for candidate in candidates {
377 if candidate.is_dir() {
378 return Ok(Some(normalize_windows_verbatim_path(
379 fs::canonicalize(&candidate).unwrap_or(candidate),
380 )));
381 }
382 }
383 Ok(None)
384}
385
386#[derive(Debug, Clone, Serialize)]
388#[serde(rename_all = "camelCase")]
389pub struct WorktreeWatchTraySnapshot {
390 pub target_label: String,
391 pub mode: String,
392 pub parent: Option<String>,
393 pub repository_count: usize,
394 pub running_watchers: usize,
395 pub any_running: bool,
396 pub all_running: bool,
397 pub parent_watcher_running: bool,
398 pub parent_watcher_pid: Option<u32>,
399 pub total_unsynced_files: u64,
400 pub total_unsynced_records: u64,
401 pub repositories: Vec<WorktreeWatchTrayRepoStatus>,
402 pub stats_line: Option<String>,
403}
404
405#[derive(Debug, Clone, Serialize)]
406#[serde(rename_all = "camelCase")]
407pub struct WorktreeWatchTrayRepoStatus {
408 pub root: String,
409 pub owner: String,
410 pub name: String,
411 pub branch: String,
412 pub running: bool,
413 pub pid: Option<u32>,
414 pub unsynced_files: u64,
415 pub unsynced_records: u64,
416}
417
418#[derive(Debug, Clone)]
419pub struct WorktreeWatchStartOptions {
420 pub target: WorktreeWatchTargetOptions,
421 pub detach: bool,
422 pub sync_interval_seconds: u64,
423 pub once: bool,
424}
425
426#[derive(Debug, Clone)]
427pub struct WorktreeWatchSyncOptions {
428 pub target: WorktreeWatchTargetOptions,
429 pub dry_run: bool,
430 pub resync: bool,
431}
432
433#[derive(Debug, Clone)]
434pub struct WorktreeWatchStopOptions {
435 pub target: WorktreeWatchTargetOptions,
436 pub force: bool,
437}
438
439#[derive(Debug, Clone)]
440pub struct WorktreeWatchStatusOptions {
441 pub target: WorktreeWatchTargetOptions,
442 pub json: bool,
443 pub records: bool,
444 pub record_limit: usize,
445 pub stats: bool,
446 pub repo_activity: bool,
447 pub stats_gap_minutes: u64,
448}
449
450#[derive(Debug, Clone)]
451struct RepoIdentity {
452 owner: String,
453 name: String,
454 branch: String,
455 root: PathBuf,
456 head_sha: Option<String>,
457}
458
459#[derive(Debug, Clone)]
460struct SpoolLayout {
461 root: PathBuf,
462 events_file: PathBuf,
463 commits_file: PathBuf,
464 stats_file: PathBuf,
465 watcher_state_file: PathBuf,
466 sync_log_file: PathBuf,
467 event_dedupe_file: PathBuf,
468 event_dedupe_dir: PathBuf,
469 commit_dedupe_dir: PathBuf,
470}
471
472#[derive(Debug, Clone)]
473struct ParentSpoolLayout {
474 watcher_state_file: PathBuf,
475}
476
477#[derive(Debug)]
478struct WatchedRepo {
479 identity: RepoIdentity,
480 layout: SpoolLayout,
481 last_head: Option<String>,
482 event_dedupe: RecentEventDedupe,
483}
484
485#[derive(Debug, Serialize, Deserialize, Clone)]
486#[serde(rename_all = "camelCase")]
487struct WorktreeMutationEvent {
488 id: String,
489 #[serde(default, skip_serializing_if = "Option::is_none")]
490 fingerprint: Option<String>,
491 repo_owner: String,
492 repo_name: String,
493 branch_name: String,
494 repo_root: String,
495 head_sha: Option<String>,
496 event_kind: String,
497 paths: Vec<String>,
498 primary_path: Option<String>,
499 old_path: Option<String>,
500 new_path: Option<String>,
501 added_lines: Option<u64>,
502 removed_lines: Option<u64>,
503 total_lines: Option<u64>,
504 file_created: bool,
505 file_removed: bool,
506 folder_created: bool,
507 folder_removed: bool,
508 renamed_or_moved: bool,
509 raw_kind: String,
510 occurred_at: DateTime<Utc>,
511}
512
513#[derive(Debug, Serialize, Deserialize, Clone)]
514#[serde(rename_all = "camelCase")]
515struct WorktreeCommitEvent {
516 id: String,
517 #[serde(default, skip_serializing_if = "Option::is_none")]
518 fingerprint: Option<String>,
519 repo_owner: String,
520 repo_name: String,
521 branch_name: String,
522 repo_root: String,
523 previous_head_sha: Option<String>,
524 head_sha: String,
525 subject: Option<String>,
526 author_name: Option<String>,
527 author_email: Option<String>,
528 committed_at: Option<String>,
529 occurred_at: DateTime<Utc>,
530}
531
532#[derive(Debug, Serialize)]
533#[serde(rename_all = "camelCase")]
534struct WorktreeMutationIngestPayload {
535 device: WorktreeMutationDevicePayload,
536 repository: WorktreeMutationRepositoryPayload,
537 events: Vec<WorktreeMutationEvent>,
538 commits: Vec<WorktreeCommitEvent>,
539}
540
541#[derive(Debug, Serialize)]
542#[serde(rename_all = "camelCase")]
543struct WorktreeMutationDevicePayload {
544 hardware_id: String,
545 device_name: Option<String>,
546 hostname: Option<String>,
547 platform: String,
548 #[serde(skip_serializing_if = "Option::is_none")]
551 runtime_key: Option<String>,
552}
553
554#[derive(Debug, Serialize)]
555#[serde(rename_all = "camelCase")]
556struct WorktreeMutationRepositoryPayload {
557 owner: String,
558 name: String,
559 branch_name: String,
560 repo_root: String,
561}
562
563#[derive(Debug)]
564struct SyncCandidate {
565 path: PathBuf,
566 records: SyncRecords,
567}
568
569#[derive(Debug)]
570struct WorktreeMutationUploadBatch {
571 events: Vec<WorktreeMutationEvent>,
572 commits: Vec<WorktreeCommitEvent>,
573 estimated_json_bytes: usize,
574}
575
576#[derive(Debug, Clone, Copy, PartialEq, Eq)]
577enum WorktreeMutationSyncErrorKind {
578 Retryable,
579 NonRetryable,
580}
581
582#[derive(Debug)]
583struct WorktreeMutationSyncError {
584 kind: WorktreeMutationSyncErrorKind,
585 message: String,
586}
587
588struct SyncLogAppend<'a> {
589 identity: &'a RepoIdentity,
590 layout: &'a SpoolLayout,
591 endpoint: &'a str,
592 status_code: u16,
593 event_count: usize,
594 commit_count: usize,
595 candidates: &'a [SyncCandidate],
596 resync: bool,
597}
598
599#[derive(Debug, Clone, Copy)]
600enum SyncFileKind {
601 Events,
602 Commits,
603}
604
605#[derive(Debug)]
606enum SyncRecords {
607 Events(Vec<WorktreeMutationEvent>),
608 Commits(Vec<WorktreeCommitEvent>),
609}
610
611impl SyncRecords {
612 fn len(&self) -> usize {
613 match self {
614 Self::Events(records) => records.len(),
615 Self::Commits(records) => records.len(),
616 }
617 }
618
619 fn is_empty(&self) -> bool {
620 self.len() == 0
621 }
622}
623
624#[derive(Debug, Serialize, Deserialize, Clone)]
625#[serde(rename_all = "camelCase")]
626struct WorktreeWatchStatsSummary {
627 generated_at: DateTime<Utc>,
628 repository_owner: String,
629 repository_name: String,
630 branch_name: String,
631 repo_root: String,
632 spool_root: String,
633 session_gap_minutes: u64,
634 total_events: u64,
635 total_commits: u64,
636 first_event_at: Option<DateTime<Utc>>,
637 last_event_at: Option<DateTime<Utc>>,
638 session_count: u64,
639 observed_span_seconds: u64,
640 estimated_coding_seconds: u64,
641 added_lines: u64,
642 removed_lines: u64,
643 by_file: Vec<WorktreeWatchFileStats>,
644 by_filetype: Vec<WorktreeWatchBucketStats>,
645 by_event_kind: Vec<WorktreeWatchBucketStats>,
646}
647
648#[derive(Debug, Serialize, Clone)]
649#[serde(rename_all = "camelCase")]
650struct WorktreeWatchRepoActivitySummary {
651 generated_at: DateTime<Utc>,
652 repository_owner: String,
653 repository_name: String,
654 repo_root: String,
655 repository_spool_root: String,
656 stats_file: String,
657 session_gap_minutes: u64,
658 branch_count: u64,
659 total_events: u64,
660 total_commits: u64,
661 first_event_at: Option<DateTime<Utc>>,
662 last_event_at: Option<DateTime<Utc>>,
663 session_count: u64,
664 observed_span_seconds: u64,
665 estimated_coding_seconds: u64,
666 added_lines: u64,
667 removed_lines: u64,
668 branches: Vec<WorktreeWatchBranchActivityStats>,
669}
670
671#[derive(Debug, Serialize, Clone)]
672#[serde(rename_all = "camelCase")]
673struct WorktreeWatchBranchActivityStats {
674 branch_name: String,
675 spool_root: String,
676 total_events: u64,
677 total_commits: u64,
678 first_event_at: Option<DateTime<Utc>>,
679 last_event_at: Option<DateTime<Utc>>,
680 session_count: u64,
681 observed_span_seconds: u64,
682 estimated_coding_seconds: u64,
683 added_lines: u64,
684 removed_lines: u64,
685}
686
687#[derive(Debug, Serialize, Deserialize, Clone, Default)]
688#[serde(rename_all = "camelCase")]
689struct WorktreeWatchFileStats {
690 path: String,
691 filetype: String,
692 event_count: u64,
693 estimated_coding_seconds: u64,
694 added_lines: u64,
695 removed_lines: u64,
696}
697
698#[derive(Debug, Serialize, Deserialize, Clone, Default)]
699#[serde(rename_all = "camelCase")]
700struct WorktreeWatchBucketStats {
701 name: String,
702 event_count: u64,
703 estimated_coding_seconds: u64,
704 added_lines: u64,
705 removed_lines: u64,
706}
707
708#[derive(Debug, Serialize, Deserialize, Clone)]
709#[serde(rename_all = "camelCase")]
710struct WorktreeWatcherState {
711 pid: u32,
712 repo_root: String,
713 repository_owner: String,
714 repository_name: String,
715 branch_name: String,
716 executable: String,
717 started_at: DateTime<Utc>,
718 #[serde(default, skip_serializing_if = "Option::is_none")]
720 runtime_key: Option<String>,
721 #[serde(default, skip_serializing_if = "Option::is_none")]
722 platform: Option<String>,
723}
724
725#[derive(Debug, Serialize, Deserialize, Clone)]
726#[serde(rename_all = "camelCase")]
727struct ParentWorktreeWatcherState {
728 pid: u32,
729 parent_root: String,
730 executable: String,
731 started_at: DateTime<Utc>,
732 #[serde(default, skip_serializing_if = "Option::is_none")]
733 runtime_key: Option<String>,
734 #[serde(default, skip_serializing_if = "Option::is_none")]
735 platform: Option<String>,
736}
737
738#[derive(Debug, Serialize, Deserialize, Clone)]
739#[serde(rename_all = "camelCase")]
740struct WorktreeWatchSyncLogEntry {
741 id: String,
742 synced_at: DateTime<Utc>,
743 endpoint: String,
744 repository_owner: String,
745 repository_name: String,
746 branch_name: String,
747 repo_root: String,
748 resync: bool,
749 status_code: u16,
750 event_count: usize,
751 commit_count: usize,
752 spool_file_count: usize,
753 spool_files: Vec<String>,
754}
755
756#[derive(Debug, Clone, Copy, PartialEq, Eq)]
757enum StopWatcherOutcome {
758 Stopped(u32),
759 RemovedStaleState,
760 NoState,
761}
762
763#[derive(Debug, Serialize, Deserialize, Clone)]
764#[serde(rename_all = "camelCase")]
765struct WorktreeWatchEventDedupeRecord {
766 fingerprint: String,
767 first_seen_at: DateTime<Utc>,
768 event_kind: String,
769 primary_path: Option<String>,
770}
771
772#[derive(Debug, Default)]
773struct RecentEventDedupe {
774 fingerprints: BTreeSet<String>,
775}
776
777pub async fn run_worktree_watch_start(options: WorktreeWatchStartOptions) -> Result<(), String> {
778 if options.detach {
779 return run_worktree_watch_start_detached(options);
782 }
783
784 if let Some(parent) = options.target.parent.as_deref() {
785 let identities = resolve_target_identities(&options.target)?;
786 if identities.is_empty() {
787 println!("No git repositories found under parent folder.");
788 return Ok(());
789 }
790
791 if options.once {
792 for identity in &identities {
793 let layout = prepare_spool_layout(identity)?;
794 record_commit_snapshot(identity, &layout, None)?;
795 println!("Recorded current git state for {}", identity.root.display());
796 }
797 return Ok(());
798 }
799
800 let parent = canonical_parent_path(parent)?;
801 println!(
802 "Watching {} recursively and routing mutations for {} repo(s).",
803 parent.display(),
804 identities.len()
805 );
806 return watch_parent_foreground(parent, identities, options.sync_interval_seconds).await;
807 }
808
809 let identity = resolve_repo_identity(options.target.repo.as_deref())?;
810 let layout = prepare_spool_layout(&identity)?;
811 println!(
812 "Watching {} and spooling mutations to {} [runtime={}]",
813 identity.root.display(),
814 layout.root.display(),
815 worktree_runtime_key()
816 );
817
818 if options.once {
819 record_commit_snapshot(&identity, &layout, None)?;
820 return Ok(());
821 }
822
823 watch_foreground(identity, layout, options.sync_interval_seconds).await
824}
825
826pub fn run_worktree_watch_start_detached(
831 options: WorktreeWatchStartOptions,
832) -> Result<(), String> {
833 if let Some(parent) = options.target.parent.as_deref() {
834 let parent_path = canonical_parent_path(parent)?;
836 let repo_count = count_git_roots_under(&parent_path)?;
837 if repo_count == 0 {
838 println!("No git repositories found under parent folder.");
839 return Ok(());
840 }
841 let path = spawn_detached_parent_worktree_watch(&parent_path)?;
842 println!(
843 "Started one background worktree watcher for {} covering {} repo(s).",
844 path.display(),
845 repo_count
846 );
847 if let Some(port) =
848 crate::commands::worktree_watch_api::resolve_worktree_watch_api_port(None)
849 {
850 println!(
851 "Local API (when free): http://127.0.0.1:{port} — dashboard: apps/worktree-watch-app/"
852 );
853 }
854 return Ok(());
855 }
856
857 if !options.target.repos.is_empty() {
858 for repo in &options.target.repos {
859 let path = spawn_detached_worktree_watch(Some(repo.as_path()))?;
860 println!("Started background worktree watcher for {}", path.display());
861 }
862 if let Some(port) =
863 crate::commands::worktree_watch_api::resolve_worktree_watch_api_port(None)
864 {
865 println!(
866 "Local API (when free): http://127.0.0.1:{port} — dashboard: apps/worktree-watch-app/"
867 );
868 }
869 return Ok(());
870 }
871
872 spawn_detached_worktree_watch(options.target.repo.as_deref()).map(|path| {
873 println!("Started background worktree watcher for {}", path.display());
874 if let Some(port) =
875 crate::commands::worktree_watch_api::resolve_worktree_watch_api_port(None)
876 {
877 println!(
878 "Local API (when free): http://127.0.0.1:{port} — dashboard: apps/worktree-watch-app/"
879 );
880 }
881 })
882}
883
884pub async fn run_worktree_watch_sync(options: WorktreeWatchSyncOptions) -> Result<(), String> {
885 let identities = resolve_target_identities(&options.target)?;
886 let repo_count = identities.len();
887 let mut plans = Vec::new();
888 let mut total_files = 0usize;
889 let mut total_records = 0usize;
890
891 for identity in identities {
892 let layout = prepare_spool_layout(&identity)?;
893 let candidates = collect_spool_candidates(&layout, options.resync)?;
894 total_files += candidates.len();
895 total_records += candidates
896 .iter()
897 .map(|candidate| candidate.records.len())
898 .sum::<usize>();
899 if !candidates.is_empty() {
900 plans.push((identity, candidates));
901 }
902 }
903
904 if options.dry_run {
905 println!(
906 "Would sync {} record(s) from {} spool file(s) across {} repo(s).",
907 total_records, total_files, repo_count
908 );
909 if options.resync {
910 println!("Resync mode includes files already marked `.synced.*.jsonl`.");
911 }
912 return Ok(());
913 }
914
915 if plans.is_empty() {
916 print_no_sync_candidates_message(&options.target, options.resync)?;
917 return Ok(());
918 }
919
920 let upload_repo_count = plans.len();
921 for (identity, candidates) in plans {
922 sync_candidates(&identity, candidates, options.resync).await?;
923 }
924 println!(
925 "Synced {} worktree mutation record(s) across {} repo(s).",
926 total_records, upload_repo_count
927 );
928 Ok(())
929}
930
931pub fn run_worktree_watch_stop(options: WorktreeWatchStopOptions) -> Result<(), String> {
932 if let Some(parent) = options.target.parent.as_deref() {
933 let parent = canonical_parent_path(parent)?;
934 match stop_existing_parent_watcher(&parent, options.force)? {
935 StopWatcherOutcome::Stopped(pid) => {
936 println!(
937 "Stopped background parent worktree watcher {} for {}",
938 pid,
939 parent.display()
940 );
941 }
942 StopWatcherOutcome::RemovedStaleState => {
943 println!(
944 "Removed stale parent worktree watcher state for {}",
945 parent.display()
946 );
947 }
948 StopWatcherOutcome::NoState => {
949 println!("No parent worktree watcher state for {}", parent.display());
950 }
951 }
952 }
953
954 let identities = resolve_target_identities(&options.target)?;
955 if identities.is_empty() {
956 println!("No git repositories found for worktree watcher stop.");
957 return Ok(());
958 }
959
960 let mut stopped = 0usize;
961 let mut stale = 0usize;
962 let mut missing = 0usize;
963 for identity in identities {
964 let layout = prepare_spool_layout(&identity)?;
965 match stop_existing_watcher(&identity, &layout, options.force)? {
966 StopWatcherOutcome::Stopped(pid) => {
967 stopped += 1;
968 println!(
969 "Stopped background worktree watcher {} for {}",
970 pid,
971 identity.root.display()
972 );
973 }
974 StopWatcherOutcome::RemovedStaleState => {
975 stale += 1;
976 println!(
977 "Removed stale worktree watcher state for {}",
978 identity.root.display()
979 );
980 }
981 StopWatcherOutcome::NoState => {
982 missing += 1;
983 println!(
984 "No background worktree watcher state for {}",
985 identity.root.display()
986 );
987 }
988 }
989 }
990
991 println!(
992 "Worktree watcher stop complete: {stopped} stopped, {stale} stale state file(s) removed, {missing} without state."
993 );
994 Ok(())
995}
996
997pub fn stop_active_worktree_watch_before_install() {
1003 let mut stopped_parent = false;
1004
1005 if let Ok(mut candidates) = list_parent_watcher_state_files() {
1008 candidates.sort_by(|left, right| right.1.started_at.cmp(&left.1.started_at));
1009 for (_path, state) in candidates {
1010 let system = process_system_for_pids(&[state.pid]);
1011 if !is_live_parent_watcher_process_with_system(&system, &state) {
1012 continue;
1013 }
1014 let parent = parent_root_as_local_path(&state.parent_root);
1015 match stop_existing_parent_watcher(&parent, true) {
1016 Ok(StopWatcherOutcome::Stopped(pid)) => {
1017 stopped_parent = true;
1018 println!(
1019 "Stopped worktree-watch parent watcher {} for {} before install.",
1020 pid,
1021 parent.display()
1022 );
1023 }
1024 Ok(StopWatcherOutcome::RemovedStaleState) => {
1025 println!(
1026 "Removed stale parent worktree-watch state for {} before install.",
1027 parent.display()
1028 );
1029 }
1030 Ok(StopWatcherOutcome::NoState) => {}
1031 Err(error) => {
1032 println!(
1033 "Warning: could not stop parent worktree-watch for {}: {error}",
1034 parent.display()
1035 );
1036 }
1037 }
1038 }
1039 }
1040
1041 if stopped_parent {
1042 return;
1043 }
1044
1045 let Ok(target) = resolve_default_worktree_watch_status_target() else {
1047 return;
1048 };
1049 if target.parent.is_some() {
1052 return;
1053 }
1054
1055 let running = collect_worktree_watch_tray_snapshot(&target)
1056 .map(|snapshot| snapshot.any_running)
1057 .unwrap_or(false);
1058 if !running {
1059 return;
1060 }
1061
1062 match run_worktree_watch_stop(WorktreeWatchStopOptions {
1063 target,
1064 force: true,
1065 }) {
1066 Ok(()) => {
1067 println!("Stopped worktree-watch before install.");
1068 }
1069 Err(error) => {
1070 println!("Warning: could not stop worktree-watch before install: {error}");
1071 }
1072 }
1073}
1074
1075pub async fn run_worktree_watch_status(options: WorktreeWatchStatusOptions) -> Result<(), String> {
1076 let need_heavy = options.records || options.stats || options.repo_activity;
1079 let snapshot = collect_worktree_watch_tray_snapshot(&options.target)?;
1080
1081 let mut payloads = Vec::new();
1082 let mut total_files = 0usize;
1083 let mut total_records = 0usize;
1084
1085 for tray_repo in &snapshot.repositories {
1086 let identity = identity_from_tray_repo(tray_repo);
1087 let mut status = if need_heavy {
1088 worktree_watch_status_payload(&identity, options.records, options.record_limit)?
1089 } else {
1090 worktree_watch_status_from_tray_repo(tray_repo)?
1092 };
1093
1094 if let Some(object) = status.as_object_mut() {
1095 object.insert("running".to_string(), json!(tray_repo.running));
1096 object.insert("pid".to_string(), json!(tray_repo.pid));
1097 if let Some(last_sync) = object.get("lastSync") {
1098 if let Some(synced_at) = last_sync.get("syncedAt").and_then(Value::as_str) {
1099 if let Ok(dt) = DateTime::parse_from_rfc3339(synced_at) {
1100 let age = (Utc::now() - dt.with_timezone(&Utc))
1101 .num_seconds()
1102 .max(0) as u64;
1103 object.insert("lastSyncSecondsAgo".to_string(), json!(age));
1104 }
1105 }
1106 }
1107 }
1108
1109 let stats = if options.stats {
1110 Some(generate_and_store_stats(
1111 &identity,
1112 options.stats_gap_minutes,
1113 )?)
1114 } else {
1115 None
1116 };
1117 let repo_activity = if options.repo_activity {
1118 Some(generate_and_store_repo_activity(
1119 &identity,
1120 options.stats_gap_minutes,
1121 )?)
1122 } else {
1123 None
1124 };
1125 if options.stats || options.repo_activity {
1127 if let Err(err) = crate::commands::todos::effort::recompute_effort(
1128 &identity.root,
1129 options.stats_gap_minutes,
1130 true,
1131 ) {
1132 eprintln!("note: todo effort recompute: {err}");
1133 }
1134 }
1135 let status = attach_optional_stats(status, stats, repo_activity)?;
1136 total_files += status
1137 .get("unsyncedFiles")
1138 .and_then(Value::as_u64)
1139 .unwrap_or(0) as usize;
1140 total_records += status
1141 .get("unsyncedRecords")
1142 .and_then(Value::as_u64)
1143 .unwrap_or(0) as usize;
1144 payloads.push(status);
1145 }
1146
1147 if options.json {
1148 let payload = if options.target.parent.is_some() || payloads.len() > 1 {
1149 json!({
1150 "targetLabel": snapshot.target_label,
1151 "mode": snapshot.mode,
1152 "parent": snapshot.parent,
1153 "parentWatcherRunning": snapshot.parent_watcher_running,
1154 "parentWatcherPid": snapshot.parent_watcher_pid,
1155 "anyRunning": snapshot.any_running,
1156 "allRunning": snapshot.all_running,
1157 "runningWatchers": snapshot.running_watchers,
1158 "repositories": payloads,
1159 "repositoryCount": payloads.len(),
1160 "unsyncedFiles": total_files,
1161 "unsyncedRecords": total_records,
1162 })
1163 } else {
1164 payloads.into_iter().next().unwrap_or_else(|| json!({}))
1165 };
1166 println!(
1167 "{}",
1168 serde_json::to_string_pretty(&payload)
1169 .map_err(|error| format!("Failed to render status JSON: {error}"))?
1170 );
1171 } else {
1172 print_status_overview(&snapshot, total_files, total_records);
1173 print_status_matrix(&payloads, &snapshot);
1174 if need_heavy {
1175 for (index, payload) in payloads.iter().enumerate() {
1176 if index > 0 {
1177 println!();
1178 }
1179 if options.records {
1180 println!(
1181 "{} {}/{}",
1182 "records".bright_black(),
1183 payload
1184 .get("repositoryOwner")
1185 .and_then(Value::as_str)
1186 .unwrap_or("unknown"),
1187 payload
1188 .get("repositoryName")
1189 .and_then(Value::as_str)
1190 .unwrap_or("repository")
1191 );
1192 print_status_records(payload);
1193 }
1194 if options.stats {
1195 print_status_stats(payload);
1196 }
1197 if options.repo_activity {
1198 print_repo_activity(payload);
1199 }
1200 }
1201 }
1202 }
1203
1204 Ok(())
1205}
1206
1207fn print_status_overview(
1208 snapshot: &WorktreeWatchTraySnapshot,
1209 total_files: usize,
1210 total_records: usize,
1211) {
1212 let running_label = if snapshot.any_running {
1213 "RUNNING".green().bold()
1214 } else {
1215 "STOPPED".red().bold()
1216 };
1217 println!(
1218 "{} {} {}",
1219 "worktree-watch".bright_magenta().bold(),
1220 running_label,
1221 snapshot.target_label.bright_white()
1222 );
1223
1224 if snapshot.mode == "parent" {
1225 let parent_label = if snapshot.parent_watcher_running {
1226 format!(
1227 "parent watcher {}",
1228 snapshot
1229 .parent_watcher_pid
1230 .map(|pid| format!("pid {pid}"))
1231 .unwrap_or_else(|| "active".to_string())
1232 )
1233 .green()
1234 .to_string()
1235 } else {
1236 "parent watcher not detected".red().to_string()
1237 };
1238 println!(
1239 " {} {} {} repo(s) covered",
1240 parent_label,
1241 "·".bright_black(),
1242 snapshot.repository_count
1243 );
1244 } else {
1245 println!(
1246 " {} watching · {} repo(s)",
1247 snapshot.running_watchers, snapshot.repository_count
1248 );
1249 }
1250
1251 println!(
1252 " {} unsynced record(s) in {} file(s)",
1253 total_records.to_string().yellow(),
1254 total_files
1255 );
1256 if let Some(line) = snapshot.stats_line.as_ref() {
1257 println!(" {}", line.bright_black());
1258 }
1259 println!();
1260}
1261
1262fn print_status_matrix(payloads: &[Value], snapshot: &WorktreeWatchTraySnapshot) {
1263 if payloads.is_empty() {
1264 println!("{}", "No repositories found for this target.".yellow());
1265 return;
1266 }
1267
1268 let headers = [
1269 "REPO",
1270 "BRANCH",
1271 "STATE",
1272 "PID",
1273 "UNSYNCED",
1274 "LAST SYNC",
1275 "AGE",
1276 ];
1277 let mut rows = Vec::with_capacity(payloads.len());
1278
1279 for payload in payloads {
1280 let owner = payload
1281 .get("repositoryOwner")
1282 .or_else(|| payload.get("owner"))
1283 .and_then(Value::as_str)
1284 .unwrap_or("unknown");
1285 let name = payload
1286 .get("repositoryName")
1287 .or_else(|| payload.get("name"))
1288 .and_then(Value::as_str)
1289 .unwrap_or("repository");
1290 let branch = payload
1291 .get("branchName")
1292 .or_else(|| payload.get("branch"))
1293 .and_then(Value::as_str)
1294 .unwrap_or("unknown");
1295 let running = payload
1296 .get("running")
1297 .and_then(Value::as_bool)
1298 .unwrap_or(false);
1299 let pid = payload
1300 .get("pid")
1301 .and_then(Value::as_u64)
1302 .map(|value| value.to_string())
1303 .unwrap_or_else(|| {
1304 if running {
1305 snapshot
1306 .parent_watcher_pid
1307 .map(|value| value.to_string())
1308 .unwrap_or_else(|| "—".to_string())
1309 } else {
1310 "—".to_string()
1311 }
1312 });
1313 let unsynced = payload
1314 .get("unsyncedRecords")
1315 .and_then(Value::as_u64)
1316 .unwrap_or(0);
1317 let (last_sync_at, age) = last_sync_display(payload);
1318 let state = if running {
1319 "RUN".green().bold().to_string()
1320 } else {
1321 "OFF".red().to_string()
1322 };
1323 let unsynced_cell = if unsynced > 0 {
1324 unsynced.to_string().yellow().to_string()
1325 } else {
1326 "0".bright_black().to_string()
1327 };
1328 let age_cell = if age == "never" {
1329 age.bright_black().to_string()
1330 } else {
1331 age.cyan().to_string()
1332 };
1333
1334 rows.push(vec![
1335 format!("{owner}/{name}"),
1336 branch.to_string(),
1337 state,
1338 pid,
1339 unsynced_cell,
1340 last_sync_at,
1341 age_cell,
1342 ]);
1343 }
1344
1345 print!(
1346 "{}",
1347 render_table(&headers, &rows, TableStyle::Box, "")
1348 );
1349
1350 let running = snapshot.running_watchers;
1351 let stopped = snapshot
1352 .repository_count
1353 .saturating_sub(snapshot.running_watchers);
1354 println!();
1355 println!(
1356 "{} {} running · {} stopped · {} total",
1357 "summary".bright_black(),
1358 running.to_string().green().bold(),
1359 stopped.to_string().red(),
1360 snapshot.repository_count
1361 );
1362}
1363
1364fn last_sync_display(payload: &Value) -> (String, String) {
1365 let Some(last_sync) = payload.get("lastSync") else {
1366 return ("—".to_string(), "never".to_string());
1367 };
1368 let synced_at = last_sync
1369 .get("syncedAt")
1370 .and_then(Value::as_str)
1371 .unwrap_or("unknown");
1372 let age = if let Some(seconds) = payload
1373 .get("lastSyncSecondsAgo")
1374 .and_then(Value::as_u64)
1375 {
1376 format_age_seconds(seconds)
1377 } else if let Ok(dt) = DateTime::parse_from_rfc3339(synced_at) {
1378 let seconds = (Utc::now() - dt.with_timezone(&Utc)).num_seconds().max(0) as u64;
1379 format_age_seconds(seconds)
1380 } else {
1381 "unknown".to_string()
1382 };
1383 let compact = if synced_at.len() >= 19 {
1385 synced_at[11..19].to_string()
1386 } else {
1387 synced_at.to_string()
1388 };
1389 (compact, age)
1390}
1391
1392fn format_age_seconds(seconds: u64) -> String {
1393 if seconds < 60 {
1394 format!("{seconds}s ago")
1395 } else if seconds < 3600 {
1396 format!("{}m ago", seconds / 60)
1397 } else if seconds < 86400 {
1398 format!("{}h ago", seconds / 3600)
1399 } else {
1400 format!("{}d ago", seconds / 86400)
1401 }
1402}
1403
1404fn attach_optional_stats(
1405 mut payload: Value,
1406 stats: Option<WorktreeWatchStatsSummary>,
1407 repo_activity: Option<WorktreeWatchRepoActivitySummary>,
1408) -> Result<Value, String> {
1409 if let Some(object) = payload.as_object_mut() {
1410 if let Some(stats) = stats {
1411 object.insert(
1412 "stats".to_string(),
1413 serde_json::to_value(stats)
1414 .map_err(|error| format!("Failed to render stats payload: {error}"))?,
1415 );
1416 }
1417 if let Some(repo_activity) = repo_activity {
1418 object.insert(
1419 "repositoryActivity".to_string(),
1420 serde_json::to_value(repo_activity)
1421 .map_err(|error| format!("Failed to render repo activity payload: {error}"))?,
1422 );
1423 }
1424 }
1425 Ok(payload)
1426}
1427
1428pub fn spawn_detached_worktree_watch_for_current_repo() -> Result<Option<PathBuf>, String> {
1429 if is_background_child() {
1430 return Ok(None);
1431 }
1432
1433 let identity = match resolve_repo_identity(None) {
1434 Ok(identity) => identity,
1435 Err(_) => return Ok(None),
1436 };
1437 spawn_detached_worktree_watch_for_identity(&identity).map(Some)
1438}
1439
1440#[derive(Debug, Clone, Serialize, Deserialize)]
1442#[serde(rename_all = "camelCase")]
1443pub struct WorktreeWatchDiagnostic {
1444 pub global_xbp_root: String,
1445 pub mutations_root: String,
1446 pub runtime_key: String,
1447 pub platform: String,
1448 pub is_wsl: bool,
1449 pub repo_owner: Option<String>,
1450 pub repo_name: Option<String>,
1451 pub branch: Option<String>,
1452 pub branch_spool: Option<String>,
1453 pub branch_spool_exists: bool,
1454 pub watcher_state_path: Option<String>,
1455 pub watcher_state_present: bool,
1456 pub watcher_running: bool,
1457 pub event_jsonl_files: usize,
1458 pub commit_jsonl_files: usize,
1459 pub alternate_spool_roots: Vec<String>,
1460 pub notes: Vec<String>,
1461}
1462
1463#[derive(Debug, Clone)]
1465pub struct MutationActivityEvent {
1466 pub paths: Vec<String>,
1467 pub primary_path: Option<String>,
1468 pub occurred_at: DateTime<Utc>,
1469 pub added_lines: Option<u64>,
1470 pub removed_lines: Option<u64>,
1471 pub head_sha: Option<String>,
1472 pub branch_name: String,
1473}
1474
1475#[derive(Debug, Clone)]
1477pub struct CommitActivityEvent {
1478 pub occurred_at: DateTime<Utc>,
1479 pub head_sha: String,
1480 pub subject: Option<String>,
1481 pub branch_name: String,
1482 pub repo_root: String,
1483}
1484
1485pub fn load_repo_mutation_activity(
1487 owner: &str,
1488 name: &str,
1489) -> Result<Vec<MutationActivityEvent>, String> {
1490 let identity = RepoIdentity {
1491 owner: owner.to_string(),
1492 name: name.to_string(),
1493 branch: "main".to_string(),
1494 root: PathBuf::from("."),
1495 head_sha: None,
1496 };
1497 let mut events = Vec::new();
1498 for repo_root in repository_spool_search_roots(&identity)? {
1499 if !repo_root.is_dir() {
1500 continue;
1501 }
1502 for entry in fs::read_dir(&repo_root).map_err(|e| {
1503 format!(
1504 "Failed to read mutation spool {}: {e}",
1505 repo_root.display()
1506 )
1507 })? {
1508 let entry = entry.map_err(|e| format!("Failed to read spool entry: {e}"))?;
1509 if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
1510 continue;
1511 }
1512 let branch_layout = spool_layout_for_existing_root(&entry.path());
1513 let candidates = collect_spool_candidates(&branch_layout, true)?;
1514 for candidate in candidates {
1515 if let SyncRecords::Events(records) = candidate.records {
1516 for record in records {
1517 events.push(MutationActivityEvent {
1518 paths: record.paths,
1519 primary_path: record.primary_path,
1520 occurred_at: record.occurred_at,
1521 added_lines: record.added_lines,
1522 removed_lines: record.removed_lines,
1523 head_sha: record.head_sha,
1524 branch_name: record.branch_name,
1525 });
1526 }
1527 }
1528 }
1529 }
1530 }
1531 events.sort_by_key(|e| e.occurred_at);
1532 Ok(events)
1533}
1534
1535pub fn load_repo_commit_activity(
1537 owner: &str,
1538 name: &str,
1539) -> Result<Vec<CommitActivityEvent>, String> {
1540 let identity = RepoIdentity {
1541 owner: owner.to_string(),
1542 name: name.to_string(),
1543 branch: "main".to_string(),
1544 root: PathBuf::from("."),
1545 head_sha: None,
1546 };
1547 let mut commits = Vec::new();
1548 for repo_root in repository_spool_search_roots(&identity)? {
1549 if !repo_root.is_dir() {
1550 continue;
1551 }
1552 for entry in fs::read_dir(&repo_root).map_err(|e| {
1553 format!(
1554 "Failed to read mutation spool {}: {e}",
1555 repo_root.display()
1556 )
1557 })? {
1558 let entry = entry.map_err(|e| format!("Failed to read spool entry: {e}"))?;
1559 if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
1560 continue;
1561 }
1562 let branch_layout = spool_layout_for_existing_root(&entry.path());
1563 let candidates = collect_spool_candidates(&branch_layout, true)?;
1564 for candidate in candidates {
1565 if let SyncRecords::Commits(records) = candidate.records {
1566 for record in records {
1567 commits.push(CommitActivityEvent {
1568 occurred_at: record.occurred_at,
1569 head_sha: record.head_sha,
1570 subject: record.subject,
1571 branch_name: record.branch_name,
1572 repo_root: record.repo_root,
1573 });
1574 }
1575 }
1576 }
1577 }
1578 }
1579 commits.sort_by_key(|c| c.occurred_at);
1580 Ok(commits)
1581}
1582
1583pub fn collect_worktree_watch_diagnostic() -> WorktreeWatchDiagnostic {
1585 let runtime_key = worktree_runtime_key();
1586 let platform = std::env::consts::OS.to_string();
1587 let is_wsl = is_wsl_runtime();
1588 let mut notes = Vec::new();
1589
1590 let global_xbp_root = match ensure_global_xbp_paths() {
1591 Ok(paths) => paths.root_dir,
1592 Err(error) => {
1593 notes.push(format!("Failed to resolve global XBP home: {error}"));
1594 resolve_global_xbp_root_dir()
1595 }
1596 };
1597 let mutations_root = global_xbp_root.join("mutations");
1598
1599 let identity = resolve_repo_identity(None).ok();
1600 let (
1601 repo_owner,
1602 repo_name,
1603 branch,
1604 branch_spool,
1605 branch_spool_exists,
1606 watcher_state_path,
1607 watcher_state_present,
1608 watcher_running,
1609 event_jsonl_files,
1610 commit_jsonl_files,
1611 alternate_spool_roots,
1612 ) = if let Some(identity) = identity.as_ref() {
1613 let layout = prepare_spool_layout(identity).ok();
1614 let branch_spool = layout
1615 .as_ref()
1616 .map(|value| value.root.display().to_string());
1617 let branch_spool_exists = layout
1618 .as_ref()
1619 .map(|value| value.root.exists())
1620 .unwrap_or(false);
1621 let watcher_state_path = layout
1622 .as_ref()
1623 .map(|value| value.watcher_state_file.display().to_string());
1624 let watcher_state_present = layout
1625 .as_ref()
1626 .map(|value| {
1627 watcher_state_read_candidates(&value.watcher_state_file)
1628 .iter()
1629 .any(|candidate| candidate.exists())
1630 })
1631 .unwrap_or(false);
1632 let watcher_running = layout
1633 .as_ref()
1634 .and_then(|value| read_watcher_state(&value.watcher_state_file).ok().flatten())
1635 .map(|state| is_matching_watcher_process(&state))
1636 .unwrap_or(false);
1637
1638 let mut event_jsonl_files = 0usize;
1639 let mut commit_jsonl_files = 0usize;
1640 if let Some(layout) = layout.as_ref() {
1641 if let Ok(entries) = fs::read_dir(&layout.root) {
1642 for entry in entries.flatten() {
1643 let name = entry.file_name().to_string_lossy().to_string();
1644 if name.starts_with("events-") && name.ends_with(".jsonl") {
1645 event_jsonl_files += 1;
1646 } else if name.starts_with("commits-") && name.ends_with(".jsonl") {
1647 commit_jsonl_files += 1;
1648 }
1649 }
1650 }
1651 }
1652
1653 let alternate_spool_roots = repository_spool_search_roots(identity)
1654 .unwrap_or_default()
1655 .into_iter()
1656 .map(|path| {
1657 path.join(sanitize_path_component(&identity.branch))
1658 .display()
1659 .to_string()
1660 })
1661 .filter(|path| {
1662 layout
1663 .as_ref()
1664 .map(|value| path_identity_key(Path::new(path)) != path_identity_key(&value.root))
1665 .unwrap_or(true)
1666 })
1667 .filter(|path| Path::new(path).exists())
1668 .collect::<Vec<_>>();
1669
1670 let root_display = global_xbp_root
1671 .display()
1672 .to_string()
1673 .replace('\\', "/")
1674 .to_ascii_lowercase();
1675 if is_wsl && !root_display.starts_with("/mnt/") {
1676 notes.push(
1677 "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."
1678 .to_string(),
1679 );
1680 }
1681 if is_wsl && root_display.starts_with("/mnt/") {
1682 notes.push(
1683 "Using a Windows-profile-backed XBP home from WSL; spoils are reconcilable with native Windows."
1684 .to_string(),
1685 );
1686 }
1687 if !alternate_spool_roots.is_empty() {
1688 notes.push(format!(
1689 "Found {} alternate spool location(s) with historical data (legacy homes / runtime layouts).",
1690 alternate_spool_roots.len()
1691 ));
1692 }
1693
1694 (
1695 Some(identity.owner.clone()),
1696 Some(identity.name.clone()),
1697 Some(identity.branch.clone()),
1698 branch_spool,
1699 branch_spool_exists,
1700 watcher_state_path,
1701 watcher_state_present,
1702 watcher_running,
1703 event_jsonl_files,
1704 commit_jsonl_files,
1705 alternate_spool_roots,
1706 )
1707 } else {
1708 notes.push(
1709 "Not inside a git repository with a resolvable remote; showing global XBP home only."
1710 .to_string(),
1711 );
1712 (
1713 None,
1714 None,
1715 None,
1716 None,
1717 false,
1718 None,
1719 false,
1720 false,
1721 0,
1722 0,
1723 Vec::new(),
1724 )
1725 };
1726
1727 WorktreeWatchDiagnostic {
1728 global_xbp_root: global_xbp_root.display().to_string(),
1729 mutations_root: mutations_root.display().to_string(),
1730 runtime_key,
1731 platform,
1732 is_wsl,
1733 repo_owner,
1734 repo_name,
1735 branch,
1736 branch_spool,
1737 branch_spool_exists,
1738 watcher_state_path,
1739 watcher_state_present,
1740 watcher_running,
1741 event_jsonl_files,
1742 commit_jsonl_files,
1743 alternate_spool_roots,
1744 notes,
1745 }
1746}
1747
1748fn resolve_target_identities(
1749 target: &WorktreeWatchTargetOptions,
1750) -> Result<Vec<RepoIdentity>, String> {
1751 if target.repo.is_some() && target.parent.is_some() {
1752 return Err("Pass either `--repo` or `--parent`, not both.".to_string());
1753 }
1754 if !target.repos.is_empty() && target.parent.is_some() {
1755 return Err("Pass either `--repos` or `--parent`, not both.".to_string());
1756 }
1757 if target.repo.is_some() && !target.repos.is_empty() {
1758 return Err("Pass either `--repo` or `--repos`, not both.".to_string());
1759 }
1760
1761 if let Some(parent) = target.parent.as_deref() {
1762 return discover_repo_identities_under(parent);
1763 }
1764
1765 if !target.repos.is_empty() {
1766 let mut identities = Vec::new();
1767 let mut seen = BTreeSet::new();
1768 for repo in &target.repos {
1769 let identity = resolve_repo_identity(Some(repo.as_path()))?;
1770 let key = path_identity_key(&identity.root);
1771 if seen.insert(key) {
1772 identities.push(identity);
1773 }
1774 }
1775 return Ok(identities);
1776 }
1777
1778 resolve_repo_identity(target.repo.as_deref()).map(|identity| vec![identity])
1779}
1780
1781pub fn collect_worktree_watch_tray_snapshot(
1783 target: &WorktreeWatchTargetOptions,
1784) -> Result<WorktreeWatchTraySnapshot, String> {
1785 let identities = resolve_target_identities(target)?;
1786 let mode = if target.parent.is_some() {
1787 "parent"
1788 } else if !target.repos.is_empty() {
1789 "repos"
1790 } else {
1791 "repo"
1792 }
1793 .to_string();
1794
1795 let parent_label = target
1796 .parent
1797 .as_ref()
1798 .map(|path| path.display().to_string());
1799 let target_label = if let Some(parent) = parent_label.as_ref() {
1800 format!("parent:{parent}")
1801 } else if !target.repos.is_empty() {
1802 format!("{} repo(s)", target.repos.len())
1803 } else if let Some(repo) = target.repo.as_ref() {
1804 repo.display().to_string()
1805 } else {
1806 identities
1807 .first()
1808 .map(|identity| identity.root.display().to_string())
1809 .unwrap_or_else(|| "current repo".to_string())
1810 };
1811
1812 let mut parent_state: Option<ParentWorktreeWatcherState> = None;
1815 if let Some(parent) = target.parent.as_deref() {
1816 if let Ok(parent) = canonical_parent_path(parent) {
1817 if let Ok(layout) = prepare_parent_spool_layout(&parent) {
1818 parent_state = read_parent_watcher_state(&layout.watcher_state_file)?;
1819 }
1820 }
1821 }
1822
1823 let mut repo_states: Vec<(usize, WorktreeWatcherState)> = Vec::new();
1824 let mut layouts: Vec<SpoolLayout> = Vec::with_capacity(identities.len());
1825 for (index, identity) in identities.iter().enumerate() {
1826 let layout = prepare_spool_layout(identity)?;
1827 if let Some(state) = read_watcher_state(&layout.watcher_state_file)? {
1828 if same_repo_watcher_state(identity, &state) {
1829 repo_states.push((index, state));
1830 }
1831 }
1832 layouts.push(layout);
1833 }
1834
1835 let mut pids: Vec<u32> = repo_states.iter().map(|(_, state)| state.pid).collect();
1836 if let Some(state) = parent_state.as_ref() {
1837 pids.push(state.pid);
1838 }
1839 let process_system = process_system_for_pids(&pids);
1840
1841 let mut parent_watcher_running = false;
1842 let mut parent_watcher_pid = None;
1843 if let Some(state) = parent_state.as_ref() {
1844 if is_live_parent_watcher_process_with_system(&process_system, state) {
1848 parent_watcher_running = true;
1849 parent_watcher_pid = Some(state.pid);
1850 }
1851 }
1852
1853 let mut repositories = Vec::new();
1854 let mut running_watchers = 0usize;
1855 let mut total_unsynced_files = 0u64;
1856 let mut total_unsynced_records = 0u64;
1857
1858 for (index, identity) in identities.iter().enumerate() {
1859 let layout = &layouts[index];
1860 let counts = count_spool_summary(layout)?;
1862 let unsynced_files = counts.unsynced_files;
1863 let unsynced_records = counts.unsynced_records;
1864 total_unsynced_files += unsynced_files;
1865 total_unsynced_records += unsynced_records;
1866
1867 let (running, pid) = match repo_states
1868 .iter()
1869 .find(|(repo_index, _)| *repo_index == index)
1870 .map(|(_, state)| state)
1871 {
1872 Some(state) if is_live_watcher_process_with_system(&process_system, state) => {
1873 (true, Some(state.pid))
1874 }
1875 _ => (false, None),
1876 };
1877 let running = running || parent_watcher_running;
1879 let pid = pid.or(if running {
1880 parent_watcher_pid
1881 } else {
1882 None
1883 });
1884 if running {
1885 running_watchers += 1;
1886 }
1887
1888 repositories.push(WorktreeWatchTrayRepoStatus {
1889 root: normalize_repo_root_for_payload(&identity.root),
1890 owner: identity.owner.clone(),
1891 name: identity.name.clone(),
1892 branch: identity.branch.clone(),
1893 running,
1894 pid,
1895 unsynced_files,
1896 unsynced_records,
1897 });
1898 }
1899
1900 let repository_count = repositories.len();
1901 let any_running = parent_watcher_running || running_watchers > 0;
1902 let all_running = repository_count > 0 && running_watchers >= repository_count;
1903
1904 let stats_line = Some(format!(
1906 "{repository_count} repo(s) · {running_watchers} watching · {total_unsynced_records} unsynced · {total_unsynced_files} file(s)"
1907 ));
1908
1909 Ok(WorktreeWatchTraySnapshot {
1910 target_label,
1911 mode,
1912 parent: parent_label,
1913 repository_count,
1914 running_watchers,
1915 any_running,
1916 all_running,
1917 parent_watcher_running,
1918 parent_watcher_pid,
1919 total_unsynced_files,
1920 total_unsynced_records,
1921 repositories,
1922 stats_line,
1923 })
1924}
1925
1926pub const WORKTREE_WATCH_API_DEFAULT_STATS_GAP_MINUTES: u64 = 30;
1928
1929pub fn collect_worktree_watch_api_status(
1931 target: &WorktreeWatchTargetOptions,
1932 last_error: Option<&str>,
1933) -> Result<Value, String> {
1934 let snapshot = collect_worktree_watch_tray_snapshot(target)?;
1935 let mut repositories = Vec::new();
1936
1937 for tray_repo in &snapshot.repositories {
1938 let mut status = worktree_watch_status_from_tray_repo(tray_repo)?;
1940 if let Some(object) = status.as_object_mut() {
1941 object.insert("running".to_string(), json!(tray_repo.running));
1942 object.insert("pid".to_string(), json!(tray_repo.pid));
1943 object.insert("owner".to_string(), json!(tray_repo.owner));
1944 object.insert("name".to_string(), json!(tray_repo.name));
1945 object.insert("branch".to_string(), json!(tray_repo.branch));
1946 object.insert("root".to_string(), json!(tray_repo.root));
1947 if let Some(spool) = object.get("spoolRoot").cloned() {
1948 object.insert("spoolPath".to_string(), spool);
1949 }
1950 object.insert("lastError".to_string(), Value::Null);
1951 if let Some(last_sync) = object.get("lastSync") {
1952 if let Some(synced_at) = last_sync.get("syncedAt").and_then(Value::as_str) {
1953 if let Ok(dt) = DateTime::parse_from_rfc3339(synced_at) {
1954 let age = (Utc::now() - dt.with_timezone(&Utc))
1955 .num_seconds()
1956 .max(0) as u64;
1957 object.insert("lastSyncSecondsAgo".to_string(), json!(age));
1958 }
1959 }
1960 }
1961 }
1962 repositories.push(status);
1963 }
1964
1965 Ok(json!({
1966 "targetLabel": snapshot.target_label,
1967 "mode": snapshot.mode,
1968 "parent": snapshot.parent,
1969 "repositoryCount": snapshot.repository_count,
1970 "runningWatchers": snapshot.running_watchers,
1971 "anyRunning": snapshot.any_running,
1972 "allRunning": snapshot.all_running,
1973 "parentWatcherRunning": snapshot.parent_watcher_running,
1974 "parentWatcherPid": snapshot.parent_watcher_pid,
1975 "totalUnsyncedFiles": snapshot.total_unsynced_files,
1976 "totalUnsyncedRecords": snapshot.total_unsynced_records,
1977 "unsyncedFiles": snapshot.total_unsynced_files,
1978 "unsyncedRecords": snapshot.total_unsynced_records,
1979 "statsLine": snapshot.stats_line,
1980 "lastError": last_error,
1981 "repositories": repositories,
1982 }))
1983}
1984
1985fn identity_from_tray_repo(repo: &WorktreeWatchTrayRepoStatus) -> RepoIdentity {
1986 RepoIdentity {
1987 owner: repo.owner.clone(),
1988 name: repo.name.clone(),
1989 branch: repo.branch.clone(),
1990 root: parent_root_as_local_path(&repo.root),
1991 head_sha: None,
1992 }
1993}
1994
1995fn worktree_watch_status_from_tray_repo(
1996 repo: &WorktreeWatchTrayRepoStatus,
1997) -> Result<Value, String> {
1998 let identity = identity_from_tray_repo(repo);
1999 let layout = prepare_spool_layout(&identity)?;
2000 let mut payload = json!({
2001 "repoRoot": repo.root,
2002 "repositoryOwner": repo.owner,
2003 "repositoryName": repo.name,
2004 "branchName": repo.branch,
2005 "platform": std::env::consts::OS,
2006 "runtimeKey": worktree_runtime_key(),
2007 "spoolRoot": layout.root.display().to_string(),
2008 "unsyncedFiles": repo.unsynced_files,
2009 "unsyncedRecords": repo.unsynced_records,
2010 "syncedFiles": 0,
2011 "syncedRecords": 0,
2012 "localSpoolFiles": repo.unsynced_files,
2013 "localSpoolRecords": repo.unsynced_records,
2014 "running": repo.running,
2015 "pid": repo.pid,
2016 });
2017
2018 if let Some(last_sync) = read_last_sync_log_entry(&layout.sync_log_file)? {
2019 if let Some(object) = payload.as_object_mut() {
2020 object.insert(
2021 "lastSync".to_string(),
2022 serde_json::to_value(last_sync)
2023 .map_err(|error| format!("Failed to render sync log payload: {error}"))?,
2024 );
2025 }
2026 }
2027
2028 Ok(payload)
2029}
2030
2031pub fn collect_worktree_watch_api_repositories(
2033 target: &WorktreeWatchTargetOptions,
2034) -> Result<Value, String> {
2035 let status = collect_worktree_watch_api_status(target, None)?;
2036 let repositories = status
2037 .get("repositories")
2038 .cloned()
2039 .unwrap_or_else(|| json!([]));
2040 Ok(json!({ "repositories": repositories }))
2041}
2042
2043pub fn collect_worktree_watch_api_stats(
2048 target: &WorktreeWatchTargetOptions,
2049 session_gap_minutes: u64,
2050) -> Result<Value, String> {
2051 let identities = resolve_target_identities(target)?;
2052 if identities.is_empty() {
2053 return Ok(json!({
2054 "generatedAt": null,
2055 "message": "no stats yet",
2056 "repositories": [],
2057 }));
2058 }
2059
2060 let gap = if session_gap_minutes == 0 {
2061 WORKTREE_WATCH_API_DEFAULT_STATS_GAP_MINUTES
2062 } else {
2063 session_gap_minutes
2064 };
2065
2066 let mut repositories = Vec::new();
2067 for identity in &identities {
2068 let stats = read_or_generate_stats(identity, gap, Duration::from_secs(120))?;
2069 repositories.push(json!({
2070 "owner": identity.owner,
2071 "name": identity.name,
2072 "branch": identity.branch,
2073 "repositoryOwner": identity.owner,
2074 "repositoryName": identity.name,
2075 "branchName": identity.branch,
2076 "repoRoot": normalize_repo_root_for_payload(&identity.root),
2077 "root": normalize_repo_root_for_payload(&identity.root),
2078 "stats": stats,
2079 }));
2080 }
2081
2082 if repositories.len() == 1 {
2083 let first = repositories.remove(0);
2084 let stats = first
2085 .get("stats")
2086 .cloned()
2087 .unwrap_or(Value::Null);
2088 if let Some(mut object) = stats.as_object().cloned() {
2090 object.insert(
2091 "repositories".to_string(),
2092 json!([{
2093 "owner": first.get("owner"),
2094 "name": first.get("name"),
2095 "branch": first.get("branch"),
2096 "stats": first.get("stats"),
2097 }]),
2098 );
2099 return Ok(Value::Object(object));
2100 }
2101 return Ok(first);
2102 }
2103
2104 Ok(json!({
2105 "repositories": repositories,
2106 "repositoryCount": repositories.len(),
2107 }))
2108}
2109
2110fn worktree_watch_status_payload_light(identity: &RepoIdentity) -> Result<Value, String> {
2112 let layout = prepare_spool_layout(identity)?;
2113 let counts = count_spool_summary(&layout)?;
2114
2115 let mut payload = json!({
2116 "repoRoot": normalize_repo_root_for_payload(&identity.root),
2117 "repositoryOwner": identity.owner.clone(),
2118 "repositoryName": identity.name.clone(),
2119 "branchName": identity.branch.clone(),
2120 "platform": std::env::consts::OS,
2121 "runtimeKey": worktree_runtime_key(),
2122 "spoolRoot": layout.root.display().to_string(),
2123 "unsyncedFiles": counts.unsynced_files,
2124 "unsyncedRecords": counts.unsynced_records,
2125 "syncedFiles": counts.synced_files,
2126 "syncedRecords": counts.synced_records,
2127 "localSpoolFiles": counts.unsynced_files + counts.synced_files,
2128 "localSpoolRecords": counts.unsynced_records + counts.synced_records,
2129 });
2130
2131 if let Some(last_sync) = read_last_sync_log_entry(&layout.sync_log_file)? {
2132 if let Some(object) = payload.as_object_mut() {
2133 object.insert(
2134 "lastSync".to_string(),
2135 serde_json::to_value(last_sync)
2136 .map_err(|error| format!("Failed to render sync log payload: {error}"))?,
2137 );
2138 }
2139 }
2140
2141 Ok(payload)
2142}
2143
2144fn worktree_watch_status_payload(
2145 identity: &RepoIdentity,
2146 include_records: bool,
2147 record_limit: usize,
2148) -> Result<Value, String> {
2149 if !include_records {
2152 return worktree_watch_status_payload_light(identity);
2153 }
2154
2155 let layout = prepare_spool_layout(identity)?;
2156 let candidates = collect_sync_candidates(&layout)?;
2157 let all_candidates = collect_spool_candidates(&layout, true)?;
2158 let record_count: usize = candidates
2159 .iter()
2160 .map(|candidate| candidate.records.len())
2161 .sum();
2162 let all_record_count: usize = all_candidates
2163 .iter()
2164 .map(|candidate| candidate.records.len())
2165 .sum();
2166 let synced_file_count = all_candidates.len().saturating_sub(candidates.len());
2167 let synced_record_count = all_record_count.saturating_sub(record_count);
2168
2169 let mut payload = json!({
2170 "repoRoot": normalize_repo_root_for_payload(&identity.root),
2171 "repositoryOwner": identity.owner.clone(),
2172 "repositoryName": identity.name.clone(),
2173 "branchName": identity.branch.clone(),
2174 "platform": std::env::consts::OS,
2175 "runtimeKey": worktree_runtime_key(),
2176 "spoolRoot": layout.root.display().to_string(),
2177 "unsyncedFiles": candidates.len(),
2178 "unsyncedRecords": record_count,
2179 "syncedFiles": synced_file_count,
2180 "syncedRecords": synced_record_count,
2181 "localSpoolFiles": all_candidates.len(),
2182 "localSpoolRecords": all_record_count,
2183 });
2184
2185 if let Some(last_sync) = read_last_sync_log_entry(&layout.sync_log_file)? {
2186 if let Some(object) = payload.as_object_mut() {
2187 object.insert(
2188 "lastSync".to_string(),
2189 serde_json::to_value(last_sync)
2190 .map_err(|error| format!("Failed to render sync log payload: {error}"))?,
2191 );
2192 }
2193 }
2194
2195 if let Some(object) = payload.as_object_mut() {
2196 object.insert(
2197 "records".to_string(),
2198 collect_status_records(&candidates, record_limit)?,
2199 );
2200 }
2201
2202 Ok(payload)
2203}
2204
2205#[derive(Debug, Default, Clone, Copy)]
2206struct SpoolCountSummary {
2207 unsynced_files: u64,
2208 unsynced_records: u64,
2209 synced_files: u64,
2210 synced_records: u64,
2211}
2212
2213fn count_spool_summary(layout: &SpoolLayout) -> Result<SpoolCountSummary, String> {
2215 let mut roots = vec![layout.root.clone()];
2216 if let Some(branch) = layout.root.file_name().and_then(|name| name.to_str()) {
2217 if let Some(repo_root) = layout.root.parent() {
2218 if let (Some(name), Some(owner)) = (
2219 repo_root.file_name().and_then(|n| n.to_str()),
2220 repo_root
2221 .parent()
2222 .and_then(|p| p.file_name())
2223 .and_then(|n| n.to_str()),
2224 ) {
2225 let identity = RepoIdentity {
2226 owner: owner.to_string(),
2227 name: name.to_string(),
2228 branch: branch.to_string(),
2229 root: PathBuf::new(),
2230 head_sha: None,
2231 };
2232 if let Ok(search_roots) = repository_spool_search_roots(&identity) {
2233 for search_root in search_roots {
2234 let branch_root = search_root.join(branch);
2235 if !roots.iter().any(|existing| {
2236 path_identity_key(existing) == path_identity_key(&branch_root)
2237 }) {
2238 roots.push(branch_root);
2239 }
2240 }
2241 }
2242 }
2243 }
2244 }
2245
2246 let mut summary = SpoolCountSummary::default();
2247 let mut seen_files = BTreeSet::new();
2248 for root in roots {
2249 if !root.exists() {
2250 continue;
2251 }
2252 for entry in fs::read_dir(&root).map_err(|error| {
2253 format!(
2254 "Failed to read spool directory {}: {error}",
2255 root.display()
2256 )
2257 })? {
2258 let path = entry
2259 .map_err(|error| format!("Failed to read spool entry: {error}"))?
2260 .path();
2261 let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
2262 continue;
2263 };
2264 if !file_name.ends_with(".jsonl") {
2265 continue;
2266 }
2267 if !(file_name.starts_with("events-") || file_name.starts_with("commits-")) {
2268 continue;
2269 }
2270 let identity_key = path_identity_key(&path);
2271 if !seen_files.insert(identity_key) {
2272 continue;
2273 }
2274 let lines = count_nonempty_lines(&path)?;
2275 if file_name.contains(".synced.") {
2276 summary.synced_files += 1;
2277 summary.synced_records += lines;
2278 } else {
2279 summary.unsynced_files += 1;
2280 summary.unsynced_records += lines;
2281 }
2282 }
2283 }
2284 Ok(summary)
2285}
2286
2287fn count_nonempty_lines(path: &Path) -> Result<u64, String> {
2288 let file = File::open(path)
2289 .map_err(|error| format!("Failed to open spool file {}: {error}", path.display()))?;
2290 let reader = BufReader::new(file);
2291 let mut count = 0u64;
2292 for line in reader.lines() {
2293 let line =
2294 line.map_err(|error| format!("Failed to read spool file {}: {error}", path.display()))?;
2295 if !line.trim().is_empty() {
2296 count += 1;
2297 }
2298 }
2299 Ok(count)
2300}
2301
2302fn read_or_generate_stats(
2303 identity: &RepoIdentity,
2304 session_gap_minutes: u64,
2305 max_age: Duration,
2306) -> Result<WorktreeWatchStatsSummary, String> {
2307 let layout = prepare_spool_layout(identity)?;
2308 if layout.stats_file.exists() {
2309 if let Ok(raw) = fs::read_to_string(&layout.stats_file) {
2310 if let Ok(stats) = serde_json::from_str::<WorktreeWatchStatsSummary>(&raw) {
2311 let age = Utc::now()
2312 .signed_duration_since(stats.generated_at)
2313 .to_std()
2314 .unwrap_or(Duration::from_secs(u64::MAX));
2315 if age <= max_age {
2316 return Ok(stats);
2317 }
2318 }
2319 }
2320 }
2321 generate_and_store_stats(identity, session_gap_minutes)
2322}
2323
2324fn collect_status_records(candidates: &[SyncCandidate], limit: usize) -> Result<Value, String> {
2325 let available: usize = candidates
2326 .iter()
2327 .map(|candidate| candidate.records.len())
2328 .sum();
2329 let mut shown = 0usize;
2330 let mut events = Vec::new();
2331 let mut commits = Vec::new();
2332
2333 'candidates: for candidate in candidates {
2334 match &candidate.records {
2335 SyncRecords::Events(records) => {
2336 for event in records {
2337 if shown >= limit {
2338 break 'candidates;
2339 }
2340 events.push(json!({
2341 "sourceFile": candidate.path.display().to_string(),
2342 "occurredAt": event.occurred_at,
2343 "eventKind": event.event_kind.clone(),
2344 "primaryPath": event.primary_path.clone(),
2345 "oldPath": event.old_path.clone(),
2346 "newPath": event.new_path.clone(),
2347 "paths": event.paths.clone(),
2348 "addedLines": event.added_lines,
2349 "removedLines": event.removed_lines,
2350 "totalLines": event.total_lines,
2351 "headSha": event.head_sha.clone(),
2352 "rawKind": event.raw_kind.clone(),
2353 }));
2354 shown += 1;
2355 }
2356 }
2357 SyncRecords::Commits(records) => {
2358 for commit in records {
2359 if shown >= limit {
2360 break 'candidates;
2361 }
2362 commits.push(json!({
2363 "sourceFile": candidate.path.display().to_string(),
2364 "occurredAt": commit.occurred_at,
2365 "headSha": commit.head_sha.clone(),
2366 "previousHeadSha": commit.previous_head_sha.clone(),
2367 "subject": commit.subject.clone(),
2368 "authorName": commit.author_name.clone(),
2369 "authorEmail": commit.author_email.clone(),
2370 "committedAt": commit.committed_at.clone(),
2371 }));
2372 shown += 1;
2373 }
2374 }
2375 }
2376 }
2377
2378 Ok(json!({
2379 "available": available,
2380 "shown": shown,
2381 "truncated": shown < available,
2382 "events": events,
2383 "commits": commits,
2384 }))
2385}
2386
2387fn print_status_records(payload: &Value) {
2388 let Some(records) = payload.get("records") else {
2389 return;
2390 };
2391 let shown = records.get("shown").and_then(Value::as_u64).unwrap_or(0);
2392 if shown == 0 {
2393 println!("records: none");
2394 return;
2395 }
2396
2397 println!("records:");
2398 if let Some(events) = records.get("events").and_then(Value::as_array) {
2399 if !events.is_empty() {
2400 println!(" mutations:");
2401 for event in events {
2402 print_status_event_record(event);
2403 }
2404 }
2405 }
2406 if let Some(commits) = records.get("commits").and_then(Value::as_array) {
2407 if !commits.is_empty() {
2408 println!(" commits:");
2409 for commit in commits {
2410 print_status_commit_record(commit);
2411 }
2412 }
2413 }
2414 if records
2415 .get("truncated")
2416 .and_then(Value::as_bool)
2417 .unwrap_or(false)
2418 {
2419 let available = records
2420 .get("available")
2421 .and_then(Value::as_u64)
2422 .unwrap_or(shown);
2423 println!(" showing {shown} of {available} record(s)");
2424 }
2425}
2426
2427fn print_status_stats(payload: &Value) {
2428 let Some(stats) = payload.get("stats") else {
2429 return;
2430 };
2431 println!("stats:");
2432 println!(
2433 " coding time: {}",
2434 format_duration(
2435 stats
2436 .get("estimatedCodingSeconds")
2437 .and_then(Value::as_u64)
2438 .unwrap_or(0)
2439 )
2440 );
2441 println!(
2442 " sessions: {}",
2443 stats
2444 .get("sessionCount")
2445 .and_then(Value::as_u64)
2446 .unwrap_or(0)
2447 );
2448 println!(
2449 " observed span: {}",
2450 format_duration(
2451 stats
2452 .get("observedSpanSeconds")
2453 .and_then(Value::as_u64)
2454 .unwrap_or(0)
2455 )
2456 );
2457 println!(
2458 " events: {}",
2459 stats
2460 .get("totalEvents")
2461 .and_then(Value::as_u64)
2462 .unwrap_or(0)
2463 );
2464 println!(
2465 " commits: {}",
2466 stats
2467 .get("totalCommits")
2468 .and_then(Value::as_u64)
2469 .unwrap_or(0)
2470 );
2471 println!(
2472 " lines: +{} -{}",
2473 stats.get("addedLines").and_then(Value::as_u64).unwrap_or(0),
2474 stats
2475 .get("removedLines")
2476 .and_then(Value::as_u64)
2477 .unwrap_or(0)
2478 );
2479 if let Some(spool_root) = value_str(stats, "spoolRoot") {
2480 println!(
2481 " stored: {}",
2482 Path::new(spool_root).join("stats.json").display()
2483 );
2484 }
2485 print_stats_bucket_section(stats, "byFiletype", " by filetype:", 10);
2486 print_stats_bucket_section(stats, "byEventKind", " by event kind:", 10);
2487 print_stats_file_section(stats, 10);
2488}
2489
2490fn print_repo_activity(payload: &Value) {
2491 let Some(activity) = payload.get("repositoryActivity") else {
2492 return;
2493 };
2494 println!("repo activity:");
2495 println!(
2496 " coding time: {}",
2497 format_duration(
2498 activity
2499 .get("estimatedCodingSeconds")
2500 .and_then(Value::as_u64)
2501 .unwrap_or(0)
2502 )
2503 );
2504 println!(
2505 " observed span: {}",
2506 format_duration(
2507 activity
2508 .get("observedSpanSeconds")
2509 .and_then(Value::as_u64)
2510 .unwrap_or(0)
2511 )
2512 );
2513 println!(
2514 " branches: {}",
2515 activity
2516 .get("branchCount")
2517 .and_then(Value::as_u64)
2518 .unwrap_or(0)
2519 );
2520 println!(
2521 " sessions: {}, events: {}, commits: {}, lines: +{} -{}",
2522 activity
2523 .get("sessionCount")
2524 .and_then(Value::as_u64)
2525 .unwrap_or(0),
2526 activity
2527 .get("totalEvents")
2528 .and_then(Value::as_u64)
2529 .unwrap_or(0),
2530 activity
2531 .get("totalCommits")
2532 .and_then(Value::as_u64)
2533 .unwrap_or(0),
2534 activity
2535 .get("addedLines")
2536 .and_then(Value::as_u64)
2537 .unwrap_or(0),
2538 activity
2539 .get("removedLines")
2540 .and_then(Value::as_u64)
2541 .unwrap_or(0)
2542 );
2543 if let Some(stats_file) = value_str(activity, "statsFile") {
2544 println!(" stored: {stats_file}");
2545 }
2546 let Some(branches) = activity.get("branches").and_then(Value::as_array) else {
2547 return;
2548 };
2549 if branches.is_empty() {
2550 return;
2551 }
2552 println!(" by branch:");
2553 for branch in branches.iter().take(20) {
2554 let name = value_str(branch, "branchName").unwrap_or("unknown");
2555 let coding_seconds = branch
2556 .get("estimatedCodingSeconds")
2557 .and_then(Value::as_u64)
2558 .unwrap_or(0);
2559 let observed_seconds = branch
2560 .get("observedSpanSeconds")
2561 .and_then(Value::as_u64)
2562 .unwrap_or(0);
2563 let sessions = branch
2564 .get("sessionCount")
2565 .and_then(Value::as_u64)
2566 .unwrap_or(0);
2567 let events = branch
2568 .get("totalEvents")
2569 .and_then(Value::as_u64)
2570 .unwrap_or(0);
2571 let commits = branch
2572 .get("totalCommits")
2573 .and_then(Value::as_u64)
2574 .unwrap_or(0);
2575 println!(
2576 " {name}: {} coding, {} span, {} session(s), {} event(s), {} commit(s)",
2577 format_duration(coding_seconds),
2578 format_duration(observed_seconds),
2579 sessions,
2580 events,
2581 commits
2582 );
2583 }
2584}
2585
2586#[allow(dead_code)]
2587fn print_last_sync(payload: &Value) {
2588 let Some(last_sync) = payload.get("lastSync") else {
2589 return;
2590 };
2591 let synced_at = value_str(last_sync, "syncedAt").unwrap_or("unknown-time");
2592 let status = last_sync
2593 .get("statusCode")
2594 .and_then(Value::as_u64)
2595 .unwrap_or(0);
2596 let events = last_sync
2597 .get("eventCount")
2598 .and_then(Value::as_u64)
2599 .unwrap_or(0);
2600 let commits = last_sync
2601 .get("commitCount")
2602 .and_then(Value::as_u64)
2603 .unwrap_or(0);
2604 let files = last_sync
2605 .get("spoolFileCount")
2606 .and_then(Value::as_u64)
2607 .unwrap_or(0);
2608 let resync = last_sync
2609 .get("resync")
2610 .and_then(Value::as_bool)
2611 .unwrap_or(false);
2612 println!(
2613 "last sync: {synced_at}, HTTP {status}, {events} event(s), {commits} commit(s), {files} file(s), resync={resync}"
2614 );
2615}
2616
2617fn print_stats_bucket_section(stats: &Value, key: &str, title: &str, limit: usize) {
2618 let Some(items) = stats.get(key).and_then(Value::as_array) else {
2619 return;
2620 };
2621 if items.is_empty() {
2622 return;
2623 }
2624 println!("{title}");
2625 for item in items.iter().take(limit) {
2626 let name = value_str(item, "name").unwrap_or("unknown");
2627 let events = item.get("eventCount").and_then(Value::as_u64).unwrap_or(0);
2628 let seconds = item
2629 .get("estimatedCodingSeconds")
2630 .and_then(Value::as_u64)
2631 .unwrap_or(0);
2632 let added = item.get("addedLines").and_then(Value::as_u64).unwrap_or(0);
2633 let removed = item
2634 .get("removedLines")
2635 .and_then(Value::as_u64)
2636 .unwrap_or(0);
2637 println!(
2638 " {name}: {}, {} event(s), +{} -{}",
2639 format_duration(seconds),
2640 events,
2641 added,
2642 removed
2643 );
2644 }
2645}
2646
2647fn print_stats_file_section(stats: &Value, limit: usize) {
2648 let Some(items) = stats.get("byFile").and_then(Value::as_array) else {
2649 return;
2650 };
2651 if items.is_empty() {
2652 return;
2653 }
2654 println!(" by file:");
2655 for item in items.iter().take(limit) {
2656 let path = value_str(item, "path").unwrap_or("(no path)");
2657 let filetype = value_str(item, "filetype").unwrap_or("(none)");
2658 let events = item.get("eventCount").and_then(Value::as_u64).unwrap_or(0);
2659 let seconds = item
2660 .get("estimatedCodingSeconds")
2661 .and_then(Value::as_u64)
2662 .unwrap_or(0);
2663 let added = item.get("addedLines").and_then(Value::as_u64).unwrap_or(0);
2664 let removed = item
2665 .get("removedLines")
2666 .and_then(Value::as_u64)
2667 .unwrap_or(0);
2668 println!(
2669 " {path} [{filetype}]: {}, {} event(s), +{} -{}",
2670 format_duration(seconds),
2671 events,
2672 added,
2673 removed
2674 );
2675 }
2676}
2677
2678fn print_status_event_record(event: &Value) {
2679 let occurred_at = value_str(event, "occurredAt").unwrap_or("unknown-time");
2680 let kind = value_str(event, "eventKind").unwrap_or("event");
2681 let primary_path = event_display_path(event);
2682 let added = event.get("addedLines").and_then(Value::as_u64).unwrap_or(0);
2683 let removed = event
2684 .get("removedLines")
2685 .and_then(Value::as_u64)
2686 .unwrap_or(0);
2687 let total_lines = event.get("totalLines").and_then(Value::as_u64);
2688 if let Some(total_lines) = total_lines {
2689 println!(
2690 " {occurred_at} {kind} {primary_path} (+{added} -{removed}, {total_lines} lines)"
2691 );
2692 } else {
2693 println!(" {occurred_at} {kind} {primary_path} (+{added} -{removed})");
2694 }
2695
2696 if let Some(head_sha) = value_str(event, "headSha") {
2697 println!(" head: {}", short_sha(head_sha));
2698 }
2699 if let Some(paths) = event.get("paths").and_then(Value::as_array) {
2700 if paths.len() > 1 {
2701 let rendered = paths
2702 .iter()
2703 .filter_map(Value::as_str)
2704 .collect::<Vec<_>>()
2705 .join(", ");
2706 println!(" paths: {rendered}");
2707 }
2708 }
2709}
2710
2711fn print_status_commit_record(commit: &Value) {
2712 let occurred_at = value_str(commit, "occurredAt").unwrap_or("unknown-time");
2713 let head_sha = value_str(commit, "headSha")
2714 .map(short_sha)
2715 .unwrap_or_else(|| "unknown".to_string());
2716 let subject = value_str(commit, "subject").unwrap_or("(no subject)");
2717 println!(" {occurred_at} {head_sha} {subject}");
2718
2719 if let Some(author) = value_str(commit, "authorName") {
2720 println!(" author: {author}");
2721 }
2722}
2723
2724fn event_display_path(event: &Value) -> String {
2725 let old_path = value_str(event, "oldPath");
2726 let new_path = value_str(event, "newPath");
2727 if let (Some(old_path), Some(new_path)) = (old_path, new_path) {
2728 return format!("{old_path} -> {new_path}");
2729 }
2730 value_str(event, "primaryPath")
2731 .map(str::to_string)
2732 .or_else(|| {
2733 event
2734 .get("paths")
2735 .and_then(Value::as_array)
2736 .and_then(|paths| paths.first())
2737 .and_then(Value::as_str)
2738 .map(str::to_string)
2739 })
2740 .unwrap_or_else(|| "(no path)".to_string())
2741}
2742
2743fn value_str<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
2744 value.get(key).and_then(Value::as_str)
2745}
2746
2747fn short_sha(value: &str) -> String {
2748 value.chars().take(12).collect()
2749}
2750
2751fn generate_and_store_stats(
2752 identity: &RepoIdentity,
2753 session_gap_minutes: u64,
2754) -> Result<WorktreeWatchStatsSummary, String> {
2755 let layout = prepare_spool_layout(identity)?;
2756 let candidates = collect_spool_candidates(&layout, true)?;
2757 let summary = build_stats_summary(identity, &layout, &candidates, session_gap_minutes)?;
2758 let rendered = serde_json::to_string_pretty(&summary)
2759 .map_err(|error| format!("Failed to serialize stats: {error}"))?;
2760 fs::write(&layout.stats_file, format!("{rendered}\n")).map_err(|error| {
2761 format!(
2762 "Failed to write stats file {}: {error}",
2763 layout.stats_file.display()
2764 )
2765 })?;
2766 Ok(summary)
2767}
2768
2769fn generate_and_store_repo_activity(
2770 identity: &RepoIdentity,
2771 session_gap_minutes: u64,
2772) -> Result<WorktreeWatchRepoActivitySummary, String> {
2773 let repository_spool_root = repository_spool_root(identity)?;
2774 let summary =
2775 build_repo_activity_summary(identity, &repository_spool_root, session_gap_minutes)?;
2776 let rendered = serde_json::to_string_pretty(&summary)
2777 .map_err(|error| format!("Failed to serialize repo activity: {error}"))?;
2778 fs::create_dir_all(&repository_spool_root).map_err(|error| {
2779 format!(
2780 "Failed to create repository spool root {}: {error}",
2781 repository_spool_root.display()
2782 )
2783 })?;
2784 let stats_file = repository_spool_root.join("repo-activity.json");
2785 fs::write(&stats_file, format!("{rendered}\n")).map_err(|error| {
2786 format!(
2787 "Failed to write repo activity file {}: {error}",
2788 stats_file.display()
2789 )
2790 })?;
2791 Ok(summary)
2792}
2793
2794fn build_repo_activity_summary(
2795 identity: &RepoIdentity,
2796 repository_spool_root: &Path,
2797 session_gap_minutes: u64,
2798) -> Result<WorktreeWatchRepoActivitySummary, String> {
2799 let mut branches = Vec::new();
2800 if repository_spool_root.exists() {
2801 for entry in fs::read_dir(repository_spool_root).map_err(|error| {
2802 format!(
2803 "Failed to read repository spool root {}: {error}",
2804 repository_spool_root.display()
2805 )
2806 })? {
2807 let entry =
2808 entry.map_err(|error| format!("Failed to read repository spool entry: {error}"))?;
2809 let file_type = entry.file_type().map_err(|error| {
2810 format!("Failed to inspect {}: {error}", entry.path().display())
2811 })?;
2812 if !file_type.is_dir() {
2813 continue;
2814 }
2815 let branch_name = entry.file_name().to_string_lossy().to_string();
2816 let branch_root = entry.path();
2817 let branch_layout = spool_layout_for_existing_root(&branch_root);
2818 let branch_identity = RepoIdentity {
2819 branch: branch_name.clone(),
2820 ..identity.clone()
2821 };
2822 let candidates = collect_spool_candidates(&branch_layout, true)?;
2823 let stats = build_stats_summary(
2824 &branch_identity,
2825 &branch_layout,
2826 &candidates,
2827 session_gap_minutes,
2828 )?;
2829 if stats.total_events == 0 && stats.total_commits == 0 {
2830 continue;
2831 }
2832 branches.push(WorktreeWatchBranchActivityStats {
2833 branch_name,
2834 spool_root: branch_root.display().to_string(),
2835 total_events: stats.total_events,
2836 total_commits: stats.total_commits,
2837 first_event_at: stats.first_event_at,
2838 last_event_at: stats.last_event_at,
2839 session_count: stats.session_count,
2840 observed_span_seconds: stats.observed_span_seconds,
2841 estimated_coding_seconds: stats.estimated_coding_seconds,
2842 added_lines: stats.added_lines,
2843 removed_lines: stats.removed_lines,
2844 });
2845 }
2846 }
2847
2848 branches.sort_by(|left, right| {
2849 right
2850 .estimated_coding_seconds
2851 .cmp(&left.estimated_coding_seconds)
2852 .then_with(|| right.total_events.cmp(&left.total_events))
2853 .then_with(|| left.branch_name.cmp(&right.branch_name))
2854 });
2855
2856 let first_event_at = branches
2857 .iter()
2858 .filter_map(|branch| branch.first_event_at)
2859 .min();
2860 let last_event_at = branches
2861 .iter()
2862 .filter_map(|branch| branch.last_event_at)
2863 .max();
2864 let observed_span_seconds = observed_span_seconds(first_event_at, last_event_at);
2865 let stats_file = repository_spool_root.join("repo-activity.json");
2866
2867 Ok(WorktreeWatchRepoActivitySummary {
2868 generated_at: Utc::now(),
2869 repository_owner: identity.owner.clone(),
2870 repository_name: identity.name.clone(),
2871 repo_root: identity.root.display().to_string(),
2872 repository_spool_root: repository_spool_root.display().to_string(),
2873 stats_file: stats_file.display().to_string(),
2874 session_gap_minutes,
2875 branch_count: branches.len() as u64,
2876 total_events: branches.iter().map(|branch| branch.total_events).sum(),
2877 total_commits: branches.iter().map(|branch| branch.total_commits).sum(),
2878 first_event_at,
2879 last_event_at,
2880 session_count: branches.iter().map(|branch| branch.session_count).sum(),
2881 observed_span_seconds,
2882 estimated_coding_seconds: branches
2883 .iter()
2884 .map(|branch| branch.estimated_coding_seconds)
2885 .sum(),
2886 added_lines: branches.iter().map(|branch| branch.added_lines).sum(),
2887 removed_lines: branches.iter().map(|branch| branch.removed_lines).sum(),
2888 branches,
2889 })
2890}
2891
2892fn build_stats_summary(
2893 identity: &RepoIdentity,
2894 layout: &SpoolLayout,
2895 candidates: &[SyncCandidate],
2896 session_gap_minutes: u64,
2897) -> Result<WorktreeWatchStatsSummary, String> {
2898 let mut events = Vec::new();
2899 let mut total_commits = 0u64;
2900 for candidate in candidates {
2901 match &candidate.records {
2902 SyncRecords::Events(records) => events.extend(records.iter().cloned()),
2903 SyncRecords::Commits(records) => total_commits += records.len() as u64,
2904 }
2905 }
2906 events.sort_by_key(|event| event.occurred_at);
2907
2908 let gap_seconds = session_gap_minutes.saturating_mul(60).max(60);
2909 let mut previous_at: Option<DateTime<Utc>> = None;
2910 let mut session_count = 0u64;
2911 let mut total_seconds = 0u64;
2912 let mut total_added = 0u64;
2913 let mut total_removed = 0u64;
2914 let mut by_file: BTreeMap<String, WorktreeWatchFileStats> = BTreeMap::new();
2915 let mut by_filetype: BTreeMap<String, WorktreeWatchBucketStats> = BTreeMap::new();
2916 let mut by_event_kind: BTreeMap<String, WorktreeWatchBucketStats> = BTreeMap::new();
2917
2918 for event in &events {
2919 if starts_new_coding_session(previous_at, event.occurred_at, gap_seconds) {
2920 session_count += 1;
2921 }
2922 let seconds = coding_seconds_for_event(previous_at, event.occurred_at, gap_seconds);
2923 previous_at = Some(event.occurred_at);
2924 let added = event.added_lines.unwrap_or(0);
2925 let removed = event.removed_lines.unwrap_or(0);
2926 let path = stats_event_path(event);
2927 let filetype = filetype_for_path(&path);
2928
2929 total_seconds += seconds;
2930 total_added += added;
2931 total_removed += removed;
2932 update_file_stats(&mut by_file, &path, &filetype, seconds, added, removed);
2933 update_bucket_stats(&mut by_filetype, &filetype, seconds, added, removed);
2934 update_bucket_stats(
2935 &mut by_event_kind,
2936 &event.event_kind,
2937 seconds,
2938 added,
2939 removed,
2940 );
2941 }
2942
2943 Ok(WorktreeWatchStatsSummary {
2944 generated_at: Utc::now(),
2945 repository_owner: identity.owner.clone(),
2946 repository_name: identity.name.clone(),
2947 branch_name: identity.branch.clone(),
2948 repo_root: identity.root.display().to_string(),
2949 spool_root: layout.root.display().to_string(),
2950 session_gap_minutes,
2951 total_events: events.len() as u64,
2952 total_commits,
2953 first_event_at: events.first().map(|event| event.occurred_at),
2954 last_event_at: events.last().map(|event| event.occurred_at),
2955 session_count,
2956 observed_span_seconds: observed_span_seconds(
2957 events.first().map(|event| event.occurred_at),
2958 events.last().map(|event| event.occurred_at),
2959 ),
2960 estimated_coding_seconds: total_seconds,
2961 added_lines: total_added,
2962 removed_lines: total_removed,
2963 by_file: sorted_file_stats(by_file),
2964 by_filetype: sorted_bucket_stats(by_filetype),
2965 by_event_kind: sorted_bucket_stats(by_event_kind),
2966 })
2967}
2968
2969fn coding_seconds_for_event(
2970 previous_at: Option<DateTime<Utc>>,
2971 occurred_at: DateTime<Utc>,
2972 gap_seconds: u64,
2973) -> u64 {
2974 let Some(previous_at) = previous_at else {
2975 return 60;
2976 };
2977 let delta = occurred_at.signed_duration_since(previous_at).num_seconds();
2978 if delta <= 0 {
2979 0
2980 } else {
2981 (delta as u64).min(gap_seconds)
2982 }
2983}
2984
2985fn starts_new_coding_session(
2986 previous_at: Option<DateTime<Utc>>,
2987 occurred_at: DateTime<Utc>,
2988 gap_seconds: u64,
2989) -> bool {
2990 let Some(previous_at) = previous_at else {
2991 return true;
2992 };
2993 let delta = occurred_at.signed_duration_since(previous_at).num_seconds();
2994 delta > gap_seconds as i64
2995}
2996
2997fn observed_span_seconds(
2998 first_event_at: Option<DateTime<Utc>>,
2999 last_event_at: Option<DateTime<Utc>>,
3000) -> u64 {
3001 let (Some(first_event_at), Some(last_event_at)) = (first_event_at, last_event_at) else {
3002 return 0;
3003 };
3004 last_event_at
3005 .signed_duration_since(first_event_at)
3006 .num_seconds()
3007 .max(0) as u64
3008}
3009
3010fn update_file_stats(
3011 by_file: &mut BTreeMap<String, WorktreeWatchFileStats>,
3012 path: &str,
3013 filetype: &str,
3014 seconds: u64,
3015 added: u64,
3016 removed: u64,
3017) {
3018 let stats = by_file
3019 .entry(path.to_string())
3020 .or_insert_with(|| WorktreeWatchFileStats {
3021 path: path.to_string(),
3022 filetype: filetype.to_string(),
3023 ..WorktreeWatchFileStats::default()
3024 });
3025 stats.event_count += 1;
3026 stats.estimated_coding_seconds += seconds;
3027 stats.added_lines += added;
3028 stats.removed_lines += removed;
3029}
3030
3031fn update_bucket_stats(
3032 buckets: &mut BTreeMap<String, WorktreeWatchBucketStats>,
3033 name: &str,
3034 seconds: u64,
3035 added: u64,
3036 removed: u64,
3037) {
3038 let stats = buckets
3039 .entry(name.to_string())
3040 .or_insert_with(|| WorktreeWatchBucketStats {
3041 name: name.to_string(),
3042 ..WorktreeWatchBucketStats::default()
3043 });
3044 stats.event_count += 1;
3045 stats.estimated_coding_seconds += seconds;
3046 stats.added_lines += added;
3047 stats.removed_lines += removed;
3048}
3049
3050fn sorted_file_stats(
3051 by_file: BTreeMap<String, WorktreeWatchFileStats>,
3052) -> Vec<WorktreeWatchFileStats> {
3053 let mut stats = by_file.into_values().collect::<Vec<_>>();
3054 stats.sort_by(|left, right| {
3055 right
3056 .estimated_coding_seconds
3057 .cmp(&left.estimated_coding_seconds)
3058 .then_with(|| right.event_count.cmp(&left.event_count))
3059 .then_with(|| left.path.cmp(&right.path))
3060 });
3061 stats
3062}
3063
3064fn sorted_bucket_stats(
3065 buckets: BTreeMap<String, WorktreeWatchBucketStats>,
3066) -> Vec<WorktreeWatchBucketStats> {
3067 let mut stats = buckets.into_values().collect::<Vec<_>>();
3068 stats.sort_by(|left, right| {
3069 right
3070 .estimated_coding_seconds
3071 .cmp(&left.estimated_coding_seconds)
3072 .then_with(|| right.event_count.cmp(&left.event_count))
3073 .then_with(|| left.name.cmp(&right.name))
3074 });
3075 stats
3076}
3077
3078fn stats_event_path(event: &WorktreeMutationEvent) -> String {
3079 event
3080 .new_path
3081 .as_deref()
3082 .or(event.primary_path.as_deref())
3083 .or_else(|| event.paths.first().map(String::as_str))
3084 .unwrap_or("(no path)")
3085 .to_string()
3086}
3087
3088fn filetype_for_path(path: &str) -> String {
3089 Path::new(path)
3090 .extension()
3091 .and_then(|value| value.to_str())
3092 .map(|value| value.to_ascii_lowercase())
3093 .filter(|value| !value.is_empty())
3094 .unwrap_or_else(|| "(none)".to_string())
3095}
3096
3097fn format_duration(seconds: u64) -> String {
3098 let hours = seconds / 3600;
3099 let minutes = (seconds % 3600) / 60;
3100 let remaining_seconds = seconds % 60;
3101 if hours > 0 {
3102 format!("{hours}h {minutes}m")
3103 } else if minutes > 0 {
3104 format!("{minutes}m {remaining_seconds}s")
3105 } else {
3106 format!("{remaining_seconds}s")
3107 }
3108}
3109
3110async fn watch_foreground(
3111 identity: RepoIdentity,
3112 layout: SpoolLayout,
3113 sync_interval_seconds: u64,
3114) -> Result<(), String> {
3115 if let Ok(executable) = std::env::current_exe() {
3117 let _ = write_watcher_state(&identity, &layout, std::process::id(), &executable);
3118 }
3119
3120 let api_target = WorktreeWatchTargetOptions {
3123 repo: Some(identity.root.clone()),
3124 parent: None,
3125 repos: Vec::new(),
3126 };
3127 if let Some(port) = crate::commands::worktree_watch_api::spawn_worktree_watch_api_in_background(
3128 api_target,
3129 sync_interval_seconds,
3130 None,
3131 ) {
3132 if !is_background_child() {
3133 crate::commands::worktree_watch_api::print_api_listen_hint(port);
3134 } else {
3135 tracing::info!(
3136 "worktree-watch local API listening on http://127.0.0.1:{port}"
3137 );
3138 }
3139 }
3140
3141 let (tx, rx) = mpsc::channel();
3142 let mut watcher = create_path_watcher(&identity.root, tx)?;
3143 watcher
3144 .watch(&identity.root, RecursiveMode::Recursive)
3145 .map_err(|error| {
3146 format!(
3147 "Failed to watch repository root {}: {error}",
3148 identity.root.display()
3149 )
3150 })?;
3151 log_watcher_backend(&identity.root, "repository");
3152
3153 let mut last_head = identity.head_sha.clone();
3154 let mut last_commit_check = Instant::now();
3155 let mut last_sync = Instant::now();
3156 let mut event_dedupe = RecentEventDedupe {
3157 fingerprints: load_event_dedupe_fingerprints(&layout.event_dedupe_file)?,
3158 };
3159
3160 loop {
3161 match rx.recv_timeout(Duration::from_millis(750)) {
3162 Ok(Ok(event)) => {
3163 if let Some(mutation) = mutation_event_from_notify(&identity, event) {
3164 append_unique_mutation_event(&layout, &mutation, &mut event_dedupe)?;
3165 }
3166 }
3167 Ok(Err(error)) => {
3168 eprintln!("worktree watcher error: {error}");
3169 }
3170 Err(mpsc::RecvTimeoutError::Timeout) => {}
3171 Err(mpsc::RecvTimeoutError::Disconnected) => {
3172 return Err("Filesystem watcher disconnected.".to_string());
3173 }
3174 }
3175
3176 if last_commit_check.elapsed() >= Duration::from_secs(3) {
3177 last_head = record_commit_snapshot(&identity, &layout, last_head.as_deref())?;
3178 last_commit_check = Instant::now();
3179 }
3180
3181 if sync_interval_seconds > 0
3182 && last_sync.elapsed() >= Duration::from_secs(sync_interval_seconds)
3183 {
3184 if let Err(error) = sync_spool_for_identity(&identity, &layout).await {
3185 eprintln!("worktree mutation sync failed: {error}");
3186 }
3187 last_sync = Instant::now();
3188 }
3189 }
3190}
3191
3192async fn watch_parent_foreground(
3193 parent_root: PathBuf,
3194 identities: Vec<RepoIdentity>,
3195 sync_interval_seconds: u64,
3196) -> Result<(), String> {
3197 if let Ok(layout) = prepare_parent_spool_layout(&parent_root) {
3200 if let Ok(executable) = std::env::current_exe() {
3201 let _ = write_parent_watcher_state(
3202 &parent_root,
3203 &layout,
3204 std::process::id(),
3205 &executable,
3206 );
3207 }
3208 }
3209
3210 let api_target = WorktreeWatchTargetOptions {
3211 repo: None,
3212 parent: Some(parent_root.clone()),
3213 repos: Vec::new(),
3214 };
3215 if let Some(port) = crate::commands::worktree_watch_api::spawn_worktree_watch_api_in_background(
3216 api_target,
3217 sync_interval_seconds,
3218 None,
3219 ) {
3220 if !is_background_child() {
3221 crate::commands::worktree_watch_api::print_api_listen_hint(port);
3222 } else {
3223 tracing::info!(
3224 "worktree-watch local API listening on http://127.0.0.1:{port}"
3225 );
3226 }
3227 }
3228
3229 let (tx, rx) = mpsc::channel();
3230 let mut watcher = create_path_watcher(&parent_root, tx)?;
3231 watcher
3232 .watch(&parent_root, RecursiveMode::Recursive)
3233 .map_err(|error| {
3234 format!(
3235 "Failed to watch parent folder {}: {error}",
3236 parent_root.display()
3237 )
3238 })?;
3239 log_watcher_backend(&parent_root, "parent");
3240
3241 let mut repos = prepare_watched_repos(identities)?;
3242 let mut last_commit_check = Instant::now();
3243 let mut last_sync = Instant::now();
3244
3245 loop {
3246 match rx.recv_timeout(Duration::from_millis(750)) {
3247 Ok(Ok(event)) => {
3248 for (repo_index, routed_event) in route_parent_event(&repos, &event) {
3249 let repo = &mut repos[repo_index];
3250 if let Some(mutation) = mutation_event_from_notify(&repo.identity, routed_event)
3251 {
3252 append_unique_mutation_event(
3253 &repo.layout,
3254 &mutation,
3255 &mut repo.event_dedupe,
3256 )?;
3257 }
3258 }
3259 }
3260 Ok(Err(error)) => {
3261 eprintln!("parent worktree watcher error: {error}");
3262 }
3263 Err(mpsc::RecvTimeoutError::Timeout) => {}
3264 Err(mpsc::RecvTimeoutError::Disconnected) => {
3265 return Err("Parent filesystem watcher disconnected.".to_string());
3266 }
3267 }
3268
3269 if last_commit_check.elapsed() >= Duration::from_secs(3) {
3270 for repo in &mut repos {
3271 repo.last_head = record_commit_snapshot(
3272 &repo.identity,
3273 &repo.layout,
3274 repo.last_head.as_deref(),
3275 )?;
3276 }
3277 last_commit_check = Instant::now();
3278 }
3279
3280 if sync_interval_seconds > 0
3281 && last_sync.elapsed() >= Duration::from_secs(sync_interval_seconds)
3282 {
3283 for repo in &repos {
3284 if let Err(error) = sync_spool_for_identity(&repo.identity, &repo.layout).await {
3285 eprintln!(
3286 "worktree mutation sync failed for {}: {error}",
3287 repo.identity.root.display()
3288 );
3289 }
3290 }
3291 last_sync = Instant::now();
3292 }
3293 }
3294}
3295
3296enum PathWatcher {
3299 Recommended(RecommendedWatcher),
3300 Poll(notify::PollWatcher),
3301}
3302
3303impl PathWatcher {
3304 fn watch(&mut self, path: &Path, mode: RecursiveMode) -> notify::Result<()> {
3305 match self {
3306 Self::Recommended(watcher) => watcher.watch(path, mode),
3307 Self::Poll(watcher) => watcher.watch(path, mode),
3308 }
3309 }
3310}
3311
3312fn create_path_watcher(
3313 path: &Path,
3314 tx: mpsc::Sender<notify::Result<Event>>,
3315) -> Result<PathWatcher, String> {
3316 let config = Config::default();
3317 if should_use_poll_watcher(path) {
3318 let poll_config = config.with_poll_interval(Duration::from_secs(2));
3319 notify::PollWatcher::new(
3320 move |result| {
3321 let _ = tx.send(result);
3322 },
3323 poll_config,
3324 )
3325 .map(PathWatcher::Poll)
3326 .map_err(|error| format!("Failed to create poll filesystem watcher: {error}"))
3327 } else {
3328 RecommendedWatcher::new(
3329 move |result| {
3330 let _ = tx.send(result);
3331 },
3332 config,
3333 )
3334 .map(PathWatcher::Recommended)
3335 .map_err(|error| format!("Failed to create filesystem watcher: {error}"))
3336 }
3337}
3338
3339fn should_use_poll_watcher(path: &Path) -> bool {
3340 if !is_wsl_runtime() {
3342 return false;
3343 }
3344 let key = path_identity_key(path).replace('\\', "/");
3345 key.starts_with("/mnt/") || key.contains("/mnt/")
3346}
3347
3348fn log_watcher_backend(path: &Path, label: &str) {
3349 if should_use_poll_watcher(path) {
3350 eprintln!(
3351 "worktree-watch: using poll watcher for {label} {} (WSL mount; inotify is unreliable)",
3352 path.display()
3353 );
3354 }
3355}
3356
3357fn prepare_watched_repos(identities: Vec<RepoIdentity>) -> Result<Vec<WatchedRepo>, String> {
3358 let mut repos = Vec::with_capacity(identities.len());
3359 for identity in identities {
3360 let layout = prepare_spool_layout(&identity)?;
3361 let event_dedupe = RecentEventDedupe {
3362 fingerprints: load_event_dedupe_fingerprints(&layout.event_dedupe_file)?,
3363 };
3364 repos.push(WatchedRepo {
3365 last_head: identity.head_sha.clone(),
3366 identity,
3367 layout,
3368 event_dedupe,
3369 });
3370 }
3371 repos.sort_by_key(|repo| std::cmp::Reverse(repo.identity.root.components().count()));
3372 Ok(repos)
3373}
3374
3375fn route_parent_event(repos: &[WatchedRepo], event: &Event) -> Vec<(usize, Event)> {
3376 let mut paths_by_repo: BTreeMap<usize, Vec<PathBuf>> = BTreeMap::new();
3377 for path in &event.paths {
3378 for (index, repo) in repos.iter().enumerate() {
3379 if path_belongs_to_repo(path, &repo.identity.root) {
3380 let paths = paths_by_repo.entry(index).or_default();
3381 if !paths.iter().any(|existing| existing == path) {
3382 paths.push(path.clone());
3383 }
3384 break;
3385 }
3386 }
3387 }
3388
3389 let mut routed = Vec::new();
3390 for (index, paths) in paths_by_repo {
3391 let mut repo_event = event.clone();
3392 repo_event.paths = paths;
3393 routed.push((index, repo_event));
3394 }
3395 routed
3396}
3397
3398fn path_belongs_to_repo(path: &Path, repo_root: &Path) -> bool {
3399 let path_key = path_identity_key(path).replace('\\', "/");
3400 let root_key = path_identity_key(repo_root).replace('\\', "/");
3401 path_key == root_key || path_key.starts_with(&format!("{root_key}/"))
3402}
3403
3404fn mutation_event_from_notify(
3405 identity: &RepoIdentity,
3406 event: Event,
3407) -> Option<WorktreeMutationEvent> {
3408 if event.kind.is_access() || event.kind.is_other() {
3409 return None;
3410 }
3411
3412 let paths: Vec<String> = event
3413 .paths
3414 .iter()
3415 .filter(|path| !is_ignored_path(&identity.root, path))
3416 .filter_map(|path| relative_slash_path(&identity.root, path))
3417 .fold(Vec::new(), |mut paths, path| {
3418 if !paths.iter().any(|existing| existing == &path) {
3419 paths.push(path);
3420 }
3421 paths
3422 });
3423
3424 if paths.is_empty() {
3425 return None;
3426 }
3427
3428 let primary_path = paths.first().cloned();
3429 let (old_path, new_path) = rename_paths(&event.kind, &paths);
3430 let line_counts = primary_path
3431 .as_deref()
3432 .and_then(|path| git_numstat_for_path(&identity.root, path).ok());
3433 let total_lines = primary_path
3434 .as_deref()
3435 .and_then(|path| file_line_count_for_path(&identity.root, path).ok());
3436 let event_kind = classify_event_kind(&event.kind);
3437 let is_dir = event
3438 .paths
3439 .first()
3440 .and_then(|path| fs::metadata(path).ok())
3441 .map(|metadata| metadata.is_dir())
3442 .unwrap_or(false);
3443
3444 let occurred_at = Utc::now();
3445 let mut mutation = WorktreeMutationEvent {
3446 id: String::new(),
3447 fingerprint: None,
3448 repo_owner: identity.owner.clone(),
3449 repo_name: identity.name.clone(),
3450 branch_name: identity.branch.clone(),
3451 repo_root: normalize_repo_root_for_payload(&identity.root),
3452 head_sha: current_head(&identity.root).ok().flatten(),
3453 event_kind: event_kind.to_string(),
3454 paths,
3455 primary_path,
3456 old_path,
3457 new_path,
3458 added_lines: line_counts.map(|counts| counts.0),
3459 removed_lines: line_counts.map(|counts| counts.1),
3460 total_lines,
3461 file_created: matches!(
3462 event.kind,
3463 EventKind::Create(CreateKind::File | CreateKind::Any)
3464 ) && !is_dir,
3465 file_removed: matches!(
3466 event.kind,
3467 EventKind::Remove(RemoveKind::File | RemoveKind::Any)
3468 ) && !is_dir,
3469 folder_created: matches!(
3470 event.kind,
3471 EventKind::Create(CreateKind::Folder | CreateKind::Any)
3472 ) && is_dir,
3473 folder_removed: matches!(
3474 event.kind,
3475 EventKind::Remove(RemoveKind::Folder | RemoveKind::Any)
3476 ) && is_dir,
3477 renamed_or_moved: is_rename_or_move(&event.kind),
3478 raw_kind: format!("{:?}", event.kind),
3479 occurred_at,
3480 };
3481 let fingerprint = mutation_event_fingerprint(&mutation);
3484 mutation.fingerprint = Some(fingerprint.clone());
3485 mutation.id = stable_event_id(&fingerprint);
3486 Some(mutation)
3487}
3488
3489fn append_unique_mutation_event(
3490 layout: &SpoolLayout,
3491 mutation: &WorktreeMutationEvent,
3492 dedupe: &mut RecentEventDedupe,
3493) -> Result<bool, String> {
3494 let fingerprint = mutation
3495 .fingerprint
3496 .clone()
3497 .unwrap_or_else(|| mutation_event_fingerprint(mutation));
3498 if dedupe.fingerprints.contains(&fingerprint)
3499 || event_dedupe_file_contains(&layout.event_dedupe_file, &fingerprint)?
3500 {
3501 dedupe.fingerprints.insert(fingerprint);
3502 return Ok(false);
3503 }
3504
3505 if !claim_event_fingerprint(layout, &fingerprint)? {
3506 dedupe.fingerprints.insert(fingerprint);
3507 return Ok(false);
3508 }
3509
3510 let mut mutation = mutation.clone();
3511 mutation.fingerprint = Some(fingerprint.clone());
3512 if mutation.id.is_empty() {
3513 mutation.id = stable_event_id(&fingerprint);
3514 }
3515 if let Err(error) = append_json_line(&layout.events_file, &mutation) {
3516 release_event_fingerprint_claim(layout, &fingerprint);
3517 return Err(error);
3518 }
3519 append_json_line(
3520 &layout.event_dedupe_file,
3521 &WorktreeWatchEventDedupeRecord {
3522 fingerprint: fingerprint.clone(),
3523 first_seen_at: Utc::now(),
3524 event_kind: mutation.event_kind.clone(),
3525 primary_path: mutation.primary_path.clone(),
3526 },
3527 )?;
3528 dedupe.fingerprints.insert(fingerprint);
3529 Ok(true)
3530}
3531
3532fn claim_event_fingerprint(layout: &SpoolLayout, fingerprint: &str) -> Result<bool, String> {
3533 fs::create_dir_all(&layout.event_dedupe_dir).map_err(|error| {
3534 format!(
3535 "Failed to create event dedupe directory {}: {error}",
3536 layout.event_dedupe_dir.display()
3537 )
3538 })?;
3539 let marker = event_dedupe_marker_path(layout, fingerprint);
3540 match OpenOptions::new()
3541 .write(true)
3542 .create_new(true)
3543 .open(&marker)
3544 {
3545 Ok(mut file) => {
3546 writeln!(file, "{}", Utc::now().to_rfc3339()).map_err(|error| {
3547 format!(
3548 "Failed to write event dedupe marker {}: {error}",
3549 marker.display()
3550 )
3551 })?;
3552 Ok(true)
3553 }
3554 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
3555 Err(error) => Err(format!(
3556 "Failed to create event dedupe marker {}: {error}",
3557 marker.display()
3558 )),
3559 }
3560}
3561
3562fn release_event_fingerprint_claim(layout: &SpoolLayout, fingerprint: &str) {
3563 let _ = fs::remove_file(event_dedupe_marker_path(layout, fingerprint));
3564}
3565
3566fn event_dedupe_marker_path(layout: &SpoolLayout, fingerprint: &str) -> PathBuf {
3567 layout.event_dedupe_dir.join(format!("{fingerprint}.seen"))
3568}
3569
3570fn mutation_event_fingerprint(mutation: &WorktreeMutationEvent) -> String {
3571 let bucket = mutation.occurred_at.timestamp() / EVENT_DEDUPE_WINDOW_SECONDS;
3572 let payload = json!({
3577 "schema": 3,
3578 "repoOwner": mutation.repo_owner,
3579 "repoName": mutation.repo_name,
3580 "branchName": mutation.branch_name,
3581 "headSha": mutation.head_sha,
3582 "eventKind": mutation.event_kind,
3583 "paths": normalize_path_list_for_fingerprint(&mutation.paths),
3584 "primaryPath": mutation.primary_path.as_deref().map(normalize_relative_watch_path),
3585 "oldPath": mutation.old_path.as_deref().map(normalize_relative_watch_path),
3586 "newPath": mutation.new_path.as_deref().map(normalize_relative_watch_path),
3587 "addedLines": mutation.added_lines,
3588 "removedLines": mutation.removed_lines,
3589 "fileCreated": mutation.file_created,
3590 "fileRemoved": mutation.file_removed,
3591 "folderCreated": mutation.folder_created,
3592 "folderRemoved": mutation.folder_removed,
3593 "renamedOrMoved": mutation.renamed_or_moved,
3594 "timeBucket": bucket,
3595 });
3596 let encoded = serde_json::to_vec(&payload).unwrap_or_default();
3597 let mut hasher = Sha256::new();
3598 hasher.update(encoded);
3599 format!("{:x}", hasher.finalize())
3600}
3601
3602fn normalize_path_list_for_fingerprint(paths: &[String]) -> Vec<String> {
3603 let mut normalized = paths
3604 .iter()
3605 .map(|path| normalize_relative_watch_path(path))
3606 .filter(|path| !path.is_empty())
3607 .collect::<Vec<_>>();
3608 normalized.sort();
3609 normalized.dedup();
3610 normalized
3611}
3612
3613fn stable_event_id(fingerprint: &str) -> String {
3614 format!("wtmut_{fingerprint}")
3617}
3618
3619fn normalize_repo_root_for_payload(root: &Path) -> String {
3620 path_identity_key(root).replace('\\', "/")
3621}
3622
3623fn load_event_dedupe_fingerprints(path: &Path) -> Result<BTreeSet<String>, String> {
3624 let mut fingerprints = BTreeSet::new();
3625 if !path.exists() {
3626 return Ok(fingerprints);
3627 }
3628
3629 for value in read_jsonl_values(path)? {
3630 if let Some(fingerprint) = value.get("fingerprint").and_then(Value::as_str) {
3631 fingerprints.insert(fingerprint.to_string());
3632 }
3633 }
3634 Ok(fingerprints)
3635}
3636
3637fn event_dedupe_file_contains(path: &Path, fingerprint: &str) -> Result<bool, String> {
3638 if !path.exists() {
3639 return Ok(false);
3640 }
3641 for value in read_jsonl_values(path)? {
3642 if value
3643 .get("fingerprint")
3644 .and_then(Value::as_str)
3645 .map(|value| value == fingerprint)
3646 .unwrap_or(false)
3647 {
3648 return Ok(true);
3649 }
3650 }
3651 Ok(false)
3652}
3653
3654fn classify_event_kind(kind: &EventKind) -> &'static str {
3655 match kind {
3656 EventKind::Create(CreateKind::File) => "file_create",
3657 EventKind::Create(CreateKind::Folder) => "folder_create",
3658 EventKind::Create(_) => "create",
3659 EventKind::Remove(RemoveKind::File) => "file_remove",
3660 EventKind::Remove(RemoveKind::Folder) => "folder_remove",
3661 EventKind::Remove(_) => "remove",
3662 EventKind::Modify(ModifyKind::Name(
3663 RenameMode::From | RenameMode::To | RenameMode::Both,
3664 )) => "rename_or_move",
3665 EventKind::Modify(ModifyKind::Data(_)) => "file_modify",
3666 EventKind::Modify(ModifyKind::Metadata(_)) => "metadata_modify",
3667 EventKind::Modify(_) => "modify",
3668 _ => "other",
3669 }
3670}
3671
3672fn is_rename_or_move(kind: &EventKind) -> bool {
3673 matches!(
3674 kind,
3675 EventKind::Modify(ModifyKind::Name(
3676 RenameMode::From | RenameMode::To | RenameMode::Both
3677 ))
3678 )
3679}
3680
3681fn rename_paths(kind: &EventKind, paths: &[String]) -> (Option<String>, Option<String>) {
3682 if !is_rename_or_move(kind) {
3683 return (None, None);
3684 }
3685
3686 match paths {
3687 [old_path, new_path, ..] => (Some(old_path.clone()), Some(new_path.clone())),
3688 [path] => match kind {
3689 EventKind::Modify(ModifyKind::Name(RenameMode::From)) => (Some(path.clone()), None),
3690 EventKind::Modify(ModifyKind::Name(RenameMode::To)) => (None, Some(path.clone())),
3691 _ => (None, Some(path.clone())),
3692 },
3693 _ => (None, None),
3694 }
3695}
3696
3697fn record_commit_snapshot(
3698 identity: &RepoIdentity,
3699 layout: &SpoolLayout,
3700 previous_head: Option<&str>,
3701) -> Result<Option<String>, String> {
3702 let head = current_head(&identity.root)?;
3703 let Some(head_sha) = head else {
3704 return Ok(None);
3705 };
3706
3707 if previous_head == Some(head_sha.as_str()) {
3708 return Ok(Some(head_sha));
3709 }
3710
3711 let mut commit = WorktreeCommitEvent {
3712 id: String::new(),
3713 fingerprint: None,
3714 repo_owner: identity.owner.clone(),
3715 repo_name: identity.name.clone(),
3716 branch_name: identity.branch.clone(),
3717 repo_root: normalize_repo_root_for_payload(&identity.root),
3718 previous_head_sha: previous_head.map(str::to_string),
3719 head_sha: head_sha.clone(),
3720 subject: git_output(&identity.root, &["log", "-1", "--pretty=%s"]).ok(),
3721 author_name: git_output(&identity.root, &["log", "-1", "--pretty=%an"]).ok(),
3722 author_email: git_output(&identity.root, &["log", "-1", "--pretty=%ae"]).ok(),
3723 committed_at: git_output(&identity.root, &["log", "-1", "--pretty=%cI"]).ok(),
3724 occurred_at: Utc::now(),
3725 };
3726 let fingerprint = commit_event_fingerprint(&commit);
3727 commit.fingerprint = Some(fingerprint.clone());
3728 commit.id = stable_event_id(&fingerprint);
3729 append_unique_commit_event(layout, &commit)?;
3730 Ok(Some(head_sha))
3731}
3732
3733fn append_unique_commit_event(
3734 layout: &SpoolLayout,
3735 commit: &WorktreeCommitEvent,
3736) -> Result<bool, String> {
3737 let fingerprint = commit
3738 .fingerprint
3739 .clone()
3740 .unwrap_or_else(|| commit_event_fingerprint(commit));
3741 if !claim_commit_fingerprint(layout, &fingerprint)? {
3742 return Ok(false);
3743 }
3744
3745 let mut commit = commit.clone();
3746 commit.fingerprint = Some(fingerprint.clone());
3747 if commit.id.is_empty() {
3748 commit.id = stable_event_id(&fingerprint);
3749 }
3750 if let Err(error) = append_json_line(&layout.commits_file, &commit) {
3751 release_commit_fingerprint_claim(layout, &fingerprint);
3752 return Err(error);
3753 }
3754 Ok(true)
3755}
3756
3757fn commit_event_fingerprint(commit: &WorktreeCommitEvent) -> String {
3758 let payload = json!({
3759 "schema": 3,
3760 "repoOwner": commit.repo_owner,
3761 "repoName": commit.repo_name,
3762 "branchName": commit.branch_name,
3763 "previousHeadSha": commit.previous_head_sha,
3764 "headSha": commit.head_sha,
3765 });
3766 let encoded = serde_json::to_vec(&payload).unwrap_or_default();
3767 let mut hasher = Sha256::new();
3768 hasher.update(encoded);
3769 format!("{:x}", hasher.finalize())
3770}
3771
3772fn claim_commit_fingerprint(layout: &SpoolLayout, fingerprint: &str) -> Result<bool, String> {
3773 fs::create_dir_all(&layout.commit_dedupe_dir).map_err(|error| {
3774 format!(
3775 "Failed to create commit dedupe directory {}: {error}",
3776 layout.commit_dedupe_dir.display()
3777 )
3778 })?;
3779 let marker = commit_dedupe_marker_path(layout, fingerprint);
3780 match OpenOptions::new()
3781 .write(true)
3782 .create_new(true)
3783 .open(&marker)
3784 {
3785 Ok(mut file) => {
3786 writeln!(file, "{}", Utc::now().to_rfc3339()).map_err(|error| {
3787 format!(
3788 "Failed to write commit dedupe marker {}: {error}",
3789 marker.display()
3790 )
3791 })?;
3792 Ok(true)
3793 }
3794 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
3795 Err(error) => Err(format!(
3796 "Failed to create commit dedupe marker {}: {error}",
3797 marker.display()
3798 )),
3799 }
3800}
3801
3802fn release_commit_fingerprint_claim(layout: &SpoolLayout, fingerprint: &str) {
3803 let _ = fs::remove_file(commit_dedupe_marker_path(layout, fingerprint));
3804}
3805
3806fn commit_dedupe_marker_path(layout: &SpoolLayout, fingerprint: &str) -> PathBuf {
3807 layout.commit_dedupe_dir.join(format!("{fingerprint}.seen"))
3808}
3809
3810async fn sync_spool_for_identity(
3811 identity: &RepoIdentity,
3812 layout: &SpoolLayout,
3813) -> Result<(), String> {
3814 let candidates = collect_sync_candidates(layout)?;
3815 if candidates.is_empty() {
3816 return Ok(());
3817 }
3818
3819 sync_candidates(identity, candidates, false).await
3820}
3821
3822async fn sync_candidates(
3823 identity: &RepoIdentity,
3824 candidates: Vec<SyncCandidate>,
3825 resync: bool,
3826) -> Result<(), String> {
3827 let mut events = Vec::new();
3828 let mut commits = Vec::new();
3829 for candidate in &candidates {
3830 match &candidate.records {
3831 SyncRecords::Events(records) => events.extend(records.iter().cloned()),
3832 SyncRecords::Commits(records) => commits.extend(records.iter().cloned()),
3833 }
3834 }
3835
3836 let token = resolve_cli_access_token()?;
3837 let device = resolve_device_identity()?;
3838 let client = cli_request_client()?;
3839 let api = ApiConfig::from_env();
3840 let endpoint = api.cli_worktree_mutations_endpoint();
3841 let event_count = events.len();
3842 let commit_count = commits.len();
3843 let hardware_id = worktree_device_hardware_id(&device.hardware_id);
3844 let hostname = current_hostname();
3845 let platform = std::env::consts::OS.to_string();
3846 let runtime_key = worktree_runtime_key();
3847 let repo_root = normalize_repo_root_for_payload(&identity.root);
3848 let events = events
3850 .into_iter()
3851 .map(ensure_mutation_event_identity)
3852 .collect::<Vec<_>>();
3853 let commits = commits
3854 .into_iter()
3855 .map(ensure_commit_event_identity)
3856 .collect::<Vec<_>>();
3857 let batches = split_worktree_mutation_upload_batches(events.clone(), commits.clone());
3858 let batch_count = batches.len();
3859
3860 if batch_count > 1 {
3861 println!(
3862 "Uploading {} worktree mutation record(s) in {} batch(es) for {}/{} on {} [{}].",
3863 event_count + commit_count,
3864 batch_count,
3865 identity.owner,
3866 identity.name,
3867 identity.branch,
3868 runtime_key
3869 );
3870 }
3871
3872 let mut status_code = 202;
3873 for attempt in 1..=WORKTREE_MUTATION_SYNC_MAX_ATTEMPTS {
3874 let attempt_batches =
3875 split_worktree_mutation_upload_batches(events.clone(), commits.clone());
3876 match upload_worktree_mutation_batches(
3877 &client,
3878 &endpoint,
3879 &token,
3880 &hardware_id,
3881 hostname.as_deref(),
3882 &platform,
3883 &runtime_key,
3884 identity,
3885 &repo_root,
3886 attempt_batches,
3887 )
3888 .await
3889 {
3890 Ok(code) => {
3891 status_code = code;
3892 break;
3893 }
3894 Err(error)
3895 if error.kind == WorktreeMutationSyncErrorKind::Retryable
3896 && attempt < WORKTREE_MUTATION_SYNC_MAX_ATTEMPTS =>
3897 {
3898 eprintln!(
3899 "Worktree mutation sync attempt {attempt}/{} failed for {}/{} on {}: {} Restarting from the first batch in {} second(s).",
3900 WORKTREE_MUTATION_SYNC_MAX_ATTEMPTS,
3901 identity.owner,
3902 identity.name,
3903 identity.branch,
3904 error.message,
3905 WORKTREE_MUTATION_SYNC_RETRY_DELAY_SECONDS
3906 );
3907 tokio::time::sleep(Duration::from_secs(
3908 WORKTREE_MUTATION_SYNC_RETRY_DELAY_SECONDS,
3909 ))
3910 .await;
3911 }
3912 Err(error) => return Err(error.message),
3913 }
3914 }
3915
3916 let layout = prepare_spool_layout(identity)?;
3917 append_sync_log(SyncLogAppend {
3918 identity,
3919 layout: &layout,
3920 endpoint: &endpoint,
3921 status_code,
3922 event_count,
3923 commit_count,
3924 candidates: &candidates,
3925 resync,
3926 })?;
3927
3928 for candidate in candidates {
3929 if !is_synced_spool_path(&candidate.path) {
3930 mark_synced(&candidate.path)?;
3931 }
3932 }
3933
3934 Ok(())
3935}
3936
3937async fn upload_worktree_mutation_batches(
3938 client: &reqwest::Client,
3939 endpoint: &str,
3940 token: &str,
3941 hardware_id: &str,
3942 hostname: Option<&str>,
3943 platform: &str,
3944 runtime_key: &str,
3945 identity: &RepoIdentity,
3946 repo_root: &str,
3947 batches: Vec<WorktreeMutationUploadBatch>,
3948) -> Result<u16, WorktreeMutationSyncError> {
3949 let batch_count = batches.len();
3950 let mut status_code = 202;
3951
3952 for (batch_index, batch) in batches.into_iter().enumerate() {
3953 let batch_event_count = batch.events.len();
3954 let batch_commit_count = batch.commits.len();
3955 let payload = WorktreeMutationIngestPayload {
3956 device: WorktreeMutationDevicePayload {
3957 hardware_id: hardware_id.to_string(),
3958 device_name: hostname.map(str::to_string),
3959 hostname: hostname.map(str::to_string),
3960 platform: platform.to_string(),
3961 runtime_key: Some(runtime_key.to_string()),
3962 },
3963 repository: WorktreeMutationRepositoryPayload {
3964 owner: identity.owner.clone(),
3965 name: identity.name.clone(),
3966 branch_name: identity.branch.clone(),
3967 repo_root: repo_root.to_string(),
3968 },
3969 events: batch.events,
3970 commits: batch.commits,
3971 };
3972
3973 let response = client
3974 .post(endpoint)
3975 .bearer_auth(token)
3976 .json(&payload)
3977 .send()
3978 .await
3979 .map_err(|error| WorktreeMutationSyncError {
3980 kind: WorktreeMutationSyncErrorKind::Retryable,
3981 message: format!(
3982 "Failed to upload worktree mutation batch {}/{}: {error}",
3983 batch_index + 1,
3984 batch_count
3985 ),
3986 })?;
3987
3988 if response.status() == StatusCode::UNAUTHORIZED {
3989 return Err(WorktreeMutationSyncError {
3990 kind: WorktreeMutationSyncErrorKind::NonRetryable,
3991 message: "Your stored CLI session is no longer valid. Run `xbp login` again."
3992 .to_string(),
3993 });
3994 }
3995
3996 if !response.status().is_success() {
3997 let status = response.status();
3998 let body = response.text().await.unwrap_or_default();
3999 return Err(WorktreeMutationSyncError {
4000 kind: if should_retry_worktree_mutation_sync_status(status) {
4001 WorktreeMutationSyncErrorKind::Retryable
4002 } else {
4003 WorktreeMutationSyncErrorKind::NonRetryable
4004 },
4005 message: format!(
4006 "Worktree mutation upload batch {}/{} failed with {status}: {body}",
4007 batch_index + 1,
4008 batch_count
4009 ),
4010 });
4011 }
4012
4013 status_code = response.status().as_u16();
4014 if batch_count > 1 {
4015 println!(
4016 "Uploaded worktree mutation batch {}/{} ({} event(s), {} commit(s)).",
4017 batch_index + 1,
4018 batch_count,
4019 batch_event_count,
4020 batch_commit_count
4021 );
4022 }
4023 }
4024
4025 Ok(status_code)
4026}
4027
4028fn should_retry_worktree_mutation_sync_status(status: StatusCode) -> bool {
4029 status.is_server_error()
4030 || status == StatusCode::REQUEST_TIMEOUT
4031 || status == StatusCode::TOO_MANY_REQUESTS
4032}
4033
4034fn split_worktree_mutation_upload_batches(
4035 events: Vec<WorktreeMutationEvent>,
4036 commits: Vec<WorktreeCommitEvent>,
4037) -> Vec<WorktreeMutationUploadBatch> {
4038 let mut batches = Vec::new();
4039 let mut current = empty_worktree_mutation_upload_batch();
4040
4041 for event in events {
4042 let estimated_json_bytes = serialized_json_len(&event);
4043 if should_start_next_worktree_mutation_upload_batch(¤t, estimated_json_bytes) {
4044 batches.push(current);
4045 current = empty_worktree_mutation_upload_batch();
4046 }
4047 current.estimated_json_bytes += estimated_json_bytes;
4048 current.events.push(event);
4049 }
4050
4051 for commit in commits {
4052 let estimated_json_bytes = serialized_json_len(&commit);
4053 if should_start_next_worktree_mutation_upload_batch(¤t, estimated_json_bytes) {
4054 batches.push(current);
4055 current = empty_worktree_mutation_upload_batch();
4056 }
4057 current.estimated_json_bytes += estimated_json_bytes;
4058 current.commits.push(commit);
4059 }
4060
4061 if current_record_count(¤t) > 0 {
4062 batches.push(current);
4063 }
4064
4065 batches
4066}
4067
4068fn empty_worktree_mutation_upload_batch() -> WorktreeMutationUploadBatch {
4069 WorktreeMutationUploadBatch {
4070 events: Vec::new(),
4071 commits: Vec::new(),
4072 estimated_json_bytes: 0,
4073 }
4074}
4075
4076fn should_start_next_worktree_mutation_upload_batch(
4077 batch: &WorktreeMutationUploadBatch,
4078 next_record_json_bytes: usize,
4079) -> bool {
4080 if current_record_count(batch) == 0 {
4081 return false;
4082 }
4083
4084 current_record_count(batch) >= WORKTREE_MUTATION_SYNC_BATCH_RECORD_LIMIT
4085 || batch.estimated_json_bytes + next_record_json_bytes
4086 > WORKTREE_MUTATION_SYNC_BATCH_JSON_BYTES
4087}
4088
4089fn current_record_count(batch: &WorktreeMutationUploadBatch) -> usize {
4090 batch.events.len() + batch.commits.len()
4091}
4092
4093fn serialized_json_len<T: Serialize>(value: &T) -> usize {
4094 serde_json::to_vec(value)
4095 .map(|encoded| encoded.len())
4096 .unwrap_or(0)
4097}
4098
4099fn collect_sync_candidates(layout: &SpoolLayout) -> Result<Vec<SyncCandidate>, String> {
4100 collect_spool_candidates(layout, false)
4101}
4102
4103fn print_no_sync_candidates_message(
4104 target: &WorktreeWatchTargetOptions,
4105 resync: bool,
4106) -> Result<(), String> {
4107 if resync {
4108 println!("No local worktree mutation JSONL files found.");
4109 return Ok(());
4110 }
4111
4112 let identities = resolve_target_identities(target)?;
4113 let mut synced_files = 0usize;
4114 let mut synced_records = 0usize;
4115 for identity in identities {
4116 let layout = prepare_spool_layout(&identity)?;
4117 let all_candidates = collect_spool_candidates(&layout, true)?;
4118 let unsynced_candidates = collect_sync_candidates(&layout)?;
4119 synced_files += all_candidates
4120 .len()
4121 .saturating_sub(unsynced_candidates.len());
4122 let all_records = all_candidates
4123 .iter()
4124 .map(|candidate| candidate.records.len())
4125 .sum::<usize>();
4126 let unsynced_records = unsynced_candidates
4127 .iter()
4128 .map(|candidate| candidate.records.len())
4129 .sum::<usize>();
4130 synced_records += all_records.saturating_sub(unsynced_records);
4131 }
4132
4133 if synced_files > 0 {
4134 println!(
4135 "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."
4136 );
4137 } else {
4138 println!("No unsynced worktree mutation files found.");
4139 }
4140 Ok(())
4141}
4142
4143fn collect_spool_candidates(
4144 layout: &SpoolLayout,
4145 include_synced: bool,
4146) -> Result<Vec<SyncCandidate>, String> {
4147 let mut roots = vec![layout.root.clone()];
4150 if let Some(branch) = layout.root.file_name().and_then(|name| name.to_str()) {
4151 if let Some(repo_root) = layout.root.parent() {
4152 if let (Some(name), Some(owner)) = (
4153 repo_root.file_name().and_then(|n| n.to_str()),
4154 repo_root.parent().and_then(|p| p.file_name()).and_then(|n| n.to_str()),
4155 ) {
4156 let identity = RepoIdentity {
4157 owner: owner.to_string(),
4158 name: name.to_string(),
4159 branch: branch.to_string(),
4160 root: PathBuf::new(),
4161 head_sha: None,
4162 };
4163 if let Ok(search_roots) = repository_spool_search_roots(&identity) {
4164 for search_root in search_roots {
4165 let branch_root = search_root.join(branch);
4166 if !roots.iter().any(|existing| {
4167 path_identity_key(existing) == path_identity_key(&branch_root)
4168 }) {
4169 roots.push(branch_root);
4170 }
4171 }
4172 }
4173 }
4174 }
4175 }
4176
4177 let mut candidates = Vec::new();
4178 let mut seen_files = BTreeSet::new();
4179 for root in roots {
4180 collect_spool_candidates_from_root(&root, include_synced, &mut candidates, &mut seen_files)?;
4181 }
4182 Ok(candidates)
4183}
4184
4185fn collect_spool_candidates_from_root(
4186 root: &Path,
4187 include_synced: bool,
4188 candidates: &mut Vec<SyncCandidate>,
4189 seen_files: &mut BTreeSet<String>,
4190) -> Result<(), String> {
4191 if !root.exists() {
4192 return Ok(());
4193 }
4194
4195 for entry in fs::read_dir(root).map_err(|error| {
4196 format!(
4197 "Failed to read spool directory {}: {error}",
4198 root.display()
4199 )
4200 })? {
4201 let path = entry
4202 .map_err(|error| format!("Failed to read spool entry: {error}"))?
4203 .path();
4204 let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
4205 continue;
4206 };
4207 if !file_name.ends_with(".jsonl") || (!include_synced && file_name.contains(".synced.")) {
4208 continue;
4209 }
4210
4211 let kind = if file_name.starts_with("events-") {
4212 SyncFileKind::Events
4213 } else if file_name.starts_with("commits-") {
4214 SyncFileKind::Commits
4215 } else {
4216 continue;
4217 };
4218
4219 let identity_key = path_identity_key(&path);
4220 if !seen_files.insert(identity_key) {
4221 continue;
4222 }
4223
4224 let records = sanitize_spool_records(&path, kind, read_spool_records(&path, kind)?)?;
4225 if records.is_empty() {
4226 continue;
4227 }
4228 candidates.push(SyncCandidate { path, records });
4229 }
4230
4231 Ok(())
4232}
4233
4234fn append_sync_log(request: SyncLogAppend<'_>) -> Result<(), String> {
4235 let entry = WorktreeWatchSyncLogEntry {
4236 id: Uuid::new_v4().to_string(),
4237 synced_at: Utc::now(),
4238 endpoint: request.endpoint.to_string(),
4239 repository_owner: request.identity.owner.clone(),
4240 repository_name: request.identity.name.clone(),
4241 branch_name: request.identity.branch.clone(),
4242 repo_root: request.identity.root.display().to_string(),
4243 resync: request.resync,
4244 status_code: request.status_code,
4245 event_count: request.event_count,
4246 commit_count: request.commit_count,
4247 spool_file_count: request.candidates.len(),
4248 spool_files: request
4249 .candidates
4250 .iter()
4251 .map(|candidate| candidate.path.display().to_string())
4252 .collect(),
4253 };
4254 append_json_line(&request.layout.sync_log_file, &entry)
4255}
4256
4257fn sanitize_spool_records(
4258 path: &Path,
4259 kind: SyncFileKind,
4260 records: SyncRecords,
4261) -> Result<SyncRecords, String> {
4262 match (kind, records) {
4263 (SyncFileKind::Events, SyncRecords::Events(records)) => {
4264 let mut sanitized = Vec::with_capacity(records.len());
4265 let mut changed = false;
4266
4267 for record in records {
4268 let original = serde_json::to_value(&record).ok();
4269 let Some(record) = sanitize_spooled_event(record) else {
4270 changed = true;
4271 continue;
4272 };
4273 if !changed {
4274 changed = serde_json::to_value(&record).ok() != original;
4275 }
4276 sanitized.push(record);
4277 }
4278
4279 if changed {
4280 rewrite_jsonl_records(path, &sanitized)?;
4281 }
4282
4283 Ok(SyncRecords::Events(sanitized))
4284 }
4285 (_, records) => Ok(records),
4286 }
4287}
4288
4289fn sanitize_spooled_event(mut event: WorktreeMutationEvent) -> Option<WorktreeMutationEvent> {
4290 event.paths = dedupe_filtered_spool_paths(event.paths);
4291 event.primary_path = sanitize_optional_spool_path(event.primary_path);
4292 event.old_path = sanitize_optional_spool_path(event.old_path);
4293 event.new_path = sanitize_optional_spool_path(event.new_path);
4294
4295 if event.paths.is_empty() {
4296 if let Some(path) = event.primary_path.clone() {
4297 event.paths.push(path);
4298 }
4299 if let Some(path) = event.old_path.clone() {
4300 if !event.paths.iter().any(|existing| existing == &path) {
4301 event.paths.push(path);
4302 }
4303 }
4304 if let Some(path) = event.new_path.clone() {
4305 if !event.paths.iter().any(|existing| existing == &path) {
4306 event.paths.push(path);
4307 }
4308 }
4309 }
4310
4311 if event.primary_path.is_none() {
4312 event.primary_path = event.paths.first().cloned();
4313 }
4314
4315 if event.paths.is_empty() {
4316 return None;
4317 }
4318
4319 Some(event)
4320}
4321
4322fn sanitize_optional_spool_path(path: Option<String>) -> Option<String> {
4323 path.and_then(sanitize_spool_path)
4324}
4325
4326fn sanitize_spool_path(path: String) -> Option<String> {
4327 let trimmed = path.trim();
4328 if trimmed.is_empty() || watch_ignore_rules().ignores_relative(trimmed) {
4329 return None;
4330 }
4331 Some(trimmed.replace('\\', "/"))
4332}
4333
4334fn dedupe_filtered_spool_paths(paths: Vec<String>) -> Vec<String> {
4335 let mut sanitized = Vec::with_capacity(paths.len());
4336 for path in paths {
4337 let Some(path) = sanitize_spool_path(path) else {
4338 continue;
4339 };
4340 if !sanitized.iter().any(|existing| existing == &path) {
4341 sanitized.push(path);
4342 }
4343 }
4344 sanitized
4345}
4346
4347fn rewrite_jsonl_records<T: Serialize>(path: &Path, records: &[T]) -> Result<(), String> {
4348 if records.is_empty() {
4349 if path.exists() {
4350 fs::remove_file(path).map_err(|error| {
4351 format!(
4352 "Failed to remove emptied spool file {}: {error}",
4353 path.display()
4354 )
4355 })?;
4356 }
4357 return Ok(());
4358 }
4359
4360 let temp_path = path.with_extension(format!(
4361 "{}.{}.tmp",
4362 path.extension()
4363 .and_then(|value| value.to_str())
4364 .unwrap_or("jsonl"),
4365 Uuid::new_v4()
4366 ));
4367 {
4368 let mut file = File::create(&temp_path).map_err(|error| {
4369 format!(
4370 "Failed to create temporary spool file {}: {error}",
4371 temp_path.display()
4372 )
4373 })?;
4374 for record in records {
4375 let line = serde_json::to_string(record)
4376 .map_err(|error| format!("Failed to serialize worktree event: {error}"))?;
4377 writeln!(file, "{line}").map_err(|error| {
4378 format!(
4379 "Failed to write temporary spool file {}: {error}",
4380 temp_path.display()
4381 )
4382 })?;
4383 }
4384 }
4385
4386 if path.exists() {
4387 fs::remove_file(path).map_err(|error| {
4388 format!(
4389 "Failed to replace rewritten spool file {}: {error}",
4390 path.display()
4391 )
4392 })?;
4393 }
4394
4395 fs::rename(&temp_path, path)
4396 .map_err(|error| format!("Failed to rewrite spool file {}: {error}", path.display()))
4397}
4398
4399fn read_last_sync_log_entry(path: &Path) -> Result<Option<WorktreeWatchSyncLogEntry>, String> {
4400 if !path.exists() {
4401 return Ok(None);
4402 }
4403 let values = read_jsonl_values(path)?;
4404 let Some(value) = values.into_iter().last() else {
4405 return Ok(None);
4406 };
4407 serde_json::from_value(value)
4408 .map(Some)
4409 .map_err(|error| format!("Failed to decode sync log {}: {error}", path.display()))
4410}
4411
4412fn is_synced_spool_path(path: &Path) -> bool {
4413 path.file_name()
4414 .and_then(|value| value.to_str())
4415 .map(|value| value.contains(".synced."))
4416 .unwrap_or(false)
4417}
4418
4419fn mark_synced(path: &Path) -> Result<(), String> {
4420 let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
4421 return Ok(());
4422 };
4423 let synced_name = file_name.replace(
4424 ".jsonl",
4425 &format!(".synced.{}.jsonl", Utc::now().timestamp()),
4426 );
4427 let synced_path = path.with_file_name(synced_name);
4428 fs::rename(path, &synced_path).map_err(|error| {
4429 format!(
4430 "Failed to mark spool file {} as synced: {error}",
4431 path.display()
4432 )
4433 })
4434}
4435
4436fn read_spool_records(path: &Path, kind: SyncFileKind) -> Result<SyncRecords, String> {
4437 match kind {
4438 SyncFileKind::Events => read_jsonl_records::<WorktreeMutationEvent>(path)
4439 .map(SyncRecords::Events)
4440 .map_err(|error| format!("Failed to decode event from {}: {error}", path.display())),
4441 SyncFileKind::Commits => read_jsonl_records::<WorktreeCommitEvent>(path)
4442 .map(SyncRecords::Commits)
4443 .map_err(|error| format!("Failed to decode commit from {}: {error}", path.display())),
4444 }
4445}
4446
4447fn sanitize_jsonl_line(line: &str) -> Option<&str> {
4451 let trimmed = line.trim().trim_matches('\0').trim();
4452 if trimmed.is_empty() {
4453 None
4454 } else {
4455 Some(trimmed)
4456 }
4457}
4458
4459fn read_jsonl_records<T>(path: &Path) -> Result<Vec<T>, String>
4460where
4461 T: for<'de> Deserialize<'de>,
4462{
4463 let file = File::open(path)
4464 .map_err(|error| format!("Failed to open spool file {}: {error}", path.display()))?;
4465 let reader = BufReader::new(file);
4466 let mut records = Vec::new();
4467 for (line_no, line) in reader.lines().enumerate() {
4468 let line =
4469 line.map_err(|error| format!("Failed to read spool file {}: {error}", path.display()))?;
4470 let Some(payload) = sanitize_jsonl_line(&line) else {
4471 continue;
4472 };
4473 match serde_json::from_str(payload) {
4474 Ok(record) => records.push(record),
4475 Err(error) => {
4476 eprintln!(
4479 "warning: skipping unparseable JSONL record in {} at line {}: {error}",
4480 path.display(),
4481 line_no + 1
4482 );
4483 }
4484 }
4485 }
4486 Ok(records)
4487}
4488
4489fn read_jsonl_values(path: &Path) -> Result<Vec<Value>, String> {
4490 read_jsonl_records(path)
4491}
4492
4493fn spawn_detached_worktree_watch(repo: Option<&Path>) -> Result<PathBuf, String> {
4494 let identity = resolve_repo_identity(repo)?;
4495 spawn_detached_worktree_watch_for_identity(&identity)
4496}
4497
4498fn spawn_detached_parent_worktree_watch(parent: &Path) -> Result<PathBuf, String> {
4499 let parent = canonical_parent_path(parent)?;
4500 let layout = prepare_parent_spool_layout(&parent)?;
4501 replace_existing_parent_watcher(&parent, &layout)?;
4502 let executable = std::env::current_exe()
4503 .map_err(|error| format!("Failed to resolve current XBP executable: {error}"))?;
4504 let mut command = Command::new(&executable);
4505 command
4506 .arg("worktree-watch")
4507 .arg("start")
4508 .arg("--parent")
4509 .arg(&parent)
4510 .env(BACKGROUND_CHILD_ENV, "1")
4511 .current_dir(&parent)
4512 .stdin(Stdio::null())
4513 .stdout(Stdio::null())
4514 .stderr(Stdio::null());
4515 configure_detached_child_process(&mut command);
4516
4517 let child = command
4518 .spawn()
4519 .map_err(|error| format!("Failed to spawn background parent worktree watcher: {error}"))?;
4520 write_parent_watcher_state(&parent, &layout, child.id(), &executable)?;
4521 Ok(parent)
4522}
4523
4524fn spawn_detached_worktree_watch_for_identity(identity: &RepoIdentity) -> Result<PathBuf, String> {
4525 let layout = prepare_spool_layout(identity)?;
4526 replace_existing_watcher(identity, &layout)?;
4527 let executable = std::env::current_exe()
4528 .map_err(|error| format!("Failed to resolve current XBP executable: {error}"))?;
4529 let mut command = Command::new(&executable);
4530 command
4531 .arg("worktree-watch")
4532 .arg("start")
4533 .arg("--repo")
4534 .arg(&identity.root)
4535 .env(BACKGROUND_CHILD_ENV, "1")
4536 .current_dir(&identity.root)
4537 .stdin(Stdio::null())
4538 .stdout(Stdio::null())
4539 .stderr(Stdio::null());
4540 configure_detached_child_process(&mut command);
4541
4542 let child: std::process::Child = command
4543 .spawn()
4544 .map_err(|error| format!("Failed to spawn background worktree watcher: {error}"))?;
4545 write_watcher_state(identity, &layout, child.id(), &executable)?;
4546 Ok(identity.root.clone())
4547}
4548
4549fn configure_detached_child_process(command: &mut Command) {
4550 #[cfg(windows)]
4551 {
4552 use std::os::windows::process::CommandExt;
4553 command.creation_flags(CREATE_NO_WINDOW);
4554 }
4555
4556 #[cfg(unix)]
4559 {
4560 use std::os::unix::process::CommandExt;
4561 unsafe {
4563 command.pre_exec(|| {
4564 extern "C" {
4565 fn setsid() -> i32;
4566 }
4567 let _ = setsid();
4568 Ok(())
4569 });
4570 }
4571 }
4572}
4573
4574fn replace_existing_parent_watcher(
4575 parent: &Path,
4576 layout: &ParentSpoolLayout,
4577) -> Result<(), String> {
4578 let Some(state) = read_parent_watcher_state(&layout.watcher_state_file)? else {
4579 return Ok(());
4580 };
4581
4582 if path_identity_key(parent) != path_identity_key(Path::new(&state.parent_root)) {
4583 return Ok(());
4584 }
4585
4586 if is_matching_parent_watcher_process(&state) {
4587 stop_parent_process(&state, false)?;
4588 println!(
4589 "Replaced existing background parent worktree watcher {} for {}",
4590 state.pid,
4591 parent.display()
4592 );
4593 let _ = fs::remove_file(&layout.watcher_state_file);
4594 return Ok(());
4595 }
4596
4597 if parent_watcher_state_pid_exists_with_same_executable(&state) {
4598 return Err(format!(
4599 "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.",
4600 parent.display(),
4601 state.pid,
4602 parent.display()
4603 ));
4604 }
4605
4606 let _ = fs::remove_file(&layout.watcher_state_file);
4607 Ok(())
4608}
4609
4610fn replace_existing_watcher(identity: &RepoIdentity, layout: &SpoolLayout) -> Result<(), String> {
4611 let Some(state) = read_watcher_state(&layout.watcher_state_file)? else {
4612 return Ok(());
4613 };
4614
4615 if !same_repo_watcher_state(identity, &state) {
4616 return Ok(());
4617 }
4618
4619 if is_matching_watcher_process(&state) {
4620 stop_process(&state, false)?;
4621 println!(
4622 "Replaced existing background worktree watcher {} for {}",
4623 state.pid,
4624 identity.root.display()
4625 );
4626 let _ = fs::remove_file(&layout.watcher_state_file);
4627 return Ok(());
4628 }
4629
4630 if watcher_state_pid_exists_with_same_executable(&state) {
4631 return Err(format!(
4632 "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.",
4633 identity.root.display(),
4634 state.pid,
4635 identity.root.display()
4636 ));
4637 }
4638
4639 let _ = fs::remove_file(&layout.watcher_state_file);
4640 Ok(())
4641}
4642
4643fn stop_existing_watcher(
4644 identity: &RepoIdentity,
4645 layout: &SpoolLayout,
4646 force: bool,
4647) -> Result<StopWatcherOutcome, String> {
4648 let Some(state) = read_watcher_state(&layout.watcher_state_file)? else {
4649 return Ok(StopWatcherOutcome::NoState);
4650 };
4651
4652 if !same_repo_watcher_state(identity, &state) {
4653 return Ok(StopWatcherOutcome::NoState);
4654 }
4655
4656 if is_matching_watcher_process(&state) || force {
4657 let pid = state.pid;
4658 stop_process(&state, force)?;
4659 let _ = fs::remove_file(&layout.watcher_state_file);
4660 return Ok(StopWatcherOutcome::Stopped(pid));
4661 }
4662
4663 if watcher_state_pid_exists_with_same_executable(&state) {
4664 return Err(format!(
4665 "Watcher state for {} points to running XBP process {}, but its command line could not be verified. Re-run with `--force` to stop that PID.",
4666 identity.root.display(),
4667 state.pid
4668 ));
4669 }
4670
4671 let _ = fs::remove_file(&layout.watcher_state_file);
4672 Ok(StopWatcherOutcome::RemovedStaleState)
4673}
4674
4675fn stop_existing_parent_watcher(parent: &Path, force: bool) -> Result<StopWatcherOutcome, String> {
4676 let layout = prepare_parent_spool_layout(parent)?;
4677 let Some(state) = read_parent_watcher_state(&layout.watcher_state_file)? else {
4678 return Ok(StopWatcherOutcome::NoState);
4679 };
4680
4681 if path_identity_key(parent) != path_identity_key(Path::new(&state.parent_root)) {
4682 return Ok(StopWatcherOutcome::NoState);
4683 }
4684
4685 if is_matching_parent_watcher_process(&state) || force {
4686 let pid = state.pid;
4687 stop_parent_process(&state, force)?;
4688 let _ = fs::remove_file(&layout.watcher_state_file);
4689 return Ok(StopWatcherOutcome::Stopped(pid));
4690 }
4691
4692 if parent_watcher_state_pid_exists_with_same_executable(&state) {
4693 return Err(format!(
4694 "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.",
4695 parent.display(),
4696 state.pid
4697 ));
4698 }
4699
4700 let _ = fs::remove_file(&layout.watcher_state_file);
4701 Ok(StopWatcherOutcome::RemovedStaleState)
4702}
4703
4704fn read_watcher_state(path: &Path) -> Result<Option<WorktreeWatcherState>, String> {
4705 for candidate in watcher_state_read_candidates(path) {
4707 if !candidate.exists() {
4708 continue;
4709 }
4710 let raw = fs::read_to_string(&candidate).map_err(|error| {
4711 format!(
4712 "Failed to read watcher state {}: {error}",
4713 candidate.display()
4714 )
4715 })?;
4716 return serde_json::from_str(&raw).map(Some).map_err(|error| {
4717 format!(
4718 "Failed to parse watcher state {}: {error}",
4719 candidate.display()
4720 )
4721 });
4722 }
4723 Ok(None)
4724}
4725
4726fn watcher_state_read_candidates(path: &Path) -> Vec<PathBuf> {
4727 let mut candidates = vec![path.to_path_buf()];
4728 if let Some(parent) = path.parent() {
4729 let legacy = parent.join("watcher-state.json");
4730 if legacy != path {
4731 candidates.push(legacy);
4732 }
4733 }
4734 candidates
4735}
4736
4737fn read_parent_watcher_state(path: &Path) -> Result<Option<ParentWorktreeWatcherState>, String> {
4738 if !path.exists() {
4739 return Ok(None);
4740 }
4741 let raw = fs::read_to_string(path).map_err(|error| {
4742 format!(
4743 "Failed to read parent watcher state {}: {error}",
4744 path.display()
4745 )
4746 })?;
4747 serde_json::from_str(&raw).map(Some).map_err(|error| {
4748 format!(
4749 "Failed to parse parent watcher state {}: {error}",
4750 path.display()
4751 )
4752 })
4753}
4754
4755fn write_watcher_state(
4756 identity: &RepoIdentity,
4757 layout: &SpoolLayout,
4758 pid: u32,
4759 executable: &Path,
4760) -> Result<(), String> {
4761 let state = WorktreeWatcherState {
4762 pid,
4763 repo_root: normalize_repo_root_for_payload(&identity.root),
4764 repository_owner: identity.owner.clone(),
4765 repository_name: identity.name.clone(),
4766 branch_name: identity.branch.clone(),
4767 executable: executable.display().to_string(),
4768 started_at: Utc::now(),
4769 runtime_key: Some(worktree_runtime_key()),
4770 platform: Some(std::env::consts::OS.to_string()),
4771 };
4772 let rendered = serde_json::to_string_pretty(&state)
4773 .map_err(|error| format!("Failed to serialize watcher state: {error}"))?;
4774 fs::write(&layout.watcher_state_file, format!("{rendered}\n")).map_err(|error| {
4775 format!(
4776 "Failed to write watcher state {}: {error}",
4777 layout.watcher_state_file.display()
4778 )
4779 })
4780}
4781
4782fn write_parent_watcher_state(
4783 parent: &Path,
4784 layout: &ParentSpoolLayout,
4785 pid: u32,
4786 executable: &Path,
4787) -> Result<(), String> {
4788 let state = ParentWorktreeWatcherState {
4789 pid,
4790 parent_root: normalize_repo_root_for_payload(parent),
4791 executable: executable.display().to_string(),
4792 started_at: Utc::now(),
4793 runtime_key: Some(worktree_runtime_key()),
4794 platform: Some(std::env::consts::OS.to_string()),
4795 };
4796 let rendered = serde_json::to_string_pretty(&state)
4797 .map_err(|error| format!("Failed to serialize parent watcher state: {error}"))?;
4798 fs::write(&layout.watcher_state_file, format!("{rendered}\n")).map_err(|error| {
4799 format!(
4800 "Failed to write parent watcher state {}: {error}",
4801 layout.watcher_state_file.display()
4802 )
4803 })
4804}
4805
4806fn same_repo_watcher_state(identity: &RepoIdentity, state: &WorktreeWatcherState) -> bool {
4807 if !runtime_keys_compatible(state.runtime_key.as_deref()) {
4808 return false;
4809 }
4810 path_identity_key(&identity.root) == path_identity_key(Path::new(&state.repo_root))
4811 && identity.owner == state.repository_owner
4812 && identity.name == state.repository_name
4813 && identity.branch == state.branch_name
4814}
4815
4816fn runtime_keys_compatible(state_runtime: Option<&str>) -> bool {
4817 let current = worktree_runtime_key();
4818 match state_runtime {
4819 Some(key) => key == current,
4820 None => !is_wsl_runtime(),
4823 }
4824}
4825
4826fn process_system_for_pids(pids: &[u32]) -> System {
4827 let mut system = System::new();
4828 if pids.is_empty() {
4829 return system;
4830 }
4831 let pid_list: Vec<Pid> = pids
4832 .iter()
4833 .copied()
4834 .filter(|pid| *pid != 0 && *pid != std::process::id())
4835 .map(Pid::from_u32)
4836 .collect();
4837 if pid_list.is_empty() {
4838 return system;
4839 }
4840 system.refresh_processes_specifics(
4842 ProcessesToUpdate::Some(&pid_list),
4843 true,
4844 ProcessRefreshKind::everything()
4845 .with_cmd(UpdateKind::Always)
4846 .with_exe(UpdateKind::Always),
4847 );
4848 system
4849}
4850
4851fn is_matching_watcher_process(state: &WorktreeWatcherState) -> bool {
4852 let system = process_system_for_pids(&[state.pid]);
4853 is_matching_watcher_process_with_system(&system, state)
4855}
4856
4857fn is_live_watcher_process_with_system(system: &System, state: &WorktreeWatcherState) -> bool {
4859 if state.pid == std::process::id() {
4860 return runtime_keys_compatible(state.runtime_key.as_deref());
4861 }
4862 is_matching_watcher_process_with_system(system, state)
4863}
4864
4865fn is_matching_watcher_process_with_system(
4866 system: &System,
4867 state: &WorktreeWatcherState,
4868) -> bool {
4869 if state.pid == std::process::id() {
4870 return false;
4871 }
4872 let Some(process) = system.process(Pid::from_u32(state.pid)) else {
4873 return false;
4874 };
4875 let command_line = process
4876 .cmd()
4877 .iter()
4878 .map(|part| part.to_string_lossy())
4879 .collect::<Vec<_>>()
4880 .join(" ");
4881 let command_matches = command_line_contains_ignore_case(&command_line, "worktree-watch")
4882 && command_line_contains_ignore_case(&command_line, "start")
4883 && command_line_mentions_path(&command_line, &state.repo_root);
4884 command_matches || watcher_state_process_identity_matches(process, state)
4885}
4886
4887fn is_matching_parent_watcher_process(state: &ParentWorktreeWatcherState) -> bool {
4888 let system = process_system_for_pids(&[state.pid]);
4889 is_matching_parent_watcher_process_with_system(&system, state)
4890}
4891
4892fn is_live_parent_watcher_process_with_system(
4894 system: &System,
4895 state: &ParentWorktreeWatcherState,
4896) -> bool {
4897 if state.pid == std::process::id() {
4898 return runtime_keys_compatible(state.runtime_key.as_deref());
4899 }
4900 is_matching_parent_watcher_process_with_system(system, state)
4901}
4902
4903fn is_matching_parent_watcher_process_with_system(
4904 system: &System,
4905 state: &ParentWorktreeWatcherState,
4906) -> bool {
4907 if state.pid == std::process::id() {
4908 return false;
4909 }
4910 if !runtime_keys_compatible(state.runtime_key.as_deref()) {
4911 return false;
4912 }
4913 let Some(process) = system.process(Pid::from_u32(state.pid)) else {
4914 return false;
4915 };
4916 let command_line = process
4917 .cmd()
4918 .iter()
4919 .map(|part| part.to_string_lossy())
4920 .collect::<Vec<_>>()
4921 .join(" ");
4922 let command_matches = command_line_contains_ignore_case(&command_line, "worktree-watch")
4923 && command_line_contains_ignore_case(&command_line, "start")
4924 && command_line_contains_ignore_case(&command_line, "--parent")
4925 && command_line_mentions_path(&command_line, &state.parent_root);
4926 command_matches
4929 || parent_watcher_state_process_identity_matches(process, state)
4930 || (process_executable_matches_path(process, Path::new(&state.executable))
4931 && process_looks_like_worktree_watch(process))
4932}
4933
4934fn process_looks_like_worktree_watch(process: &sysinfo::Process) -> bool {
4935 let command_line = process
4936 .cmd()
4937 .iter()
4938 .map(|part| part.to_string_lossy())
4939 .collect::<Vec<_>>()
4940 .join(" ");
4941 if command_line_contains_ignore_case(&command_line, "worktree-watch") {
4942 return true;
4943 }
4944 command_line.trim().is_empty()
4946}
4947
4948fn command_line_contains_ignore_case(haystack: &str, needle: &str) -> bool {
4949 haystack.to_ascii_lowercase().contains(&needle.to_ascii_lowercase())
4950}
4951
4952fn command_line_mentions_path(command_line: &str, path: &str) -> bool {
4953 if path.is_empty() {
4954 return false;
4955 }
4956 if command_line.contains(path) {
4957 return true;
4958 }
4959 let command_lower = command_line.to_ascii_lowercase().replace('\\', "/");
4960 let path_lower = path.to_ascii_lowercase().replace('\\', "/");
4961 if command_lower.contains(&path_lower) {
4962 return true;
4963 }
4964 let path_key = path_identity_key(Path::new(path));
4966 if !path_key.is_empty() && command_lower.contains(&path_key) {
4967 return true;
4968 }
4969 for token in command_line.split_whitespace() {
4970 let cleaned = token.trim_matches('"').trim_matches('\'');
4971 if cleaned.is_empty() {
4972 continue;
4973 }
4974 if path_identity_key(Path::new(cleaned)) == path_key {
4975 return true;
4976 }
4977 }
4978 Path::new(path)
4980 .file_name()
4981 .and_then(|name| name.to_str())
4982 .map(|name| command_line_contains_ignore_case(command_line, name))
4983 .unwrap_or(false)
4984}
4985
4986fn watcher_state_pid_exists_with_same_executable(state: &WorktreeWatcherState) -> bool {
4987 if state.pid == std::process::id() {
4988 return false;
4989 }
4990 let system = process_system_for_pids(&[state.pid]);
4991 let Some(process) = system.process(Pid::from_u32(state.pid)) else {
4992 return false;
4993 };
4994 process_executable_matches_state(process, state)
4995}
4996
4997fn parent_watcher_state_pid_exists_with_same_executable(
4998 state: &ParentWorktreeWatcherState,
4999) -> bool {
5000 if state.pid == std::process::id() {
5001 return false;
5002 }
5003 let system = process_system_for_pids(&[state.pid]);
5004 let Some(process) = system.process(Pid::from_u32(state.pid)) else {
5005 return false;
5006 };
5007 process_executable_matches_path(process, Path::new(&state.executable))
5008}
5009
5010fn watcher_state_process_identity_matches(
5011 process: &sysinfo::Process,
5012 state: &WorktreeWatcherState,
5013) -> bool {
5014 process_executable_matches_state(process, state)
5015 && process_start_time_matches(process.start_time(), state.started_at)
5016}
5017
5018fn parent_watcher_state_process_identity_matches(
5019 process: &sysinfo::Process,
5020 state: &ParentWorktreeWatcherState,
5021) -> bool {
5022 process_executable_matches_path(process, Path::new(&state.executable))
5023 && process_start_time_matches(process.start_time(), state.started_at)
5024}
5025
5026fn process_executable_matches_state(
5027 process: &sysinfo::Process,
5028 state: &WorktreeWatcherState,
5029) -> bool {
5030 process_executable_matches_path(process, Path::new(&state.executable))
5031}
5032
5033fn process_executable_matches_path(process: &sysinfo::Process, executable: &Path) -> bool {
5034 let expected = path_identity_key(executable);
5035 process
5036 .exe()
5037 .map(|path| path_identity_key(path) == expected)
5038 .unwrap_or(false)
5039}
5040
5041fn process_start_time_matches(process_started: u64, state_started: DateTime<Utc>) -> bool {
5042 if process_started == 0 {
5044 return true;
5045 }
5046 let process_started = process_started as i64;
5047 let state_started = state_started.timestamp();
5048 (process_started - state_started).abs() <= 120
5050}
5051
5052fn stop_process(state: &WorktreeWatcherState, force: bool) -> Result<(), String> {
5053 let system = process_system_for_pids(&[state.pid]);
5054 let Some(process) = system.process(Pid::from_u32(state.pid)) else {
5055 return Ok(());
5056 };
5057 if !force && !is_matching_watcher_process_with_system(&system, state) {
5058 return Ok(());
5059 }
5060 if process.kill() {
5061 Ok(())
5062 } else {
5063 Err(format!(
5064 "Failed to stop existing watcher process {}",
5065 state.pid
5066 ))
5067 }
5068}
5069
5070fn stop_parent_process(state: &ParentWorktreeWatcherState, force: bool) -> Result<(), String> {
5071 let system = process_system_for_pids(&[state.pid]);
5072 let Some(process) = system.process(Pid::from_u32(state.pid)) else {
5073 return Ok(());
5074 };
5075 if !force && !is_matching_parent_watcher_process_with_system(&system, state) {
5076 return Ok(());
5077 }
5078 if process.kill() {
5079 Ok(())
5080 } else {
5081 Err(format!(
5082 "Failed to stop existing parent watcher process {}",
5083 state.pid
5084 ))
5085 }
5086}
5087
5088fn prefer_running_parent_root() -> Option<PathBuf> {
5090 let mut candidates = list_parent_watcher_state_files().ok()?;
5091 candidates.sort_by(|left, right| right.1.started_at.cmp(&left.1.started_at));
5093 for (_path, state) in candidates {
5094 let system = process_system_for_pids(&[state.pid]);
5095 if !is_live_parent_watcher_process_with_system(&system, &state) {
5096 continue;
5097 }
5098 let root = parent_root_as_local_path(&state.parent_root);
5099 if root.is_dir() {
5100 return Some(root);
5101 }
5102 return Some(root);
5103 }
5104 None
5105}
5106
5107fn prefer_any_parent_root() -> Option<PathBuf> {
5109 let mut candidates = list_parent_watcher_state_files().ok()?;
5110 candidates.sort_by(|left, right| right.1.started_at.cmp(&left.1.started_at));
5111 for (_path, state) in candidates {
5112 let root = parent_root_as_local_path(&state.parent_root);
5113 if root.is_dir() {
5114 return Some(root);
5115 }
5116 }
5117 None
5118}
5119
5120fn count_git_roots_under(parent: &Path) -> Result<usize, String> {
5121 let mut roots = Vec::new();
5122 let mut seen = BTreeSet::new();
5123 collect_git_roots_in_dir(parent, parent, &mut seen, &mut roots)?;
5124 Ok(roots.len())
5125}
5126
5127fn list_parent_watcher_state_files() -> Result<Vec<(PathBuf, ParentWorktreeWatcherState)>, String> {
5128 let parents_root = global_mutations_root()?.join("parents");
5129 if !parents_root.is_dir() {
5130 return Ok(Vec::new());
5131 }
5132 let mut out = Vec::new();
5133 for entry in fs::read_dir(&parents_root)
5134 .map_err(|error| format!("Failed to read {}: {error}", parents_root.display()))?
5135 {
5136 let entry = entry.map_err(|error| format!("Failed to read parent spool entry: {error}"))?;
5137 if !entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
5138 continue;
5139 }
5140 for file in fs::read_dir(entry.path()).map_err(|error| {
5141 format!(
5142 "Failed to read parent spool {}: {error}",
5143 entry.path().display()
5144 )
5145 })? {
5146 let file = file.map_err(|error| format!("Failed to read parent state entry: {error}"))?;
5147 let path = file.path();
5148 let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
5149 continue;
5150 };
5151 if !(name.starts_with("watcher-state") && name.ends_with(".json")) {
5152 continue;
5153 }
5154 if let Ok(Some(state)) = read_parent_watcher_state(&path) {
5155 if let Some(runtime) = state.runtime_key.as_deref() {
5157 if runtime != worktree_runtime_key() {
5158 if !runtime_keys_compatible(Some(runtime)) {
5160 continue;
5161 }
5162 }
5163 } else if !runtime_keys_compatible(None) {
5164 continue;
5165 }
5166 out.push((path, state));
5167 }
5168 }
5169 }
5170 Ok(out)
5171}
5172
5173fn parent_root_as_local_path(parent_root: &str) -> PathBuf {
5174 let trimmed = parent_root.trim();
5175 if trimmed.is_empty() {
5176 return PathBuf::new();
5177 }
5178 if let Some(windows) = map_wsl_mnt_path_to_windows(trimmed) {
5179 return PathBuf::from(windows);
5180 }
5181 let as_path = PathBuf::from(trimmed);
5182 if as_path.is_dir() {
5183 return as_path;
5184 }
5185 if let Some(windows) = map_wsl_mnt_path_to_windows(&trimmed.replace('\\', "/")) {
5187 return PathBuf::from(windows);
5188 }
5189 as_path
5190}
5191
5192fn discover_repo_identities_under(parent: &Path) -> Result<Vec<RepoIdentity>, String> {
5193 let parent = canonical_parent_path(parent)?;
5194
5195 let mut roots = Vec::new();
5196 let mut seen = BTreeSet::new();
5197 collect_git_roots_in_dir(&parent, &parent, &mut seen, &mut roots)?;
5198
5199 let identities = resolve_repo_identities_parallel(&roots);
5202 let mut identities = identities.into_iter().flatten().collect::<Vec<_>>();
5203 identities.sort_by(|left, right| left.root.cmp(&right.root));
5204 Ok(identities)
5205}
5206
5207fn collect_git_roots_in_dir(
5208 parent: &Path,
5209 dir: &Path,
5210 seen: &mut BTreeSet<String>,
5211 roots: &mut Vec<PathBuf>,
5212) -> Result<(), String> {
5213 if dir != parent && is_skipped_discovery_dir(dir) {
5214 return Ok(());
5215 }
5216
5217 if dir.join(".git").exists() {
5218 let key = path_identity_key(dir);
5219 if seen.insert(key) {
5220 roots.push(dir.to_path_buf());
5221 }
5222 return Ok(());
5223 }
5224
5225 let entries = fs::read_dir(dir)
5226 .map_err(|error| format!("Failed to read folder {}: {error}", dir.display()))?;
5227 for entry in entries {
5228 let entry = entry.map_err(|error| {
5229 format!(
5230 "Failed to read folder entry under {}: {error}",
5231 dir.display()
5232 )
5233 })?;
5234 let file_type = entry
5235 .file_type()
5236 .map_err(|error| format!("Failed to inspect {}: {error}", entry.path().display()))?;
5237 if file_type.is_dir() {
5238 collect_git_roots_in_dir(parent, &entry.path(), seen, roots)?;
5239 }
5240 }
5241
5242 Ok(())
5243}
5244
5245fn resolve_repo_identities_parallel(roots: &[PathBuf]) -> Vec<Option<RepoIdentity>> {
5246 if roots.is_empty() {
5247 return Vec::new();
5248 }
5249 if roots.len() == 1 {
5250 return vec![resolve_repo_identity_light(Some(&roots[0])).ok()];
5251 }
5252
5253 let thread_count = roots.len().min(8).max(1);
5254 let chunk_size = (roots.len() + thread_count - 1) / thread_count;
5255 let mut handles = Vec::new();
5256 for chunk in roots.chunks(chunk_size) {
5257 let chunk = chunk.to_vec();
5258 handles.push(std::thread::spawn(move || {
5259 chunk
5260 .iter()
5261 .map(|root| resolve_repo_identity_light(Some(root)).ok())
5262 .collect::<Vec<_>>()
5263 }));
5264 }
5265
5266 let mut out = Vec::with_capacity(roots.len());
5267 for handle in handles {
5268 match handle.join() {
5269 Ok(part) => out.extend(part),
5270 Err(_) => {
5271 }
5273 }
5274 }
5275 if out.len() < roots.len() {
5277 for root in roots.iter().skip(out.len()) {
5278 out.push(resolve_repo_identity_light(Some(root)).ok());
5279 }
5280 }
5281 out
5282}
5283
5284fn resolve_repo_identity_light(repo: Option<&Path>) -> Result<RepoIdentity, String> {
5286 let start = resolve_repo_path_input(repo)?;
5287 let root_raw = git_output(&start, &["rev-parse", "--show-toplevel"]).map_err(|error| {
5288 if repo.is_some() {
5289 format!(
5290 "Failed to run git rev-parse --show-toplevel in {}: {error}",
5291 start.display()
5292 )
5293 } else {
5294 format!("Failed to run git rev-parse --show-toplevel: {error}")
5295 }
5296 })?;
5297 let root = normalize_windows_verbatim_path(
5298 fs::canonicalize(root_raw.trim()).unwrap_or_else(|_| PathBuf::from(root_raw.trim())),
5299 );
5300 let branch = git_output(&root, &["rev-parse", "--abbrev-ref", "HEAD"])
5301 .unwrap_or_else(|_| "unknown".to_string());
5302 let remote = git_output(&root, &["remote", "get-url", DEFAULT_REMOTE]).unwrap_or_default();
5303 let (owner, name) = parse_remote_owner_repo(&remote).unwrap_or_else(|| {
5304 let name = root
5305 .file_name()
5306 .and_then(|value| value.to_str())
5307 .unwrap_or("repository")
5308 .to_string();
5309 ("unknown".to_string(), name)
5310 });
5311
5312 Ok(RepoIdentity {
5313 owner,
5314 name,
5315 branch: sanitize_path_component(branch.trim()),
5316 root,
5317 head_sha: None,
5318 })
5319}
5320
5321fn resolve_repo_identity(repo: Option<&Path>) -> Result<RepoIdentity, String> {
5322 let start = resolve_repo_path_input(repo)?;
5323 let root_raw = git_output(&start, &["rev-parse", "--show-toplevel"]).map_err(|error| {
5324 if repo.is_some() {
5325 format!(
5326 "Failed to run git rev-parse --show-toplevel in {}: {error}",
5327 start.display()
5328 )
5329 } else {
5330 format!("Failed to run git rev-parse --show-toplevel: {error}")
5331 }
5332 })?;
5333 let root = normalize_windows_verbatim_path(
5334 fs::canonicalize(root_raw.trim()).unwrap_or_else(|_| PathBuf::from(root_raw.trim())),
5335 );
5336 let branch = git_output(&root, &["rev-parse", "--abbrev-ref", "HEAD"])
5337 .unwrap_or_else(|_| "unknown".to_string());
5338 let remote = git_output(&root, &["remote", "get-url", DEFAULT_REMOTE]).unwrap_or_default();
5339 let (owner, name) = parse_remote_owner_repo(&remote).unwrap_or_else(|| {
5340 let name = root
5341 .file_name()
5342 .and_then(|value| value.to_str())
5343 .unwrap_or("repository")
5344 .to_string();
5345 ("unknown".to_string(), name)
5346 });
5347 let head_sha = current_head(&root)?;
5348
5349 Ok(RepoIdentity {
5350 owner,
5351 name,
5352 branch: sanitize_path_component(branch.trim()),
5353 root,
5354 head_sha,
5355 })
5356}
5357
5358fn resolve_repo_path_input(repo: Option<&Path>) -> Result<PathBuf, String> {
5365 let Some(input) = repo else {
5366 return std::env::current_dir()
5367 .map_err(|error| format!("Failed to resolve current directory: {error}"));
5368 };
5369
5370 if path_looks_like_existing_dir(input) {
5371 return Ok(input.to_path_buf());
5372 }
5373
5374 let token = input.to_string_lossy();
5375 let token = token.trim();
5376 if token.is_empty() {
5377 return Err("--repo value is empty".to_string());
5378 }
5379
5380 if let Some(path) = lookup_repo_path_from_system_inventory(token)? {
5383 return Ok(path);
5384 }
5385
5386 if input.components().count() > 1
5388 || token.contains('/')
5389 || token.contains('\\')
5390 || token.contains(':')
5391 {
5392 return Ok(input.to_path_buf());
5393 }
5394
5395 Err(format!(
5396 "Could not resolve `--repo {token}` as a filesystem path or inventory name. \
5397Refresh with `xbp diag --refresh-system-inventory`, pass a full path, or use `--parent`."
5398 ))
5399}
5400
5401fn path_looks_like_existing_dir(path: &Path) -> bool {
5402 path.is_dir()
5403 || fs::metadata(path)
5404 .map(|meta| meta.is_dir())
5405 .unwrap_or(false)
5406}
5407
5408fn lookup_repo_path_from_system_inventory(token: &str) -> Result<Option<PathBuf>, String> {
5409 let config = match SshConfig::load() {
5410 Ok(config) => config,
5411 Err(_) => return Ok(None),
5412 };
5413 let Some(inventory) = config.system_inventory.as_ref() else {
5414 return Ok(None);
5415 };
5416
5417 if let Some(path) = match_github_repo_inventory(&inventory.github_repos, token)? {
5418 return Ok(Some(path));
5419 }
5420
5421 let needle = normalize_repo_lookup_token(token);
5423 let mut project_hits: Vec<PathBuf> = Vec::new();
5424 for project in &inventory.xbp_projects {
5425 let name = normalize_repo_lookup_token(&project.name);
5426 let root_name = Path::new(&project.root)
5427 .file_name()
5428 .and_then(|value| value.to_str())
5429 .map(normalize_repo_lookup_token)
5430 .unwrap_or_default();
5431 if name == needle || root_name == needle {
5432 let path = PathBuf::from(&project.root);
5433 if path_looks_like_existing_dir(&path)
5434 && !project_hits.iter().any(|existing| {
5435 path_identity_key(existing.as_path()) == path_identity_key(path.as_path())
5436 })
5437 {
5438 project_hits.push(path);
5439 }
5440 }
5441 }
5442 match project_hits.len() {
5443 0 => Ok(None),
5444 1 => Ok(Some(project_hits.remove(0))),
5445 _ => Err(format_ambiguous_repo_matches(token, &project_hits)),
5446 }
5447}
5448
5449fn match_github_repo_inventory(
5450 repos: &[GithubRepoRecord],
5451 token: &str,
5452) -> Result<Option<PathBuf>, String> {
5453 let needle = normalize_repo_lookup_token(token);
5454 if needle.is_empty() {
5455 return Ok(None);
5456 }
5457
5458 let mut exact_path: Vec<PathBuf> = Vec::new();
5459 let mut full_name_hits: Vec<PathBuf> = Vec::new();
5460 let mut short_name_hits: Vec<PathBuf> = Vec::new();
5461
5462 for record in repos {
5463 let path = PathBuf::from(record.path.trim());
5464 if record.path.trim().is_empty() || !path_looks_like_existing_dir(&path) {
5465 continue;
5466 }
5467
5468 let path_key = path_identity_key(&path);
5469 let full_name = normalize_repo_lookup_token(&record.full_name);
5470 let short = normalize_repo_lookup_token(&record.repo);
5471 let owner_repo = normalize_repo_lookup_token(&format!("{}/{}", record.owner, record.repo));
5472 let folder = path
5473 .file_name()
5474 .and_then(|value| value.to_str())
5475 .map(normalize_repo_lookup_token)
5476 .unwrap_or_default();
5477
5478 let push_unique = |bucket: &mut Vec<PathBuf>| {
5479 if !bucket
5480 .iter()
5481 .any(|existing| path_identity_key(existing.as_path()) == path_key)
5482 {
5483 bucket.push(path.clone());
5484 }
5485 };
5486
5487 if path_identity_key(Path::new(record.path.trim())) == path_identity_key(Path::new(token))
5488 || normalize_repo_lookup_token(&record.path) == needle
5489 {
5490 push_unique(&mut exact_path);
5491 }
5492 if full_name == needle || owner_repo == needle {
5493 push_unique(&mut full_name_hits);
5494 }
5495 if short == needle || folder == needle {
5496 push_unique(&mut short_name_hits);
5497 }
5498 }
5499
5500 if let Some(path) = exact_path.into_iter().next() {
5501 return Ok(Some(path));
5502 }
5503 match full_name_hits.len() {
5504 0 => {}
5505 1 => return Ok(Some(full_name_hits.remove(0))),
5506 _ => return Err(format_ambiguous_repo_matches(token, &full_name_hits)),
5507 }
5508 match short_name_hits.len() {
5509 0 => Ok(None),
5510 1 => Ok(Some(short_name_hits.remove(0))),
5511 _ => Err(format_ambiguous_repo_matches(token, &short_name_hits)),
5512 }
5513}
5514
5515fn normalize_repo_lookup_token(value: &str) -> String {
5516 value
5517 .trim()
5518 .trim_end_matches('/')
5519 .trim_end_matches('\\')
5520 .trim_end_matches(".git")
5521 .replace('\\', "/")
5522 .to_ascii_lowercase()
5523}
5524
5525fn format_ambiguous_repo_matches(token: &str, paths: &[PathBuf]) -> String {
5526 let list = paths
5527 .iter()
5528 .map(|path| format!(" - {}", path.display()))
5529 .collect::<Vec<_>>()
5530 .join("\n");
5531 format!(
5532 "Ambiguous `--repo {token}` matches multiple inventory paths:\n{list}\n\
5533Use a full path or `owner/repo` (for example `xylex-group/{token}`)."
5534 )
5535}
5536
5537fn prepare_spool_layout(identity: &RepoIdentity) -> Result<SpoolLayout, String> {
5538 let root = repository_spool_root(identity)?.join(sanitize_path_component(&identity.branch));
5539 fs::create_dir_all(&root).map_err(|error| {
5540 format!(
5541 "Failed to create worktree mutation spool {}: {error}",
5542 root.display()
5543 )
5544 })?;
5545 let event_dedupe_dir = root.join("event-dedupe");
5546 fs::create_dir_all(&event_dedupe_dir).map_err(|error| {
5547 format!(
5548 "Failed to create worktree mutation dedupe dir {}: {error}",
5549 event_dedupe_dir.display()
5550 )
5551 })?;
5552 let commit_dedupe_dir = root.join("commit-dedupe");
5553 fs::create_dir_all(&commit_dedupe_dir).map_err(|error| {
5554 format!(
5555 "Failed to create worktree commit dedupe dir {}: {error}",
5556 commit_dedupe_dir.display()
5557 )
5558 })?;
5559 Ok(spool_layout_for_root(
5560 root,
5561 Some(Uuid::new_v4().to_string()),
5562 ))
5563}
5564
5565fn global_mutations_root() -> Result<PathBuf, String> {
5568 let paths = ensure_global_xbp_paths()?;
5569 Ok(paths.root_dir.join("mutations"))
5570}
5571
5572fn repository_spool_root(identity: &RepoIdentity) -> Result<PathBuf, String> {
5573 Ok(global_mutations_root()?
5574 .join(sanitize_path_component(&identity.owner))
5575 .join(sanitize_path_component(&identity.name)))
5576}
5577
5578fn repository_spool_search_roots(identity: &RepoIdentity) -> Result<Vec<PathBuf>, String> {
5584 let owner = sanitize_path_component(&identity.owner);
5585 let name = sanitize_path_component(&identity.name);
5586 let mut roots: Vec<PathBuf> = Vec::new();
5587 let mut push = |path: PathBuf| {
5588 if roots.iter().any(|existing| {
5589 path_identity_key(existing.as_path()) == path_identity_key(path.as_path())
5590 }) {
5591 return;
5592 }
5593 roots.push(path);
5594 };
5595
5596 push(repository_spool_root(identity)?);
5597
5598 for base in legacy_mutations_base_candidates() {
5599 push(base.join(&owner).join(&name));
5600 let runtimes = base.join("runtimes");
5602 if runtimes.is_dir() {
5603 if let Ok(entries) = fs::read_dir(&runtimes) {
5604 for entry in entries.flatten() {
5605 if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
5606 push(entry.path().join(&owner).join(&name));
5607 }
5608 }
5609 }
5610 }
5611 }
5612
5613 Ok(roots)
5614}
5615
5616fn legacy_mutations_base_candidates() -> Vec<PathBuf> {
5617 let mut bases: Vec<PathBuf> = Vec::new();
5618 let mut push = |path: PathBuf| {
5619 if bases
5620 .iter()
5621 .any(|existing| path_identity_key(existing.as_path()) == path_identity_key(path.as_path()))
5622 {
5623 return;
5624 }
5625 bases.push(path);
5626 };
5627
5628 if let Ok(primary) = global_mutations_root() {
5629 push(primary);
5630 }
5631 push(resolve_global_xbp_root_dir().join("mutations"));
5633
5634 if let Some(home) = dirs::home_dir() {
5635 push(home.join(".xbp").join("mutations"));
5636 push(home.join(".config").join("xbp").join("mutations"));
5637 }
5638
5639 if let Ok(primary) = global_mutations_root() {
5641 let rendered = primary.to_string_lossy().replace('\\', "/");
5642 if let Some(mnt) = map_windows_path_to_wsl_mnt(&rendered) {
5643 push(PathBuf::from(mnt));
5644 }
5645 if let Some(windows) = map_wsl_mnt_path_to_windows(&rendered) {
5646 if let Some(mnt) = map_windows_path_to_wsl_mnt(&windows) {
5647 push(PathBuf::from(mnt));
5648 }
5649 push(PathBuf::from(windows));
5650 }
5651 }
5652
5653 bases
5654}
5655
5656fn spool_layout_for_existing_root(root: &Path) -> SpoolLayout {
5657 spool_layout_for_root(root.to_path_buf(), None)
5658}
5659
5660fn spool_layout_for_root(root: PathBuf, run_id: Option<String>) -> SpoolLayout {
5661 let run_id = run_id.unwrap_or_else(|| Uuid::new_v4().to_string());
5662 let event_dedupe_dir = root.join("event-dedupe");
5663 let commit_dedupe_dir = root.join("commit-dedupe");
5664 let watcher_state_file = root.join(format!(
5667 "watcher-state-{}.json",
5668 sanitize_path_component(&worktree_runtime_key())
5669 ));
5670 SpoolLayout {
5671 events_file: root.join(format!("events-{run_id}.jsonl")),
5672 commits_file: root.join(format!("commits-{run_id}.jsonl")),
5673 stats_file: root.join("stats.json"),
5674 watcher_state_file,
5675 sync_log_file: root.join("sync-log.jsonl"),
5676 event_dedupe_file: root.join("event-dedupe.jsonl"),
5677 event_dedupe_dir,
5678 commit_dedupe_dir,
5679 root,
5680 }
5681}
5682
5683fn prepare_parent_spool_layout(parent: &Path) -> Result<ParentSpoolLayout, String> {
5684 let parent = canonical_parent_path(parent)?;
5685 let parent_key = cross_platform_path_identity_key(&parent);
5687 let mut hasher = Sha256::new();
5688 hasher.update(parent_key.as_bytes());
5689 let digest = format!("{:x}", hasher.finalize());
5690 let label = parent
5691 .file_name()
5692 .and_then(|value| value.to_str())
5693 .map(sanitize_path_component)
5694 .filter(|value| !value.is_empty())
5695 .unwrap_or_else(|| "parent".to_string());
5696 let root = global_mutations_root()?
5697 .join("parents")
5698 .join(format!("{}-{}", label, &digest[..16]));
5699 fs::create_dir_all(&root).map_err(|error| {
5700 format!(
5701 "Failed to create parent worktree watcher state dir {}: {error}",
5702 root.display()
5703 )
5704 })?;
5705 Ok(ParentSpoolLayout {
5706 watcher_state_file: root.join(format!(
5707 "watcher-state-{}.json",
5708 sanitize_path_component(&worktree_runtime_key())
5709 )),
5710 })
5711}
5712
5713fn canonical_parent_path(parent: &Path) -> Result<PathBuf, String> {
5714 let normalized = normalize_windows_verbatim_path(
5715 fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf()),
5716 );
5717 if !normalized.is_dir() {
5718 return Err(format!(
5719 "Parent folder {} does not exist or is not a directory.",
5720 normalized.display()
5721 ));
5722 }
5723 Ok(normalized)
5724}
5725
5726fn append_json_line<T: Serialize>(path: &Path, value: &T) -> Result<(), String> {
5727 let mut file = OpenOptions::new()
5728 .create(true)
5729 .append(true)
5730 .open(path)
5731 .map_err(|error| format!("Failed to open spool file {}: {error}", path.display()))?;
5732 let line = serde_json::to_string(value)
5733 .map_err(|error| format!("Failed to serialize worktree event: {error}"))?;
5734 writeln!(file, "{line}")
5735 .map_err(|error| format!("Failed to append spool file {}: {error}", path.display()))
5736}
5737
5738fn git_numstat_for_path(repo_root: &Path, relative_path: &str) -> Result<(u64, u64), String> {
5739 let output = Command::new("git")
5740 .args(["diff", "--numstat", "--"])
5741 .arg(relative_path)
5742 .current_dir(repo_root)
5743 .output()
5744 .map_err(|error| format!("Failed to run git diff --numstat: {error}"))?;
5745 if !output.status.success() {
5746 return Ok((0, 0));
5747 }
5748 let stdout = String::from_utf8_lossy(&output.stdout);
5749 let mut added = 0;
5750 let mut removed = 0;
5751 for line in stdout.lines() {
5752 let mut parts = line.split_whitespace();
5753 added += parse_numstat_count(parts.next());
5754 removed += parse_numstat_count(parts.next());
5755 }
5756 Ok((added, removed))
5757}
5758
5759fn file_line_count_for_path(repo_root: &Path, relative_path: &str) -> Result<u64, String> {
5760 let path = repo_root.join(relative_path);
5761 let content = fs::read_to_string(&path)
5762 .map_err(|error| format!("Failed to read file {}: {error}", path.display()))?;
5763 Ok(content.lines().count() as u64)
5764}
5765
5766fn parse_numstat_count(value: Option<&str>) -> u64 {
5767 value.and_then(|raw| raw.parse::<u64>().ok()).unwrap_or(0)
5768}
5769
5770fn current_head(repo_root: &Path) -> Result<Option<String>, String> {
5771 match git_output(repo_root, &["rev-parse", "HEAD"]) {
5772 Ok(value) => Ok(Some(value)),
5773 Err(error)
5774 if error.contains("unknown revision") || error.contains("ambiguous argument") =>
5775 {
5776 Ok(None)
5777 }
5778 Err(error) => Err(error),
5779 }
5780}
5781
5782fn git_output(repo_root: &Path, args: &[&str]) -> Result<String, String> {
5783 let output = Command::new("git")
5784 .args(args)
5785 .current_dir(repo_root)
5786 .output()
5787 .map_err(|error| format!("Failed to run git {}: {error}", args.join(" ")))?;
5788 if !output.status.success() {
5789 return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
5790 }
5791 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
5792}
5793
5794fn parse_remote_owner_repo(remote: &str) -> Option<(String, String)> {
5795 let trimmed = remote.trim().trim_end_matches(".git");
5796 if trimmed.is_empty() {
5797 return None;
5798 }
5799
5800 let path_part = if !trimmed.contains("://") {
5801 if let Some((_, path)) = trimmed.rsplit_once(':') {
5802 path
5803 } else {
5804 trimmed
5805 }
5806 } else {
5807 trimmed
5808 .trim_start_matches("https://")
5809 .trim_start_matches("http://")
5810 .trim_start_matches("ssh://")
5811 .split_once('/')
5812 .map(|(_, path)| path)
5813 .unwrap_or(trimmed)
5814 };
5815 let mut parts = path_part.rsplitn(2, '/');
5816 let name = parts.next()?.trim();
5817 let owner = parts.next()?.trim();
5818 if owner.is_empty() || name.is_empty() {
5819 return None;
5820 }
5821 Some((
5822 sanitize_path_component(owner),
5823 sanitize_path_component(name.trim_end_matches(".git")),
5824 ))
5825}
5826
5827fn sanitize_path_component(value: &str) -> String {
5828 let sanitized: String = value
5829 .chars()
5830 .map(|ch| {
5831 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
5832 ch
5833 } else {
5834 '-'
5835 }
5836 })
5837 .collect();
5838 sanitized
5839 .trim_matches('-')
5840 .chars()
5841 .take(160)
5842 .collect::<String>()
5843}
5844
5845fn normalize_windows_verbatim_path(path: PathBuf) -> PathBuf {
5846 PathBuf::from(strip_windows_verbatim_prefix(&path.to_string_lossy()))
5847}
5848
5849fn strip_windows_verbatim_prefix(input: &str) -> &str {
5850 input.strip_prefix(r"\\?\").unwrap_or(input)
5851}
5852
5853fn path_identity_key(path: &Path) -> String {
5854 cross_platform_path_identity_key(path)
5855}
5856
5857fn cross_platform_path_identity_key(path: &Path) -> String {
5859 let mut value = path.to_string_lossy().replace('\\', "/");
5860 if let Some(mnt) = map_windows_path_to_wsl_mnt(&value) {
5861 value = mnt;
5862 } else if let Some(windows) = map_wsl_mnt_path_to_windows(&value) {
5863 if let Some(mnt) = map_windows_path_to_wsl_mnt(&windows) {
5864 value = mnt;
5865 } else {
5866 value = windows.replace('\\', "/");
5867 }
5868 }
5869 value.to_ascii_lowercase()
5871}
5872
5873fn worktree_runtime_key() -> String {
5877 static KEY: OnceLock<String> = OnceLock::new();
5878 KEY.get_or_init(|| {
5879 if is_wsl_runtime() {
5880 let distro = std::env::var("WSL_DISTRO_NAME")
5881 .ok()
5882 .map(|value| value.trim().to_string())
5883 .filter(|value| !value.is_empty())
5884 .unwrap_or_else(|| "wsl".to_string());
5885 format!("wsl-{}", sanitize_path_component(&distro).to_ascii_lowercase())
5886 } else {
5887 std::env::consts::OS.to_string()
5888 }
5889 })
5890 .clone()
5891}
5892
5893fn is_wsl_runtime() -> bool {
5894 if !cfg!(target_os = "linux") {
5895 return false;
5896 }
5897 if std::env::var_os("WSL_DISTRO_NAME").is_some() || std::env::var_os("WSL_INTEROP").is_some() {
5898 return true;
5899 }
5900 fs::read_to_string("/proc/version")
5902 .map(|content| {
5903 let lower = content.to_ascii_lowercase();
5904 lower.contains("microsoft") || lower.contains("wsl")
5905 })
5906 .unwrap_or(false)
5907}
5908
5909fn worktree_device_hardware_id(base_hardware_id: &str) -> String {
5912 format!("{base_hardware_id}@{}", worktree_runtime_key())
5913}
5914
5915fn ensure_mutation_event_identity(mut event: WorktreeMutationEvent) -> WorktreeMutationEvent {
5916 if event.fingerprint.as_ref().is_none_or(|value| value.is_empty()) {
5917 let fingerprint = mutation_event_fingerprint(&event);
5918 event.fingerprint = Some(fingerprint.clone());
5919 if event.id.is_empty() || !event.id.starts_with("wtmut_") {
5920 event.id = stable_event_id(&fingerprint);
5921 }
5922 } else if event.id.is_empty() {
5923 event.id = stable_event_id(event.fingerprint.as_deref().unwrap_or_default());
5924 }
5925 event
5926}
5927
5928fn ensure_commit_event_identity(mut commit: WorktreeCommitEvent) -> WorktreeCommitEvent {
5929 if commit.fingerprint.as_ref().is_none_or(|value| value.is_empty()) {
5930 let fingerprint = commit_event_fingerprint(&commit);
5931 commit.fingerprint = Some(fingerprint.clone());
5932 if commit.id.is_empty() || !commit.id.starts_with("wtmut_") {
5933 commit.id = stable_event_id(&fingerprint);
5934 }
5935 } else if commit.id.is_empty() {
5936 commit.id = stable_event_id(commit.fingerprint.as_deref().unwrap_or_default());
5937 }
5938 commit
5939}
5940
5941fn path_string_has_ignored_component(path: &str) -> bool {
5942 watch_ignore_rules().ignores_relative(path)
5944}
5945
5946fn is_skipped_discovery_dir(path: &Path) -> bool {
5947 watch_ignore_rules().skips_discovery_dir(path)
5948}
5949
5950fn relative_watch_path(root: &Path, path: &Path) -> Option<String> {
5956 if let Ok(relative) = path.strip_prefix(root) {
5957 return Some(normalize_relative_watch_path(&relative.to_string_lossy()));
5958 }
5959
5960 let root_norm = normalize_absolute_watch_path(root);
5961 let path_norm = normalize_absolute_watch_path(path);
5962 if path_norm == root_norm {
5963 return Some(String::new());
5964 }
5965 let prefix = format!("{root_norm}/");
5966 path_norm
5967 .strip_prefix(&prefix)
5968 .map(|relative| normalize_relative_watch_path(relative))
5969}
5970
5971fn normalize_absolute_watch_path(path: &Path) -> String {
5972 let mut value = path.to_string_lossy().replace('\\', "/");
5973 if let Some(stripped) = value.strip_prefix("//?/") {
5975 value = stripped.to_string();
5976 }
5977 if value.len() >= 2 {
5979 let bytes = value.as_bytes();
5980 if bytes[1] == b':' && bytes[0].is_ascii_alphabetic() {
5981 let mut chars = value.chars();
5982 let drive = chars.next().unwrap().to_ascii_lowercase();
5983 value = format!("{drive}{}", chars.as_str());
5984 }
5985 }
5986 value.trim_end_matches('/').to_string()
5987}
5988
5989fn relative_slash_path(root: &Path, path: &Path) -> Option<String> {
5990 relative_watch_path(root, path).or_else(|| {
5991 Some(normalize_relative_watch_path(
5992 &path.to_string_lossy().replace('\\', "/"),
5993 ))
5994 })
5995}
5996
5997fn is_ignored_path(root: &Path, path: &Path) -> bool {
5998 watch_ignore_rules_for_root(root).ignores_path(root, path)
5999}
6000
6001fn current_hostname() -> Option<String> {
6002 for key in ["COMPUTERNAME", "HOSTNAME", "NAME"] {
6003 if let Ok(value) = std::env::var(key) {
6004 let trimmed = value.trim();
6005 if !trimmed.is_empty() {
6006 return Some(trimmed.to_string());
6007 }
6008 }
6009 }
6010
6011 if let Ok(content) = fs::read_to_string("/etc/hostname") {
6012 let trimmed = content.trim();
6013 if !trimmed.is_empty() {
6014 return Some(trimmed.to_string());
6015 }
6016 }
6017
6018 if let Ok(output) = Command::new("hostname").output() {
6020 if output.status.success() {
6021 let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
6022 if !value.is_empty() {
6023 return Some(value);
6024 }
6025 }
6026 }
6027
6028 None
6029}
6030
6031fn is_background_child() -> bool {
6032 std::env::var(BACKGROUND_CHILD_ENV)
6033 .ok()
6034 .map(|value| value == "1")
6035 .unwrap_or(false)
6036}
6037
6038#[allow(dead_code)]
6039fn content_sha256(path: &Path) -> Option<String> {
6040 let bytes = fs::read(path).ok()?;
6041 let mut hasher = Sha256::new();
6042 hasher.update(bytes);
6043 Some(format!("{:x}", hasher.finalize()))
6044}
6045
6046#[cfg(test)]
6047mod tests {
6048 use super::*;
6049
6050 #[test]
6051 fn parses_https_and_ssh_remote_urls() {
6052 assert_eq!(
6053 parse_remote_owner_repo("https://github.com/xylex-group/xbp.git"),
6054 Some(("xylex-group".to_string(), "xbp".to_string()))
6055 );
6056 assert_eq!(
6057 parse_remote_owner_repo("git@github.com:xylex-group/xbp.git"),
6058 Some(("xylex-group".to_string(), "xbp".to_string()))
6059 );
6060 }
6061
6062 #[test]
6063 fn sanitizes_branch_for_storage_path() {
6064 assert_eq!(
6065 sanitize_path_component("feature/worktree watcher"),
6066 "feature-worktree-watcher"
6067 );
6068 }
6069
6070 #[test]
6071 fn normalizes_windows_verbatim_repo_roots() {
6072 assert_eq!(
6073 normalize_windows_verbatim_path(PathBuf::from(
6074 r"\\?\C:\Users\floris\Documents\GitHub\mollie-api-rust"
6075 )),
6076 PathBuf::from(r"C:\Users\floris\Documents\GitHub\mollie-api-rust")
6077 );
6078 }
6079
6080 #[test]
6081 fn parent_path_matching_uses_repo_boundaries() {
6082 let repo = Path::new(r"C:\Users\floris\Documents\GitHub\xbp");
6083 assert!(path_belongs_to_repo(
6084 Path::new(r"C:\Users\floris\Documents\GitHub\xbp\src\main.rs"),
6085 repo
6086 ));
6087 assert!(!path_belongs_to_repo(
6088 Path::new(r"C:\Users\floris\Documents\GitHub\xbp-other\src\main.rs"),
6089 repo
6090 ));
6091 }
6092
6093 #[test]
6094 fn skips_heavy_discovery_directories() {
6095 assert!(is_skipped_discovery_dir(Path::new("node_modules")));
6096 assert!(is_skipped_discovery_dir(Path::new("target")));
6097 assert!(is_skipped_discovery_dir(Path::new("dist")));
6098 assert!(is_skipped_discovery_dir(Path::new(".next")));
6099 assert!(is_skipped_discovery_dir(Path::new(".nx")));
6100 assert!(is_skipped_discovery_dir(Path::new(".xbp")));
6101 assert!(is_skipped_discovery_dir(Path::new(".cargo")));
6102 assert!(is_skipped_discovery_dir(Path::new(".codex")));
6103 assert!(is_skipped_discovery_dir(Path::new(".cursor")));
6104 assert!(is_skipped_discovery_dir(Path::new(".agents")));
6105 assert!(is_skipped_discovery_dir(Path::new(".idea")));
6106 assert!(is_skipped_discovery_dir(Path::new(".github")));
6107 assert!(is_skipped_discovery_dir(Path::new(".postman")));
6108 assert!(is_skipped_discovery_dir(Path::new("mcps")));
6109 assert!(is_skipped_discovery_dir(Path::new("agent-tools")));
6110 assert!(is_skipped_discovery_dir(Path::new("target-publish")));
6111 assert!(is_skipped_discovery_dir(Path::new("__pycache__")));
6112 assert!(is_skipped_discovery_dir(Path::new("terminals")));
6113 assert!(is_skipped_discovery_dir(Path::new(".open-next")));
6114 assert!(!is_skipped_discovery_dir(Path::new("mollie-api-rust")));
6115 }
6116
6117 #[test]
6118 fn ignores_nested_generated_mutation_paths() {
6119 let root = Path::new(r"C:\Users\floris\Documents\GitHub\athena");
6122 assert!(is_ignored_path(
6123 root,
6124 Path::new(
6125 r"C:\Users\floris\Documents\GitHub\athena\apps\docs\node_modules\@aws-sdk\client-lambda\dist-types\schemas"
6126 )
6127 ));
6128 assert!(is_ignored_path(
6129 root,
6130 Path::new(r"C:\Users\floris\Documents\GitHub\athena\target\debug\build")
6131 ));
6132 assert!(is_ignored_path(
6134 root,
6135 Path::new(r"C:\Users\floris\Documents\GitHub\athena\.nx\cache\run.json")
6136 ));
6137 assert!(is_ignored_path(
6138 root,
6139 Path::new(
6140 r"C:\Users\floris\Documents\GitHub\athena\apps\docs\.open-next\server-functions\default\handler.mjs"
6141 )
6142 ));
6143 assert!(!is_ignored_path(
6144 root,
6145 Path::new(r"C:\Users\floris\Documents\GitHub\athena\apps\web\src\main.ts")
6146 ));
6147
6148 let root_slash = Path::new("C:/Users/floris/Documents/GitHub/athena");
6150 assert!(is_ignored_path(
6151 root_slash,
6152 Path::new(
6153 "C:/Users/floris/Documents/GitHub/athena/apps/docs/node_modules/@aws-sdk/client-lambda/dist-types/schemas"
6154 )
6155 ));
6156 assert!(path_string_has_ignored_component(
6157 "apps/docs/node_modules/@aws-sdk/client-lambda/dist-types/schemas"
6158 ));
6159 }
6160
6161 #[test]
6162 fn global_config_forbidden_paths_folders_and_banned_words() {
6163 let rules = WatchIgnoreRules::from_config(&WorktreeWatchConfig {
6164 forbidden_paths: vec!["secrets/".to_string(), "apps/web/.env.local".to_string()],
6165 forbidden_folders: vec![".cache".to_string(), "coverage".to_string()],
6166 banned_words: vec!["private-key".to_string(), "CREDENTIAL".to_string()],
6167 });
6168
6169 assert!(rules.ignores_relative("secrets/prod/api.key"));
6171 assert!(rules.ignores_relative("apps/web/.env.local"));
6172 assert!(!rules.ignores_relative("apps/web/.env.local.bak"));
6174
6175 assert!(rules.ignores_relative("apps/web/.cache/tmp"));
6177 assert!(rules.ignores_relative("packages/foo/coverage/index.html"));
6178
6179 assert!(rules.ignores_relative("docs/private-key-notes.md"));
6181 assert!(rules.ignores_relative("config/my-credential-store.json"));
6182
6183 assert!(!rules.ignores_relative("apps/web/src/main.ts"));
6185 assert!(!rules.skips_discovery_dir(Path::new("mollie-api-rust")));
6186 assert!(rules.skips_discovery_dir(Path::new(".cache")));
6187 assert!(rules.skips_discovery_dir(Path::new("node_modules"))); }
6189
6190 #[test]
6191 fn global_worktree_ignore_file_honors_mcps_patterns() {
6192 let root = std::env::temp_dir().join(format!(
6193 "xbp-global-watch-ignore-{}-{}",
6194 std::process::id(),
6195 std::time::SystemTime::now()
6196 .duration_since(std::time::UNIX_EPOCH)
6197 .expect("time")
6198 .as_nanos()
6199 ));
6200 let _ = fs::remove_dir_all(&root);
6201 fs::create_dir_all(&root).expect("global root");
6202
6203 let ignore_path = root.join(crate::utils::GLOBAL_WORKTREE_IGNORE_FILENAME);
6204 fs::write(&ignore_path, "mcps/\n/custom-global/\n").expect("write global ignore");
6205
6206 let rules = WatchIgnoreRules {
6207 forbidden_path_prefixes: Vec::new(),
6208 forbidden_folders: BTreeSet::new(),
6209 banned_words: Vec::new(),
6210 global_ignore: crate::utils::load_global_worktree_ignore_from_path(&ignore_path),
6211 project_ignore: crate::utils::XbpIgnoreSet::empty(),
6212 };
6213
6214 assert!(rules.ignores_relative("mcps/github/catalog.json"));
6215 assert!(rules.ignores_relative("apps/api/mcps/local"));
6216 assert!(rules.ignores_relative("custom-global/notes.md"));
6217 assert!(!rules.ignores_relative("apps/web/src/main.ts"));
6218 assert!(rules.skips_discovery_dir(Path::new("mcps")));
6219
6220 let _ = fs::remove_dir_all(root);
6221 }
6222
6223 #[test]
6224 fn project_xbpignore_is_honored_by_worktree_watch_rules() {
6225 let root = std::env::temp_dir().join(format!(
6226 "xbp-watch-ignore-{}-{}",
6227 std::process::id(),
6228 std::time::SystemTime::now()
6229 .duration_since(std::time::UNIX_EPOCH)
6230 .expect("time")
6231 .as_nanos()
6232 ));
6233 let _ = fs::remove_dir_all(&root);
6234 fs::create_dir_all(root.join(".xbp")).expect("xbp dir");
6235 fs::write(
6236 root.join(".xbp").join(".xbpignore"),
6237 "packages/icons/\n*.generated.ts\n",
6238 )
6239 .expect("write ignore");
6240 fs::write(
6241 root.join(".xbp").join("xbp.yaml"),
6242 "project_name: demo\nversion: 0.1.0\nport: 3000\nbuild_dir: ./\nignore_paths:\n - e2e/\nwatch_ignore_paths:\n - tmp-watch/\n",
6243 )
6244 .expect("write config");
6245
6246 let rules = WatchIgnoreRules::from_config_and_project(
6247 &WorktreeWatchConfig::default(),
6248 Some(&root),
6249 );
6250 assert!(rules.ignores_relative("packages/icons/src/index.ts"));
6252 assert!(rules.ignores_relative("apps/web/foo.generated.ts"));
6253 assert!(!rules.ignores_relative("e2e/login.spec.ts"));
6255 assert!(rules.ignores_relative("tmp-watch/file.ts"));
6257 assert!(!rules.ignores_relative("apps/web/src/main.ts"));
6258
6259 let _ = fs::remove_dir_all(root);
6260 }
6261
6262 #[test]
6263 fn builds_coding_stats_by_file_and_filetype() {
6264 let identity = RepoIdentity {
6265 owner: "xylex-group".to_string(),
6266 name: "xbp".to_string(),
6267 branch: "main".to_string(),
6268 root: PathBuf::from(r"C:\Users\floris\Documents\GitHub\xbp"),
6269 head_sha: None,
6270 };
6271 let layout = SpoolLayout {
6272 root: PathBuf::from(r"C:\Users\floris\.xbp\mutations\xylex-group\xbp\main"),
6273 events_file: PathBuf::from("events.jsonl"),
6274 commits_file: PathBuf::from("commits.jsonl"),
6275 stats_file: PathBuf::from("stats.json"),
6276 watcher_state_file: PathBuf::from("watcher-state.json"),
6277 sync_log_file: PathBuf::from("sync-log.jsonl"),
6278 event_dedupe_file: PathBuf::from("event-dedupe.jsonl"),
6279 event_dedupe_dir: PathBuf::from("event-dedupe"),
6280 commit_dedupe_dir: PathBuf::from("commit-dedupe"),
6281 };
6282 let candidates = vec![SyncCandidate {
6283 path: PathBuf::from("events.jsonl"),
6284 records: SyncRecords::Events(vec![
6285 stats_test_event_record("2026-07-08T10:00:00Z", "src/main.rs", 4, 1),
6286 stats_test_event_record("2026-07-08T10:05:00Z", "src/main.rs", 2, 0),
6287 stats_test_event_record("2026-07-08T11:00:00Z", "README.md", 1, 3),
6288 ]),
6289 }];
6290
6291 let summary = build_stats_summary(&identity, &layout, &candidates, 15).unwrap();
6292
6293 assert_eq!(summary.total_events, 3);
6294 assert_eq!(summary.estimated_coding_seconds, 60 + 300 + 900);
6295 assert_eq!(summary.session_count, 2);
6296 assert_eq!(summary.observed_span_seconds, 60 * 60);
6297 assert_eq!(summary.added_lines, 7);
6298 assert_eq!(summary.removed_lines, 4);
6299 assert_eq!(summary.by_file[0].path, "README.md");
6300 assert_eq!(summary.by_file[0].estimated_coding_seconds, 900);
6301 assert_eq!(summary.by_filetype[0].name, "md");
6302 assert_eq!(summary.by_filetype[0].estimated_coding_seconds, 900);
6303 assert_eq!(summary.by_filetype[1].name, "rs");
6304 assert_eq!(summary.by_filetype[1].estimated_coding_seconds, 360);
6305 }
6306
6307 #[test]
6308 fn builds_repo_activity_across_branch_spools() {
6309 let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
6310 let main_root = temp_root.join("main");
6311 let feature_root = temp_root.join("feature-login");
6312 fs::create_dir_all(&main_root).unwrap();
6313 fs::create_dir_all(&feature_root).unwrap();
6314
6315 append_json_line(
6316 &main_root.join("events-main.jsonl"),
6317 &stats_test_event_record("2026-07-08T10:00:00Z", "src/main.rs", 4, 1),
6318 )
6319 .unwrap();
6320 append_json_line(
6321 &main_root.join("events-main.jsonl"),
6322 &stats_test_event_record("2026-07-08T10:10:00Z", "src/lib.rs", 2, 0),
6323 )
6324 .unwrap();
6325 append_json_line(
6326 &feature_root.join("events-feature.jsonl"),
6327 &stats_test_event_record("2026-07-08T11:00:00Z", "src/auth.rs", 10, 2),
6328 )
6329 .unwrap();
6330 append_json_line(
6331 &feature_root.join("events-feature.jsonl"),
6332 &stats_test_event_record("2026-07-08T11:30:00Z", "src/auth.rs", 3, 1),
6333 )
6334 .unwrap();
6335 append_json_line(
6336 &feature_root.join("commits-feature.jsonl"),
6337 &worktree_commit_test_event("previous", "current"),
6338 )
6339 .unwrap();
6340
6341 let identity = RepoIdentity {
6342 owner: "xylex-group".to_string(),
6343 name: "xbp".to_string(),
6344 branch: "main".to_string(),
6345 root: PathBuf::from(r"C:\Users\floris\Documents\GitHub\xbp"),
6346 head_sha: None,
6347 };
6348
6349 let summary = build_repo_activity_summary(&identity, &temp_root, 15).unwrap();
6350
6351 assert_eq!(summary.branch_count, 2);
6352 assert_eq!(summary.total_events, 4);
6353 assert_eq!(summary.total_commits, 1);
6354 assert_eq!(summary.session_count, 3);
6355 assert_eq!(summary.estimated_coding_seconds, 60 + 600 + 60 + 900);
6356 assert_eq!(summary.observed_span_seconds, 90 * 60);
6357 assert_eq!(summary.branches[0].branch_name, "feature-login");
6358 assert_eq!(summary.branches[0].session_count, 2);
6359 assert_eq!(summary.branches[0].observed_span_seconds, 30 * 60);
6360 assert_eq!(summary.branches[1].branch_name, "main");
6361
6362 let _ = fs::remove_dir_all(temp_root);
6363 }
6364
6365 #[test]
6366 fn mutation_fingerprint_ignores_random_event_id_but_keeps_time_bucket() {
6367 let first: WorktreeMutationEvent = serde_json::from_value(stats_test_event(
6368 "2026-07-08T10:00:00Z",
6369 "src/main.rs",
6370 4,
6371 1,
6372 ))
6373 .unwrap();
6374 let same_bucket: WorktreeMutationEvent = serde_json::from_value(stats_test_event(
6375 "2026-07-08T10:00:00Z",
6376 "src/main.rs",
6377 4,
6378 1,
6379 ))
6380 .unwrap();
6381 let next_bucket: WorktreeMutationEvent = serde_json::from_value(stats_test_event(
6382 "2026-07-08T10:00:06Z",
6383 "src/main.rs",
6384 4,
6385 1,
6386 ))
6387 .unwrap();
6388
6389 assert_eq!(
6390 mutation_event_fingerprint(&first),
6391 mutation_event_fingerprint(&same_bucket)
6392 );
6393 assert_ne!(
6394 mutation_event_fingerprint(&first),
6395 mutation_event_fingerprint(&next_bucket)
6396 );
6397
6398 let mut windows_root = first.clone();
6400 windows_root.repo_root = r"C:\Users\floris\Documents\GitHub\xbp".to_string();
6401 let mut wsl_root = first.clone();
6402 wsl_root.repo_root = "/mnt/c/Users/floris/Documents/GitHub/xbp".to_string();
6403 assert_eq!(
6404 mutation_event_fingerprint(&windows_root),
6405 mutation_event_fingerprint(&wsl_root)
6406 );
6407
6408 let fingerprint = mutation_event_fingerprint(&first);
6409 assert_eq!(stable_event_id(&fingerprint), format!("wtmut_{fingerprint}"));
6410 assert_eq!(
6411 ensure_mutation_event_identity(first.clone()).id,
6412 stable_event_id(&fingerprint)
6413 );
6414 }
6415
6416 #[test]
6417 fn runtime_key_and_device_id_are_stable_and_scoped() {
6418 let key = worktree_runtime_key();
6419 assert!(!key.is_empty());
6420 assert_eq!(key, worktree_runtime_key());
6421 assert!(
6423 key == "windows"
6424 || key == "linux"
6425 || key == "macos"
6426 || key.starts_with("wsl-")
6427 || key == "wsl",
6428 "unexpected runtime key {key}"
6429 );
6430 let device = worktree_device_hardware_id("xbp_hw_test");
6431 assert_eq!(device, format!("xbp_hw_test@{key}"));
6432 }
6433
6434 #[test]
6435 fn should_poll_only_for_wsl_mnt_paths() {
6436 let mnt = Path::new("/mnt/c/Users/floris/Documents/GitHub/xbp");
6438 let home = Path::new("/home/floris/src/xbp");
6439 if is_wsl_runtime() {
6440 assert!(should_use_poll_watcher(mnt));
6441 assert!(!should_use_poll_watcher(home));
6442 } else {
6443 assert!(!should_use_poll_watcher(mnt));
6444 assert!(!should_use_poll_watcher(home));
6445 }
6446 }
6447
6448 #[test]
6449 fn repository_spool_root_uses_global_xbp_home_mutations() {
6450 let identity = RepoIdentity {
6451 owner: "xylex-group".to_string(),
6452 name: "xbp".to_string(),
6453 branch: "main".to_string(),
6454 root: PathBuf::from("/tmp/xbp"),
6455 head_sha: None,
6456 };
6457 let root = repository_spool_root(&identity).expect("global xbp home");
6458 let rendered = root.to_string_lossy().replace('\\', "/");
6459 assert!(
6460 rendered.contains("/mutations/"),
6461 "expected home-level mutations path, got {rendered}"
6462 );
6463 assert!(
6464 !rendered.contains("/mutations/runtimes/"),
6465 "spool must not be runtime-split when sharing one established home: {rendered}"
6466 );
6467 assert!(
6468 rendered.ends_with("xylex-group/xbp") || rendered.contains("xylex-group/xbp"),
6469 "expected owner/name suffix, got {rendered}"
6470 );
6471 assert!(!rendered.contains("/tmp/xbp/"));
6473 }
6474
6475 #[test]
6476 fn cross_platform_path_identity_reconciles_wsl_and_windows() {
6477 let windows = Path::new(r"C:\Users\szymon\.xbp\mutations\SuitsFinance\speedrun-formations\main");
6478 let wsl = Path::new(
6479 "/mnt/c/Users/szymon/.xbp/mutations/SuitsFinance/speedrun-formations/main",
6480 );
6481 assert_eq!(
6482 cross_platform_path_identity_key(windows),
6483 cross_platform_path_identity_key(wsl)
6484 );
6485 }
6486
6487 #[test]
6488 fn command_line_mentions_path_is_case_and_slash_insensitive() {
6489 let cmdline = r#""C:\xbp.exe" worktree-watch start --parent C:\Users\floris\Documents\GitHub"#;
6490 assert!(command_line_mentions_path(
6491 cmdline,
6492 "/mnt/c/users/floris/documents/github"
6493 ));
6494 assert!(command_line_mentions_path(
6495 cmdline,
6496 r"C:\Users\floris\Documents\GitHub"
6497 ));
6498 assert!(command_line_mentions_path(cmdline, "GitHub"));
6499 }
6500
6501 #[test]
6502 fn append_unique_mutation_event_is_idempotent_across_dedupe_instances() {
6503 let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
6504 fs::create_dir_all(&temp_root).unwrap();
6505 let layout = SpoolLayout {
6506 root: temp_root.clone(),
6507 events_file: temp_root.join("events.jsonl"),
6508 commits_file: temp_root.join("commits.jsonl"),
6509 stats_file: temp_root.join("stats.json"),
6510 watcher_state_file: temp_root.join("watcher-state.json"),
6511 sync_log_file: temp_root.join("sync-log.jsonl"),
6512 event_dedupe_file: temp_root.join("event-dedupe.jsonl"),
6513 event_dedupe_dir: temp_root.join("event-dedupe"),
6514 commit_dedupe_dir: temp_root.join("commit-dedupe"),
6515 };
6516 let mutation: WorktreeMutationEvent = serde_json::from_value(stats_test_event(
6517 "2026-07-08T10:00:00Z",
6518 "src/main.rs",
6519 4,
6520 1,
6521 ))
6522 .unwrap();
6523
6524 let first =
6525 append_unique_mutation_event(&layout, &mutation, &mut RecentEventDedupe::default())
6526 .unwrap();
6527 let second =
6528 append_unique_mutation_event(&layout, &mutation, &mut RecentEventDedupe::default())
6529 .unwrap();
6530
6531 assert!(first);
6532 assert!(!second);
6533 let events = read_jsonl_values(&layout.events_file).unwrap();
6534 assert_eq!(events.len(), 1);
6535 assert_eq!(
6536 events[0].get("fingerprint").and_then(Value::as_str),
6537 Some(mutation_event_fingerprint(&mutation).as_str())
6538 );
6539 let _ = fs::remove_dir_all(temp_root);
6540 }
6541
6542 #[test]
6543 fn reads_spool_records_as_typed_events() {
6544 let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
6545 fs::create_dir_all(&temp_root).unwrap();
6546 let events_file = temp_root.join("events.jsonl");
6547 append_json_line(
6548 &events_file,
6549 &stats_test_event_record("2026-07-08T10:00:00Z", "src/main.rs", 4, 1),
6550 )
6551 .unwrap();
6552
6553 let records = read_spool_records(&events_file, SyncFileKind::Events).unwrap();
6554
6555 match records {
6556 SyncRecords::Events(events) => {
6557 assert_eq!(events.len(), 1);
6558 assert_eq!(events[0].primary_path.as_deref(), Some("src/main.rs"));
6559 }
6560 SyncRecords::Commits(_) => panic!("expected typed event records"),
6561 }
6562 let _ = fs::remove_dir_all(temp_root);
6563 }
6564
6565 #[test]
6566 fn read_spool_records_skips_nul_only_padding() {
6567 let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
6568 fs::create_dir_all(&temp_root).unwrap();
6569 let events_file = temp_root.join("events.jsonl");
6570 fs::write(&events_file, vec![0; 4096]).unwrap();
6571
6572 let records = read_spool_records(&events_file, SyncFileKind::Events).unwrap();
6573
6574 match records {
6575 SyncRecords::Events(events) => assert!(events.is_empty()),
6576 SyncRecords::Commits(_) => panic!("expected event records"),
6577 }
6578 let _ = fs::remove_dir_all(temp_root);
6579 }
6580
6581 #[test]
6582 fn read_jsonl_strips_leading_nuls_before_json() {
6583 let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
6584 fs::create_dir_all(&temp_root).unwrap();
6585 let path = temp_root.join("event-dedupe.jsonl");
6586 let mut bytes = vec![0u8; 64];
6587 bytes.extend_from_slice(
6588 br#"{"fingerprint":"abc","firstSeenAt":"2026-07-09T00:00:00Z","eventKind":"modify","primaryPath":".xbp"}"#,
6589 );
6590 bytes.push(b'\n');
6591 fs::write(&path, bytes).unwrap();
6592
6593 let values = read_jsonl_values(&path).unwrap();
6594 assert_eq!(values.len(), 1);
6595 assert_eq!(
6596 values[0].get("fingerprint").and_then(Value::as_str),
6597 Some("abc")
6598 );
6599 let _ = fs::remove_dir_all(temp_root);
6600 }
6601
6602 #[test]
6603 fn read_jsonl_skips_garbage_lines() {
6604 let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
6605 fs::create_dir_all(&temp_root).unwrap();
6606 let path = temp_root.join("mixed.jsonl");
6607 fs::write(
6608 &path,
6609 "not-json\n{\"fingerprint\":\"ok\"}\n\n{\"broken\":\n",
6610 )
6611 .unwrap();
6612
6613 let values = read_jsonl_values(&path).unwrap();
6614 assert_eq!(values.len(), 1);
6615 assert_eq!(
6616 values[0].get("fingerprint").and_then(Value::as_str),
6617 Some("ok")
6618 );
6619 let _ = fs::remove_dir_all(temp_root);
6620 }
6621
6622 #[test]
6623 fn collect_spool_candidates_rewrites_legacy_generated_events() {
6624 let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
6625 fs::create_dir_all(&temp_root).unwrap();
6626 let events_file = temp_root.join("events-legacy.jsonl");
6627 append_json_line(
6628 &events_file,
6629 &stats_test_event_record(
6630 "2026-07-08T10:00:00Z",
6631 "apps/docs/node_modules/@aws-sdk/client-lambda/dist-types/schemas",
6632 0,
6633 0,
6634 ),
6635 )
6636 .unwrap();
6637 append_json_line(
6638 &events_file,
6639 &stats_test_event_record("2026-07-08T10:01:00Z", "apps/web/src/main.ts", 4, 1),
6640 )
6641 .unwrap();
6642 let layout = SpoolLayout {
6643 root: temp_root.clone(),
6644 events_file: temp_root.join("events.jsonl"),
6645 commits_file: temp_root.join("commits.jsonl"),
6646 stats_file: temp_root.join("stats.json"),
6647 watcher_state_file: temp_root.join("watcher-state.json"),
6648 sync_log_file: temp_root.join("sync-log.jsonl"),
6649 event_dedupe_file: temp_root.join("event-dedupe.jsonl"),
6650 event_dedupe_dir: temp_root.join("event-dedupe"),
6651 commit_dedupe_dir: temp_root.join("commit-dedupe"),
6652 };
6653
6654 let candidates = collect_spool_candidates(&layout, false).unwrap();
6655
6656 assert_eq!(candidates.len(), 1);
6657 match &candidates[0].records {
6658 SyncRecords::Events(events) => {
6659 assert_eq!(events.len(), 1);
6660 assert_eq!(
6661 events[0].primary_path.as_deref(),
6662 Some("apps/web/src/main.ts")
6663 );
6664 }
6665 SyncRecords::Commits(_) => panic!("expected event spool candidate"),
6666 }
6667 let rewritten = read_jsonl_records::<WorktreeMutationEvent>(&events_file).unwrap();
6668 assert_eq!(rewritten.len(), 1);
6669 assert_eq!(
6670 rewritten[0].primary_path.as_deref(),
6671 Some("apps/web/src/main.ts")
6672 );
6673 let _ = fs::remove_dir_all(temp_root);
6674 }
6675
6676 #[test]
6677 fn collect_spool_candidates_deletes_fully_ignored_event_files() {
6678 let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
6679 fs::create_dir_all(&temp_root).unwrap();
6680 let events_file = temp_root.join("events-legacy.jsonl");
6681 append_json_line(
6682 &events_file,
6683 &stats_test_event_record(
6684 "2026-07-08T10:00:00Z",
6685 "apps/docs/node_modules/@aws-sdk/client-lambda/dist-types/schemas",
6686 0,
6687 0,
6688 ),
6689 )
6690 .unwrap();
6691 let layout = SpoolLayout {
6692 root: temp_root.clone(),
6693 events_file: temp_root.join("events.jsonl"),
6694 commits_file: temp_root.join("commits.jsonl"),
6695 stats_file: temp_root.join("stats.json"),
6696 watcher_state_file: temp_root.join("watcher-state.json"),
6697 sync_log_file: temp_root.join("sync-log.jsonl"),
6698 event_dedupe_file: temp_root.join("event-dedupe.jsonl"),
6699 event_dedupe_dir: temp_root.join("event-dedupe"),
6700 commit_dedupe_dir: temp_root.join("commit-dedupe"),
6701 };
6702
6703 let candidates = collect_spool_candidates(&layout, false).unwrap();
6704
6705 assert!(candidates.is_empty());
6706 assert!(!events_file.exists());
6707 let _ = fs::remove_dir_all(temp_root);
6708 }
6709
6710 #[test]
6711 fn append_unique_commit_event_is_idempotent_across_watcher_instances() {
6712 let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
6713 fs::create_dir_all(&temp_root).unwrap();
6714 let layout = SpoolLayout {
6715 root: temp_root.clone(),
6716 events_file: temp_root.join("events.jsonl"),
6717 commits_file: temp_root.join("commits.jsonl"),
6718 stats_file: temp_root.join("stats.json"),
6719 watcher_state_file: temp_root.join("watcher-state.json"),
6720 sync_log_file: temp_root.join("sync-log.jsonl"),
6721 event_dedupe_file: temp_root.join("event-dedupe.jsonl"),
6722 event_dedupe_dir: temp_root.join("event-dedupe"),
6723 commit_dedupe_dir: temp_root.join("commit-dedupe"),
6724 };
6725 let commit = WorktreeCommitEvent {
6726 id: Uuid::new_v4().to_string(),
6727 fingerprint: None,
6728 repo_owner: "xylex-group".to_string(),
6729 repo_name: "xbp".to_string(),
6730 branch_name: "main".to_string(),
6731 repo_root: r"C:\Users\floris\Documents\GitHub\xbp".to_string(),
6732 previous_head_sha: Some("previous".to_string()),
6733 head_sha: "current".to_string(),
6734 subject: Some("test commit".to_string()),
6735 author_name: Some("Floris".to_string()),
6736 author_email: Some("floris@xylex.group".to_string()),
6737 committed_at: Some("2026-07-08T10:00:00Z".to_string()),
6738 occurred_at: Utc::now(),
6739 };
6740
6741 assert!(append_unique_commit_event(&layout, &commit).unwrap());
6742 assert!(!append_unique_commit_event(&layout, &commit).unwrap());
6743 let commits = read_jsonl_values(&layout.commits_file).unwrap();
6744 assert_eq!(commits.len(), 1);
6745 assert_eq!(
6746 commits[0].get("fingerprint").and_then(Value::as_str),
6747 Some(commit_event_fingerprint(&commit).as_str())
6748 );
6749 let _ = fs::remove_dir_all(temp_root);
6750 }
6751
6752 #[test]
6753 fn splits_worktree_mutation_uploads_into_bounded_batches() {
6754 let events = (0..(WORKTREE_MUTATION_SYNC_BATCH_RECORD_LIMIT + 1))
6755 .map(|index| {
6756 serde_json::from_value::<WorktreeMutationEvent>(stats_test_event(
6757 "2026-07-08T10:00:00Z",
6758 &format!("src/file-{index}.rs"),
6759 1,
6760 0,
6761 ))
6762 .unwrap()
6763 })
6764 .collect::<Vec<_>>();
6765 let commits = vec![
6766 worktree_commit_test_event("previous-a", "current-a"),
6767 worktree_commit_test_event("previous-b", "current-b"),
6768 ];
6769
6770 let batches = split_worktree_mutation_upload_batches(events, commits);
6771
6772 assert_eq!(batches.len(), 2);
6773 assert_eq!(
6774 batches[0].events.len() + batches[0].commits.len(),
6775 WORKTREE_MUTATION_SYNC_BATCH_RECORD_LIMIT
6776 );
6777 assert_eq!(batches[1].events.len(), 1);
6778 assert_eq!(batches[1].commits.len(), 2);
6779 }
6780
6781 #[test]
6782 fn splits_worktree_mutation_uploads_by_estimated_json_bytes() {
6783 let long_path = format!("src/{}.rs", "x".repeat(8_000));
6784 let events = (0..200)
6785 .map(|index| {
6786 serde_json::from_value::<WorktreeMutationEvent>(stats_test_event(
6787 "2026-07-08T10:00:00Z",
6788 &format!("{long_path}-{index}"),
6789 1,
6790 0,
6791 ))
6792 .unwrap()
6793 })
6794 .collect::<Vec<_>>();
6795
6796 let batches = split_worktree_mutation_upload_batches(events, Vec::new());
6797
6798 assert!(batches.len() > 1);
6799 for batch in batches {
6800 assert!(
6801 batch.estimated_json_bytes <= WORKTREE_MUTATION_SYNC_BATCH_JSON_BYTES,
6802 "batch estimated JSON bytes exceeded limit: {}",
6803 batch.estimated_json_bytes
6804 );
6805 }
6806 }
6807
6808 #[test]
6809 fn classifies_create_remove_and_rename_events() {
6810 assert_eq!(
6811 classify_event_kind(&EventKind::Create(CreateKind::File)),
6812 "file_create"
6813 );
6814 assert_eq!(
6815 classify_event_kind(&EventKind::Remove(RemoveKind::Folder)),
6816 "folder_remove"
6817 );
6818 assert_eq!(
6819 classify_event_kind(&EventKind::Modify(ModifyKind::Name(RenameMode::Both))),
6820 "rename_or_move"
6821 );
6822 }
6823
6824 #[test]
6825 fn retries_only_retryable_worktree_sync_statuses() {
6826 assert!(should_retry_worktree_mutation_sync_status(
6827 StatusCode::INTERNAL_SERVER_ERROR
6828 ));
6829 assert!(should_retry_worktree_mutation_sync_status(
6830 StatusCode::REQUEST_TIMEOUT
6831 ));
6832 assert!(should_retry_worktree_mutation_sync_status(
6833 StatusCode::TOO_MANY_REQUESTS
6834 ));
6835 assert!(!should_retry_worktree_mutation_sync_status(
6836 StatusCode::BAD_REQUEST
6837 ));
6838 assert!(!should_retry_worktree_mutation_sync_status(
6839 StatusCode::UNAUTHORIZED
6840 ));
6841 assert!(!should_retry_worktree_mutation_sync_status(
6842 StatusCode::PAYLOAD_TOO_LARGE
6843 ));
6844 }
6845
6846 #[test]
6847 fn match_github_repo_inventory_resolves_short_name_and_owner_repo() {
6848 let temp = std::env::temp_dir().join(format!("xbp-repo-lookup-{}", Uuid::new_v4()));
6849 let athena = temp.join("athena");
6850 let other = temp.join("other-athena");
6851 fs::create_dir_all(&athena).unwrap();
6852 fs::create_dir_all(&other).unwrap();
6853
6854 let repos = vec![
6855 GithubRepoRecord {
6856 owner: "xylex-group".to_string(),
6857 repo: "athena".to_string(),
6858 full_name: "xylex-group/athena".to_string(),
6859 path: athena.to_string_lossy().to_string(),
6860 remote_url: "https://github.com/xylex-group/athena.git".to_string(),
6861 },
6862 GithubRepoRecord {
6863 owner: "someone".to_string(),
6864 repo: "other".to_string(),
6865 full_name: "someone/other".to_string(),
6866 path: other.to_string_lossy().to_string(),
6867 remote_url: "https://github.com/someone/other.git".to_string(),
6868 },
6869 ];
6870
6871 let by_short = match_github_repo_inventory(&repos, "athena").unwrap().unwrap();
6872 assert_eq!(
6873 path_identity_key(&by_short),
6874 path_identity_key(athena.as_path())
6875 );
6876
6877 let by_full = match_github_repo_inventory(&repos, "xylex-group/athena")
6878 .unwrap()
6879 .unwrap();
6880 assert_eq!(
6881 path_identity_key(&by_full),
6882 path_identity_key(athena.as_path())
6883 );
6884
6885 assert!(match_github_repo_inventory(&repos, "missing")
6886 .unwrap()
6887 .is_none());
6888
6889 let _ = fs::remove_dir_all(temp);
6890 }
6891
6892 #[test]
6893 fn match_github_repo_inventory_errors_on_ambiguous_short_name() {
6894 let temp = std::env::temp_dir().join(format!("xbp-repo-lookup-ambig-{}", Uuid::new_v4()));
6895 let a = temp.join("a");
6896 let b = temp.join("b");
6897 fs::create_dir_all(&a).unwrap();
6898 fs::create_dir_all(&b).unwrap();
6899
6900 let repos = vec![
6901 GithubRepoRecord {
6902 owner: "one".to_string(),
6903 repo: "dup".to_string(),
6904 full_name: "one/dup".to_string(),
6905 path: a.to_string_lossy().to_string(),
6906 remote_url: "https://github.com/one/dup.git".to_string(),
6907 },
6908 GithubRepoRecord {
6909 owner: "two".to_string(),
6910 repo: "dup".to_string(),
6911 full_name: "two/dup".to_string(),
6912 path: b.to_string_lossy().to_string(),
6913 remote_url: "https://github.com/two/dup.git".to_string(),
6914 },
6915 ];
6916
6917 let err = match_github_repo_inventory(&repos, "dup").unwrap_err();
6918 assert!(err.contains("Ambiguous"));
6919 assert!(err.contains("one/dup") || err.contains(a.to_string_lossy().as_ref()));
6920
6921 let unique = match_github_repo_inventory(&repos, "two/dup").unwrap().unwrap();
6922 assert_eq!(path_identity_key(&unique), path_identity_key(b.as_path()));
6923
6924 let _ = fs::remove_dir_all(temp);
6925 }
6926
6927 fn stats_test_event(
6928 occurred_at: &str,
6929 path: &str,
6930 added_lines: u64,
6931 removed_lines: u64,
6932 ) -> Value {
6933 json!({
6934 "id": Uuid::new_v4().to_string(),
6935 "repoOwner": "xylex-group",
6936 "repoName": "xbp",
6937 "branchName": "main",
6938 "repoRoot": r"C:\Users\floris\Documents\GitHub\xbp",
6939 "headSha": null,
6940 "eventKind": "file_modify",
6941 "paths": [path],
6942 "primaryPath": path,
6943 "oldPath": null,
6944 "newPath": null,
6945 "addedLines": added_lines,
6946 "removedLines": removed_lines,
6947 "totalLines": added_lines + 42,
6948 "fileCreated": false,
6949 "fileRemoved": false,
6950 "folderCreated": false,
6951 "folderRemoved": false,
6952 "renamedOrMoved": false,
6953 "rawKind": "Modify(Data(Content))",
6954 "occurredAt": occurred_at,
6955 })
6956 }
6957
6958 fn stats_test_event_record(
6959 occurred_at: &str,
6960 path: &str,
6961 added_lines: u64,
6962 removed_lines: u64,
6963 ) -> WorktreeMutationEvent {
6964 serde_json::from_value(stats_test_event(
6965 occurred_at,
6966 path,
6967 added_lines,
6968 removed_lines,
6969 ))
6970 .unwrap()
6971 }
6972
6973 fn worktree_commit_test_event(previous_head_sha: &str, head_sha: &str) -> WorktreeCommitEvent {
6974 WorktreeCommitEvent {
6975 id: Uuid::new_v4().to_string(),
6976 fingerprint: None,
6977 repo_owner: "xylex-group".to_string(),
6978 repo_name: "xbp".to_string(),
6979 branch_name: "main".to_string(),
6980 repo_root: r"C:\Users\floris\Documents\GitHub\xbp".to_string(),
6981 previous_head_sha: Some(previous_head_sha.to_string()),
6982 head_sha: head_sha.to_string(),
6983 subject: Some("test commit".to_string()),
6984 author_name: Some("Floris".to_string()),
6985 author_email: Some("floris@xylex.group".to_string()),
6986 committed_at: Some("2026-07-08T10:00:00Z".to_string()),
6987 occurred_at: Utc::now(),
6988 }
6989 }
6990}