Skip to main content

xbp_cli/commands/
worktree_watch.rs

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/// Client-side ignore rules: built-in VCS/generated dirs + global config filters
33/// + project `.xbp/.xbpignore` / `ignore_paths`.
34/// Never uploaded; applied before events are spooled and again before sync.
35#[derive(Debug, Clone, Default)]
36struct WatchIgnoreRules {
37    /// Normalized lowercase prefixes without leading `./` (e.g. `secrets`, `apps/web/.env`).
38    forbidden_path_prefixes: Vec<String>,
39    /// Lowercase path segment names blocked anywhere (includes built-ins + config).
40    forbidden_folders: BTreeSet<String>,
41    /// Lowercase substrings matched against the full relative path.
42    banned_words: Vec<String>,
43    /// Project-local gitignore-style rules (`.xbp/.xbpignore`, yaml `ignore_paths`).
44    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            // Also treat a single-segment prefix as a folder ban anywhere.
126            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    // Prefer patterns declared in project xbp.yaml when present.
168    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
227/// Global config rules merged with per-repo `.xbp/.xbpignore` / `ignore_paths`.
228fn 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    /// Stable runtime isolation key (e.g. `windows`, `wsl-ubuntu`, `linux`).
372    /// Lets Windows + WSL watchers coexist as distinct upload sources.
373    #[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    /// Present on modern state files so Windows/WSL/Linux watchers never clobber each other.
542    #[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/// Snapshot of worktree-watch storage and watcher state for `xbp diag`.
952#[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
974/// Collect home-level worktree-watch paths and optional current-repo spool status.
975pub 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
2123/// Filesystem watcher handle that can be either native (inotify/FSEvents/ReadDirectoryChanges)
2124/// or polling (needed for WSL mounts of Windows drives where inotify is unreliable).
2125enum 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    // WSL inotify does not reliably cover 9p/drvfs mounts under /mnt/*.
2168    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    // Stable id/fingerprint for idempotent spool + upload (same logical mutation
2309    // within the dedupe window always maps to the same identity).
2310    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    // Omit absolute `repoRoot` so Windows `C:\...` and WSL `/mnt/c/...` spellings
2400    // of the same tree share one fingerprint. Platform is kept only as telemetry
2401    // on the upload device payload — not in the local idempotency key — so both
2402    // environments can share one home-level spool safely.
2403    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    // Deterministic id derived from fingerprint so resync / multi-process claims
2442    // never invent a second identity for the same logical mutation.
2443    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    // Ensure every record has a stable fingerprint/id before upload (legacy spool rows).
2676    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(&current, 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(&current, 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(&current) > 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    // When the layout root is a branch spool, also scan sibling legacy roots for
2975    // the same owner/repo/branch so WSL can see Windows-written files and vice versa.
2976    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    // On Unix (including WSL), start a new session so the watcher survives the
3366    // launching shell and does not receive SIGHUP when the parent exits.
3367    #[cfg(unix)]
3368    {
3369        use std::os::unix::process::CommandExt;
3370        // SAFETY: setsid is called only in the child just after fork, before exec.
3371        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    // Prefer runtime-scoped state; fall back to legacy shared watcher-state.json.
3515    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        // Legacy state files (pre-runtime isolation) only match native Windows/Linux
3630        // paths that still live outside the runtimes/ spool tree — never cross WSL.
3631        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    // Linux/WSL cmd often shows a shortened form; match on the final path segment.
3691    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
3917/// Canonical mutations root under the established global XBP home
3918/// (`XBP_HOME` / Windows profile `.xbp` / `~/.xbp`), not the repo.
3919fn 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
3930/// All mutation roots that may already hold data for this identity.
3931///
3932/// Primary root is the established global XBP home; legacy/alternate homes and
3933/// older `runtimes/*` layouts are included so WSL/Windows can still read each
3934/// other's historical spoils while new writes go to the primary path.
3935fn 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        // Previous runtime-isolated layout.
3953        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    // Also surface the raw resolved root without create-side effects for alternate spellings.
3984    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    // Dual-map the primary mutations root across WSL/Windows path spellings.
3992    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    // Events/stats/dedupe are shared across Windows+WSL on the established home.
4017    // Watcher PID state is runtime-scoped so both can run without clobbering stop/start.
4018    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    // Prefer a path-identity that is stable across WSL `/mnt/c/...` and Windows `C:\...`.
4038    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
4209/// Path identity that treats `C:\foo` and `/mnt/c/foo` as the same location.
4210fn 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    // Drive-backed paths are case-insensitive on Windows; always fold for stable keys.
4222    value.to_ascii_lowercase()
4223}
4224
4225/// Runtime isolation key for spoils + device identity.
4226///
4227/// Examples: `windows`, `linux`, `macos`, `wsl-ubuntu`, `wsl-debian`.
4228fn 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    // Fall back to /proc/version marker used by Microsoft WSL kernels.
4253    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
4261/// Device id scoped by runtime so Windows and WSL can share one xbp config home
4262/// without looking like the same upload source.
4263fn 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    // No project root available; apply global + built-in rules only.
4295    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    // WSL often has a Linux hostname distinct from the Windows host; prefer it when set.
4329    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        // Nx cache and OpenNext build output anywhere under the repo.
4427        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        // Nested under a watched tree still blocked by prefix.
4452        assert!(rules.ignores_relative("secrets/prod/api.key"));
4453        assert!(rules.ignores_relative("apps/web/.env.local"));
4454        // Exact file ban is not a string-prefix of other filenames.
4455        assert!(!rules.ignores_relative("apps/web/.env.local.bak"));
4456
4457        // Folder name anywhere
4458        assert!(rules.ignores_relative("apps/web/.cache/tmp"));
4459        assert!(rules.ignores_relative("packages/foo/coverage/index.html"));
4460
4461        // Banned word (case-insensitive substring)
4462        assert!(rules.ignores_relative("docs/private-key-notes.md"));
4463        assert!(rules.ignores_relative("config/my-credential-store.json"));
4464
4465        // Still allow normal source
4466        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"))); // built-in
4470    }
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        // Fingerprint is path-root independent: absolute Windows/WSL spellings must not diverge.
4644        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        // On this host: windows | linux | macos | wsl-*
4667        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        // On non-WSL CI/dev hosts this is always false; the path heuristic itself is cheap.
4682        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        // Must live under the established global root, never the repo path.
4717        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}