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