1use crate::commands::cli_session::{cli_request_client, resolve_cli_access_token};
2use crate::config::{
3 ensure_global_xbp_paths, map_windows_path_to_wsl_mnt, map_wsl_mnt_path_to_windows,
4 resolve_device_identity, resolve_global_xbp_root_dir, resolve_worktree_watch_config, ApiConfig,
5 WorktreeWatchConfig,
6};
7use chrono::{DateTime, Utc};
8use notify::event::{CreateKind, ModifyKind, RemoveKind, RenameMode};
9use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
10use reqwest::StatusCode;
11use serde::{Deserialize, Serialize};
12use serde_json::{json, Value};
13use sha2::{Digest, Sha256};
14use std::collections::{BTreeMap, BTreeSet};
15use std::fs::{self, File, OpenOptions};
16use std::io::{BufRead, BufReader, Write};
17use std::path::{Path, PathBuf};
18use std::process::{Command, Stdio};
19use std::sync::{mpsc, OnceLock};
20use std::time::{Duration, Instant};
21use sysinfo::{Pid, System};
22use uuid::Uuid;
23
24const BACKGROUND_CHILD_ENV: &str = "XBP_WORKTREE_WATCH_BACKGROUND_CHILD";
25const DEFAULT_REMOTE: &str = "origin";
26const EVENT_DEDUPE_WINDOW_SECONDS: i64 = 5;
27const WORKTREE_MUTATION_SYNC_BATCH_RECORD_LIMIT: usize = 250;
28const WORKTREE_MUTATION_SYNC_BATCH_JSON_BYTES: usize = 768 * 1024;
29const WORKTREE_MUTATION_SYNC_MAX_ATTEMPTS: usize = 3;
30const WORKTREE_MUTATION_SYNC_RETRY_DELAY_SECONDS: u64 = 2;
31
32#[derive(Debug, Clone, Default)]
39struct WatchIgnoreRules {
40 forbidden_path_prefixes: Vec<String>,
42 forbidden_folders: BTreeSet<String>,
44 banned_words: Vec<String>,
46 project_ignore: crate::utils::XbpIgnoreSet,
48}
49
50impl WatchIgnoreRules {
51 fn load() -> Self {
52 Self::from_config(&resolve_worktree_watch_config())
53 }
54
55 fn from_config(config: &WorktreeWatchConfig) -> Self {
56 Self::from_config_and_project(config, None)
57 }
58
59 fn from_config_and_project(config: &WorktreeWatchConfig, project_root: Option<&Path>) -> Self {
60 let mut forbidden_folders = built_in_forbidden_folders();
61 for folder in &config.forbidden_folders {
62 let normalized = normalize_folder_token(folder);
63 if !normalized.is_empty() {
64 forbidden_folders.insert(normalized);
65 }
66 }
67
68 let forbidden_path_prefixes = config
69 .forbidden_paths
70 .iter()
71 .map(|path| normalize_path_prefix(path))
72 .filter(|path| !path.is_empty())
73 .collect::<Vec<_>>();
74
75 let banned_words = config
76 .banned_words
77 .iter()
78 .map(|word| word.trim().to_ascii_lowercase())
79 .filter(|word| !word.is_empty())
80 .collect::<Vec<_>>();
81
82 let project_ignore = project_root
83 .map(|root| load_project_watch_ignore(root))
84 .unwrap_or_default();
85
86 Self {
87 forbidden_path_prefixes,
88 forbidden_folders,
89 banned_words,
90 project_ignore,
91 }
92 }
93
94 fn with_project_root(mut self, project_root: &Path) -> Self {
95 self.project_ignore = load_project_watch_ignore(project_root);
96 self
97 }
98
99 fn ignores_relative(&self, relative: &str) -> bool {
100 let normalized = normalize_relative_watch_path(relative);
101 if normalized.is_empty() {
102 return false;
103 }
104
105 if self.project_ignore.is_ignored_relative(&normalized, false)
106 || self.project_ignore.is_ignored_relative(&normalized, true)
107 {
108 return true;
109 }
110
111 let lower = normalized.to_ascii_lowercase();
112 let components = lower
113 .split('/')
114 .filter(|component| !component.is_empty())
115 .collect::<Vec<_>>();
116
117 if components
118 .iter()
119 .any(|component| self.forbidden_folders.contains(*component))
120 {
121 return true;
122 }
123
124 for prefix in &self.forbidden_path_prefixes {
125 if lower == *prefix || lower.starts_with(&format!("{prefix}/")) {
126 return true;
127 }
128 if !prefix.contains('/') && components.iter().any(|component| *component == prefix) {
130 return true;
131 }
132 }
133
134 for word in &self.banned_words {
135 if lower.contains(word) {
136 return true;
137 }
138 }
139
140 false
141 }
142
143 fn ignores_path(&self, root: &Path, path: &Path) -> bool {
144 if let Some(relative) = relative_watch_path(root, path) {
145 return self.ignores_relative(&relative);
146 }
147 false
148 }
149
150 fn skips_discovery_dir(&self, path: &Path) -> bool {
151 let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
152 return false;
153 };
154 let trimmed = name.trim();
155 if self
156 .forbidden_folders
157 .contains(&trimmed.to_ascii_lowercase())
158 {
159 return true;
160 }
161 self.project_ignore.skips_dir_name(trimmed)
162 }
163}
164
165fn load_project_watch_ignore(project_root: &Path) -> crate::utils::XbpIgnoreSet {
166 use crate::strategies::XbpConfig;
167 use crate::utils::{load_project_xbp_ignore, parse_config_with_auto_heal};
168
169 let mut extra = Vec::new();
170 if let Some(found) = crate::utils::find_xbp_config_upwards(project_root) {
174 if let Ok(content) = fs::read_to_string(&found.config_path) {
175 if let Ok((config, _)) = parse_config_with_auto_heal::<XbpConfig>(&content, found.kind)
176 {
177 extra = config.watch_ignore_paths;
178 }
179 }
180 return load_project_xbp_ignore(&found.project_root, &extra);
181 }
182 load_project_xbp_ignore(project_root, &extra)
183}
184
185fn built_in_forbidden_folders() -> BTreeSet<String> {
186 [
187 ".git",
188 ".hg",
189 ".svn",
190 ".next",
191 ".nx",
192 ".open-next",
193 ".turbo",
194 ".vercel",
195 "node_modules",
196 "target",
197 "dist",
198 "build",
199 ]
200 .into_iter()
201 .map(str::to_string)
202 .collect()
203}
204
205fn normalize_folder_token(raw: &str) -> String {
206 raw.trim()
207 .trim_matches(|ch| ch == '/' || ch == '\\')
208 .to_ascii_lowercase()
209}
210
211fn normalize_path_prefix(raw: &str) -> String {
212 let trimmed = raw.trim().replace('\\', "/");
213 let without_dot = trimmed
214 .trim_start_matches("./")
215 .trim_matches(|ch| ch == '/' || ch == '\\');
216 without_dot.to_ascii_lowercase()
217}
218
219fn normalize_relative_watch_path(raw: &str) -> String {
220 raw.trim()
221 .replace('\\', "/")
222 .trim_start_matches("./")
223 .trim_matches('/')
224 .to_string()
225}
226
227fn watch_ignore_rules() -> &'static WatchIgnoreRules {
228 static RULES: OnceLock<WatchIgnoreRules> = OnceLock::new();
229 RULES.get_or_init(WatchIgnoreRules::load)
230}
231
232fn watch_ignore_rules_for_root(root: &Path) -> WatchIgnoreRules {
234 watch_ignore_rules().clone().with_project_root(root)
235}
236
237#[cfg(windows)]
238const CREATE_NO_WINDOW: u32 = 0x08000000;
239
240#[derive(Debug, Clone, Default)]
241pub struct WorktreeWatchTargetOptions {
242 pub repo: Option<PathBuf>,
243 pub parent: Option<PathBuf>,
244 pub repos: Vec<PathBuf>,
246}
247
248#[derive(Debug, Clone, Serialize)]
250#[serde(rename_all = "camelCase")]
251pub struct WorktreeWatchTraySnapshot {
252 pub target_label: String,
253 pub mode: String,
254 pub parent: Option<String>,
255 pub repository_count: usize,
256 pub running_watchers: usize,
257 pub any_running: bool,
258 pub all_running: bool,
259 pub parent_watcher_running: bool,
260 pub parent_watcher_pid: Option<u32>,
261 pub total_unsynced_files: u64,
262 pub total_unsynced_records: u64,
263 pub repositories: Vec<WorktreeWatchTrayRepoStatus>,
264 pub stats_line: Option<String>,
265}
266
267#[derive(Debug, Clone, Serialize)]
268#[serde(rename_all = "camelCase")]
269pub struct WorktreeWatchTrayRepoStatus {
270 pub root: String,
271 pub owner: String,
272 pub name: String,
273 pub branch: String,
274 pub running: bool,
275 pub pid: Option<u32>,
276 pub unsynced_files: u64,
277 pub unsynced_records: u64,
278}
279
280#[derive(Debug, Clone)]
281pub struct WorktreeWatchStartOptions {
282 pub target: WorktreeWatchTargetOptions,
283 pub detach: bool,
284 pub sync_interval_seconds: u64,
285 pub once: bool,
286}
287
288#[derive(Debug, Clone)]
289pub struct WorktreeWatchSyncOptions {
290 pub target: WorktreeWatchTargetOptions,
291 pub dry_run: bool,
292 pub resync: bool,
293}
294
295#[derive(Debug, Clone)]
296pub struct WorktreeWatchStopOptions {
297 pub target: WorktreeWatchTargetOptions,
298 pub force: bool,
299}
300
301#[derive(Debug, Clone)]
302pub struct WorktreeWatchStatusOptions {
303 pub target: WorktreeWatchTargetOptions,
304 pub json: bool,
305 pub records: bool,
306 pub record_limit: usize,
307 pub stats: bool,
308 pub repo_activity: bool,
309 pub stats_gap_minutes: u64,
310}
311
312#[derive(Debug, Clone)]
313struct RepoIdentity {
314 owner: String,
315 name: String,
316 branch: String,
317 root: PathBuf,
318 head_sha: Option<String>,
319}
320
321#[derive(Debug, Clone)]
322struct SpoolLayout {
323 root: PathBuf,
324 events_file: PathBuf,
325 commits_file: PathBuf,
326 stats_file: PathBuf,
327 watcher_state_file: PathBuf,
328 sync_log_file: PathBuf,
329 event_dedupe_file: PathBuf,
330 event_dedupe_dir: PathBuf,
331 commit_dedupe_dir: PathBuf,
332}
333
334#[derive(Debug, Clone)]
335struct ParentSpoolLayout {
336 watcher_state_file: PathBuf,
337}
338
339#[derive(Debug)]
340struct WatchedRepo {
341 identity: RepoIdentity,
342 layout: SpoolLayout,
343 last_head: Option<String>,
344 event_dedupe: RecentEventDedupe,
345}
346
347#[derive(Debug, Serialize, Deserialize, Clone)]
348#[serde(rename_all = "camelCase")]
349struct WorktreeMutationEvent {
350 id: String,
351 #[serde(default, skip_serializing_if = "Option::is_none")]
352 fingerprint: Option<String>,
353 repo_owner: String,
354 repo_name: String,
355 branch_name: String,
356 repo_root: String,
357 head_sha: Option<String>,
358 event_kind: String,
359 paths: Vec<String>,
360 primary_path: Option<String>,
361 old_path: Option<String>,
362 new_path: Option<String>,
363 added_lines: Option<u64>,
364 removed_lines: Option<u64>,
365 total_lines: Option<u64>,
366 file_created: bool,
367 file_removed: bool,
368 folder_created: bool,
369 folder_removed: bool,
370 renamed_or_moved: bool,
371 raw_kind: String,
372 occurred_at: DateTime<Utc>,
373}
374
375#[derive(Debug, Serialize, Deserialize, Clone)]
376#[serde(rename_all = "camelCase")]
377struct WorktreeCommitEvent {
378 id: String,
379 #[serde(default, skip_serializing_if = "Option::is_none")]
380 fingerprint: Option<String>,
381 repo_owner: String,
382 repo_name: String,
383 branch_name: String,
384 repo_root: String,
385 previous_head_sha: Option<String>,
386 head_sha: String,
387 subject: Option<String>,
388 author_name: Option<String>,
389 author_email: Option<String>,
390 committed_at: Option<String>,
391 occurred_at: DateTime<Utc>,
392}
393
394#[derive(Debug, Serialize)]
395#[serde(rename_all = "camelCase")]
396struct WorktreeMutationIngestPayload {
397 device: WorktreeMutationDevicePayload,
398 repository: WorktreeMutationRepositoryPayload,
399 events: Vec<WorktreeMutationEvent>,
400 commits: Vec<WorktreeCommitEvent>,
401}
402
403#[derive(Debug, Serialize)]
404#[serde(rename_all = "camelCase")]
405struct WorktreeMutationDevicePayload {
406 hardware_id: String,
407 device_name: Option<String>,
408 hostname: Option<String>,
409 platform: String,
410 #[serde(skip_serializing_if = "Option::is_none")]
413 runtime_key: Option<String>,
414}
415
416#[derive(Debug, Serialize)]
417#[serde(rename_all = "camelCase")]
418struct WorktreeMutationRepositoryPayload {
419 owner: String,
420 name: String,
421 branch_name: String,
422 repo_root: String,
423}
424
425#[derive(Debug)]
426struct SyncCandidate {
427 path: PathBuf,
428 records: SyncRecords,
429}
430
431#[derive(Debug)]
432struct WorktreeMutationUploadBatch {
433 events: Vec<WorktreeMutationEvent>,
434 commits: Vec<WorktreeCommitEvent>,
435 estimated_json_bytes: usize,
436}
437
438#[derive(Debug, Clone, Copy, PartialEq, Eq)]
439enum WorktreeMutationSyncErrorKind {
440 Retryable,
441 NonRetryable,
442}
443
444#[derive(Debug)]
445struct WorktreeMutationSyncError {
446 kind: WorktreeMutationSyncErrorKind,
447 message: String,
448}
449
450struct SyncLogAppend<'a> {
451 identity: &'a RepoIdentity,
452 layout: &'a SpoolLayout,
453 endpoint: &'a str,
454 status_code: u16,
455 event_count: usize,
456 commit_count: usize,
457 candidates: &'a [SyncCandidate],
458 resync: bool,
459}
460
461#[derive(Debug, Clone, Copy)]
462enum SyncFileKind {
463 Events,
464 Commits,
465}
466
467#[derive(Debug)]
468enum SyncRecords {
469 Events(Vec<WorktreeMutationEvent>),
470 Commits(Vec<WorktreeCommitEvent>),
471}
472
473impl SyncRecords {
474 fn len(&self) -> usize {
475 match self {
476 Self::Events(records) => records.len(),
477 Self::Commits(records) => records.len(),
478 }
479 }
480
481 fn is_empty(&self) -> bool {
482 self.len() == 0
483 }
484}
485
486#[derive(Debug, Serialize, Clone)]
487#[serde(rename_all = "camelCase")]
488struct WorktreeWatchStatsSummary {
489 generated_at: DateTime<Utc>,
490 repository_owner: String,
491 repository_name: String,
492 branch_name: String,
493 repo_root: String,
494 spool_root: String,
495 session_gap_minutes: u64,
496 total_events: u64,
497 total_commits: u64,
498 first_event_at: Option<DateTime<Utc>>,
499 last_event_at: Option<DateTime<Utc>>,
500 session_count: u64,
501 observed_span_seconds: u64,
502 estimated_coding_seconds: u64,
503 added_lines: u64,
504 removed_lines: u64,
505 by_file: Vec<WorktreeWatchFileStats>,
506 by_filetype: Vec<WorktreeWatchBucketStats>,
507 by_event_kind: Vec<WorktreeWatchBucketStats>,
508}
509
510#[derive(Debug, Serialize, Clone)]
511#[serde(rename_all = "camelCase")]
512struct WorktreeWatchRepoActivitySummary {
513 generated_at: DateTime<Utc>,
514 repository_owner: String,
515 repository_name: String,
516 repo_root: String,
517 repository_spool_root: String,
518 stats_file: String,
519 session_gap_minutes: u64,
520 branch_count: u64,
521 total_events: u64,
522 total_commits: u64,
523 first_event_at: Option<DateTime<Utc>>,
524 last_event_at: Option<DateTime<Utc>>,
525 session_count: u64,
526 observed_span_seconds: u64,
527 estimated_coding_seconds: u64,
528 added_lines: u64,
529 removed_lines: u64,
530 branches: Vec<WorktreeWatchBranchActivityStats>,
531}
532
533#[derive(Debug, Serialize, Clone)]
534#[serde(rename_all = "camelCase")]
535struct WorktreeWatchBranchActivityStats {
536 branch_name: String,
537 spool_root: String,
538 total_events: u64,
539 total_commits: u64,
540 first_event_at: Option<DateTime<Utc>>,
541 last_event_at: Option<DateTime<Utc>>,
542 session_count: u64,
543 observed_span_seconds: u64,
544 estimated_coding_seconds: u64,
545 added_lines: u64,
546 removed_lines: u64,
547}
548
549#[derive(Debug, Serialize, Clone, Default)]
550#[serde(rename_all = "camelCase")]
551struct WorktreeWatchFileStats {
552 path: String,
553 filetype: String,
554 event_count: u64,
555 estimated_coding_seconds: u64,
556 added_lines: u64,
557 removed_lines: u64,
558}
559
560#[derive(Debug, Serialize, Clone, Default)]
561#[serde(rename_all = "camelCase")]
562struct WorktreeWatchBucketStats {
563 name: String,
564 event_count: u64,
565 estimated_coding_seconds: u64,
566 added_lines: u64,
567 removed_lines: u64,
568}
569
570#[derive(Debug, Serialize, Deserialize, Clone)]
571#[serde(rename_all = "camelCase")]
572struct WorktreeWatcherState {
573 pid: u32,
574 repo_root: String,
575 repository_owner: String,
576 repository_name: String,
577 branch_name: String,
578 executable: String,
579 started_at: DateTime<Utc>,
580 #[serde(default, skip_serializing_if = "Option::is_none")]
582 runtime_key: Option<String>,
583 #[serde(default, skip_serializing_if = "Option::is_none")]
584 platform: Option<String>,
585}
586
587#[derive(Debug, Serialize, Deserialize, Clone)]
588#[serde(rename_all = "camelCase")]
589struct ParentWorktreeWatcherState {
590 pid: u32,
591 parent_root: String,
592 executable: String,
593 started_at: DateTime<Utc>,
594 #[serde(default, skip_serializing_if = "Option::is_none")]
595 runtime_key: Option<String>,
596 #[serde(default, skip_serializing_if = "Option::is_none")]
597 platform: Option<String>,
598}
599
600#[derive(Debug, Serialize, Deserialize, Clone)]
601#[serde(rename_all = "camelCase")]
602struct WorktreeWatchSyncLogEntry {
603 id: String,
604 synced_at: DateTime<Utc>,
605 endpoint: String,
606 repository_owner: String,
607 repository_name: String,
608 branch_name: String,
609 repo_root: String,
610 resync: bool,
611 status_code: u16,
612 event_count: usize,
613 commit_count: usize,
614 spool_file_count: usize,
615 spool_files: Vec<String>,
616}
617
618#[derive(Debug, Clone, Copy, PartialEq, Eq)]
619enum StopWatcherOutcome {
620 Stopped(u32),
621 RemovedStaleState,
622 NoState,
623}
624
625#[derive(Debug, Serialize, Deserialize, Clone)]
626#[serde(rename_all = "camelCase")]
627struct WorktreeWatchEventDedupeRecord {
628 fingerprint: String,
629 first_seen_at: DateTime<Utc>,
630 event_kind: String,
631 primary_path: Option<String>,
632}
633
634#[derive(Debug, Default)]
635struct RecentEventDedupe {
636 fingerprints: BTreeSet<String>,
637}
638
639pub async fn run_worktree_watch_start(options: WorktreeWatchStartOptions) -> Result<(), String> {
640 if let Some(parent) = options.target.parent.as_deref() {
641 let identities = resolve_target_identities(&options.target)?;
642 if identities.is_empty() {
643 println!("No git repositories found under parent folder.");
644 return Ok(());
645 }
646
647 if options.detach {
648 let path = spawn_detached_parent_worktree_watch(parent)?;
649 println!(
650 "Started one background worktree watcher for {} covering {} repo(s).",
651 path.display(),
652 identities.len()
653 );
654 return Ok(());
655 }
656
657 if options.once {
658 for identity in &identities {
659 let layout = prepare_spool_layout(identity)?;
660 record_commit_snapshot(identity, &layout, None)?;
661 println!("Recorded current git state for {}", identity.root.display());
662 }
663 return Ok(());
664 }
665
666 let parent = canonical_parent_path(parent)?;
667 println!(
668 "Watching {} recursively and routing mutations for {} repo(s).",
669 parent.display(),
670 identities.len()
671 );
672 return watch_parent_foreground(parent, identities, options.sync_interval_seconds).await;
673 }
674
675 if options.detach {
676 return spawn_detached_worktree_watch(options.target.repo.as_deref()).map(|path| {
677 println!("Started background worktree watcher for {}", path.display());
678 });
679 }
680
681 let identity = resolve_repo_identity(options.target.repo.as_deref())?;
682 let layout = prepare_spool_layout(&identity)?;
683 println!(
684 "Watching {} and spooling mutations to {} [runtime={}]",
685 identity.root.display(),
686 layout.root.display(),
687 worktree_runtime_key()
688 );
689
690 if options.once {
691 record_commit_snapshot(&identity, &layout, None)?;
692 return Ok(());
693 }
694
695 watch_foreground(identity, layout, options.sync_interval_seconds).await
696}
697
698pub async fn run_worktree_watch_sync(options: WorktreeWatchSyncOptions) -> Result<(), String> {
699 let identities = resolve_target_identities(&options.target)?;
700 let repo_count = identities.len();
701 let mut plans = Vec::new();
702 let mut total_files = 0usize;
703 let mut total_records = 0usize;
704
705 for identity in identities {
706 let layout = prepare_spool_layout(&identity)?;
707 let candidates = collect_spool_candidates(&layout, options.resync)?;
708 total_files += candidates.len();
709 total_records += candidates
710 .iter()
711 .map(|candidate| candidate.records.len())
712 .sum::<usize>();
713 if !candidates.is_empty() {
714 plans.push((identity, candidates));
715 }
716 }
717
718 if options.dry_run {
719 println!(
720 "Would sync {} record(s) from {} spool file(s) across {} repo(s).",
721 total_records, total_files, repo_count
722 );
723 if options.resync {
724 println!("Resync mode includes files already marked `.synced.*.jsonl`.");
725 }
726 return Ok(());
727 }
728
729 if plans.is_empty() {
730 print_no_sync_candidates_message(&options.target, options.resync)?;
731 return Ok(());
732 }
733
734 let upload_repo_count = plans.len();
735 for (identity, candidates) in plans {
736 sync_candidates(&identity, candidates, options.resync).await?;
737 }
738 println!(
739 "Synced {} worktree mutation record(s) across {} repo(s).",
740 total_records, upload_repo_count
741 );
742 Ok(())
743}
744
745pub fn run_worktree_watch_stop(options: WorktreeWatchStopOptions) -> Result<(), String> {
746 if let Some(parent) = options.target.parent.as_deref() {
747 let parent = canonical_parent_path(parent)?;
748 match stop_existing_parent_watcher(&parent, options.force)? {
749 StopWatcherOutcome::Stopped(pid) => {
750 println!(
751 "Stopped background parent worktree watcher {} for {}",
752 pid,
753 parent.display()
754 );
755 }
756 StopWatcherOutcome::RemovedStaleState => {
757 println!(
758 "Removed stale parent worktree watcher state for {}",
759 parent.display()
760 );
761 }
762 StopWatcherOutcome::NoState => {
763 println!("No parent worktree watcher state for {}", parent.display());
764 }
765 }
766 }
767
768 let identities = resolve_target_identities(&options.target)?;
769 if identities.is_empty() {
770 println!("No git repositories found for worktree watcher stop.");
771 return Ok(());
772 }
773
774 let mut stopped = 0usize;
775 let mut stale = 0usize;
776 let mut missing = 0usize;
777 for identity in identities {
778 let layout = prepare_spool_layout(&identity)?;
779 match stop_existing_watcher(&identity, &layout, options.force)? {
780 StopWatcherOutcome::Stopped(pid) => {
781 stopped += 1;
782 println!(
783 "Stopped background worktree watcher {} for {}",
784 pid,
785 identity.root.display()
786 );
787 }
788 StopWatcherOutcome::RemovedStaleState => {
789 stale += 1;
790 println!(
791 "Removed stale worktree watcher state for {}",
792 identity.root.display()
793 );
794 }
795 StopWatcherOutcome::NoState => {
796 missing += 1;
797 println!(
798 "No background worktree watcher state for {}",
799 identity.root.display()
800 );
801 }
802 }
803 }
804
805 println!(
806 "Worktree watcher stop complete: {stopped} stopped, {stale} stale state file(s) removed, {missing} without state."
807 );
808 Ok(())
809}
810
811pub async fn run_worktree_watch_status(options: WorktreeWatchStatusOptions) -> Result<(), String> {
812 let identities = resolve_target_identities(&options.target)?;
813 let mut payloads = Vec::new();
814 let mut total_files = 0usize;
815 let mut total_records = 0usize;
816
817 for identity in identities {
818 let status =
819 worktree_watch_status_payload(&identity, options.records, options.record_limit)?;
820 let stats = if options.stats {
821 Some(generate_and_store_stats(
822 &identity,
823 options.stats_gap_minutes,
824 )?)
825 } else {
826 None
827 };
828 let repo_activity = if options.repo_activity {
829 Some(generate_and_store_repo_activity(
830 &identity,
831 options.stats_gap_minutes,
832 )?)
833 } else {
834 None
835 };
836 let status = attach_optional_stats(status, stats, repo_activity)?;
837 total_files += status
838 .get("unsyncedFiles")
839 .and_then(Value::as_u64)
840 .unwrap_or(0) as usize;
841 total_records += status
842 .get("unsyncedRecords")
843 .and_then(Value::as_u64)
844 .unwrap_or(0) as usize;
845 payloads.push(status);
846 }
847
848 if options.json {
849 let payload = if options.target.parent.is_some() {
850 json!({
851 "repositories": payloads,
852 "repositoryCount": payloads.len(),
853 "unsyncedFiles": total_files,
854 "unsyncedRecords": total_records,
855 })
856 } else {
857 payloads.into_iter().next().unwrap_or_else(|| json!({}))
858 };
859 println!(
860 "{}",
861 serde_json::to_string_pretty(&payload)
862 .map_err(|error| format!("Failed to render status JSON: {error}"))?
863 );
864 } else {
865 for (index, payload) in payloads.iter().enumerate() {
866 if index > 0 {
867 println!();
868 }
869 println!(
870 "repo: {}/{}",
871 payload
872 .get("repositoryOwner")
873 .and_then(Value::as_str)
874 .unwrap_or("unknown"),
875 payload
876 .get("repositoryName")
877 .and_then(Value::as_str)
878 .unwrap_or("repository")
879 );
880 println!(
881 "branch: {}",
882 payload
883 .get("branchName")
884 .and_then(Value::as_str)
885 .unwrap_or("unknown")
886 );
887 println!(
888 "root: {}",
889 payload
890 .get("repoRoot")
891 .and_then(Value::as_str)
892 .unwrap_or("")
893 );
894 println!(
895 "spool: {}",
896 payload
897 .get("spoolRoot")
898 .and_then(Value::as_str)
899 .unwrap_or("")
900 );
901 println!(
902 "unsynced files: {}",
903 payload
904 .get("unsyncedFiles")
905 .and_then(Value::as_u64)
906 .unwrap_or(0)
907 );
908 println!(
909 "unsynced records: {}",
910 payload
911 .get("unsyncedRecords")
912 .and_then(Value::as_u64)
913 .unwrap_or(0)
914 );
915 println!(
916 "synced files: {}",
917 payload
918 .get("syncedFiles")
919 .and_then(Value::as_u64)
920 .unwrap_or(0)
921 );
922 println!(
923 "synced records: {}",
924 payload
925 .get("syncedRecords")
926 .and_then(Value::as_u64)
927 .unwrap_or(0)
928 );
929 print_last_sync(payload);
930 if options.records {
931 print_status_records(payload);
932 }
933 if options.stats {
934 print_status_stats(payload);
935 }
936 if options.repo_activity {
937 print_repo_activity(payload);
938 }
939 }
940 if options.target.parent.is_some() {
941 println!();
942 println!(
943 "total: {} repo(s), {} unsynced file(s), {} unsynced record(s)",
944 payloads.len(),
945 total_files,
946 total_records
947 );
948 }
949 }
950
951 Ok(())
952}
953
954fn attach_optional_stats(
955 mut payload: Value,
956 stats: Option<WorktreeWatchStatsSummary>,
957 repo_activity: Option<WorktreeWatchRepoActivitySummary>,
958) -> Result<Value, String> {
959 if let Some(object) = payload.as_object_mut() {
960 if let Some(stats) = stats {
961 object.insert(
962 "stats".to_string(),
963 serde_json::to_value(stats)
964 .map_err(|error| format!("Failed to render stats payload: {error}"))?,
965 );
966 }
967 if let Some(repo_activity) = repo_activity {
968 object.insert(
969 "repositoryActivity".to_string(),
970 serde_json::to_value(repo_activity)
971 .map_err(|error| format!("Failed to render repo activity payload: {error}"))?,
972 );
973 }
974 }
975 Ok(payload)
976}
977
978pub fn spawn_detached_worktree_watch_for_current_repo() -> Result<Option<PathBuf>, String> {
979 if is_background_child() {
980 return Ok(None);
981 }
982
983 let identity = match resolve_repo_identity(None) {
984 Ok(identity) => identity,
985 Err(_) => return Ok(None),
986 };
987 spawn_detached_worktree_watch_for_identity(&identity).map(Some)
988}
989
990#[derive(Debug, Clone, Serialize, Deserialize)]
992#[serde(rename_all = "camelCase")]
993pub struct WorktreeWatchDiagnostic {
994 pub global_xbp_root: String,
995 pub mutations_root: String,
996 pub runtime_key: String,
997 pub platform: String,
998 pub is_wsl: bool,
999 pub repo_owner: Option<String>,
1000 pub repo_name: Option<String>,
1001 pub branch: Option<String>,
1002 pub branch_spool: Option<String>,
1003 pub branch_spool_exists: bool,
1004 pub watcher_state_path: Option<String>,
1005 pub watcher_state_present: bool,
1006 pub watcher_running: bool,
1007 pub event_jsonl_files: usize,
1008 pub commit_jsonl_files: usize,
1009 pub alternate_spool_roots: Vec<String>,
1010 pub notes: Vec<String>,
1011}
1012
1013pub fn collect_worktree_watch_diagnostic() -> WorktreeWatchDiagnostic {
1015 let runtime_key = worktree_runtime_key();
1016 let platform = std::env::consts::OS.to_string();
1017 let is_wsl = is_wsl_runtime();
1018 let mut notes = Vec::new();
1019
1020 let global_xbp_root = match ensure_global_xbp_paths() {
1021 Ok(paths) => paths.root_dir,
1022 Err(error) => {
1023 notes.push(format!("Failed to resolve global XBP home: {error}"));
1024 resolve_global_xbp_root_dir()
1025 }
1026 };
1027 let mutations_root = global_xbp_root.join("mutations");
1028
1029 let identity = resolve_repo_identity(None).ok();
1030 let (
1031 repo_owner,
1032 repo_name,
1033 branch,
1034 branch_spool,
1035 branch_spool_exists,
1036 watcher_state_path,
1037 watcher_state_present,
1038 watcher_running,
1039 event_jsonl_files,
1040 commit_jsonl_files,
1041 alternate_spool_roots,
1042 ) = if let Some(identity) = identity.as_ref() {
1043 let layout = prepare_spool_layout(identity).ok();
1044 let branch_spool = layout
1045 .as_ref()
1046 .map(|value| value.root.display().to_string());
1047 let branch_spool_exists = layout
1048 .as_ref()
1049 .map(|value| value.root.exists())
1050 .unwrap_or(false);
1051 let watcher_state_path = layout
1052 .as_ref()
1053 .map(|value| value.watcher_state_file.display().to_string());
1054 let watcher_state_present = layout
1055 .as_ref()
1056 .map(|value| {
1057 watcher_state_read_candidates(&value.watcher_state_file)
1058 .iter()
1059 .any(|candidate| candidate.exists())
1060 })
1061 .unwrap_or(false);
1062 let watcher_running = layout
1063 .as_ref()
1064 .and_then(|value| read_watcher_state(&value.watcher_state_file).ok().flatten())
1065 .map(|state| is_matching_watcher_process(&state))
1066 .unwrap_or(false);
1067
1068 let mut event_jsonl_files = 0usize;
1069 let mut commit_jsonl_files = 0usize;
1070 if let Some(layout) = layout.as_ref() {
1071 if let Ok(entries) = fs::read_dir(&layout.root) {
1072 for entry in entries.flatten() {
1073 let name = entry.file_name().to_string_lossy().to_string();
1074 if name.starts_with("events-") && name.ends_with(".jsonl") {
1075 event_jsonl_files += 1;
1076 } else if name.starts_with("commits-") && name.ends_with(".jsonl") {
1077 commit_jsonl_files += 1;
1078 }
1079 }
1080 }
1081 }
1082
1083 let alternate_spool_roots = repository_spool_search_roots(identity)
1084 .unwrap_or_default()
1085 .into_iter()
1086 .map(|path| {
1087 path.join(sanitize_path_component(&identity.branch))
1088 .display()
1089 .to_string()
1090 })
1091 .filter(|path| {
1092 layout
1093 .as_ref()
1094 .map(|value| path_identity_key(Path::new(path)) != path_identity_key(&value.root))
1095 .unwrap_or(true)
1096 })
1097 .filter(|path| Path::new(path).exists())
1098 .collect::<Vec<_>>();
1099
1100 let root_display = global_xbp_root
1101 .display()
1102 .to_string()
1103 .replace('\\', "/")
1104 .to_ascii_lowercase();
1105 if is_wsl && !root_display.starts_with("/mnt/") {
1106 notes.push(
1107 "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."
1108 .to_string(),
1109 );
1110 }
1111 if is_wsl && root_display.starts_with("/mnt/") {
1112 notes.push(
1113 "Using a Windows-profile-backed XBP home from WSL; spoils are reconcilable with native Windows."
1114 .to_string(),
1115 );
1116 }
1117 if !alternate_spool_roots.is_empty() {
1118 notes.push(format!(
1119 "Found {} alternate spool location(s) with historical data (legacy homes / runtime layouts).",
1120 alternate_spool_roots.len()
1121 ));
1122 }
1123
1124 (
1125 Some(identity.owner.clone()),
1126 Some(identity.name.clone()),
1127 Some(identity.branch.clone()),
1128 branch_spool,
1129 branch_spool_exists,
1130 watcher_state_path,
1131 watcher_state_present,
1132 watcher_running,
1133 event_jsonl_files,
1134 commit_jsonl_files,
1135 alternate_spool_roots,
1136 )
1137 } else {
1138 notes.push(
1139 "Not inside a git repository with a resolvable remote; showing global XBP home only."
1140 .to_string(),
1141 );
1142 (
1143 None,
1144 None,
1145 None,
1146 None,
1147 false,
1148 None,
1149 false,
1150 false,
1151 0,
1152 0,
1153 Vec::new(),
1154 )
1155 };
1156
1157 WorktreeWatchDiagnostic {
1158 global_xbp_root: global_xbp_root.display().to_string(),
1159 mutations_root: mutations_root.display().to_string(),
1160 runtime_key,
1161 platform,
1162 is_wsl,
1163 repo_owner,
1164 repo_name,
1165 branch,
1166 branch_spool,
1167 branch_spool_exists,
1168 watcher_state_path,
1169 watcher_state_present,
1170 watcher_running,
1171 event_jsonl_files,
1172 commit_jsonl_files,
1173 alternate_spool_roots,
1174 notes,
1175 }
1176}
1177
1178fn resolve_target_identities(
1179 target: &WorktreeWatchTargetOptions,
1180) -> Result<Vec<RepoIdentity>, String> {
1181 if target.repo.is_some() && target.parent.is_some() {
1182 return Err("Pass either `--repo` or `--parent`, not both.".to_string());
1183 }
1184 if !target.repos.is_empty() && target.parent.is_some() {
1185 return Err("Pass either `--repos` or `--parent`, not both.".to_string());
1186 }
1187 if target.repo.is_some() && !target.repos.is_empty() {
1188 return Err("Pass either `--repo` or `--repos`, not both.".to_string());
1189 }
1190
1191 if let Some(parent) = target.parent.as_deref() {
1192 return discover_repo_identities_under(parent);
1193 }
1194
1195 if !target.repos.is_empty() {
1196 let mut identities = Vec::new();
1197 let mut seen = BTreeSet::new();
1198 for repo in &target.repos {
1199 let identity = resolve_repo_identity(Some(repo.as_path()))?;
1200 let key = path_identity_key(&identity.root);
1201 if seen.insert(key) {
1202 identities.push(identity);
1203 }
1204 }
1205 return Ok(identities);
1206 }
1207
1208 resolve_repo_identity(target.repo.as_deref()).map(|identity| vec![identity])
1209}
1210
1211pub fn collect_worktree_watch_tray_snapshot(
1213 target: &WorktreeWatchTargetOptions,
1214) -> Result<WorktreeWatchTraySnapshot, String> {
1215 let identities = resolve_target_identities(target)?;
1216 let mode = if target.parent.is_some() {
1217 "parent"
1218 } else if !target.repos.is_empty() {
1219 "repos"
1220 } else {
1221 "repo"
1222 }
1223 .to_string();
1224
1225 let parent_label = target
1226 .parent
1227 .as_ref()
1228 .map(|path| path.display().to_string());
1229 let target_label = if let Some(parent) = parent_label.as_ref() {
1230 format!("parent:{parent}")
1231 } else if !target.repos.is_empty() {
1232 format!("{} repo(s)", target.repos.len())
1233 } else if let Some(repo) = target.repo.as_ref() {
1234 repo.display().to_string()
1235 } else {
1236 identities
1237 .first()
1238 .map(|identity| identity.root.display().to_string())
1239 .unwrap_or_else(|| "current repo".to_string())
1240 };
1241
1242 let mut parent_watcher_running = false;
1243 let mut parent_watcher_pid = None;
1244 if let Some(parent) = target.parent.as_deref() {
1245 if let Ok(parent) = canonical_parent_path(parent) {
1246 if let Ok(layout) = prepare_parent_spool_layout(&parent) {
1247 if let Ok(Some(state)) = read_parent_watcher_state(&layout.watcher_state_file) {
1248 if is_matching_parent_watcher_process(&state) {
1249 parent_watcher_running = true;
1250 parent_watcher_pid = Some(state.pid);
1251 }
1252 }
1253 }
1254 }
1255 }
1256
1257 let mut repositories = Vec::new();
1258 let mut running_watchers = 0usize;
1259 let mut total_unsynced_files = 0u64;
1260 let mut total_unsynced_records = 0u64;
1261
1262 for identity in &identities {
1263 let layout = prepare_spool_layout(identity)?;
1264 let candidates = collect_sync_candidates(&layout)?;
1265 let unsynced_files = candidates.len() as u64;
1266 let unsynced_records = candidates
1267 .iter()
1268 .map(|candidate| candidate.records.len() as u64)
1269 .sum::<u64>();
1270 total_unsynced_files += unsynced_files;
1271 total_unsynced_records += unsynced_records;
1272
1273 let (running, pid) = match read_watcher_state(&layout.watcher_state_file)? {
1274 Some(state) if same_repo_watcher_state(identity, &state) => {
1275 if is_matching_watcher_process(&state) {
1276 (true, Some(state.pid))
1277 } else {
1278 (false, None)
1279 }
1280 }
1281 _ => (false, None),
1282 };
1283 let running = running || parent_watcher_running;
1285 if running {
1286 running_watchers += 1;
1287 }
1288
1289 repositories.push(WorktreeWatchTrayRepoStatus {
1290 root: normalize_repo_root_for_payload(&identity.root),
1291 owner: identity.owner.clone(),
1292 name: identity.name.clone(),
1293 branch: identity.branch.clone(),
1294 running,
1295 pid,
1296 unsynced_files,
1297 unsynced_records,
1298 });
1299 }
1300
1301 let repository_count = repositories.len();
1302 let any_running = parent_watcher_running || running_watchers > 0;
1303 let all_running = repository_count > 0 && running_watchers >= repository_count;
1304
1305 let stats_line = Some(format!(
1307 "{repository_count} repo(s) · {running_watchers} watching · {total_unsynced_records} unsynced · {total_unsynced_files} file(s)"
1308 ));
1309
1310 Ok(WorktreeWatchTraySnapshot {
1311 target_label,
1312 mode,
1313 parent: parent_label,
1314 repository_count,
1315 running_watchers,
1316 any_running,
1317 all_running,
1318 parent_watcher_running,
1319 parent_watcher_pid,
1320 total_unsynced_files,
1321 total_unsynced_records,
1322 repositories,
1323 stats_line,
1324 })
1325}
1326
1327fn worktree_watch_status_payload(
1328 identity: &RepoIdentity,
1329 include_records: bool,
1330 record_limit: usize,
1331) -> Result<Value, String> {
1332 let layout = prepare_spool_layout(identity)?;
1333 let candidates = collect_sync_candidates(&layout)?;
1334 let all_candidates = collect_spool_candidates(&layout, true)?;
1335 let record_count: usize = candidates
1336 .iter()
1337 .map(|candidate| candidate.records.len())
1338 .sum();
1339 let all_record_count: usize = all_candidates
1340 .iter()
1341 .map(|candidate| candidate.records.len())
1342 .sum();
1343 let synced_file_count = all_candidates.len().saturating_sub(candidates.len());
1344 let synced_record_count = all_record_count.saturating_sub(record_count);
1345
1346 let mut payload = json!({
1347 "repoRoot": normalize_repo_root_for_payload(&identity.root),
1348 "repositoryOwner": identity.owner.clone(),
1349 "repositoryName": identity.name.clone(),
1350 "branchName": identity.branch.clone(),
1351 "platform": std::env::consts::OS,
1352 "runtimeKey": worktree_runtime_key(),
1353 "spoolRoot": layout.root.display().to_string(),
1354 "unsyncedFiles": candidates.len(),
1355 "unsyncedRecords": record_count,
1356 "syncedFiles": synced_file_count,
1357 "syncedRecords": synced_record_count,
1358 "localSpoolFiles": all_candidates.len(),
1359 "localSpoolRecords": all_record_count,
1360 });
1361
1362 if let Some(last_sync) = read_last_sync_log_entry(&layout.sync_log_file)? {
1363 if let Some(object) = payload.as_object_mut() {
1364 object.insert(
1365 "lastSync".to_string(),
1366 serde_json::to_value(last_sync)
1367 .map_err(|error| format!("Failed to render sync log payload: {error}"))?,
1368 );
1369 }
1370 }
1371
1372 if include_records {
1373 if let Some(object) = payload.as_object_mut() {
1374 object.insert(
1375 "records".to_string(),
1376 collect_status_records(&candidates, record_limit)?,
1377 );
1378 }
1379 }
1380
1381 Ok(payload)
1382}
1383
1384fn collect_status_records(candidates: &[SyncCandidate], limit: usize) -> Result<Value, String> {
1385 let available: usize = candidates
1386 .iter()
1387 .map(|candidate| candidate.records.len())
1388 .sum();
1389 let mut shown = 0usize;
1390 let mut events = Vec::new();
1391 let mut commits = Vec::new();
1392
1393 'candidates: for candidate in candidates {
1394 match &candidate.records {
1395 SyncRecords::Events(records) => {
1396 for event in records {
1397 if shown >= limit {
1398 break 'candidates;
1399 }
1400 events.push(json!({
1401 "sourceFile": candidate.path.display().to_string(),
1402 "occurredAt": event.occurred_at,
1403 "eventKind": event.event_kind.clone(),
1404 "primaryPath": event.primary_path.clone(),
1405 "oldPath": event.old_path.clone(),
1406 "newPath": event.new_path.clone(),
1407 "paths": event.paths.clone(),
1408 "addedLines": event.added_lines,
1409 "removedLines": event.removed_lines,
1410 "totalLines": event.total_lines,
1411 "headSha": event.head_sha.clone(),
1412 "rawKind": event.raw_kind.clone(),
1413 }));
1414 shown += 1;
1415 }
1416 }
1417 SyncRecords::Commits(records) => {
1418 for commit in records {
1419 if shown >= limit {
1420 break 'candidates;
1421 }
1422 commits.push(json!({
1423 "sourceFile": candidate.path.display().to_string(),
1424 "occurredAt": commit.occurred_at,
1425 "headSha": commit.head_sha.clone(),
1426 "previousHeadSha": commit.previous_head_sha.clone(),
1427 "subject": commit.subject.clone(),
1428 "authorName": commit.author_name.clone(),
1429 "authorEmail": commit.author_email.clone(),
1430 "committedAt": commit.committed_at.clone(),
1431 }));
1432 shown += 1;
1433 }
1434 }
1435 }
1436 }
1437
1438 Ok(json!({
1439 "available": available,
1440 "shown": shown,
1441 "truncated": shown < available,
1442 "events": events,
1443 "commits": commits,
1444 }))
1445}
1446
1447fn print_status_records(payload: &Value) {
1448 let Some(records) = payload.get("records") else {
1449 return;
1450 };
1451 let shown = records.get("shown").and_then(Value::as_u64).unwrap_or(0);
1452 if shown == 0 {
1453 println!("records: none");
1454 return;
1455 }
1456
1457 println!("records:");
1458 if let Some(events) = records.get("events").and_then(Value::as_array) {
1459 if !events.is_empty() {
1460 println!(" mutations:");
1461 for event in events {
1462 print_status_event_record(event);
1463 }
1464 }
1465 }
1466 if let Some(commits) = records.get("commits").and_then(Value::as_array) {
1467 if !commits.is_empty() {
1468 println!(" commits:");
1469 for commit in commits {
1470 print_status_commit_record(commit);
1471 }
1472 }
1473 }
1474 if records
1475 .get("truncated")
1476 .and_then(Value::as_bool)
1477 .unwrap_or(false)
1478 {
1479 let available = records
1480 .get("available")
1481 .and_then(Value::as_u64)
1482 .unwrap_or(shown);
1483 println!(" showing {shown} of {available} record(s)");
1484 }
1485}
1486
1487fn print_status_stats(payload: &Value) {
1488 let Some(stats) = payload.get("stats") else {
1489 return;
1490 };
1491 println!("stats:");
1492 println!(
1493 " coding time: {}",
1494 format_duration(
1495 stats
1496 .get("estimatedCodingSeconds")
1497 .and_then(Value::as_u64)
1498 .unwrap_or(0)
1499 )
1500 );
1501 println!(
1502 " sessions: {}",
1503 stats
1504 .get("sessionCount")
1505 .and_then(Value::as_u64)
1506 .unwrap_or(0)
1507 );
1508 println!(
1509 " observed span: {}",
1510 format_duration(
1511 stats
1512 .get("observedSpanSeconds")
1513 .and_then(Value::as_u64)
1514 .unwrap_or(0)
1515 )
1516 );
1517 println!(
1518 " events: {}",
1519 stats
1520 .get("totalEvents")
1521 .and_then(Value::as_u64)
1522 .unwrap_or(0)
1523 );
1524 println!(
1525 " commits: {}",
1526 stats
1527 .get("totalCommits")
1528 .and_then(Value::as_u64)
1529 .unwrap_or(0)
1530 );
1531 println!(
1532 " lines: +{} -{}",
1533 stats.get("addedLines").and_then(Value::as_u64).unwrap_or(0),
1534 stats
1535 .get("removedLines")
1536 .and_then(Value::as_u64)
1537 .unwrap_or(0)
1538 );
1539 if let Some(spool_root) = value_str(stats, "spoolRoot") {
1540 println!(
1541 " stored: {}",
1542 Path::new(spool_root).join("stats.json").display()
1543 );
1544 }
1545 print_stats_bucket_section(stats, "byFiletype", " by filetype:", 10);
1546 print_stats_bucket_section(stats, "byEventKind", " by event kind:", 10);
1547 print_stats_file_section(stats, 10);
1548}
1549
1550fn print_repo_activity(payload: &Value) {
1551 let Some(activity) = payload.get("repositoryActivity") else {
1552 return;
1553 };
1554 println!("repo activity:");
1555 println!(
1556 " coding time: {}",
1557 format_duration(
1558 activity
1559 .get("estimatedCodingSeconds")
1560 .and_then(Value::as_u64)
1561 .unwrap_or(0)
1562 )
1563 );
1564 println!(
1565 " observed span: {}",
1566 format_duration(
1567 activity
1568 .get("observedSpanSeconds")
1569 .and_then(Value::as_u64)
1570 .unwrap_or(0)
1571 )
1572 );
1573 println!(
1574 " branches: {}",
1575 activity
1576 .get("branchCount")
1577 .and_then(Value::as_u64)
1578 .unwrap_or(0)
1579 );
1580 println!(
1581 " sessions: {}, events: {}, commits: {}, lines: +{} -{}",
1582 activity
1583 .get("sessionCount")
1584 .and_then(Value::as_u64)
1585 .unwrap_or(0),
1586 activity
1587 .get("totalEvents")
1588 .and_then(Value::as_u64)
1589 .unwrap_or(0),
1590 activity
1591 .get("totalCommits")
1592 .and_then(Value::as_u64)
1593 .unwrap_or(0),
1594 activity
1595 .get("addedLines")
1596 .and_then(Value::as_u64)
1597 .unwrap_or(0),
1598 activity
1599 .get("removedLines")
1600 .and_then(Value::as_u64)
1601 .unwrap_or(0)
1602 );
1603 if let Some(stats_file) = value_str(activity, "statsFile") {
1604 println!(" stored: {stats_file}");
1605 }
1606 let Some(branches) = activity.get("branches").and_then(Value::as_array) else {
1607 return;
1608 };
1609 if branches.is_empty() {
1610 return;
1611 }
1612 println!(" by branch:");
1613 for branch in branches.iter().take(20) {
1614 let name = value_str(branch, "branchName").unwrap_or("unknown");
1615 let coding_seconds = branch
1616 .get("estimatedCodingSeconds")
1617 .and_then(Value::as_u64)
1618 .unwrap_or(0);
1619 let observed_seconds = branch
1620 .get("observedSpanSeconds")
1621 .and_then(Value::as_u64)
1622 .unwrap_or(0);
1623 let sessions = branch
1624 .get("sessionCount")
1625 .and_then(Value::as_u64)
1626 .unwrap_or(0);
1627 let events = branch
1628 .get("totalEvents")
1629 .and_then(Value::as_u64)
1630 .unwrap_or(0);
1631 let commits = branch
1632 .get("totalCommits")
1633 .and_then(Value::as_u64)
1634 .unwrap_or(0);
1635 println!(
1636 " {name}: {} coding, {} span, {} session(s), {} event(s), {} commit(s)",
1637 format_duration(coding_seconds),
1638 format_duration(observed_seconds),
1639 sessions,
1640 events,
1641 commits
1642 );
1643 }
1644}
1645
1646fn print_last_sync(payload: &Value) {
1647 let Some(last_sync) = payload.get("lastSync") else {
1648 return;
1649 };
1650 let synced_at = value_str(last_sync, "syncedAt").unwrap_or("unknown-time");
1651 let status = last_sync
1652 .get("statusCode")
1653 .and_then(Value::as_u64)
1654 .unwrap_or(0);
1655 let events = last_sync
1656 .get("eventCount")
1657 .and_then(Value::as_u64)
1658 .unwrap_or(0);
1659 let commits = last_sync
1660 .get("commitCount")
1661 .and_then(Value::as_u64)
1662 .unwrap_or(0);
1663 let files = last_sync
1664 .get("spoolFileCount")
1665 .and_then(Value::as_u64)
1666 .unwrap_or(0);
1667 let resync = last_sync
1668 .get("resync")
1669 .and_then(Value::as_bool)
1670 .unwrap_or(false);
1671 println!(
1672 "last sync: {synced_at}, HTTP {status}, {events} event(s), {commits} commit(s), {files} file(s), resync={resync}"
1673 );
1674}
1675
1676fn print_stats_bucket_section(stats: &Value, key: &str, title: &str, limit: usize) {
1677 let Some(items) = stats.get(key).and_then(Value::as_array) else {
1678 return;
1679 };
1680 if items.is_empty() {
1681 return;
1682 }
1683 println!("{title}");
1684 for item in items.iter().take(limit) {
1685 let name = value_str(item, "name").unwrap_or("unknown");
1686 let events = item.get("eventCount").and_then(Value::as_u64).unwrap_or(0);
1687 let seconds = item
1688 .get("estimatedCodingSeconds")
1689 .and_then(Value::as_u64)
1690 .unwrap_or(0);
1691 let added = item.get("addedLines").and_then(Value::as_u64).unwrap_or(0);
1692 let removed = item
1693 .get("removedLines")
1694 .and_then(Value::as_u64)
1695 .unwrap_or(0);
1696 println!(
1697 " {name}: {}, {} event(s), +{} -{}",
1698 format_duration(seconds),
1699 events,
1700 added,
1701 removed
1702 );
1703 }
1704}
1705
1706fn print_stats_file_section(stats: &Value, limit: usize) {
1707 let Some(items) = stats.get("byFile").and_then(Value::as_array) else {
1708 return;
1709 };
1710 if items.is_empty() {
1711 return;
1712 }
1713 println!(" by file:");
1714 for item in items.iter().take(limit) {
1715 let path = value_str(item, "path").unwrap_or("(no path)");
1716 let filetype = value_str(item, "filetype").unwrap_or("(none)");
1717 let events = item.get("eventCount").and_then(Value::as_u64).unwrap_or(0);
1718 let seconds = item
1719 .get("estimatedCodingSeconds")
1720 .and_then(Value::as_u64)
1721 .unwrap_or(0);
1722 let added = item.get("addedLines").and_then(Value::as_u64).unwrap_or(0);
1723 let removed = item
1724 .get("removedLines")
1725 .and_then(Value::as_u64)
1726 .unwrap_or(0);
1727 println!(
1728 " {path} [{filetype}]: {}, {} event(s), +{} -{}",
1729 format_duration(seconds),
1730 events,
1731 added,
1732 removed
1733 );
1734 }
1735}
1736
1737fn print_status_event_record(event: &Value) {
1738 let occurred_at = value_str(event, "occurredAt").unwrap_or("unknown-time");
1739 let kind = value_str(event, "eventKind").unwrap_or("event");
1740 let primary_path = event_display_path(event);
1741 let added = event.get("addedLines").and_then(Value::as_u64).unwrap_or(0);
1742 let removed = event
1743 .get("removedLines")
1744 .and_then(Value::as_u64)
1745 .unwrap_or(0);
1746 let total_lines = event.get("totalLines").and_then(Value::as_u64);
1747 if let Some(total_lines) = total_lines {
1748 println!(
1749 " {occurred_at} {kind} {primary_path} (+{added} -{removed}, {total_lines} lines)"
1750 );
1751 } else {
1752 println!(" {occurred_at} {kind} {primary_path} (+{added} -{removed})");
1753 }
1754
1755 if let Some(head_sha) = value_str(event, "headSha") {
1756 println!(" head: {}", short_sha(head_sha));
1757 }
1758 if let Some(paths) = event.get("paths").and_then(Value::as_array) {
1759 if paths.len() > 1 {
1760 let rendered = paths
1761 .iter()
1762 .filter_map(Value::as_str)
1763 .collect::<Vec<_>>()
1764 .join(", ");
1765 println!(" paths: {rendered}");
1766 }
1767 }
1768}
1769
1770fn print_status_commit_record(commit: &Value) {
1771 let occurred_at = value_str(commit, "occurredAt").unwrap_or("unknown-time");
1772 let head_sha = value_str(commit, "headSha")
1773 .map(short_sha)
1774 .unwrap_or_else(|| "unknown".to_string());
1775 let subject = value_str(commit, "subject").unwrap_or("(no subject)");
1776 println!(" {occurred_at} {head_sha} {subject}");
1777
1778 if let Some(author) = value_str(commit, "authorName") {
1779 println!(" author: {author}");
1780 }
1781}
1782
1783fn event_display_path(event: &Value) -> String {
1784 let old_path = value_str(event, "oldPath");
1785 let new_path = value_str(event, "newPath");
1786 if let (Some(old_path), Some(new_path)) = (old_path, new_path) {
1787 return format!("{old_path} -> {new_path}");
1788 }
1789 value_str(event, "primaryPath")
1790 .map(str::to_string)
1791 .or_else(|| {
1792 event
1793 .get("paths")
1794 .and_then(Value::as_array)
1795 .and_then(|paths| paths.first())
1796 .and_then(Value::as_str)
1797 .map(str::to_string)
1798 })
1799 .unwrap_or_else(|| "(no path)".to_string())
1800}
1801
1802fn value_str<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
1803 value.get(key).and_then(Value::as_str)
1804}
1805
1806fn short_sha(value: &str) -> String {
1807 value.chars().take(12).collect()
1808}
1809
1810fn generate_and_store_stats(
1811 identity: &RepoIdentity,
1812 session_gap_minutes: u64,
1813) -> Result<WorktreeWatchStatsSummary, String> {
1814 let layout = prepare_spool_layout(identity)?;
1815 let candidates = collect_spool_candidates(&layout, true)?;
1816 let summary = build_stats_summary(identity, &layout, &candidates, session_gap_minutes)?;
1817 let rendered = serde_json::to_string_pretty(&summary)
1818 .map_err(|error| format!("Failed to serialize stats: {error}"))?;
1819 fs::write(&layout.stats_file, format!("{rendered}\n")).map_err(|error| {
1820 format!(
1821 "Failed to write stats file {}: {error}",
1822 layout.stats_file.display()
1823 )
1824 })?;
1825 Ok(summary)
1826}
1827
1828fn generate_and_store_repo_activity(
1829 identity: &RepoIdentity,
1830 session_gap_minutes: u64,
1831) -> Result<WorktreeWatchRepoActivitySummary, String> {
1832 let repository_spool_root = repository_spool_root(identity)?;
1833 let summary =
1834 build_repo_activity_summary(identity, &repository_spool_root, session_gap_minutes)?;
1835 let rendered = serde_json::to_string_pretty(&summary)
1836 .map_err(|error| format!("Failed to serialize repo activity: {error}"))?;
1837 fs::create_dir_all(&repository_spool_root).map_err(|error| {
1838 format!(
1839 "Failed to create repository spool root {}: {error}",
1840 repository_spool_root.display()
1841 )
1842 })?;
1843 let stats_file = repository_spool_root.join("repo-activity.json");
1844 fs::write(&stats_file, format!("{rendered}\n")).map_err(|error| {
1845 format!(
1846 "Failed to write repo activity file {}: {error}",
1847 stats_file.display()
1848 )
1849 })?;
1850 Ok(summary)
1851}
1852
1853fn build_repo_activity_summary(
1854 identity: &RepoIdentity,
1855 repository_spool_root: &Path,
1856 session_gap_minutes: u64,
1857) -> Result<WorktreeWatchRepoActivitySummary, String> {
1858 let mut branches = Vec::new();
1859 if repository_spool_root.exists() {
1860 for entry in fs::read_dir(repository_spool_root).map_err(|error| {
1861 format!(
1862 "Failed to read repository spool root {}: {error}",
1863 repository_spool_root.display()
1864 )
1865 })? {
1866 let entry =
1867 entry.map_err(|error| format!("Failed to read repository spool entry: {error}"))?;
1868 let file_type = entry.file_type().map_err(|error| {
1869 format!("Failed to inspect {}: {error}", entry.path().display())
1870 })?;
1871 if !file_type.is_dir() {
1872 continue;
1873 }
1874 let branch_name = entry.file_name().to_string_lossy().to_string();
1875 let branch_root = entry.path();
1876 let branch_layout = spool_layout_for_existing_root(&branch_root);
1877 let branch_identity = RepoIdentity {
1878 branch: branch_name.clone(),
1879 ..identity.clone()
1880 };
1881 let candidates = collect_spool_candidates(&branch_layout, true)?;
1882 let stats = build_stats_summary(
1883 &branch_identity,
1884 &branch_layout,
1885 &candidates,
1886 session_gap_minutes,
1887 )?;
1888 if stats.total_events == 0 && stats.total_commits == 0 {
1889 continue;
1890 }
1891 branches.push(WorktreeWatchBranchActivityStats {
1892 branch_name,
1893 spool_root: branch_root.display().to_string(),
1894 total_events: stats.total_events,
1895 total_commits: stats.total_commits,
1896 first_event_at: stats.first_event_at,
1897 last_event_at: stats.last_event_at,
1898 session_count: stats.session_count,
1899 observed_span_seconds: stats.observed_span_seconds,
1900 estimated_coding_seconds: stats.estimated_coding_seconds,
1901 added_lines: stats.added_lines,
1902 removed_lines: stats.removed_lines,
1903 });
1904 }
1905 }
1906
1907 branches.sort_by(|left, right| {
1908 right
1909 .estimated_coding_seconds
1910 .cmp(&left.estimated_coding_seconds)
1911 .then_with(|| right.total_events.cmp(&left.total_events))
1912 .then_with(|| left.branch_name.cmp(&right.branch_name))
1913 });
1914
1915 let first_event_at = branches
1916 .iter()
1917 .filter_map(|branch| branch.first_event_at)
1918 .min();
1919 let last_event_at = branches
1920 .iter()
1921 .filter_map(|branch| branch.last_event_at)
1922 .max();
1923 let observed_span_seconds = observed_span_seconds(first_event_at, last_event_at);
1924 let stats_file = repository_spool_root.join("repo-activity.json");
1925
1926 Ok(WorktreeWatchRepoActivitySummary {
1927 generated_at: Utc::now(),
1928 repository_owner: identity.owner.clone(),
1929 repository_name: identity.name.clone(),
1930 repo_root: identity.root.display().to_string(),
1931 repository_spool_root: repository_spool_root.display().to_string(),
1932 stats_file: stats_file.display().to_string(),
1933 session_gap_minutes,
1934 branch_count: branches.len() as u64,
1935 total_events: branches.iter().map(|branch| branch.total_events).sum(),
1936 total_commits: branches.iter().map(|branch| branch.total_commits).sum(),
1937 first_event_at,
1938 last_event_at,
1939 session_count: branches.iter().map(|branch| branch.session_count).sum(),
1940 observed_span_seconds,
1941 estimated_coding_seconds: branches
1942 .iter()
1943 .map(|branch| branch.estimated_coding_seconds)
1944 .sum(),
1945 added_lines: branches.iter().map(|branch| branch.added_lines).sum(),
1946 removed_lines: branches.iter().map(|branch| branch.removed_lines).sum(),
1947 branches,
1948 })
1949}
1950
1951fn build_stats_summary(
1952 identity: &RepoIdentity,
1953 layout: &SpoolLayout,
1954 candidates: &[SyncCandidate],
1955 session_gap_minutes: u64,
1956) -> Result<WorktreeWatchStatsSummary, String> {
1957 let mut events = Vec::new();
1958 let mut total_commits = 0u64;
1959 for candidate in candidates {
1960 match &candidate.records {
1961 SyncRecords::Events(records) => events.extend(records.iter().cloned()),
1962 SyncRecords::Commits(records) => total_commits += records.len() as u64,
1963 }
1964 }
1965 events.sort_by_key(|event| event.occurred_at);
1966
1967 let gap_seconds = session_gap_minutes.saturating_mul(60).max(60);
1968 let mut previous_at: Option<DateTime<Utc>> = None;
1969 let mut session_count = 0u64;
1970 let mut total_seconds = 0u64;
1971 let mut total_added = 0u64;
1972 let mut total_removed = 0u64;
1973 let mut by_file: BTreeMap<String, WorktreeWatchFileStats> = BTreeMap::new();
1974 let mut by_filetype: BTreeMap<String, WorktreeWatchBucketStats> = BTreeMap::new();
1975 let mut by_event_kind: BTreeMap<String, WorktreeWatchBucketStats> = BTreeMap::new();
1976
1977 for event in &events {
1978 if starts_new_coding_session(previous_at, event.occurred_at, gap_seconds) {
1979 session_count += 1;
1980 }
1981 let seconds = coding_seconds_for_event(previous_at, event.occurred_at, gap_seconds);
1982 previous_at = Some(event.occurred_at);
1983 let added = event.added_lines.unwrap_or(0);
1984 let removed = event.removed_lines.unwrap_or(0);
1985 let path = stats_event_path(event);
1986 let filetype = filetype_for_path(&path);
1987
1988 total_seconds += seconds;
1989 total_added += added;
1990 total_removed += removed;
1991 update_file_stats(&mut by_file, &path, &filetype, seconds, added, removed);
1992 update_bucket_stats(&mut by_filetype, &filetype, seconds, added, removed);
1993 update_bucket_stats(
1994 &mut by_event_kind,
1995 &event.event_kind,
1996 seconds,
1997 added,
1998 removed,
1999 );
2000 }
2001
2002 Ok(WorktreeWatchStatsSummary {
2003 generated_at: Utc::now(),
2004 repository_owner: identity.owner.clone(),
2005 repository_name: identity.name.clone(),
2006 branch_name: identity.branch.clone(),
2007 repo_root: identity.root.display().to_string(),
2008 spool_root: layout.root.display().to_string(),
2009 session_gap_minutes,
2010 total_events: events.len() as u64,
2011 total_commits,
2012 first_event_at: events.first().map(|event| event.occurred_at),
2013 last_event_at: events.last().map(|event| event.occurred_at),
2014 session_count,
2015 observed_span_seconds: observed_span_seconds(
2016 events.first().map(|event| event.occurred_at),
2017 events.last().map(|event| event.occurred_at),
2018 ),
2019 estimated_coding_seconds: total_seconds,
2020 added_lines: total_added,
2021 removed_lines: total_removed,
2022 by_file: sorted_file_stats(by_file),
2023 by_filetype: sorted_bucket_stats(by_filetype),
2024 by_event_kind: sorted_bucket_stats(by_event_kind),
2025 })
2026}
2027
2028fn coding_seconds_for_event(
2029 previous_at: Option<DateTime<Utc>>,
2030 occurred_at: DateTime<Utc>,
2031 gap_seconds: u64,
2032) -> u64 {
2033 let Some(previous_at) = previous_at else {
2034 return 60;
2035 };
2036 let delta = occurred_at.signed_duration_since(previous_at).num_seconds();
2037 if delta <= 0 {
2038 0
2039 } else {
2040 (delta as u64).min(gap_seconds)
2041 }
2042}
2043
2044fn starts_new_coding_session(
2045 previous_at: Option<DateTime<Utc>>,
2046 occurred_at: DateTime<Utc>,
2047 gap_seconds: u64,
2048) -> bool {
2049 let Some(previous_at) = previous_at else {
2050 return true;
2051 };
2052 let delta = occurred_at.signed_duration_since(previous_at).num_seconds();
2053 delta > gap_seconds as i64
2054}
2055
2056fn observed_span_seconds(
2057 first_event_at: Option<DateTime<Utc>>,
2058 last_event_at: Option<DateTime<Utc>>,
2059) -> u64 {
2060 let (Some(first_event_at), Some(last_event_at)) = (first_event_at, last_event_at) else {
2061 return 0;
2062 };
2063 last_event_at
2064 .signed_duration_since(first_event_at)
2065 .num_seconds()
2066 .max(0) as u64
2067}
2068
2069fn update_file_stats(
2070 by_file: &mut BTreeMap<String, WorktreeWatchFileStats>,
2071 path: &str,
2072 filetype: &str,
2073 seconds: u64,
2074 added: u64,
2075 removed: u64,
2076) {
2077 let stats = by_file
2078 .entry(path.to_string())
2079 .or_insert_with(|| WorktreeWatchFileStats {
2080 path: path.to_string(),
2081 filetype: filetype.to_string(),
2082 ..WorktreeWatchFileStats::default()
2083 });
2084 stats.event_count += 1;
2085 stats.estimated_coding_seconds += seconds;
2086 stats.added_lines += added;
2087 stats.removed_lines += removed;
2088}
2089
2090fn update_bucket_stats(
2091 buckets: &mut BTreeMap<String, WorktreeWatchBucketStats>,
2092 name: &str,
2093 seconds: u64,
2094 added: u64,
2095 removed: u64,
2096) {
2097 let stats = buckets
2098 .entry(name.to_string())
2099 .or_insert_with(|| WorktreeWatchBucketStats {
2100 name: name.to_string(),
2101 ..WorktreeWatchBucketStats::default()
2102 });
2103 stats.event_count += 1;
2104 stats.estimated_coding_seconds += seconds;
2105 stats.added_lines += added;
2106 stats.removed_lines += removed;
2107}
2108
2109fn sorted_file_stats(
2110 by_file: BTreeMap<String, WorktreeWatchFileStats>,
2111) -> Vec<WorktreeWatchFileStats> {
2112 let mut stats = by_file.into_values().collect::<Vec<_>>();
2113 stats.sort_by(|left, right| {
2114 right
2115 .estimated_coding_seconds
2116 .cmp(&left.estimated_coding_seconds)
2117 .then_with(|| right.event_count.cmp(&left.event_count))
2118 .then_with(|| left.path.cmp(&right.path))
2119 });
2120 stats
2121}
2122
2123fn sorted_bucket_stats(
2124 buckets: BTreeMap<String, WorktreeWatchBucketStats>,
2125) -> Vec<WorktreeWatchBucketStats> {
2126 let mut stats = buckets.into_values().collect::<Vec<_>>();
2127 stats.sort_by(|left, right| {
2128 right
2129 .estimated_coding_seconds
2130 .cmp(&left.estimated_coding_seconds)
2131 .then_with(|| right.event_count.cmp(&left.event_count))
2132 .then_with(|| left.name.cmp(&right.name))
2133 });
2134 stats
2135}
2136
2137fn stats_event_path(event: &WorktreeMutationEvent) -> String {
2138 event
2139 .new_path
2140 .as_deref()
2141 .or(event.primary_path.as_deref())
2142 .or_else(|| event.paths.first().map(String::as_str))
2143 .unwrap_or("(no path)")
2144 .to_string()
2145}
2146
2147fn filetype_for_path(path: &str) -> String {
2148 Path::new(path)
2149 .extension()
2150 .and_then(|value| value.to_str())
2151 .map(|value| value.to_ascii_lowercase())
2152 .filter(|value| !value.is_empty())
2153 .unwrap_or_else(|| "(none)".to_string())
2154}
2155
2156fn format_duration(seconds: u64) -> String {
2157 let hours = seconds / 3600;
2158 let minutes = (seconds % 3600) / 60;
2159 let remaining_seconds = seconds % 60;
2160 if hours > 0 {
2161 format!("{hours}h {minutes}m")
2162 } else if minutes > 0 {
2163 format!("{minutes}m {remaining_seconds}s")
2164 } else {
2165 format!("{remaining_seconds}s")
2166 }
2167}
2168
2169async fn watch_foreground(
2170 identity: RepoIdentity,
2171 layout: SpoolLayout,
2172 sync_interval_seconds: u64,
2173) -> Result<(), String> {
2174 let (tx, rx) = mpsc::channel();
2175 let mut watcher = create_path_watcher(&identity.root, tx)?;
2176 watcher
2177 .watch(&identity.root, RecursiveMode::Recursive)
2178 .map_err(|error| {
2179 format!(
2180 "Failed to watch repository root {}: {error}",
2181 identity.root.display()
2182 )
2183 })?;
2184 log_watcher_backend(&identity.root, "repository");
2185
2186 let mut last_head = identity.head_sha.clone();
2187 let mut last_commit_check = Instant::now();
2188 let mut last_sync = Instant::now();
2189 let mut event_dedupe = RecentEventDedupe {
2190 fingerprints: load_event_dedupe_fingerprints(&layout.event_dedupe_file)?,
2191 };
2192
2193 loop {
2194 match rx.recv_timeout(Duration::from_millis(750)) {
2195 Ok(Ok(event)) => {
2196 if let Some(mutation) = mutation_event_from_notify(&identity, event) {
2197 append_unique_mutation_event(&layout, &mutation, &mut event_dedupe)?;
2198 }
2199 }
2200 Ok(Err(error)) => {
2201 eprintln!("worktree watcher error: {error}");
2202 }
2203 Err(mpsc::RecvTimeoutError::Timeout) => {}
2204 Err(mpsc::RecvTimeoutError::Disconnected) => {
2205 return Err("Filesystem watcher disconnected.".to_string());
2206 }
2207 }
2208
2209 if last_commit_check.elapsed() >= Duration::from_secs(3) {
2210 last_head = record_commit_snapshot(&identity, &layout, last_head.as_deref())?;
2211 last_commit_check = Instant::now();
2212 }
2213
2214 if sync_interval_seconds > 0
2215 && last_sync.elapsed() >= Duration::from_secs(sync_interval_seconds)
2216 {
2217 if let Err(error) = sync_spool_for_identity(&identity, &layout).await {
2218 eprintln!("worktree mutation sync failed: {error}");
2219 }
2220 last_sync = Instant::now();
2221 }
2222 }
2223}
2224
2225async fn watch_parent_foreground(
2226 parent_root: PathBuf,
2227 identities: Vec<RepoIdentity>,
2228 sync_interval_seconds: u64,
2229) -> Result<(), String> {
2230 let (tx, rx) = mpsc::channel();
2231 let mut watcher = create_path_watcher(&parent_root, tx)?;
2232 watcher
2233 .watch(&parent_root, RecursiveMode::Recursive)
2234 .map_err(|error| {
2235 format!(
2236 "Failed to watch parent folder {}: {error}",
2237 parent_root.display()
2238 )
2239 })?;
2240 log_watcher_backend(&parent_root, "parent");
2241
2242 let mut repos = prepare_watched_repos(identities)?;
2243 let mut last_commit_check = Instant::now();
2244 let mut last_sync = Instant::now();
2245
2246 loop {
2247 match rx.recv_timeout(Duration::from_millis(750)) {
2248 Ok(Ok(event)) => {
2249 for (repo_index, routed_event) in route_parent_event(&repos, &event) {
2250 let repo = &mut repos[repo_index];
2251 if let Some(mutation) = mutation_event_from_notify(&repo.identity, routed_event)
2252 {
2253 append_unique_mutation_event(
2254 &repo.layout,
2255 &mutation,
2256 &mut repo.event_dedupe,
2257 )?;
2258 }
2259 }
2260 }
2261 Ok(Err(error)) => {
2262 eprintln!("parent worktree watcher error: {error}");
2263 }
2264 Err(mpsc::RecvTimeoutError::Timeout) => {}
2265 Err(mpsc::RecvTimeoutError::Disconnected) => {
2266 return Err("Parent filesystem watcher disconnected.".to_string());
2267 }
2268 }
2269
2270 if last_commit_check.elapsed() >= Duration::from_secs(3) {
2271 for repo in &mut repos {
2272 repo.last_head = record_commit_snapshot(
2273 &repo.identity,
2274 &repo.layout,
2275 repo.last_head.as_deref(),
2276 )?;
2277 }
2278 last_commit_check = Instant::now();
2279 }
2280
2281 if sync_interval_seconds > 0
2282 && last_sync.elapsed() >= Duration::from_secs(sync_interval_seconds)
2283 {
2284 for repo in &repos {
2285 if let Err(error) = sync_spool_for_identity(&repo.identity, &repo.layout).await {
2286 eprintln!(
2287 "worktree mutation sync failed for {}: {error}",
2288 repo.identity.root.display()
2289 );
2290 }
2291 }
2292 last_sync = Instant::now();
2293 }
2294 }
2295}
2296
2297enum PathWatcher {
2300 Recommended(RecommendedWatcher),
2301 Poll(notify::PollWatcher),
2302}
2303
2304impl PathWatcher {
2305 fn watch(&mut self, path: &Path, mode: RecursiveMode) -> notify::Result<()> {
2306 match self {
2307 Self::Recommended(watcher) => watcher.watch(path, mode),
2308 Self::Poll(watcher) => watcher.watch(path, mode),
2309 }
2310 }
2311}
2312
2313fn create_path_watcher(
2314 path: &Path,
2315 tx: mpsc::Sender<notify::Result<Event>>,
2316) -> Result<PathWatcher, String> {
2317 let config = Config::default();
2318 if should_use_poll_watcher(path) {
2319 let poll_config = config.with_poll_interval(Duration::from_secs(2));
2320 notify::PollWatcher::new(
2321 move |result| {
2322 let _ = tx.send(result);
2323 },
2324 poll_config,
2325 )
2326 .map(PathWatcher::Poll)
2327 .map_err(|error| format!("Failed to create poll filesystem watcher: {error}"))
2328 } else {
2329 RecommendedWatcher::new(
2330 move |result| {
2331 let _ = tx.send(result);
2332 },
2333 config,
2334 )
2335 .map(PathWatcher::Recommended)
2336 .map_err(|error| format!("Failed to create filesystem watcher: {error}"))
2337 }
2338}
2339
2340fn should_use_poll_watcher(path: &Path) -> bool {
2341 if !is_wsl_runtime() {
2343 return false;
2344 }
2345 let key = path_identity_key(path).replace('\\', "/");
2346 key.starts_with("/mnt/") || key.contains("/mnt/")
2347}
2348
2349fn log_watcher_backend(path: &Path, label: &str) {
2350 if should_use_poll_watcher(path) {
2351 eprintln!(
2352 "worktree-watch: using poll watcher for {label} {} (WSL mount; inotify is unreliable)",
2353 path.display()
2354 );
2355 }
2356}
2357
2358fn prepare_watched_repos(identities: Vec<RepoIdentity>) -> Result<Vec<WatchedRepo>, String> {
2359 let mut repos = Vec::with_capacity(identities.len());
2360 for identity in identities {
2361 let layout = prepare_spool_layout(&identity)?;
2362 let event_dedupe = RecentEventDedupe {
2363 fingerprints: load_event_dedupe_fingerprints(&layout.event_dedupe_file)?,
2364 };
2365 repos.push(WatchedRepo {
2366 last_head: identity.head_sha.clone(),
2367 identity,
2368 layout,
2369 event_dedupe,
2370 });
2371 }
2372 repos.sort_by_key(|repo| std::cmp::Reverse(repo.identity.root.components().count()));
2373 Ok(repos)
2374}
2375
2376fn route_parent_event(repos: &[WatchedRepo], event: &Event) -> Vec<(usize, Event)> {
2377 let mut paths_by_repo: BTreeMap<usize, Vec<PathBuf>> = BTreeMap::new();
2378 for path in &event.paths {
2379 for (index, repo) in repos.iter().enumerate() {
2380 if path_belongs_to_repo(path, &repo.identity.root) {
2381 let paths = paths_by_repo.entry(index).or_default();
2382 if !paths.iter().any(|existing| existing == path) {
2383 paths.push(path.clone());
2384 }
2385 break;
2386 }
2387 }
2388 }
2389
2390 let mut routed = Vec::new();
2391 for (index, paths) in paths_by_repo {
2392 let mut repo_event = event.clone();
2393 repo_event.paths = paths;
2394 routed.push((index, repo_event));
2395 }
2396 routed
2397}
2398
2399fn path_belongs_to_repo(path: &Path, repo_root: &Path) -> bool {
2400 let path_key = path_identity_key(path).replace('\\', "/");
2401 let root_key = path_identity_key(repo_root).replace('\\', "/");
2402 path_key == root_key || path_key.starts_with(&format!("{root_key}/"))
2403}
2404
2405fn mutation_event_from_notify(
2406 identity: &RepoIdentity,
2407 event: Event,
2408) -> Option<WorktreeMutationEvent> {
2409 if event.kind.is_access() || event.kind.is_other() {
2410 return None;
2411 }
2412
2413 let paths: Vec<String> = event
2414 .paths
2415 .iter()
2416 .filter(|path| !is_ignored_path(&identity.root, path))
2417 .filter_map(|path| relative_slash_path(&identity.root, path))
2418 .fold(Vec::new(), |mut paths, path| {
2419 if !paths.iter().any(|existing| existing == &path) {
2420 paths.push(path);
2421 }
2422 paths
2423 });
2424
2425 if paths.is_empty() {
2426 return None;
2427 }
2428
2429 let primary_path = paths.first().cloned();
2430 let (old_path, new_path) = rename_paths(&event.kind, &paths);
2431 let line_counts = primary_path
2432 .as_deref()
2433 .and_then(|path| git_numstat_for_path(&identity.root, path).ok());
2434 let total_lines = primary_path
2435 .as_deref()
2436 .and_then(|path| file_line_count_for_path(&identity.root, path).ok());
2437 let event_kind = classify_event_kind(&event.kind);
2438 let is_dir = event
2439 .paths
2440 .first()
2441 .and_then(|path| fs::metadata(path).ok())
2442 .map(|metadata| metadata.is_dir())
2443 .unwrap_or(false);
2444
2445 let occurred_at = Utc::now();
2446 let mut mutation = WorktreeMutationEvent {
2447 id: String::new(),
2448 fingerprint: None,
2449 repo_owner: identity.owner.clone(),
2450 repo_name: identity.name.clone(),
2451 branch_name: identity.branch.clone(),
2452 repo_root: normalize_repo_root_for_payload(&identity.root),
2453 head_sha: current_head(&identity.root).ok().flatten(),
2454 event_kind: event_kind.to_string(),
2455 paths,
2456 primary_path,
2457 old_path,
2458 new_path,
2459 added_lines: line_counts.map(|counts| counts.0),
2460 removed_lines: line_counts.map(|counts| counts.1),
2461 total_lines,
2462 file_created: matches!(
2463 event.kind,
2464 EventKind::Create(CreateKind::File | CreateKind::Any)
2465 ) && !is_dir,
2466 file_removed: matches!(
2467 event.kind,
2468 EventKind::Remove(RemoveKind::File | RemoveKind::Any)
2469 ) && !is_dir,
2470 folder_created: matches!(
2471 event.kind,
2472 EventKind::Create(CreateKind::Folder | CreateKind::Any)
2473 ) && is_dir,
2474 folder_removed: matches!(
2475 event.kind,
2476 EventKind::Remove(RemoveKind::Folder | RemoveKind::Any)
2477 ) && is_dir,
2478 renamed_or_moved: is_rename_or_move(&event.kind),
2479 raw_kind: format!("{:?}", event.kind),
2480 occurred_at,
2481 };
2482 let fingerprint = mutation_event_fingerprint(&mutation);
2485 mutation.fingerprint = Some(fingerprint.clone());
2486 mutation.id = stable_event_id(&fingerprint);
2487 Some(mutation)
2488}
2489
2490fn append_unique_mutation_event(
2491 layout: &SpoolLayout,
2492 mutation: &WorktreeMutationEvent,
2493 dedupe: &mut RecentEventDedupe,
2494) -> Result<bool, String> {
2495 let fingerprint = mutation
2496 .fingerprint
2497 .clone()
2498 .unwrap_or_else(|| mutation_event_fingerprint(mutation));
2499 if dedupe.fingerprints.contains(&fingerprint)
2500 || event_dedupe_file_contains(&layout.event_dedupe_file, &fingerprint)?
2501 {
2502 dedupe.fingerprints.insert(fingerprint);
2503 return Ok(false);
2504 }
2505
2506 if !claim_event_fingerprint(layout, &fingerprint)? {
2507 dedupe.fingerprints.insert(fingerprint);
2508 return Ok(false);
2509 }
2510
2511 let mut mutation = mutation.clone();
2512 mutation.fingerprint = Some(fingerprint.clone());
2513 if mutation.id.is_empty() {
2514 mutation.id = stable_event_id(&fingerprint);
2515 }
2516 if let Err(error) = append_json_line(&layout.events_file, &mutation) {
2517 release_event_fingerprint_claim(layout, &fingerprint);
2518 return Err(error);
2519 }
2520 append_json_line(
2521 &layout.event_dedupe_file,
2522 &WorktreeWatchEventDedupeRecord {
2523 fingerprint: fingerprint.clone(),
2524 first_seen_at: Utc::now(),
2525 event_kind: mutation.event_kind.clone(),
2526 primary_path: mutation.primary_path.clone(),
2527 },
2528 )?;
2529 dedupe.fingerprints.insert(fingerprint);
2530 Ok(true)
2531}
2532
2533fn claim_event_fingerprint(layout: &SpoolLayout, fingerprint: &str) -> Result<bool, String> {
2534 fs::create_dir_all(&layout.event_dedupe_dir).map_err(|error| {
2535 format!(
2536 "Failed to create event dedupe directory {}: {error}",
2537 layout.event_dedupe_dir.display()
2538 )
2539 })?;
2540 let marker = event_dedupe_marker_path(layout, fingerprint);
2541 match OpenOptions::new()
2542 .write(true)
2543 .create_new(true)
2544 .open(&marker)
2545 {
2546 Ok(mut file) => {
2547 writeln!(file, "{}", Utc::now().to_rfc3339()).map_err(|error| {
2548 format!(
2549 "Failed to write event dedupe marker {}: {error}",
2550 marker.display()
2551 )
2552 })?;
2553 Ok(true)
2554 }
2555 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
2556 Err(error) => Err(format!(
2557 "Failed to create event dedupe marker {}: {error}",
2558 marker.display()
2559 )),
2560 }
2561}
2562
2563fn release_event_fingerprint_claim(layout: &SpoolLayout, fingerprint: &str) {
2564 let _ = fs::remove_file(event_dedupe_marker_path(layout, fingerprint));
2565}
2566
2567fn event_dedupe_marker_path(layout: &SpoolLayout, fingerprint: &str) -> PathBuf {
2568 layout.event_dedupe_dir.join(format!("{fingerprint}.seen"))
2569}
2570
2571fn mutation_event_fingerprint(mutation: &WorktreeMutationEvent) -> String {
2572 let bucket = mutation.occurred_at.timestamp() / EVENT_DEDUPE_WINDOW_SECONDS;
2573 let payload = json!({
2578 "schema": 3,
2579 "repoOwner": mutation.repo_owner,
2580 "repoName": mutation.repo_name,
2581 "branchName": mutation.branch_name,
2582 "headSha": mutation.head_sha,
2583 "eventKind": mutation.event_kind,
2584 "paths": normalize_path_list_for_fingerprint(&mutation.paths),
2585 "primaryPath": mutation.primary_path.as_deref().map(normalize_relative_watch_path),
2586 "oldPath": mutation.old_path.as_deref().map(normalize_relative_watch_path),
2587 "newPath": mutation.new_path.as_deref().map(normalize_relative_watch_path),
2588 "addedLines": mutation.added_lines,
2589 "removedLines": mutation.removed_lines,
2590 "fileCreated": mutation.file_created,
2591 "fileRemoved": mutation.file_removed,
2592 "folderCreated": mutation.folder_created,
2593 "folderRemoved": mutation.folder_removed,
2594 "renamedOrMoved": mutation.renamed_or_moved,
2595 "timeBucket": bucket,
2596 });
2597 let encoded = serde_json::to_vec(&payload).unwrap_or_default();
2598 let mut hasher = Sha256::new();
2599 hasher.update(encoded);
2600 format!("{:x}", hasher.finalize())
2601}
2602
2603fn normalize_path_list_for_fingerprint(paths: &[String]) -> Vec<String> {
2604 let mut normalized = paths
2605 .iter()
2606 .map(|path| normalize_relative_watch_path(path))
2607 .filter(|path| !path.is_empty())
2608 .collect::<Vec<_>>();
2609 normalized.sort();
2610 normalized.dedup();
2611 normalized
2612}
2613
2614fn stable_event_id(fingerprint: &str) -> String {
2615 format!("wtmut_{fingerprint}")
2618}
2619
2620fn normalize_repo_root_for_payload(root: &Path) -> String {
2621 path_identity_key(root).replace('\\', "/")
2622}
2623
2624fn load_event_dedupe_fingerprints(path: &Path) -> Result<BTreeSet<String>, String> {
2625 let mut fingerprints = BTreeSet::new();
2626 if !path.exists() {
2627 return Ok(fingerprints);
2628 }
2629
2630 for value in read_jsonl_values(path)? {
2631 if let Some(fingerprint) = value.get("fingerprint").and_then(Value::as_str) {
2632 fingerprints.insert(fingerprint.to_string());
2633 }
2634 }
2635 Ok(fingerprints)
2636}
2637
2638fn event_dedupe_file_contains(path: &Path, fingerprint: &str) -> Result<bool, String> {
2639 if !path.exists() {
2640 return Ok(false);
2641 }
2642 for value in read_jsonl_values(path)? {
2643 if value
2644 .get("fingerprint")
2645 .and_then(Value::as_str)
2646 .map(|value| value == fingerprint)
2647 .unwrap_or(false)
2648 {
2649 return Ok(true);
2650 }
2651 }
2652 Ok(false)
2653}
2654
2655fn classify_event_kind(kind: &EventKind) -> &'static str {
2656 match kind {
2657 EventKind::Create(CreateKind::File) => "file_create",
2658 EventKind::Create(CreateKind::Folder) => "folder_create",
2659 EventKind::Create(_) => "create",
2660 EventKind::Remove(RemoveKind::File) => "file_remove",
2661 EventKind::Remove(RemoveKind::Folder) => "folder_remove",
2662 EventKind::Remove(_) => "remove",
2663 EventKind::Modify(ModifyKind::Name(
2664 RenameMode::From | RenameMode::To | RenameMode::Both,
2665 )) => "rename_or_move",
2666 EventKind::Modify(ModifyKind::Data(_)) => "file_modify",
2667 EventKind::Modify(ModifyKind::Metadata(_)) => "metadata_modify",
2668 EventKind::Modify(_) => "modify",
2669 _ => "other",
2670 }
2671}
2672
2673fn is_rename_or_move(kind: &EventKind) -> bool {
2674 matches!(
2675 kind,
2676 EventKind::Modify(ModifyKind::Name(
2677 RenameMode::From | RenameMode::To | RenameMode::Both
2678 ))
2679 )
2680}
2681
2682fn rename_paths(kind: &EventKind, paths: &[String]) -> (Option<String>, Option<String>) {
2683 if !is_rename_or_move(kind) {
2684 return (None, None);
2685 }
2686
2687 match paths {
2688 [old_path, new_path, ..] => (Some(old_path.clone()), Some(new_path.clone())),
2689 [path] => match kind {
2690 EventKind::Modify(ModifyKind::Name(RenameMode::From)) => (Some(path.clone()), None),
2691 EventKind::Modify(ModifyKind::Name(RenameMode::To)) => (None, Some(path.clone())),
2692 _ => (None, Some(path.clone())),
2693 },
2694 _ => (None, None),
2695 }
2696}
2697
2698fn record_commit_snapshot(
2699 identity: &RepoIdentity,
2700 layout: &SpoolLayout,
2701 previous_head: Option<&str>,
2702) -> Result<Option<String>, String> {
2703 let head = current_head(&identity.root)?;
2704 let Some(head_sha) = head else {
2705 return Ok(None);
2706 };
2707
2708 if previous_head == Some(head_sha.as_str()) {
2709 return Ok(Some(head_sha));
2710 }
2711
2712 let mut commit = WorktreeCommitEvent {
2713 id: String::new(),
2714 fingerprint: None,
2715 repo_owner: identity.owner.clone(),
2716 repo_name: identity.name.clone(),
2717 branch_name: identity.branch.clone(),
2718 repo_root: normalize_repo_root_for_payload(&identity.root),
2719 previous_head_sha: previous_head.map(str::to_string),
2720 head_sha: head_sha.clone(),
2721 subject: git_output(&identity.root, &["log", "-1", "--pretty=%s"]).ok(),
2722 author_name: git_output(&identity.root, &["log", "-1", "--pretty=%an"]).ok(),
2723 author_email: git_output(&identity.root, &["log", "-1", "--pretty=%ae"]).ok(),
2724 committed_at: git_output(&identity.root, &["log", "-1", "--pretty=%cI"]).ok(),
2725 occurred_at: Utc::now(),
2726 };
2727 let fingerprint = commit_event_fingerprint(&commit);
2728 commit.fingerprint = Some(fingerprint.clone());
2729 commit.id = stable_event_id(&fingerprint);
2730 append_unique_commit_event(layout, &commit)?;
2731 Ok(Some(head_sha))
2732}
2733
2734fn append_unique_commit_event(
2735 layout: &SpoolLayout,
2736 commit: &WorktreeCommitEvent,
2737) -> Result<bool, String> {
2738 let fingerprint = commit
2739 .fingerprint
2740 .clone()
2741 .unwrap_or_else(|| commit_event_fingerprint(commit));
2742 if !claim_commit_fingerprint(layout, &fingerprint)? {
2743 return Ok(false);
2744 }
2745
2746 let mut commit = commit.clone();
2747 commit.fingerprint = Some(fingerprint.clone());
2748 if commit.id.is_empty() {
2749 commit.id = stable_event_id(&fingerprint);
2750 }
2751 if let Err(error) = append_json_line(&layout.commits_file, &commit) {
2752 release_commit_fingerprint_claim(layout, &fingerprint);
2753 return Err(error);
2754 }
2755 Ok(true)
2756}
2757
2758fn commit_event_fingerprint(commit: &WorktreeCommitEvent) -> String {
2759 let payload = json!({
2760 "schema": 3,
2761 "repoOwner": commit.repo_owner,
2762 "repoName": commit.repo_name,
2763 "branchName": commit.branch_name,
2764 "previousHeadSha": commit.previous_head_sha,
2765 "headSha": commit.head_sha,
2766 });
2767 let encoded = serde_json::to_vec(&payload).unwrap_or_default();
2768 let mut hasher = Sha256::new();
2769 hasher.update(encoded);
2770 format!("{:x}", hasher.finalize())
2771}
2772
2773fn claim_commit_fingerprint(layout: &SpoolLayout, fingerprint: &str) -> Result<bool, String> {
2774 fs::create_dir_all(&layout.commit_dedupe_dir).map_err(|error| {
2775 format!(
2776 "Failed to create commit dedupe directory {}: {error}",
2777 layout.commit_dedupe_dir.display()
2778 )
2779 })?;
2780 let marker = commit_dedupe_marker_path(layout, fingerprint);
2781 match OpenOptions::new()
2782 .write(true)
2783 .create_new(true)
2784 .open(&marker)
2785 {
2786 Ok(mut file) => {
2787 writeln!(file, "{}", Utc::now().to_rfc3339()).map_err(|error| {
2788 format!(
2789 "Failed to write commit dedupe marker {}: {error}",
2790 marker.display()
2791 )
2792 })?;
2793 Ok(true)
2794 }
2795 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
2796 Err(error) => Err(format!(
2797 "Failed to create commit dedupe marker {}: {error}",
2798 marker.display()
2799 )),
2800 }
2801}
2802
2803fn release_commit_fingerprint_claim(layout: &SpoolLayout, fingerprint: &str) {
2804 let _ = fs::remove_file(commit_dedupe_marker_path(layout, fingerprint));
2805}
2806
2807fn commit_dedupe_marker_path(layout: &SpoolLayout, fingerprint: &str) -> PathBuf {
2808 layout.commit_dedupe_dir.join(format!("{fingerprint}.seen"))
2809}
2810
2811async fn sync_spool_for_identity(
2812 identity: &RepoIdentity,
2813 layout: &SpoolLayout,
2814) -> Result<(), String> {
2815 let candidates = collect_sync_candidates(layout)?;
2816 if candidates.is_empty() {
2817 return Ok(());
2818 }
2819
2820 sync_candidates(identity, candidates, false).await
2821}
2822
2823async fn sync_candidates(
2824 identity: &RepoIdentity,
2825 candidates: Vec<SyncCandidate>,
2826 resync: bool,
2827) -> Result<(), String> {
2828 let mut events = Vec::new();
2829 let mut commits = Vec::new();
2830 for candidate in &candidates {
2831 match &candidate.records {
2832 SyncRecords::Events(records) => events.extend(records.iter().cloned()),
2833 SyncRecords::Commits(records) => commits.extend(records.iter().cloned()),
2834 }
2835 }
2836
2837 let token = resolve_cli_access_token()?;
2838 let device = resolve_device_identity()?;
2839 let client = cli_request_client()?;
2840 let api = ApiConfig::from_env();
2841 let endpoint = api.cli_worktree_mutations_endpoint();
2842 let event_count = events.len();
2843 let commit_count = commits.len();
2844 let hardware_id = worktree_device_hardware_id(&device.hardware_id);
2845 let hostname = current_hostname();
2846 let platform = std::env::consts::OS.to_string();
2847 let runtime_key = worktree_runtime_key();
2848 let repo_root = normalize_repo_root_for_payload(&identity.root);
2849 let events = events
2851 .into_iter()
2852 .map(ensure_mutation_event_identity)
2853 .collect::<Vec<_>>();
2854 let commits = commits
2855 .into_iter()
2856 .map(ensure_commit_event_identity)
2857 .collect::<Vec<_>>();
2858 let batches = split_worktree_mutation_upload_batches(events.clone(), commits.clone());
2859 let batch_count = batches.len();
2860
2861 if batch_count > 1 {
2862 println!(
2863 "Uploading {} worktree mutation record(s) in {} batch(es) for {}/{} on {} [{}].",
2864 event_count + commit_count,
2865 batch_count,
2866 identity.owner,
2867 identity.name,
2868 identity.branch,
2869 runtime_key
2870 );
2871 }
2872
2873 let mut status_code = 202;
2874 for attempt in 1..=WORKTREE_MUTATION_SYNC_MAX_ATTEMPTS {
2875 let attempt_batches =
2876 split_worktree_mutation_upload_batches(events.clone(), commits.clone());
2877 match upload_worktree_mutation_batches(
2878 &client,
2879 &endpoint,
2880 &token,
2881 &hardware_id,
2882 hostname.as_deref(),
2883 &platform,
2884 &runtime_key,
2885 identity,
2886 &repo_root,
2887 attempt_batches,
2888 )
2889 .await
2890 {
2891 Ok(code) => {
2892 status_code = code;
2893 break;
2894 }
2895 Err(error)
2896 if error.kind == WorktreeMutationSyncErrorKind::Retryable
2897 && attempt < WORKTREE_MUTATION_SYNC_MAX_ATTEMPTS =>
2898 {
2899 eprintln!(
2900 "Worktree mutation sync attempt {attempt}/{} failed for {}/{} on {}: {} Restarting from the first batch in {} second(s).",
2901 WORKTREE_MUTATION_SYNC_MAX_ATTEMPTS,
2902 identity.owner,
2903 identity.name,
2904 identity.branch,
2905 error.message,
2906 WORKTREE_MUTATION_SYNC_RETRY_DELAY_SECONDS
2907 );
2908 tokio::time::sleep(Duration::from_secs(
2909 WORKTREE_MUTATION_SYNC_RETRY_DELAY_SECONDS,
2910 ))
2911 .await;
2912 }
2913 Err(error) => return Err(error.message),
2914 }
2915 }
2916
2917 let layout = prepare_spool_layout(identity)?;
2918 append_sync_log(SyncLogAppend {
2919 identity,
2920 layout: &layout,
2921 endpoint: &endpoint,
2922 status_code,
2923 event_count,
2924 commit_count,
2925 candidates: &candidates,
2926 resync,
2927 })?;
2928
2929 for candidate in candidates {
2930 if !is_synced_spool_path(&candidate.path) {
2931 mark_synced(&candidate.path)?;
2932 }
2933 }
2934
2935 Ok(())
2936}
2937
2938async fn upload_worktree_mutation_batches(
2939 client: &reqwest::Client,
2940 endpoint: &str,
2941 token: &str,
2942 hardware_id: &str,
2943 hostname: Option<&str>,
2944 platform: &str,
2945 runtime_key: &str,
2946 identity: &RepoIdentity,
2947 repo_root: &str,
2948 batches: Vec<WorktreeMutationUploadBatch>,
2949) -> Result<u16, WorktreeMutationSyncError> {
2950 let batch_count = batches.len();
2951 let mut status_code = 202;
2952
2953 for (batch_index, batch) in batches.into_iter().enumerate() {
2954 let batch_event_count = batch.events.len();
2955 let batch_commit_count = batch.commits.len();
2956 let payload = WorktreeMutationIngestPayload {
2957 device: WorktreeMutationDevicePayload {
2958 hardware_id: hardware_id.to_string(),
2959 device_name: hostname.map(str::to_string),
2960 hostname: hostname.map(str::to_string),
2961 platform: platform.to_string(),
2962 runtime_key: Some(runtime_key.to_string()),
2963 },
2964 repository: WorktreeMutationRepositoryPayload {
2965 owner: identity.owner.clone(),
2966 name: identity.name.clone(),
2967 branch_name: identity.branch.clone(),
2968 repo_root: repo_root.to_string(),
2969 },
2970 events: batch.events,
2971 commits: batch.commits,
2972 };
2973
2974 let response = client
2975 .post(endpoint)
2976 .bearer_auth(token)
2977 .json(&payload)
2978 .send()
2979 .await
2980 .map_err(|error| WorktreeMutationSyncError {
2981 kind: WorktreeMutationSyncErrorKind::Retryable,
2982 message: format!(
2983 "Failed to upload worktree mutation batch {}/{}: {error}",
2984 batch_index + 1,
2985 batch_count
2986 ),
2987 })?;
2988
2989 if response.status() == StatusCode::UNAUTHORIZED {
2990 return Err(WorktreeMutationSyncError {
2991 kind: WorktreeMutationSyncErrorKind::NonRetryable,
2992 message: "Your stored CLI session is no longer valid. Run `xbp login` again."
2993 .to_string(),
2994 });
2995 }
2996
2997 if !response.status().is_success() {
2998 let status = response.status();
2999 let body = response.text().await.unwrap_or_default();
3000 return Err(WorktreeMutationSyncError {
3001 kind: if should_retry_worktree_mutation_sync_status(status) {
3002 WorktreeMutationSyncErrorKind::Retryable
3003 } else {
3004 WorktreeMutationSyncErrorKind::NonRetryable
3005 },
3006 message: format!(
3007 "Worktree mutation upload batch {}/{} failed with {status}: {body}",
3008 batch_index + 1,
3009 batch_count
3010 ),
3011 });
3012 }
3013
3014 status_code = response.status().as_u16();
3015 if batch_count > 1 {
3016 println!(
3017 "Uploaded worktree mutation batch {}/{} ({} event(s), {} commit(s)).",
3018 batch_index + 1,
3019 batch_count,
3020 batch_event_count,
3021 batch_commit_count
3022 );
3023 }
3024 }
3025
3026 Ok(status_code)
3027}
3028
3029fn should_retry_worktree_mutation_sync_status(status: StatusCode) -> bool {
3030 status.is_server_error()
3031 || status == StatusCode::REQUEST_TIMEOUT
3032 || status == StatusCode::TOO_MANY_REQUESTS
3033}
3034
3035fn split_worktree_mutation_upload_batches(
3036 events: Vec<WorktreeMutationEvent>,
3037 commits: Vec<WorktreeCommitEvent>,
3038) -> Vec<WorktreeMutationUploadBatch> {
3039 let mut batches = Vec::new();
3040 let mut current = empty_worktree_mutation_upload_batch();
3041
3042 for event in events {
3043 let estimated_json_bytes = serialized_json_len(&event);
3044 if should_start_next_worktree_mutation_upload_batch(¤t, estimated_json_bytes) {
3045 batches.push(current);
3046 current = empty_worktree_mutation_upload_batch();
3047 }
3048 current.estimated_json_bytes += estimated_json_bytes;
3049 current.events.push(event);
3050 }
3051
3052 for commit in commits {
3053 let estimated_json_bytes = serialized_json_len(&commit);
3054 if should_start_next_worktree_mutation_upload_batch(¤t, estimated_json_bytes) {
3055 batches.push(current);
3056 current = empty_worktree_mutation_upload_batch();
3057 }
3058 current.estimated_json_bytes += estimated_json_bytes;
3059 current.commits.push(commit);
3060 }
3061
3062 if current_record_count(¤t) > 0 {
3063 batches.push(current);
3064 }
3065
3066 batches
3067}
3068
3069fn empty_worktree_mutation_upload_batch() -> WorktreeMutationUploadBatch {
3070 WorktreeMutationUploadBatch {
3071 events: Vec::new(),
3072 commits: Vec::new(),
3073 estimated_json_bytes: 0,
3074 }
3075}
3076
3077fn should_start_next_worktree_mutation_upload_batch(
3078 batch: &WorktreeMutationUploadBatch,
3079 next_record_json_bytes: usize,
3080) -> bool {
3081 if current_record_count(batch) == 0 {
3082 return false;
3083 }
3084
3085 current_record_count(batch) >= WORKTREE_MUTATION_SYNC_BATCH_RECORD_LIMIT
3086 || batch.estimated_json_bytes + next_record_json_bytes
3087 > WORKTREE_MUTATION_SYNC_BATCH_JSON_BYTES
3088}
3089
3090fn current_record_count(batch: &WorktreeMutationUploadBatch) -> usize {
3091 batch.events.len() + batch.commits.len()
3092}
3093
3094fn serialized_json_len<T: Serialize>(value: &T) -> usize {
3095 serde_json::to_vec(value)
3096 .map(|encoded| encoded.len())
3097 .unwrap_or(0)
3098}
3099
3100fn collect_sync_candidates(layout: &SpoolLayout) -> Result<Vec<SyncCandidate>, String> {
3101 collect_spool_candidates(layout, false)
3102}
3103
3104fn print_no_sync_candidates_message(
3105 target: &WorktreeWatchTargetOptions,
3106 resync: bool,
3107) -> Result<(), String> {
3108 if resync {
3109 println!("No local worktree mutation JSONL files found.");
3110 return Ok(());
3111 }
3112
3113 let identities = resolve_target_identities(target)?;
3114 let mut synced_files = 0usize;
3115 let mut synced_records = 0usize;
3116 for identity in identities {
3117 let layout = prepare_spool_layout(&identity)?;
3118 let all_candidates = collect_spool_candidates(&layout, true)?;
3119 let unsynced_candidates = collect_sync_candidates(&layout)?;
3120 synced_files += all_candidates
3121 .len()
3122 .saturating_sub(unsynced_candidates.len());
3123 let all_records = all_candidates
3124 .iter()
3125 .map(|candidate| candidate.records.len())
3126 .sum::<usize>();
3127 let unsynced_records = unsynced_candidates
3128 .iter()
3129 .map(|candidate| candidate.records.len())
3130 .sum::<usize>();
3131 synced_records += all_records.saturating_sub(unsynced_records);
3132 }
3133
3134 if synced_files > 0 {
3135 println!(
3136 "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."
3137 );
3138 } else {
3139 println!("No unsynced worktree mutation files found.");
3140 }
3141 Ok(())
3142}
3143
3144fn collect_spool_candidates(
3145 layout: &SpoolLayout,
3146 include_synced: bool,
3147) -> Result<Vec<SyncCandidate>, String> {
3148 let mut roots = vec![layout.root.clone()];
3151 if let Some(branch) = layout.root.file_name().and_then(|name| name.to_str()) {
3152 if let Some(repo_root) = layout.root.parent() {
3153 if let (Some(name), Some(owner)) = (
3154 repo_root.file_name().and_then(|n| n.to_str()),
3155 repo_root.parent().and_then(|p| p.file_name()).and_then(|n| n.to_str()),
3156 ) {
3157 let identity = RepoIdentity {
3158 owner: owner.to_string(),
3159 name: name.to_string(),
3160 branch: branch.to_string(),
3161 root: PathBuf::new(),
3162 head_sha: None,
3163 };
3164 if let Ok(search_roots) = repository_spool_search_roots(&identity) {
3165 for search_root in search_roots {
3166 let branch_root = search_root.join(branch);
3167 if !roots.iter().any(|existing| {
3168 path_identity_key(existing) == path_identity_key(&branch_root)
3169 }) {
3170 roots.push(branch_root);
3171 }
3172 }
3173 }
3174 }
3175 }
3176 }
3177
3178 let mut candidates = Vec::new();
3179 let mut seen_files = BTreeSet::new();
3180 for root in roots {
3181 collect_spool_candidates_from_root(&root, include_synced, &mut candidates, &mut seen_files)?;
3182 }
3183 Ok(candidates)
3184}
3185
3186fn collect_spool_candidates_from_root(
3187 root: &Path,
3188 include_synced: bool,
3189 candidates: &mut Vec<SyncCandidate>,
3190 seen_files: &mut BTreeSet<String>,
3191) -> Result<(), String> {
3192 if !root.exists() {
3193 return Ok(());
3194 }
3195
3196 for entry in fs::read_dir(root).map_err(|error| {
3197 format!(
3198 "Failed to read spool directory {}: {error}",
3199 root.display()
3200 )
3201 })? {
3202 let path = entry
3203 .map_err(|error| format!("Failed to read spool entry: {error}"))?
3204 .path();
3205 let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
3206 continue;
3207 };
3208 if !file_name.ends_with(".jsonl") || (!include_synced && file_name.contains(".synced.")) {
3209 continue;
3210 }
3211
3212 let kind = if file_name.starts_with("events-") {
3213 SyncFileKind::Events
3214 } else if file_name.starts_with("commits-") {
3215 SyncFileKind::Commits
3216 } else {
3217 continue;
3218 };
3219
3220 let identity_key = path_identity_key(&path);
3221 if !seen_files.insert(identity_key) {
3222 continue;
3223 }
3224
3225 let records = sanitize_spool_records(&path, kind, read_spool_records(&path, kind)?)?;
3226 if records.is_empty() {
3227 continue;
3228 }
3229 candidates.push(SyncCandidate { path, records });
3230 }
3231
3232 Ok(())
3233}
3234
3235fn append_sync_log(request: SyncLogAppend<'_>) -> Result<(), String> {
3236 let entry = WorktreeWatchSyncLogEntry {
3237 id: Uuid::new_v4().to_string(),
3238 synced_at: Utc::now(),
3239 endpoint: request.endpoint.to_string(),
3240 repository_owner: request.identity.owner.clone(),
3241 repository_name: request.identity.name.clone(),
3242 branch_name: request.identity.branch.clone(),
3243 repo_root: request.identity.root.display().to_string(),
3244 resync: request.resync,
3245 status_code: request.status_code,
3246 event_count: request.event_count,
3247 commit_count: request.commit_count,
3248 spool_file_count: request.candidates.len(),
3249 spool_files: request
3250 .candidates
3251 .iter()
3252 .map(|candidate| candidate.path.display().to_string())
3253 .collect(),
3254 };
3255 append_json_line(&request.layout.sync_log_file, &entry)
3256}
3257
3258fn sanitize_spool_records(
3259 path: &Path,
3260 kind: SyncFileKind,
3261 records: SyncRecords,
3262) -> Result<SyncRecords, String> {
3263 match (kind, records) {
3264 (SyncFileKind::Events, SyncRecords::Events(records)) => {
3265 let mut sanitized = Vec::with_capacity(records.len());
3266 let mut changed = false;
3267
3268 for record in records {
3269 let original = serde_json::to_value(&record).ok();
3270 let Some(record) = sanitize_spooled_event(record) else {
3271 changed = true;
3272 continue;
3273 };
3274 if !changed {
3275 changed = serde_json::to_value(&record).ok() != original;
3276 }
3277 sanitized.push(record);
3278 }
3279
3280 if changed {
3281 rewrite_jsonl_records(path, &sanitized)?;
3282 }
3283
3284 Ok(SyncRecords::Events(sanitized))
3285 }
3286 (_, records) => Ok(records),
3287 }
3288}
3289
3290fn sanitize_spooled_event(mut event: WorktreeMutationEvent) -> Option<WorktreeMutationEvent> {
3291 event.paths = dedupe_filtered_spool_paths(event.paths);
3292 event.primary_path = sanitize_optional_spool_path(event.primary_path);
3293 event.old_path = sanitize_optional_spool_path(event.old_path);
3294 event.new_path = sanitize_optional_spool_path(event.new_path);
3295
3296 if event.paths.is_empty() {
3297 if let Some(path) = event.primary_path.clone() {
3298 event.paths.push(path);
3299 }
3300 if let Some(path) = event.old_path.clone() {
3301 if !event.paths.iter().any(|existing| existing == &path) {
3302 event.paths.push(path);
3303 }
3304 }
3305 if let Some(path) = event.new_path.clone() {
3306 if !event.paths.iter().any(|existing| existing == &path) {
3307 event.paths.push(path);
3308 }
3309 }
3310 }
3311
3312 if event.primary_path.is_none() {
3313 event.primary_path = event.paths.first().cloned();
3314 }
3315
3316 if event.paths.is_empty() {
3317 return None;
3318 }
3319
3320 Some(event)
3321}
3322
3323fn sanitize_optional_spool_path(path: Option<String>) -> Option<String> {
3324 path.and_then(sanitize_spool_path)
3325}
3326
3327fn sanitize_spool_path(path: String) -> Option<String> {
3328 let trimmed = path.trim();
3329 if trimmed.is_empty() || watch_ignore_rules().ignores_relative(trimmed) {
3330 return None;
3331 }
3332 Some(trimmed.replace('\\', "/"))
3333}
3334
3335fn dedupe_filtered_spool_paths(paths: Vec<String>) -> Vec<String> {
3336 let mut sanitized = Vec::with_capacity(paths.len());
3337 for path in paths {
3338 let Some(path) = sanitize_spool_path(path) else {
3339 continue;
3340 };
3341 if !sanitized.iter().any(|existing| existing == &path) {
3342 sanitized.push(path);
3343 }
3344 }
3345 sanitized
3346}
3347
3348fn rewrite_jsonl_records<T: Serialize>(path: &Path, records: &[T]) -> Result<(), String> {
3349 if records.is_empty() {
3350 if path.exists() {
3351 fs::remove_file(path).map_err(|error| {
3352 format!(
3353 "Failed to remove emptied spool file {}: {error}",
3354 path.display()
3355 )
3356 })?;
3357 }
3358 return Ok(());
3359 }
3360
3361 let temp_path = path.with_extension(format!(
3362 "{}.{}.tmp",
3363 path.extension()
3364 .and_then(|value| value.to_str())
3365 .unwrap_or("jsonl"),
3366 Uuid::new_v4()
3367 ));
3368 {
3369 let mut file = File::create(&temp_path).map_err(|error| {
3370 format!(
3371 "Failed to create temporary spool file {}: {error}",
3372 temp_path.display()
3373 )
3374 })?;
3375 for record in records {
3376 let line = serde_json::to_string(record)
3377 .map_err(|error| format!("Failed to serialize worktree event: {error}"))?;
3378 writeln!(file, "{line}").map_err(|error| {
3379 format!(
3380 "Failed to write temporary spool file {}: {error}",
3381 temp_path.display()
3382 )
3383 })?;
3384 }
3385 }
3386
3387 if path.exists() {
3388 fs::remove_file(path).map_err(|error| {
3389 format!(
3390 "Failed to replace rewritten spool file {}: {error}",
3391 path.display()
3392 )
3393 })?;
3394 }
3395
3396 fs::rename(&temp_path, path)
3397 .map_err(|error| format!("Failed to rewrite spool file {}: {error}", path.display()))
3398}
3399
3400fn read_last_sync_log_entry(path: &Path) -> Result<Option<WorktreeWatchSyncLogEntry>, String> {
3401 if !path.exists() {
3402 return Ok(None);
3403 }
3404 let values = read_jsonl_values(path)?;
3405 let Some(value) = values.into_iter().last() else {
3406 return Ok(None);
3407 };
3408 serde_json::from_value(value)
3409 .map(Some)
3410 .map_err(|error| format!("Failed to decode sync log {}: {error}", path.display()))
3411}
3412
3413fn is_synced_spool_path(path: &Path) -> bool {
3414 path.file_name()
3415 .and_then(|value| value.to_str())
3416 .map(|value| value.contains(".synced."))
3417 .unwrap_or(false)
3418}
3419
3420fn mark_synced(path: &Path) -> Result<(), String> {
3421 let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
3422 return Ok(());
3423 };
3424 let synced_name = file_name.replace(
3425 ".jsonl",
3426 &format!(".synced.{}.jsonl", Utc::now().timestamp()),
3427 );
3428 let synced_path = path.with_file_name(synced_name);
3429 fs::rename(path, &synced_path).map_err(|error| {
3430 format!(
3431 "Failed to mark spool file {} as synced: {error}",
3432 path.display()
3433 )
3434 })
3435}
3436
3437fn read_spool_records(path: &Path, kind: SyncFileKind) -> Result<SyncRecords, String> {
3438 match kind {
3439 SyncFileKind::Events => read_jsonl_records::<WorktreeMutationEvent>(path)
3440 .map(SyncRecords::Events)
3441 .map_err(|error| format!("Failed to decode event from {}: {error}", path.display())),
3442 SyncFileKind::Commits => read_jsonl_records::<WorktreeCommitEvent>(path)
3443 .map(SyncRecords::Commits)
3444 .map_err(|error| format!("Failed to decode commit from {}: {error}", path.display())),
3445 }
3446}
3447
3448fn read_jsonl_records<T>(path: &Path) -> Result<Vec<T>, String>
3449where
3450 T: for<'de> Deserialize<'de>,
3451{
3452 let file = File::open(path)
3453 .map_err(|error| format!("Failed to open spool file {}: {error}", path.display()))?;
3454 let reader = BufReader::new(file);
3455 let mut records = Vec::new();
3456 for line in reader.lines() {
3457 let line =
3458 line.map_err(|error| format!("Failed to read spool file {}: {error}", path.display()))?;
3459 if line.trim().is_empty() || line.trim_matches('\0').trim().is_empty() {
3460 continue;
3461 }
3462 records.push(serde_json::from_str(&line).map_err(|error| {
3463 format!(
3464 "Failed to parse JSONL record in {}: {error}",
3465 path.display()
3466 )
3467 })?);
3468 }
3469 Ok(records)
3470}
3471
3472fn read_jsonl_values(path: &Path) -> Result<Vec<Value>, String> {
3473 read_jsonl_records(path)
3474}
3475
3476fn spawn_detached_worktree_watch(repo: Option<&Path>) -> Result<PathBuf, String> {
3477 let identity = resolve_repo_identity(repo)?;
3478 spawn_detached_worktree_watch_for_identity(&identity)
3479}
3480
3481fn spawn_detached_parent_worktree_watch(parent: &Path) -> Result<PathBuf, String> {
3482 let parent = canonical_parent_path(parent)?;
3483 let layout = prepare_parent_spool_layout(&parent)?;
3484 replace_existing_parent_watcher(&parent, &layout)?;
3485 let executable = std::env::current_exe()
3486 .map_err(|error| format!("Failed to resolve current XBP executable: {error}"))?;
3487 let mut command = Command::new(&executable);
3488 command
3489 .arg("worktree-watch")
3490 .arg("start")
3491 .arg("--parent")
3492 .arg(&parent)
3493 .env(BACKGROUND_CHILD_ENV, "1")
3494 .current_dir(&parent)
3495 .stdin(Stdio::null())
3496 .stdout(Stdio::null())
3497 .stderr(Stdio::null());
3498 configure_detached_child_process(&mut command);
3499
3500 let child = command
3501 .spawn()
3502 .map_err(|error| format!("Failed to spawn background parent worktree watcher: {error}"))?;
3503 write_parent_watcher_state(&parent, &layout, child.id(), &executable)?;
3504 Ok(parent)
3505}
3506
3507fn spawn_detached_worktree_watch_for_identity(identity: &RepoIdentity) -> Result<PathBuf, String> {
3508 let layout = prepare_spool_layout(identity)?;
3509 replace_existing_watcher(identity, &layout)?;
3510 let executable = std::env::current_exe()
3511 .map_err(|error| format!("Failed to resolve current XBP executable: {error}"))?;
3512 let mut command = Command::new(&executable);
3513 command
3514 .arg("worktree-watch")
3515 .arg("start")
3516 .arg("--repo")
3517 .arg(&identity.root)
3518 .env(BACKGROUND_CHILD_ENV, "1")
3519 .current_dir(&identity.root)
3520 .stdin(Stdio::null())
3521 .stdout(Stdio::null())
3522 .stderr(Stdio::null());
3523 configure_detached_child_process(&mut command);
3524
3525 let child: std::process::Child = command
3526 .spawn()
3527 .map_err(|error| format!("Failed to spawn background worktree watcher: {error}"))?;
3528 write_watcher_state(identity, &layout, child.id(), &executable)?;
3529 Ok(identity.root.clone())
3530}
3531
3532fn configure_detached_child_process(command: &mut Command) {
3533 #[cfg(windows)]
3534 {
3535 use std::os::windows::process::CommandExt;
3536 command.creation_flags(CREATE_NO_WINDOW);
3537 }
3538
3539 #[cfg(unix)]
3542 {
3543 use std::os::unix::process::CommandExt;
3544 unsafe {
3546 command.pre_exec(|| {
3547 extern "C" {
3548 fn setsid() -> i32;
3549 }
3550 let _ = setsid();
3551 Ok(())
3552 });
3553 }
3554 }
3555}
3556
3557fn replace_existing_parent_watcher(
3558 parent: &Path,
3559 layout: &ParentSpoolLayout,
3560) -> Result<(), String> {
3561 let Some(state) = read_parent_watcher_state(&layout.watcher_state_file)? else {
3562 return Ok(());
3563 };
3564
3565 if path_identity_key(parent) != path_identity_key(Path::new(&state.parent_root)) {
3566 return Ok(());
3567 }
3568
3569 if is_matching_parent_watcher_process(&state) {
3570 stop_parent_process(&state, false)?;
3571 println!(
3572 "Replaced existing background parent worktree watcher {} for {}",
3573 state.pid,
3574 parent.display()
3575 );
3576 let _ = fs::remove_file(&layout.watcher_state_file);
3577 return Ok(());
3578 }
3579
3580 if parent_watcher_state_pid_exists_with_same_executable(&state) {
3581 return Err(format!(
3582 "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.",
3583 parent.display(),
3584 state.pid,
3585 parent.display()
3586 ));
3587 }
3588
3589 let _ = fs::remove_file(&layout.watcher_state_file);
3590 Ok(())
3591}
3592
3593fn replace_existing_watcher(identity: &RepoIdentity, layout: &SpoolLayout) -> Result<(), String> {
3594 let Some(state) = read_watcher_state(&layout.watcher_state_file)? else {
3595 return Ok(());
3596 };
3597
3598 if !same_repo_watcher_state(identity, &state) {
3599 return Ok(());
3600 }
3601
3602 if is_matching_watcher_process(&state) {
3603 stop_process(&state, false)?;
3604 println!(
3605 "Replaced existing background worktree watcher {} for {}",
3606 state.pid,
3607 identity.root.display()
3608 );
3609 let _ = fs::remove_file(&layout.watcher_state_file);
3610 return Ok(());
3611 }
3612
3613 if watcher_state_pid_exists_with_same_executable(&state) {
3614 return Err(format!(
3615 "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.",
3616 identity.root.display(),
3617 state.pid,
3618 identity.root.display()
3619 ));
3620 }
3621
3622 let _ = fs::remove_file(&layout.watcher_state_file);
3623 Ok(())
3624}
3625
3626fn stop_existing_watcher(
3627 identity: &RepoIdentity,
3628 layout: &SpoolLayout,
3629 force: bool,
3630) -> Result<StopWatcherOutcome, String> {
3631 let Some(state) = read_watcher_state(&layout.watcher_state_file)? else {
3632 return Ok(StopWatcherOutcome::NoState);
3633 };
3634
3635 if !same_repo_watcher_state(identity, &state) {
3636 return Ok(StopWatcherOutcome::NoState);
3637 }
3638
3639 if is_matching_watcher_process(&state) || force {
3640 let pid = state.pid;
3641 stop_process(&state, force)?;
3642 let _ = fs::remove_file(&layout.watcher_state_file);
3643 return Ok(StopWatcherOutcome::Stopped(pid));
3644 }
3645
3646 if watcher_state_pid_exists_with_same_executable(&state) {
3647 return Err(format!(
3648 "Watcher state for {} points to running XBP process {}, but its command line could not be verified. Re-run with `--force` to stop that PID.",
3649 identity.root.display(),
3650 state.pid
3651 ));
3652 }
3653
3654 let _ = fs::remove_file(&layout.watcher_state_file);
3655 Ok(StopWatcherOutcome::RemovedStaleState)
3656}
3657
3658fn stop_existing_parent_watcher(parent: &Path, force: bool) -> Result<StopWatcherOutcome, String> {
3659 let layout = prepare_parent_spool_layout(parent)?;
3660 let Some(state) = read_parent_watcher_state(&layout.watcher_state_file)? else {
3661 return Ok(StopWatcherOutcome::NoState);
3662 };
3663
3664 if path_identity_key(parent) != path_identity_key(Path::new(&state.parent_root)) {
3665 return Ok(StopWatcherOutcome::NoState);
3666 }
3667
3668 if is_matching_parent_watcher_process(&state) || force {
3669 let pid = state.pid;
3670 stop_parent_process(&state, force)?;
3671 let _ = fs::remove_file(&layout.watcher_state_file);
3672 return Ok(StopWatcherOutcome::Stopped(pid));
3673 }
3674
3675 if parent_watcher_state_pid_exists_with_same_executable(&state) {
3676 return Err(format!(
3677 "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.",
3678 parent.display(),
3679 state.pid
3680 ));
3681 }
3682
3683 let _ = fs::remove_file(&layout.watcher_state_file);
3684 Ok(StopWatcherOutcome::RemovedStaleState)
3685}
3686
3687fn read_watcher_state(path: &Path) -> Result<Option<WorktreeWatcherState>, String> {
3688 for candidate in watcher_state_read_candidates(path) {
3690 if !candidate.exists() {
3691 continue;
3692 }
3693 let raw = fs::read_to_string(&candidate).map_err(|error| {
3694 format!(
3695 "Failed to read watcher state {}: {error}",
3696 candidate.display()
3697 )
3698 })?;
3699 return serde_json::from_str(&raw).map(Some).map_err(|error| {
3700 format!(
3701 "Failed to parse watcher state {}: {error}",
3702 candidate.display()
3703 )
3704 });
3705 }
3706 Ok(None)
3707}
3708
3709fn watcher_state_read_candidates(path: &Path) -> Vec<PathBuf> {
3710 let mut candidates = vec![path.to_path_buf()];
3711 if let Some(parent) = path.parent() {
3712 let legacy = parent.join("watcher-state.json");
3713 if legacy != path {
3714 candidates.push(legacy);
3715 }
3716 }
3717 candidates
3718}
3719
3720fn read_parent_watcher_state(path: &Path) -> Result<Option<ParentWorktreeWatcherState>, String> {
3721 if !path.exists() {
3722 return Ok(None);
3723 }
3724 let raw = fs::read_to_string(path).map_err(|error| {
3725 format!(
3726 "Failed to read parent watcher state {}: {error}",
3727 path.display()
3728 )
3729 })?;
3730 serde_json::from_str(&raw).map(Some).map_err(|error| {
3731 format!(
3732 "Failed to parse parent watcher state {}: {error}",
3733 path.display()
3734 )
3735 })
3736}
3737
3738fn write_watcher_state(
3739 identity: &RepoIdentity,
3740 layout: &SpoolLayout,
3741 pid: u32,
3742 executable: &Path,
3743) -> Result<(), String> {
3744 let state = WorktreeWatcherState {
3745 pid,
3746 repo_root: normalize_repo_root_for_payload(&identity.root),
3747 repository_owner: identity.owner.clone(),
3748 repository_name: identity.name.clone(),
3749 branch_name: identity.branch.clone(),
3750 executable: executable.display().to_string(),
3751 started_at: Utc::now(),
3752 runtime_key: Some(worktree_runtime_key()),
3753 platform: Some(std::env::consts::OS.to_string()),
3754 };
3755 let rendered = serde_json::to_string_pretty(&state)
3756 .map_err(|error| format!("Failed to serialize watcher state: {error}"))?;
3757 fs::write(&layout.watcher_state_file, format!("{rendered}\n")).map_err(|error| {
3758 format!(
3759 "Failed to write watcher state {}: {error}",
3760 layout.watcher_state_file.display()
3761 )
3762 })
3763}
3764
3765fn write_parent_watcher_state(
3766 parent: &Path,
3767 layout: &ParentSpoolLayout,
3768 pid: u32,
3769 executable: &Path,
3770) -> Result<(), String> {
3771 let state = ParentWorktreeWatcherState {
3772 pid,
3773 parent_root: normalize_repo_root_for_payload(parent),
3774 executable: executable.display().to_string(),
3775 started_at: Utc::now(),
3776 runtime_key: Some(worktree_runtime_key()),
3777 platform: Some(std::env::consts::OS.to_string()),
3778 };
3779 let rendered = serde_json::to_string_pretty(&state)
3780 .map_err(|error| format!("Failed to serialize parent watcher state: {error}"))?;
3781 fs::write(&layout.watcher_state_file, format!("{rendered}\n")).map_err(|error| {
3782 format!(
3783 "Failed to write parent watcher state {}: {error}",
3784 layout.watcher_state_file.display()
3785 )
3786 })
3787}
3788
3789fn same_repo_watcher_state(identity: &RepoIdentity, state: &WorktreeWatcherState) -> bool {
3790 if !runtime_keys_compatible(state.runtime_key.as_deref()) {
3791 return false;
3792 }
3793 path_identity_key(&identity.root) == path_identity_key(Path::new(&state.repo_root))
3794 && identity.owner == state.repository_owner
3795 && identity.name == state.repository_name
3796 && identity.branch == state.branch_name
3797}
3798
3799fn runtime_keys_compatible(state_runtime: Option<&str>) -> bool {
3800 let current = worktree_runtime_key();
3801 match state_runtime {
3802 Some(key) => key == current,
3803 None => !is_wsl_runtime(),
3806 }
3807}
3808
3809fn is_matching_watcher_process(state: &WorktreeWatcherState) -> bool {
3810 if state.pid == std::process::id() {
3811 return false;
3812 }
3813 let system = System::new_all();
3814 let Some(process) = system.process(Pid::from_u32(state.pid)) else {
3815 return false;
3816 };
3817 let command_line = process
3818 .cmd()
3819 .iter()
3820 .map(|part| part.to_string_lossy())
3821 .collect::<Vec<_>>()
3822 .join(" ");
3823 let command_matches = command_line.contains("worktree-watch")
3824 && command_line.contains("start")
3825 && command_line_mentions_path(&command_line, &state.repo_root);
3826 command_matches || watcher_state_process_identity_matches(process, state)
3827}
3828
3829fn is_matching_parent_watcher_process(state: &ParentWorktreeWatcherState) -> bool {
3830 if state.pid == std::process::id() {
3831 return false;
3832 }
3833 if !runtime_keys_compatible(state.runtime_key.as_deref()) {
3834 return false;
3835 }
3836 let system = System::new_all();
3837 let Some(process) = system.process(Pid::from_u32(state.pid)) else {
3838 return false;
3839 };
3840 let command_line = process
3841 .cmd()
3842 .iter()
3843 .map(|part| part.to_string_lossy())
3844 .collect::<Vec<_>>()
3845 .join(" ");
3846 let command_matches = command_line.contains("worktree-watch")
3847 && command_line.contains("start")
3848 && command_line.contains("--parent")
3849 && command_line_mentions_path(&command_line, &state.parent_root);
3850 command_matches || parent_watcher_state_process_identity_matches(process, state)
3851}
3852
3853fn command_line_mentions_path(command_line: &str, path: &str) -> bool {
3854 if path.is_empty() {
3855 return false;
3856 }
3857 if command_line.contains(path) {
3858 return true;
3859 }
3860 let normalized = path.replace('\\', "/");
3861 if command_line.replace('\\', "/").contains(&normalized) {
3862 return true;
3863 }
3864 Path::new(path)
3866 .file_name()
3867 .and_then(|name| name.to_str())
3868 .map(|name| command_line.contains(name))
3869 .unwrap_or(false)
3870}
3871
3872fn watcher_state_pid_exists_with_same_executable(state: &WorktreeWatcherState) -> bool {
3873 if state.pid == std::process::id() {
3874 return false;
3875 }
3876 let system = System::new_all();
3877 let Some(process) = system.process(Pid::from_u32(state.pid)) else {
3878 return false;
3879 };
3880 process_executable_matches_state(process, state)
3881}
3882
3883fn parent_watcher_state_pid_exists_with_same_executable(
3884 state: &ParentWorktreeWatcherState,
3885) -> bool {
3886 if state.pid == std::process::id() {
3887 return false;
3888 }
3889 let system = System::new_all();
3890 let Some(process) = system.process(Pid::from_u32(state.pid)) else {
3891 return false;
3892 };
3893 process_executable_matches_path(process, Path::new(&state.executable))
3894}
3895
3896fn watcher_state_process_identity_matches(
3897 process: &sysinfo::Process,
3898 state: &WorktreeWatcherState,
3899) -> bool {
3900 process_executable_matches_state(process, state)
3901 && process_start_time_matches_state(process, state)
3902}
3903
3904fn parent_watcher_state_process_identity_matches(
3905 process: &sysinfo::Process,
3906 state: &ParentWorktreeWatcherState,
3907) -> bool {
3908 process_executable_matches_path(process, Path::new(&state.executable))
3909 && parent_process_start_time_matches_state(process, state)
3910}
3911
3912fn process_executable_matches_state(
3913 process: &sysinfo::Process,
3914 state: &WorktreeWatcherState,
3915) -> bool {
3916 process_executable_matches_path(process, Path::new(&state.executable))
3917}
3918
3919fn process_executable_matches_path(process: &sysinfo::Process, executable: &Path) -> bool {
3920 let expected = path_identity_key(executable);
3921 process
3922 .exe()
3923 .map(|path| path_identity_key(path) == expected)
3924 .unwrap_or(false)
3925}
3926
3927fn process_start_time_matches_state(
3928 process: &sysinfo::Process,
3929 state: &WorktreeWatcherState,
3930) -> bool {
3931 let process_started = process.start_time() as i64;
3932 let state_started = state.started_at.timestamp();
3933 (process_started - state_started).abs() <= 10
3934}
3935
3936fn parent_process_start_time_matches_state(
3937 process: &sysinfo::Process,
3938 state: &ParentWorktreeWatcherState,
3939) -> bool {
3940 let process_started = process.start_time() as i64;
3941 let state_started = state.started_at.timestamp();
3942 (process_started - state_started).abs() <= 10
3943}
3944
3945fn stop_process(state: &WorktreeWatcherState, force: bool) -> Result<(), String> {
3946 let system = System::new_all();
3947 let Some(process) = system.process(Pid::from_u32(state.pid)) else {
3948 return Ok(());
3949 };
3950 if !force && !is_matching_watcher_process(state) {
3951 return Ok(());
3952 }
3953 if process.kill() {
3954 Ok(())
3955 } else {
3956 Err(format!(
3957 "Failed to stop existing watcher process {}",
3958 state.pid
3959 ))
3960 }
3961}
3962
3963fn stop_parent_process(state: &ParentWorktreeWatcherState, force: bool) -> Result<(), String> {
3964 let system = System::new_all();
3965 let Some(process) = system.process(Pid::from_u32(state.pid)) else {
3966 return Ok(());
3967 };
3968 if !force && !is_matching_parent_watcher_process(state) {
3969 return Ok(());
3970 }
3971 if process.kill() {
3972 Ok(())
3973 } else {
3974 Err(format!(
3975 "Failed to stop existing parent watcher process {}",
3976 state.pid
3977 ))
3978 }
3979}
3980
3981fn discover_repo_identities_under(parent: &Path) -> Result<Vec<RepoIdentity>, String> {
3982 let parent = canonical_parent_path(parent)?;
3983
3984 let mut identities = Vec::new();
3985 let mut seen = BTreeSet::new();
3986 discover_repo_identities_in_dir(&parent, &parent, &mut seen, &mut identities)?;
3987 identities.sort_by(|left, right| left.root.cmp(&right.root));
3988 Ok(identities)
3989}
3990
3991fn discover_repo_identities_in_dir(
3992 parent: &Path,
3993 dir: &Path,
3994 seen: &mut BTreeSet<String>,
3995 identities: &mut Vec<RepoIdentity>,
3996) -> Result<(), String> {
3997 if dir != parent && is_skipped_discovery_dir(dir) {
3998 return Ok(());
3999 }
4000
4001 if dir.join(".git").exists() {
4002 if let Ok(identity) = resolve_repo_identity(Some(dir)) {
4003 let key = path_identity_key(&identity.root);
4004 if seen.insert(key) {
4005 identities.push(identity);
4006 }
4007 return Ok(());
4008 }
4009 }
4010
4011 let entries = fs::read_dir(dir)
4012 .map_err(|error| format!("Failed to read folder {}: {error}", dir.display()))?;
4013 for entry in entries {
4014 let entry = entry.map_err(|error| {
4015 format!(
4016 "Failed to read folder entry under {}: {error}",
4017 dir.display()
4018 )
4019 })?;
4020 let file_type = entry
4021 .file_type()
4022 .map_err(|error| format!("Failed to inspect {}: {error}", entry.path().display()))?;
4023 if file_type.is_dir() {
4024 discover_repo_identities_in_dir(parent, &entry.path(), seen, identities)?;
4025 }
4026 }
4027
4028 Ok(())
4029}
4030
4031fn resolve_repo_identity(repo: Option<&Path>) -> Result<RepoIdentity, String> {
4032 let start = match repo {
4033 Some(path) => path.to_path_buf(),
4034 None => std::env::current_dir()
4035 .map_err(|error| format!("Failed to resolve current directory: {error}"))?,
4036 };
4037 let root_raw = git_output(&start, &["rev-parse", "--show-toplevel"])?;
4038 let root = normalize_windows_verbatim_path(
4039 fs::canonicalize(root_raw.trim()).unwrap_or_else(|_| PathBuf::from(root_raw.trim())),
4040 );
4041 let branch = git_output(&root, &["rev-parse", "--abbrev-ref", "HEAD"])
4042 .unwrap_or_else(|_| "unknown".to_string());
4043 let remote = git_output(&root, &["remote", "get-url", DEFAULT_REMOTE]).unwrap_or_default();
4044 let (owner, name) = parse_remote_owner_repo(&remote).unwrap_or_else(|| {
4045 let name = root
4046 .file_name()
4047 .and_then(|value| value.to_str())
4048 .unwrap_or("repository")
4049 .to_string();
4050 ("unknown".to_string(), name)
4051 });
4052 let head_sha = current_head(&root)?;
4053
4054 Ok(RepoIdentity {
4055 owner,
4056 name,
4057 branch: sanitize_path_component(branch.trim()),
4058 root,
4059 head_sha,
4060 })
4061}
4062
4063fn prepare_spool_layout(identity: &RepoIdentity) -> Result<SpoolLayout, String> {
4064 let root = repository_spool_root(identity)?.join(sanitize_path_component(&identity.branch));
4065 fs::create_dir_all(&root).map_err(|error| {
4066 format!(
4067 "Failed to create worktree mutation spool {}: {error}",
4068 root.display()
4069 )
4070 })?;
4071 let event_dedupe_dir = root.join("event-dedupe");
4072 fs::create_dir_all(&event_dedupe_dir).map_err(|error| {
4073 format!(
4074 "Failed to create worktree mutation dedupe dir {}: {error}",
4075 event_dedupe_dir.display()
4076 )
4077 })?;
4078 let commit_dedupe_dir = root.join("commit-dedupe");
4079 fs::create_dir_all(&commit_dedupe_dir).map_err(|error| {
4080 format!(
4081 "Failed to create worktree commit dedupe dir {}: {error}",
4082 commit_dedupe_dir.display()
4083 )
4084 })?;
4085 Ok(spool_layout_for_root(
4086 root,
4087 Some(Uuid::new_v4().to_string()),
4088 ))
4089}
4090
4091fn global_mutations_root() -> Result<PathBuf, String> {
4094 let paths = ensure_global_xbp_paths()?;
4095 Ok(paths.root_dir.join("mutations"))
4096}
4097
4098fn repository_spool_root(identity: &RepoIdentity) -> Result<PathBuf, String> {
4099 Ok(global_mutations_root()?
4100 .join(sanitize_path_component(&identity.owner))
4101 .join(sanitize_path_component(&identity.name)))
4102}
4103
4104fn repository_spool_search_roots(identity: &RepoIdentity) -> Result<Vec<PathBuf>, String> {
4110 let owner = sanitize_path_component(&identity.owner);
4111 let name = sanitize_path_component(&identity.name);
4112 let mut roots: Vec<PathBuf> = Vec::new();
4113 let mut push = |path: PathBuf| {
4114 if roots.iter().any(|existing| {
4115 path_identity_key(existing.as_path()) == path_identity_key(path.as_path())
4116 }) {
4117 return;
4118 }
4119 roots.push(path);
4120 };
4121
4122 push(repository_spool_root(identity)?);
4123
4124 for base in legacy_mutations_base_candidates() {
4125 push(base.join(&owner).join(&name));
4126 let runtimes = base.join("runtimes");
4128 if runtimes.is_dir() {
4129 if let Ok(entries) = fs::read_dir(&runtimes) {
4130 for entry in entries.flatten() {
4131 if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
4132 push(entry.path().join(&owner).join(&name));
4133 }
4134 }
4135 }
4136 }
4137 }
4138
4139 Ok(roots)
4140}
4141
4142fn legacy_mutations_base_candidates() -> Vec<PathBuf> {
4143 let mut bases: Vec<PathBuf> = Vec::new();
4144 let mut push = |path: PathBuf| {
4145 if bases
4146 .iter()
4147 .any(|existing| path_identity_key(existing.as_path()) == path_identity_key(path.as_path()))
4148 {
4149 return;
4150 }
4151 bases.push(path);
4152 };
4153
4154 if let Ok(primary) = global_mutations_root() {
4155 push(primary);
4156 }
4157 push(resolve_global_xbp_root_dir().join("mutations"));
4159
4160 if let Some(home) = dirs::home_dir() {
4161 push(home.join(".xbp").join("mutations"));
4162 push(home.join(".config").join("xbp").join("mutations"));
4163 }
4164
4165 if let Ok(primary) = global_mutations_root() {
4167 let rendered = primary.to_string_lossy().replace('\\', "/");
4168 if let Some(mnt) = map_windows_path_to_wsl_mnt(&rendered) {
4169 push(PathBuf::from(mnt));
4170 }
4171 if let Some(windows) = map_wsl_mnt_path_to_windows(&rendered) {
4172 if let Some(mnt) = map_windows_path_to_wsl_mnt(&windows) {
4173 push(PathBuf::from(mnt));
4174 }
4175 push(PathBuf::from(windows));
4176 }
4177 }
4178
4179 bases
4180}
4181
4182fn spool_layout_for_existing_root(root: &Path) -> SpoolLayout {
4183 spool_layout_for_root(root.to_path_buf(), None)
4184}
4185
4186fn spool_layout_for_root(root: PathBuf, run_id: Option<String>) -> SpoolLayout {
4187 let run_id = run_id.unwrap_or_else(|| Uuid::new_v4().to_string());
4188 let event_dedupe_dir = root.join("event-dedupe");
4189 let commit_dedupe_dir = root.join("commit-dedupe");
4190 let watcher_state_file = root.join(format!(
4193 "watcher-state-{}.json",
4194 sanitize_path_component(&worktree_runtime_key())
4195 ));
4196 SpoolLayout {
4197 events_file: root.join(format!("events-{run_id}.jsonl")),
4198 commits_file: root.join(format!("commits-{run_id}.jsonl")),
4199 stats_file: root.join("stats.json"),
4200 watcher_state_file,
4201 sync_log_file: root.join("sync-log.jsonl"),
4202 event_dedupe_file: root.join("event-dedupe.jsonl"),
4203 event_dedupe_dir,
4204 commit_dedupe_dir,
4205 root,
4206 }
4207}
4208
4209fn prepare_parent_spool_layout(parent: &Path) -> Result<ParentSpoolLayout, String> {
4210 let parent = canonical_parent_path(parent)?;
4211 let parent_key = cross_platform_path_identity_key(&parent);
4213 let mut hasher = Sha256::new();
4214 hasher.update(parent_key.as_bytes());
4215 let digest = format!("{:x}", hasher.finalize());
4216 let label = parent
4217 .file_name()
4218 .and_then(|value| value.to_str())
4219 .map(sanitize_path_component)
4220 .filter(|value| !value.is_empty())
4221 .unwrap_or_else(|| "parent".to_string());
4222 let root = global_mutations_root()?
4223 .join("parents")
4224 .join(format!("{}-{}", label, &digest[..16]));
4225 fs::create_dir_all(&root).map_err(|error| {
4226 format!(
4227 "Failed to create parent worktree watcher state dir {}: {error}",
4228 root.display()
4229 )
4230 })?;
4231 Ok(ParentSpoolLayout {
4232 watcher_state_file: root.join(format!(
4233 "watcher-state-{}.json",
4234 sanitize_path_component(&worktree_runtime_key())
4235 )),
4236 })
4237}
4238
4239fn canonical_parent_path(parent: &Path) -> Result<PathBuf, String> {
4240 let normalized = normalize_windows_verbatim_path(
4241 fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf()),
4242 );
4243 if !normalized.is_dir() {
4244 return Err(format!(
4245 "Parent folder {} does not exist or is not a directory.",
4246 normalized.display()
4247 ));
4248 }
4249 Ok(normalized)
4250}
4251
4252fn append_json_line<T: Serialize>(path: &Path, value: &T) -> Result<(), String> {
4253 let mut file = OpenOptions::new()
4254 .create(true)
4255 .append(true)
4256 .open(path)
4257 .map_err(|error| format!("Failed to open spool file {}: {error}", path.display()))?;
4258 let line = serde_json::to_string(value)
4259 .map_err(|error| format!("Failed to serialize worktree event: {error}"))?;
4260 writeln!(file, "{line}")
4261 .map_err(|error| format!("Failed to append spool file {}: {error}", path.display()))
4262}
4263
4264fn git_numstat_for_path(repo_root: &Path, relative_path: &str) -> Result<(u64, u64), String> {
4265 let output = Command::new("git")
4266 .args(["diff", "--numstat", "--"])
4267 .arg(relative_path)
4268 .current_dir(repo_root)
4269 .output()
4270 .map_err(|error| format!("Failed to run git diff --numstat: {error}"))?;
4271 if !output.status.success() {
4272 return Ok((0, 0));
4273 }
4274 let stdout = String::from_utf8_lossy(&output.stdout);
4275 let mut added = 0;
4276 let mut removed = 0;
4277 for line in stdout.lines() {
4278 let mut parts = line.split_whitespace();
4279 added += parse_numstat_count(parts.next());
4280 removed += parse_numstat_count(parts.next());
4281 }
4282 Ok((added, removed))
4283}
4284
4285fn file_line_count_for_path(repo_root: &Path, relative_path: &str) -> Result<u64, String> {
4286 let path = repo_root.join(relative_path);
4287 let content = fs::read_to_string(&path)
4288 .map_err(|error| format!("Failed to read file {}: {error}", path.display()))?;
4289 Ok(content.lines().count() as u64)
4290}
4291
4292fn parse_numstat_count(value: Option<&str>) -> u64 {
4293 value.and_then(|raw| raw.parse::<u64>().ok()).unwrap_or(0)
4294}
4295
4296fn current_head(repo_root: &Path) -> Result<Option<String>, String> {
4297 match git_output(repo_root, &["rev-parse", "HEAD"]) {
4298 Ok(value) => Ok(Some(value)),
4299 Err(error)
4300 if error.contains("unknown revision") || error.contains("ambiguous argument") =>
4301 {
4302 Ok(None)
4303 }
4304 Err(error) => Err(error),
4305 }
4306}
4307
4308fn git_output(repo_root: &Path, args: &[&str]) -> Result<String, String> {
4309 let output = Command::new("git")
4310 .args(args)
4311 .current_dir(repo_root)
4312 .output()
4313 .map_err(|error| format!("Failed to run git {}: {error}", args.join(" ")))?;
4314 if !output.status.success() {
4315 return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
4316 }
4317 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
4318}
4319
4320fn parse_remote_owner_repo(remote: &str) -> Option<(String, String)> {
4321 let trimmed = remote.trim().trim_end_matches(".git");
4322 if trimmed.is_empty() {
4323 return None;
4324 }
4325
4326 let path_part = if !trimmed.contains("://") {
4327 if let Some((_, path)) = trimmed.rsplit_once(':') {
4328 path
4329 } else {
4330 trimmed
4331 }
4332 } else {
4333 trimmed
4334 .trim_start_matches("https://")
4335 .trim_start_matches("http://")
4336 .trim_start_matches("ssh://")
4337 .split_once('/')
4338 .map(|(_, path)| path)
4339 .unwrap_or(trimmed)
4340 };
4341 let mut parts = path_part.rsplitn(2, '/');
4342 let name = parts.next()?.trim();
4343 let owner = parts.next()?.trim();
4344 if owner.is_empty() || name.is_empty() {
4345 return None;
4346 }
4347 Some((
4348 sanitize_path_component(owner),
4349 sanitize_path_component(name.trim_end_matches(".git")),
4350 ))
4351}
4352
4353fn sanitize_path_component(value: &str) -> String {
4354 let sanitized: String = value
4355 .chars()
4356 .map(|ch| {
4357 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
4358 ch
4359 } else {
4360 '-'
4361 }
4362 })
4363 .collect();
4364 sanitized
4365 .trim_matches('-')
4366 .chars()
4367 .take(160)
4368 .collect::<String>()
4369}
4370
4371fn normalize_windows_verbatim_path(path: PathBuf) -> PathBuf {
4372 PathBuf::from(strip_windows_verbatim_prefix(&path.to_string_lossy()))
4373}
4374
4375fn strip_windows_verbatim_prefix(input: &str) -> &str {
4376 input.strip_prefix(r"\\?\").unwrap_or(input)
4377}
4378
4379fn path_identity_key(path: &Path) -> String {
4380 cross_platform_path_identity_key(path)
4381}
4382
4383fn cross_platform_path_identity_key(path: &Path) -> String {
4385 let mut value = path.to_string_lossy().replace('\\', "/");
4386 if let Some(mnt) = map_windows_path_to_wsl_mnt(&value) {
4387 value = mnt;
4388 } else if let Some(windows) = map_wsl_mnt_path_to_windows(&value) {
4389 if let Some(mnt) = map_windows_path_to_wsl_mnt(&windows) {
4390 value = mnt;
4391 } else {
4392 value = windows.replace('\\', "/");
4393 }
4394 }
4395 value.to_ascii_lowercase()
4397}
4398
4399fn worktree_runtime_key() -> String {
4403 static KEY: OnceLock<String> = OnceLock::new();
4404 KEY.get_or_init(|| {
4405 if is_wsl_runtime() {
4406 let distro = std::env::var("WSL_DISTRO_NAME")
4407 .ok()
4408 .map(|value| value.trim().to_string())
4409 .filter(|value| !value.is_empty())
4410 .unwrap_or_else(|| "wsl".to_string());
4411 format!("wsl-{}", sanitize_path_component(&distro).to_ascii_lowercase())
4412 } else {
4413 std::env::consts::OS.to_string()
4414 }
4415 })
4416 .clone()
4417}
4418
4419fn is_wsl_runtime() -> bool {
4420 if !cfg!(target_os = "linux") {
4421 return false;
4422 }
4423 if std::env::var_os("WSL_DISTRO_NAME").is_some() || std::env::var_os("WSL_INTEROP").is_some() {
4424 return true;
4425 }
4426 fs::read_to_string("/proc/version")
4428 .map(|content| {
4429 let lower = content.to_ascii_lowercase();
4430 lower.contains("microsoft") || lower.contains("wsl")
4431 })
4432 .unwrap_or(false)
4433}
4434
4435fn worktree_device_hardware_id(base_hardware_id: &str) -> String {
4438 format!("{base_hardware_id}@{}", worktree_runtime_key())
4439}
4440
4441fn ensure_mutation_event_identity(mut event: WorktreeMutationEvent) -> WorktreeMutationEvent {
4442 if event.fingerprint.as_ref().is_none_or(|value| value.is_empty()) {
4443 let fingerprint = mutation_event_fingerprint(&event);
4444 event.fingerprint = Some(fingerprint.clone());
4445 if event.id.is_empty() || !event.id.starts_with("wtmut_") {
4446 event.id = stable_event_id(&fingerprint);
4447 }
4448 } else if event.id.is_empty() {
4449 event.id = stable_event_id(event.fingerprint.as_deref().unwrap_or_default());
4450 }
4451 event
4452}
4453
4454fn ensure_commit_event_identity(mut commit: WorktreeCommitEvent) -> WorktreeCommitEvent {
4455 if commit.fingerprint.as_ref().is_none_or(|value| value.is_empty()) {
4456 let fingerprint = commit_event_fingerprint(&commit);
4457 commit.fingerprint = Some(fingerprint.clone());
4458 if commit.id.is_empty() || !commit.id.starts_with("wtmut_") {
4459 commit.id = stable_event_id(&fingerprint);
4460 }
4461 } else if commit.id.is_empty() {
4462 commit.id = stable_event_id(commit.fingerprint.as_deref().unwrap_or_default());
4463 }
4464 commit
4465}
4466
4467fn path_string_has_ignored_component(path: &str) -> bool {
4468 watch_ignore_rules().ignores_relative(path)
4470}
4471
4472fn is_skipped_discovery_dir(path: &Path) -> bool {
4473 watch_ignore_rules().skips_discovery_dir(path)
4474}
4475
4476fn relative_watch_path(root: &Path, path: &Path) -> Option<String> {
4482 if let Ok(relative) = path.strip_prefix(root) {
4483 return Some(normalize_relative_watch_path(&relative.to_string_lossy()));
4484 }
4485
4486 let root_norm = normalize_absolute_watch_path(root);
4487 let path_norm = normalize_absolute_watch_path(path);
4488 if path_norm == root_norm {
4489 return Some(String::new());
4490 }
4491 let prefix = format!("{root_norm}/");
4492 path_norm
4493 .strip_prefix(&prefix)
4494 .map(|relative| normalize_relative_watch_path(relative))
4495}
4496
4497fn normalize_absolute_watch_path(path: &Path) -> String {
4498 let mut value = path.to_string_lossy().replace('\\', "/");
4499 if let Some(stripped) = value.strip_prefix("//?/") {
4501 value = stripped.to_string();
4502 }
4503 if value.len() >= 2 {
4505 let bytes = value.as_bytes();
4506 if bytes[1] == b':' && bytes[0].is_ascii_alphabetic() {
4507 let mut chars = value.chars();
4508 let drive = chars.next().unwrap().to_ascii_lowercase();
4509 value = format!("{drive}{}", chars.as_str());
4510 }
4511 }
4512 value.trim_end_matches('/').to_string()
4513}
4514
4515fn relative_slash_path(root: &Path, path: &Path) -> Option<String> {
4516 relative_watch_path(root, path).or_else(|| {
4517 Some(normalize_relative_watch_path(
4518 &path.to_string_lossy().replace('\\', "/"),
4519 ))
4520 })
4521}
4522
4523fn is_ignored_path(root: &Path, path: &Path) -> bool {
4524 watch_ignore_rules_for_root(root).ignores_path(root, path)
4525}
4526
4527fn current_hostname() -> Option<String> {
4528 for key in ["COMPUTERNAME", "HOSTNAME", "NAME"] {
4529 if let Ok(value) = std::env::var(key) {
4530 let trimmed = value.trim();
4531 if !trimmed.is_empty() {
4532 return Some(trimmed.to_string());
4533 }
4534 }
4535 }
4536
4537 if let Ok(content) = fs::read_to_string("/etc/hostname") {
4538 let trimmed = content.trim();
4539 if !trimmed.is_empty() {
4540 return Some(trimmed.to_string());
4541 }
4542 }
4543
4544 if let Ok(output) = Command::new("hostname").output() {
4546 if output.status.success() {
4547 let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
4548 if !value.is_empty() {
4549 return Some(value);
4550 }
4551 }
4552 }
4553
4554 None
4555}
4556
4557fn is_background_child() -> bool {
4558 std::env::var(BACKGROUND_CHILD_ENV)
4559 .ok()
4560 .map(|value| value == "1")
4561 .unwrap_or(false)
4562}
4563
4564#[allow(dead_code)]
4565fn content_sha256(path: &Path) -> Option<String> {
4566 let bytes = fs::read(path).ok()?;
4567 let mut hasher = Sha256::new();
4568 hasher.update(bytes);
4569 Some(format!("{:x}", hasher.finalize()))
4570}
4571
4572#[cfg(test)]
4573mod tests {
4574 use super::*;
4575
4576 #[test]
4577 fn parses_https_and_ssh_remote_urls() {
4578 assert_eq!(
4579 parse_remote_owner_repo("https://github.com/xylex-group/xbp.git"),
4580 Some(("xylex-group".to_string(), "xbp".to_string()))
4581 );
4582 assert_eq!(
4583 parse_remote_owner_repo("git@github.com:xylex-group/xbp.git"),
4584 Some(("xylex-group".to_string(), "xbp".to_string()))
4585 );
4586 }
4587
4588 #[test]
4589 fn sanitizes_branch_for_storage_path() {
4590 assert_eq!(
4591 sanitize_path_component("feature/worktree watcher"),
4592 "feature-worktree-watcher"
4593 );
4594 }
4595
4596 #[test]
4597 fn normalizes_windows_verbatim_repo_roots() {
4598 assert_eq!(
4599 normalize_windows_verbatim_path(PathBuf::from(
4600 r"\\?\C:\Users\floris\Documents\GitHub\mollie-api-rust"
4601 )),
4602 PathBuf::from(r"C:\Users\floris\Documents\GitHub\mollie-api-rust")
4603 );
4604 }
4605
4606 #[test]
4607 fn parent_path_matching_uses_repo_boundaries() {
4608 let repo = Path::new(r"C:\Users\floris\Documents\GitHub\xbp");
4609 assert!(path_belongs_to_repo(
4610 Path::new(r"C:\Users\floris\Documents\GitHub\xbp\src\main.rs"),
4611 repo
4612 ));
4613 assert!(!path_belongs_to_repo(
4614 Path::new(r"C:\Users\floris\Documents\GitHub\xbp-other\src\main.rs"),
4615 repo
4616 ));
4617 }
4618
4619 #[test]
4620 fn skips_heavy_discovery_directories() {
4621 assert!(is_skipped_discovery_dir(Path::new("node_modules")));
4622 assert!(is_skipped_discovery_dir(Path::new("target")));
4623 assert!(is_skipped_discovery_dir(Path::new(".next")));
4624 assert!(is_skipped_discovery_dir(Path::new(".nx")));
4625 assert!(is_skipped_discovery_dir(Path::new(".open-next")));
4626 assert!(!is_skipped_discovery_dir(Path::new("mollie-api-rust")));
4627 }
4628
4629 #[test]
4630 fn ignores_nested_generated_mutation_paths() {
4631 let root = Path::new(r"C:\Users\floris\Documents\GitHub\athena");
4634 assert!(is_ignored_path(
4635 root,
4636 Path::new(
4637 r"C:\Users\floris\Documents\GitHub\athena\apps\docs\node_modules\@aws-sdk\client-lambda\dist-types\schemas"
4638 )
4639 ));
4640 assert!(is_ignored_path(
4641 root,
4642 Path::new(r"C:\Users\floris\Documents\GitHub\athena\target\debug\build")
4643 ));
4644 assert!(is_ignored_path(
4646 root,
4647 Path::new(r"C:\Users\floris\Documents\GitHub\athena\.nx\cache\run.json")
4648 ));
4649 assert!(is_ignored_path(
4650 root,
4651 Path::new(
4652 r"C:\Users\floris\Documents\GitHub\athena\apps\docs\.open-next\server-functions\default\handler.mjs"
4653 )
4654 ));
4655 assert!(!is_ignored_path(
4656 root,
4657 Path::new(r"C:\Users\floris\Documents\GitHub\athena\apps\web\src\main.ts")
4658 ));
4659
4660 let root_slash = Path::new("C:/Users/floris/Documents/GitHub/athena");
4662 assert!(is_ignored_path(
4663 root_slash,
4664 Path::new(
4665 "C:/Users/floris/Documents/GitHub/athena/apps/docs/node_modules/@aws-sdk/client-lambda/dist-types/schemas"
4666 )
4667 ));
4668 assert!(path_string_has_ignored_component(
4669 "apps/docs/node_modules/@aws-sdk/client-lambda/dist-types/schemas"
4670 ));
4671 }
4672
4673 #[test]
4674 fn global_config_forbidden_paths_folders_and_banned_words() {
4675 let rules = WatchIgnoreRules::from_config(&WorktreeWatchConfig {
4676 forbidden_paths: vec!["secrets/".to_string(), "apps/web/.env.local".to_string()],
4677 forbidden_folders: vec![".cache".to_string(), "coverage".to_string()],
4678 banned_words: vec!["private-key".to_string(), "CREDENTIAL".to_string()],
4679 });
4680
4681 assert!(rules.ignores_relative("secrets/prod/api.key"));
4683 assert!(rules.ignores_relative("apps/web/.env.local"));
4684 assert!(!rules.ignores_relative("apps/web/.env.local.bak"));
4686
4687 assert!(rules.ignores_relative("apps/web/.cache/tmp"));
4689 assert!(rules.ignores_relative("packages/foo/coverage/index.html"));
4690
4691 assert!(rules.ignores_relative("docs/private-key-notes.md"));
4693 assert!(rules.ignores_relative("config/my-credential-store.json"));
4694
4695 assert!(!rules.ignores_relative("apps/web/src/main.ts"));
4697 assert!(!rules.skips_discovery_dir(Path::new("mollie-api-rust")));
4698 assert!(rules.skips_discovery_dir(Path::new(".cache")));
4699 assert!(rules.skips_discovery_dir(Path::new("node_modules"))); }
4701
4702 #[test]
4703 fn project_xbpignore_is_honored_by_worktree_watch_rules() {
4704 let root = std::env::temp_dir().join(format!(
4705 "xbp-watch-ignore-{}-{}",
4706 std::process::id(),
4707 std::time::SystemTime::now()
4708 .duration_since(std::time::UNIX_EPOCH)
4709 .expect("time")
4710 .as_nanos()
4711 ));
4712 let _ = fs::remove_dir_all(&root);
4713 fs::create_dir_all(root.join(".xbp")).expect("xbp dir");
4714 fs::write(
4715 root.join(".xbp").join(".xbpignore"),
4716 "packages/icons/\n*.generated.ts\n",
4717 )
4718 .expect("write ignore");
4719 fs::write(
4720 root.join(".xbp").join("xbp.yaml"),
4721 "project_name: demo\nversion: 0.1.0\nport: 3000\nbuild_dir: ./\nignore_paths:\n - e2e/\nwatch_ignore_paths:\n - tmp-watch/\n",
4722 )
4723 .expect("write config");
4724
4725 let rules = WatchIgnoreRules::from_config_and_project(
4726 &WorktreeWatchConfig::default(),
4727 Some(&root),
4728 );
4729 assert!(rules.ignores_relative("packages/icons/src/index.ts"));
4731 assert!(rules.ignores_relative("apps/web/foo.generated.ts"));
4732 assert!(!rules.ignores_relative("e2e/login.spec.ts"));
4734 assert!(rules.ignores_relative("tmp-watch/file.ts"));
4736 assert!(!rules.ignores_relative("apps/web/src/main.ts"));
4737
4738 let _ = fs::remove_dir_all(root);
4739 }
4740
4741 #[test]
4742 fn builds_coding_stats_by_file_and_filetype() {
4743 let identity = RepoIdentity {
4744 owner: "xylex-group".to_string(),
4745 name: "xbp".to_string(),
4746 branch: "main".to_string(),
4747 root: PathBuf::from(r"C:\Users\floris\Documents\GitHub\xbp"),
4748 head_sha: None,
4749 };
4750 let layout = SpoolLayout {
4751 root: PathBuf::from(r"C:\Users\floris\.xbp\mutations\xylex-group\xbp\main"),
4752 events_file: PathBuf::from("events.jsonl"),
4753 commits_file: PathBuf::from("commits.jsonl"),
4754 stats_file: PathBuf::from("stats.json"),
4755 watcher_state_file: PathBuf::from("watcher-state.json"),
4756 sync_log_file: PathBuf::from("sync-log.jsonl"),
4757 event_dedupe_file: PathBuf::from("event-dedupe.jsonl"),
4758 event_dedupe_dir: PathBuf::from("event-dedupe"),
4759 commit_dedupe_dir: PathBuf::from("commit-dedupe"),
4760 };
4761 let candidates = vec![SyncCandidate {
4762 path: PathBuf::from("events.jsonl"),
4763 records: SyncRecords::Events(vec![
4764 stats_test_event_record("2026-07-08T10:00:00Z", "src/main.rs", 4, 1),
4765 stats_test_event_record("2026-07-08T10:05:00Z", "src/main.rs", 2, 0),
4766 stats_test_event_record("2026-07-08T11:00:00Z", "README.md", 1, 3),
4767 ]),
4768 }];
4769
4770 let summary = build_stats_summary(&identity, &layout, &candidates, 15).unwrap();
4771
4772 assert_eq!(summary.total_events, 3);
4773 assert_eq!(summary.estimated_coding_seconds, 60 + 300 + 900);
4774 assert_eq!(summary.session_count, 2);
4775 assert_eq!(summary.observed_span_seconds, 60 * 60);
4776 assert_eq!(summary.added_lines, 7);
4777 assert_eq!(summary.removed_lines, 4);
4778 assert_eq!(summary.by_file[0].path, "README.md");
4779 assert_eq!(summary.by_file[0].estimated_coding_seconds, 900);
4780 assert_eq!(summary.by_filetype[0].name, "md");
4781 assert_eq!(summary.by_filetype[0].estimated_coding_seconds, 900);
4782 assert_eq!(summary.by_filetype[1].name, "rs");
4783 assert_eq!(summary.by_filetype[1].estimated_coding_seconds, 360);
4784 }
4785
4786 #[test]
4787 fn builds_repo_activity_across_branch_spools() {
4788 let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
4789 let main_root = temp_root.join("main");
4790 let feature_root = temp_root.join("feature-login");
4791 fs::create_dir_all(&main_root).unwrap();
4792 fs::create_dir_all(&feature_root).unwrap();
4793
4794 append_json_line(
4795 &main_root.join("events-main.jsonl"),
4796 &stats_test_event_record("2026-07-08T10:00:00Z", "src/main.rs", 4, 1),
4797 )
4798 .unwrap();
4799 append_json_line(
4800 &main_root.join("events-main.jsonl"),
4801 &stats_test_event_record("2026-07-08T10:10:00Z", "src/lib.rs", 2, 0),
4802 )
4803 .unwrap();
4804 append_json_line(
4805 &feature_root.join("events-feature.jsonl"),
4806 &stats_test_event_record("2026-07-08T11:00:00Z", "src/auth.rs", 10, 2),
4807 )
4808 .unwrap();
4809 append_json_line(
4810 &feature_root.join("events-feature.jsonl"),
4811 &stats_test_event_record("2026-07-08T11:30:00Z", "src/auth.rs", 3, 1),
4812 )
4813 .unwrap();
4814 append_json_line(
4815 &feature_root.join("commits-feature.jsonl"),
4816 &worktree_commit_test_event("previous", "current"),
4817 )
4818 .unwrap();
4819
4820 let identity = RepoIdentity {
4821 owner: "xylex-group".to_string(),
4822 name: "xbp".to_string(),
4823 branch: "main".to_string(),
4824 root: PathBuf::from(r"C:\Users\floris\Documents\GitHub\xbp"),
4825 head_sha: None,
4826 };
4827
4828 let summary = build_repo_activity_summary(&identity, &temp_root, 15).unwrap();
4829
4830 assert_eq!(summary.branch_count, 2);
4831 assert_eq!(summary.total_events, 4);
4832 assert_eq!(summary.total_commits, 1);
4833 assert_eq!(summary.session_count, 3);
4834 assert_eq!(summary.estimated_coding_seconds, 60 + 600 + 60 + 900);
4835 assert_eq!(summary.observed_span_seconds, 90 * 60);
4836 assert_eq!(summary.branches[0].branch_name, "feature-login");
4837 assert_eq!(summary.branches[0].session_count, 2);
4838 assert_eq!(summary.branches[0].observed_span_seconds, 30 * 60);
4839 assert_eq!(summary.branches[1].branch_name, "main");
4840
4841 let _ = fs::remove_dir_all(temp_root);
4842 }
4843
4844 #[test]
4845 fn mutation_fingerprint_ignores_random_event_id_but_keeps_time_bucket() {
4846 let first: WorktreeMutationEvent = serde_json::from_value(stats_test_event(
4847 "2026-07-08T10:00:00Z",
4848 "src/main.rs",
4849 4,
4850 1,
4851 ))
4852 .unwrap();
4853 let same_bucket: WorktreeMutationEvent = serde_json::from_value(stats_test_event(
4854 "2026-07-08T10:00:00Z",
4855 "src/main.rs",
4856 4,
4857 1,
4858 ))
4859 .unwrap();
4860 let next_bucket: WorktreeMutationEvent = serde_json::from_value(stats_test_event(
4861 "2026-07-08T10:00:06Z",
4862 "src/main.rs",
4863 4,
4864 1,
4865 ))
4866 .unwrap();
4867
4868 assert_eq!(
4869 mutation_event_fingerprint(&first),
4870 mutation_event_fingerprint(&same_bucket)
4871 );
4872 assert_ne!(
4873 mutation_event_fingerprint(&first),
4874 mutation_event_fingerprint(&next_bucket)
4875 );
4876
4877 let mut windows_root = first.clone();
4879 windows_root.repo_root = r"C:\Users\floris\Documents\GitHub\xbp".to_string();
4880 let mut wsl_root = first.clone();
4881 wsl_root.repo_root = "/mnt/c/Users/floris/Documents/GitHub/xbp".to_string();
4882 assert_eq!(
4883 mutation_event_fingerprint(&windows_root),
4884 mutation_event_fingerprint(&wsl_root)
4885 );
4886
4887 let fingerprint = mutation_event_fingerprint(&first);
4888 assert_eq!(stable_event_id(&fingerprint), format!("wtmut_{fingerprint}"));
4889 assert_eq!(
4890 ensure_mutation_event_identity(first.clone()).id,
4891 stable_event_id(&fingerprint)
4892 );
4893 }
4894
4895 #[test]
4896 fn runtime_key_and_device_id_are_stable_and_scoped() {
4897 let key = worktree_runtime_key();
4898 assert!(!key.is_empty());
4899 assert_eq!(key, worktree_runtime_key());
4900 assert!(
4902 key == "windows"
4903 || key == "linux"
4904 || key == "macos"
4905 || key.starts_with("wsl-")
4906 || key == "wsl",
4907 "unexpected runtime key {key}"
4908 );
4909 let device = worktree_device_hardware_id("xbp_hw_test");
4910 assert_eq!(device, format!("xbp_hw_test@{key}"));
4911 }
4912
4913 #[test]
4914 fn should_poll_only_for_wsl_mnt_paths() {
4915 let mnt = Path::new("/mnt/c/Users/floris/Documents/GitHub/xbp");
4917 let home = Path::new("/home/floris/src/xbp");
4918 if is_wsl_runtime() {
4919 assert!(should_use_poll_watcher(mnt));
4920 assert!(!should_use_poll_watcher(home));
4921 } else {
4922 assert!(!should_use_poll_watcher(mnt));
4923 assert!(!should_use_poll_watcher(home));
4924 }
4925 }
4926
4927 #[test]
4928 fn repository_spool_root_uses_global_xbp_home_mutations() {
4929 let identity = RepoIdentity {
4930 owner: "xylex-group".to_string(),
4931 name: "xbp".to_string(),
4932 branch: "main".to_string(),
4933 root: PathBuf::from("/tmp/xbp"),
4934 head_sha: None,
4935 };
4936 let root = repository_spool_root(&identity).expect("global xbp home");
4937 let rendered = root.to_string_lossy().replace('\\', "/");
4938 assert!(
4939 rendered.contains("/mutations/"),
4940 "expected home-level mutations path, got {rendered}"
4941 );
4942 assert!(
4943 !rendered.contains("/mutations/runtimes/"),
4944 "spool must not be runtime-split when sharing one established home: {rendered}"
4945 );
4946 assert!(
4947 rendered.ends_with("xylex-group/xbp") || rendered.contains("xylex-group/xbp"),
4948 "expected owner/name suffix, got {rendered}"
4949 );
4950 assert!(!rendered.contains("/tmp/xbp/"));
4952 }
4953
4954 #[test]
4955 fn cross_platform_path_identity_reconciles_wsl_and_windows() {
4956 let windows = Path::new(r"C:\Users\szymon\.xbp\mutations\SuitsFinance\speedrun-formations\main");
4957 let wsl = Path::new(
4958 "/mnt/c/Users/szymon/.xbp/mutations/SuitsFinance/speedrun-formations/main",
4959 );
4960 assert_eq!(
4961 cross_platform_path_identity_key(windows),
4962 cross_platform_path_identity_key(wsl)
4963 );
4964 }
4965
4966 #[test]
4967 fn append_unique_mutation_event_is_idempotent_across_dedupe_instances() {
4968 let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
4969 fs::create_dir_all(&temp_root).unwrap();
4970 let layout = SpoolLayout {
4971 root: temp_root.clone(),
4972 events_file: temp_root.join("events.jsonl"),
4973 commits_file: temp_root.join("commits.jsonl"),
4974 stats_file: temp_root.join("stats.json"),
4975 watcher_state_file: temp_root.join("watcher-state.json"),
4976 sync_log_file: temp_root.join("sync-log.jsonl"),
4977 event_dedupe_file: temp_root.join("event-dedupe.jsonl"),
4978 event_dedupe_dir: temp_root.join("event-dedupe"),
4979 commit_dedupe_dir: temp_root.join("commit-dedupe"),
4980 };
4981 let mutation: WorktreeMutationEvent = serde_json::from_value(stats_test_event(
4982 "2026-07-08T10:00:00Z",
4983 "src/main.rs",
4984 4,
4985 1,
4986 ))
4987 .unwrap();
4988
4989 let first =
4990 append_unique_mutation_event(&layout, &mutation, &mut RecentEventDedupe::default())
4991 .unwrap();
4992 let second =
4993 append_unique_mutation_event(&layout, &mutation, &mut RecentEventDedupe::default())
4994 .unwrap();
4995
4996 assert!(first);
4997 assert!(!second);
4998 let events = read_jsonl_values(&layout.events_file).unwrap();
4999 assert_eq!(events.len(), 1);
5000 assert_eq!(
5001 events[0].get("fingerprint").and_then(Value::as_str),
5002 Some(mutation_event_fingerprint(&mutation).as_str())
5003 );
5004 let _ = fs::remove_dir_all(temp_root);
5005 }
5006
5007 #[test]
5008 fn reads_spool_records_as_typed_events() {
5009 let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
5010 fs::create_dir_all(&temp_root).unwrap();
5011 let events_file = temp_root.join("events.jsonl");
5012 append_json_line(
5013 &events_file,
5014 &stats_test_event_record("2026-07-08T10:00:00Z", "src/main.rs", 4, 1),
5015 )
5016 .unwrap();
5017
5018 let records = read_spool_records(&events_file, SyncFileKind::Events).unwrap();
5019
5020 match records {
5021 SyncRecords::Events(events) => {
5022 assert_eq!(events.len(), 1);
5023 assert_eq!(events[0].primary_path.as_deref(), Some("src/main.rs"));
5024 }
5025 SyncRecords::Commits(_) => panic!("expected typed event records"),
5026 }
5027 let _ = fs::remove_dir_all(temp_root);
5028 }
5029
5030 #[test]
5031 fn read_spool_records_skips_nul_only_padding() {
5032 let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
5033 fs::create_dir_all(&temp_root).unwrap();
5034 let events_file = temp_root.join("events.jsonl");
5035 fs::write(&events_file, vec![0; 4096]).unwrap();
5036
5037 let records = read_spool_records(&events_file, SyncFileKind::Events).unwrap();
5038
5039 match records {
5040 SyncRecords::Events(events) => assert!(events.is_empty()),
5041 SyncRecords::Commits(_) => panic!("expected event records"),
5042 }
5043 let _ = fs::remove_dir_all(temp_root);
5044 }
5045
5046 #[test]
5047 fn collect_spool_candidates_rewrites_legacy_generated_events() {
5048 let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
5049 fs::create_dir_all(&temp_root).unwrap();
5050 let events_file = temp_root.join("events-legacy.jsonl");
5051 append_json_line(
5052 &events_file,
5053 &stats_test_event_record(
5054 "2026-07-08T10:00:00Z",
5055 "apps/docs/node_modules/@aws-sdk/client-lambda/dist-types/schemas",
5056 0,
5057 0,
5058 ),
5059 )
5060 .unwrap();
5061 append_json_line(
5062 &events_file,
5063 &stats_test_event_record("2026-07-08T10:01:00Z", "apps/web/src/main.ts", 4, 1),
5064 )
5065 .unwrap();
5066 let layout = SpoolLayout {
5067 root: temp_root.clone(),
5068 events_file: temp_root.join("events.jsonl"),
5069 commits_file: temp_root.join("commits.jsonl"),
5070 stats_file: temp_root.join("stats.json"),
5071 watcher_state_file: temp_root.join("watcher-state.json"),
5072 sync_log_file: temp_root.join("sync-log.jsonl"),
5073 event_dedupe_file: temp_root.join("event-dedupe.jsonl"),
5074 event_dedupe_dir: temp_root.join("event-dedupe"),
5075 commit_dedupe_dir: temp_root.join("commit-dedupe"),
5076 };
5077
5078 let candidates = collect_spool_candidates(&layout, false).unwrap();
5079
5080 assert_eq!(candidates.len(), 1);
5081 match &candidates[0].records {
5082 SyncRecords::Events(events) => {
5083 assert_eq!(events.len(), 1);
5084 assert_eq!(
5085 events[0].primary_path.as_deref(),
5086 Some("apps/web/src/main.ts")
5087 );
5088 }
5089 SyncRecords::Commits(_) => panic!("expected event spool candidate"),
5090 }
5091 let rewritten = read_jsonl_records::<WorktreeMutationEvent>(&events_file).unwrap();
5092 assert_eq!(rewritten.len(), 1);
5093 assert_eq!(
5094 rewritten[0].primary_path.as_deref(),
5095 Some("apps/web/src/main.ts")
5096 );
5097 let _ = fs::remove_dir_all(temp_root);
5098 }
5099
5100 #[test]
5101 fn collect_spool_candidates_deletes_fully_ignored_event_files() {
5102 let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
5103 fs::create_dir_all(&temp_root).unwrap();
5104 let events_file = temp_root.join("events-legacy.jsonl");
5105 append_json_line(
5106 &events_file,
5107 &stats_test_event_record(
5108 "2026-07-08T10:00:00Z",
5109 "apps/docs/node_modules/@aws-sdk/client-lambda/dist-types/schemas",
5110 0,
5111 0,
5112 ),
5113 )
5114 .unwrap();
5115 let layout = SpoolLayout {
5116 root: temp_root.clone(),
5117 events_file: temp_root.join("events.jsonl"),
5118 commits_file: temp_root.join("commits.jsonl"),
5119 stats_file: temp_root.join("stats.json"),
5120 watcher_state_file: temp_root.join("watcher-state.json"),
5121 sync_log_file: temp_root.join("sync-log.jsonl"),
5122 event_dedupe_file: temp_root.join("event-dedupe.jsonl"),
5123 event_dedupe_dir: temp_root.join("event-dedupe"),
5124 commit_dedupe_dir: temp_root.join("commit-dedupe"),
5125 };
5126
5127 let candidates = collect_spool_candidates(&layout, false).unwrap();
5128
5129 assert!(candidates.is_empty());
5130 assert!(!events_file.exists());
5131 let _ = fs::remove_dir_all(temp_root);
5132 }
5133
5134 #[test]
5135 fn append_unique_commit_event_is_idempotent_across_watcher_instances() {
5136 let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
5137 fs::create_dir_all(&temp_root).unwrap();
5138 let layout = SpoolLayout {
5139 root: temp_root.clone(),
5140 events_file: temp_root.join("events.jsonl"),
5141 commits_file: temp_root.join("commits.jsonl"),
5142 stats_file: temp_root.join("stats.json"),
5143 watcher_state_file: temp_root.join("watcher-state.json"),
5144 sync_log_file: temp_root.join("sync-log.jsonl"),
5145 event_dedupe_file: temp_root.join("event-dedupe.jsonl"),
5146 event_dedupe_dir: temp_root.join("event-dedupe"),
5147 commit_dedupe_dir: temp_root.join("commit-dedupe"),
5148 };
5149 let commit = WorktreeCommitEvent {
5150 id: Uuid::new_v4().to_string(),
5151 fingerprint: None,
5152 repo_owner: "xylex-group".to_string(),
5153 repo_name: "xbp".to_string(),
5154 branch_name: "main".to_string(),
5155 repo_root: r"C:\Users\floris\Documents\GitHub\xbp".to_string(),
5156 previous_head_sha: Some("previous".to_string()),
5157 head_sha: "current".to_string(),
5158 subject: Some("test commit".to_string()),
5159 author_name: Some("Floris".to_string()),
5160 author_email: Some("floris@xylex.group".to_string()),
5161 committed_at: Some("2026-07-08T10:00:00Z".to_string()),
5162 occurred_at: Utc::now(),
5163 };
5164
5165 assert!(append_unique_commit_event(&layout, &commit).unwrap());
5166 assert!(!append_unique_commit_event(&layout, &commit).unwrap());
5167 let commits = read_jsonl_values(&layout.commits_file).unwrap();
5168 assert_eq!(commits.len(), 1);
5169 assert_eq!(
5170 commits[0].get("fingerprint").and_then(Value::as_str),
5171 Some(commit_event_fingerprint(&commit).as_str())
5172 );
5173 let _ = fs::remove_dir_all(temp_root);
5174 }
5175
5176 #[test]
5177 fn splits_worktree_mutation_uploads_into_bounded_batches() {
5178 let events = (0..(WORKTREE_MUTATION_SYNC_BATCH_RECORD_LIMIT + 1))
5179 .map(|index| {
5180 serde_json::from_value::<WorktreeMutationEvent>(stats_test_event(
5181 "2026-07-08T10:00:00Z",
5182 &format!("src/file-{index}.rs"),
5183 1,
5184 0,
5185 ))
5186 .unwrap()
5187 })
5188 .collect::<Vec<_>>();
5189 let commits = vec![
5190 worktree_commit_test_event("previous-a", "current-a"),
5191 worktree_commit_test_event("previous-b", "current-b"),
5192 ];
5193
5194 let batches = split_worktree_mutation_upload_batches(events, commits);
5195
5196 assert_eq!(batches.len(), 2);
5197 assert_eq!(
5198 batches[0].events.len() + batches[0].commits.len(),
5199 WORKTREE_MUTATION_SYNC_BATCH_RECORD_LIMIT
5200 );
5201 assert_eq!(batches[1].events.len(), 1);
5202 assert_eq!(batches[1].commits.len(), 2);
5203 }
5204
5205 #[test]
5206 fn splits_worktree_mutation_uploads_by_estimated_json_bytes() {
5207 let long_path = format!("src/{}.rs", "x".repeat(8_000));
5208 let events = (0..200)
5209 .map(|index| {
5210 serde_json::from_value::<WorktreeMutationEvent>(stats_test_event(
5211 "2026-07-08T10:00:00Z",
5212 &format!("{long_path}-{index}"),
5213 1,
5214 0,
5215 ))
5216 .unwrap()
5217 })
5218 .collect::<Vec<_>>();
5219
5220 let batches = split_worktree_mutation_upload_batches(events, Vec::new());
5221
5222 assert!(batches.len() > 1);
5223 for batch in batches {
5224 assert!(
5225 batch.estimated_json_bytes <= WORKTREE_MUTATION_SYNC_BATCH_JSON_BYTES,
5226 "batch estimated JSON bytes exceeded limit: {}",
5227 batch.estimated_json_bytes
5228 );
5229 }
5230 }
5231
5232 #[test]
5233 fn classifies_create_remove_and_rename_events() {
5234 assert_eq!(
5235 classify_event_kind(&EventKind::Create(CreateKind::File)),
5236 "file_create"
5237 );
5238 assert_eq!(
5239 classify_event_kind(&EventKind::Remove(RemoveKind::Folder)),
5240 "folder_remove"
5241 );
5242 assert_eq!(
5243 classify_event_kind(&EventKind::Modify(ModifyKind::Name(RenameMode::Both))),
5244 "rename_or_move"
5245 );
5246 }
5247
5248 #[test]
5249 fn retries_only_retryable_worktree_sync_statuses() {
5250 assert!(should_retry_worktree_mutation_sync_status(
5251 StatusCode::INTERNAL_SERVER_ERROR
5252 ));
5253 assert!(should_retry_worktree_mutation_sync_status(
5254 StatusCode::REQUEST_TIMEOUT
5255 ));
5256 assert!(should_retry_worktree_mutation_sync_status(
5257 StatusCode::TOO_MANY_REQUESTS
5258 ));
5259 assert!(!should_retry_worktree_mutation_sync_status(
5260 StatusCode::BAD_REQUEST
5261 ));
5262 assert!(!should_retry_worktree_mutation_sync_status(
5263 StatusCode::UNAUTHORIZED
5264 ));
5265 assert!(!should_retry_worktree_mutation_sync_status(
5266 StatusCode::PAYLOAD_TOO_LARGE
5267 ));
5268 }
5269
5270 fn stats_test_event(
5271 occurred_at: &str,
5272 path: &str,
5273 added_lines: u64,
5274 removed_lines: u64,
5275 ) -> Value {
5276 json!({
5277 "id": Uuid::new_v4().to_string(),
5278 "repoOwner": "xylex-group",
5279 "repoName": "xbp",
5280 "branchName": "main",
5281 "repoRoot": r"C:\Users\floris\Documents\GitHub\xbp",
5282 "headSha": null,
5283 "eventKind": "file_modify",
5284 "paths": [path],
5285 "primaryPath": path,
5286 "oldPath": null,
5287 "newPath": null,
5288 "addedLines": added_lines,
5289 "removedLines": removed_lines,
5290 "totalLines": added_lines + 42,
5291 "fileCreated": false,
5292 "fileRemoved": false,
5293 "folderCreated": false,
5294 "folderRemoved": false,
5295 "renamedOrMoved": false,
5296 "rawKind": "Modify(Data(Content))",
5297 "occurredAt": occurred_at,
5298 })
5299 }
5300
5301 fn stats_test_event_record(
5302 occurred_at: &str,
5303 path: &str,
5304 added_lines: u64,
5305 removed_lines: u64,
5306 ) -> WorktreeMutationEvent {
5307 serde_json::from_value(stats_test_event(
5308 occurred_at,
5309 path,
5310 added_lines,
5311 removed_lines,
5312 ))
5313 .unwrap()
5314 }
5315
5316 fn worktree_commit_test_event(previous_head_sha: &str, head_sha: &str) -> WorktreeCommitEvent {
5317 WorktreeCommitEvent {
5318 id: Uuid::new_v4().to_string(),
5319 fingerprint: None,
5320 repo_owner: "xylex-group".to_string(),
5321 repo_name: "xbp".to_string(),
5322 branch_name: "main".to_string(),
5323 repo_root: r"C:\Users\floris\Documents\GitHub\xbp".to_string(),
5324 previous_head_sha: Some(previous_head_sha.to_string()),
5325 head_sha: head_sha.to_string(),
5326 subject: Some("test commit".to_string()),
5327 author_name: Some("Floris".to_string()),
5328 author_email: Some("floris@xylex.group".to_string()),
5329 committed_at: Some("2026-07-08T10:00:00Z".to_string()),
5330 occurred_at: Utc::now(),
5331 }
5332 }
5333}