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