Skip to main content

xbp_cli/commands/
worktree_watch.rs

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