Skip to main content

xbp_cli/commands/
worktree_watch.rs

1use crate::codetime::GithubRepoRecord;
2use crate::commands::code_snapshot::{snapshots_for_file, CodeSnapshot};
3use crate::commands::cli_session::{cli_request_client, resolve_cli_access_token};
4use crate::commands::terminal_table::{render_table, TableStyle};
5use crate::config::{
6    ensure_global_xbp_paths, map_windows_path_to_wsl_mnt, map_wsl_mnt_path_to_windows,
7    resolve_device_identity, resolve_global_xbp_root_dir, resolve_worktree_watch_config, ApiConfig,
8    SshConfig, WorktreeWatchConfig, WorktreeWatchRepoRank,
9};
10use chrono::{DateTime, Utc};
11use colored::Colorize;
12use notify::event::{CreateKind, ModifyKind, RemoveKind, RenameMode};
13use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
14use reqwest::StatusCode;
15use serde::{Deserialize, Serialize};
16use serde_json::{json, Value};
17use sha2::{Digest, Sha256};
18use std::collections::{BTreeMap, BTreeSet, HashMap};
19use std::fs::{self, File, OpenOptions};
20use std::io::{BufRead, BufReader, Write};
21use std::path::{Path, PathBuf};
22use std::process::{Command, Stdio};
23use std::sync::{mpsc, Mutex, OnceLock};
24use std::time::{Duration, Instant};
25use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind};
26use uuid::Uuid;
27
28const BACKGROUND_CHILD_ENV: &str = "XBP_WORKTREE_WATCH_BACKGROUND_CHILD";
29const DEFAULT_REMOTE: &str = "origin";
30const EVENT_DEDUPE_WINDOW_SECONDS: i64 = 5;
31const WORKTREE_MUTATION_SYNC_BATCH_RECORD_LIMIT: usize = 250;
32const WORKTREE_MUTATION_SYNC_BATCH_JSON_BYTES: usize = 768 * 1024;
33const WORKTREE_MUTATION_SYNC_MAX_ATTEMPTS: usize = 3;
34const WORKTREE_MUTATION_SYNC_RETRY_DELAY_SECONDS: u64 = 2;
35/// Bound in-process fingerprint cache; durable dedupe prefers journal lines
36/// (and legacy `*.seen` markers for older spools).
37const MAX_RECENT_EVENT_DEDUPE_IN_MEMORY: usize = 4096;
38/// Rotate `event-dedupe.jsonl` when it grows past this size (bytes).
39const MAX_EVENT_DEDUPE_JSONL_BYTES: u64 = 2 * 1024 * 1024;
40/// How often parent/single watch loops prune spool artifacts.
41const SPOOL_HOUSEKEEPING_INTERVAL_SECONDS: u64 = 600;
42/// Coalesce appends to spool jsonl files (fewer open/fsync storms).
43const JSONL_FLUSH_MAX_LINES: usize = 32;
44const JSONL_FLUSH_MAX_WAIT_MS: u64 = 250;
45/// Max notify events drained per watch-loop tick (burst coalescing).
46const NOTIFY_EVENT_DRAIN_CAP: usize = 64;
47
48/// Client-side ignore rules: built-in VCS/generated dirs + global config filters
49/// + `~/.xbp/.xbp-worktree-ignore` + project `.xbp/.xbpignore` / `watch_ignore_paths`.
50///
51/// Project `ignore_paths` (service discovery) intentionally do **not** apply here so
52/// ignored package roots still count for worktree-watch activity.
53/// Never uploaded; applied before events are spooled and again before sync.
54#[derive(Debug, Clone, Default)]
55struct WatchIgnoreRules {
56    /// Normalized lowercase prefixes without leading `./` (e.g. `secrets`, `apps/web/.env`).
57    forbidden_path_prefixes: Vec<String>,
58    /// Lowercase path segment names blocked anywhere (includes built-ins + config).
59    forbidden_folders: BTreeSet<String>,
60    /// Lowercase substrings matched against the full relative path.
61    banned_words: Vec<String>,
62    /// Machine-wide gitignore-style rules (`~/.xbp/.xbp-worktree-ignore`).
63    global_ignore: crate::utils::XbpIgnoreSet,
64    /// Project-local gitignore-style rules (`.xbp/.xbpignore`, yaml `watch_ignore_paths`).
65    project_ignore: crate::utils::XbpIgnoreSet,
66}
67
68impl WatchIgnoreRules {
69    fn load() -> Self {
70        Self::from_config(&resolve_worktree_watch_config())
71    }
72
73    fn from_config(config: &WorktreeWatchConfig) -> Self {
74        Self::from_config_and_project(config, None)
75    }
76
77    fn from_config_and_project(config: &WorktreeWatchConfig, project_root: Option<&Path>) -> Self {
78        let mut forbidden_folders = built_in_forbidden_folders();
79        for folder in &config.forbidden_folders {
80            let normalized = normalize_folder_token(folder);
81            if !normalized.is_empty() {
82                forbidden_folders.insert(normalized);
83            }
84        }
85
86        let forbidden_path_prefixes = config
87            .forbidden_paths
88            .iter()
89            .map(|path| normalize_path_prefix(path))
90            .filter(|path| !path.is_empty())
91            .collect::<Vec<_>>();
92
93        let banned_words = config
94            .banned_words
95            .iter()
96            .map(|word| word.trim().to_ascii_lowercase())
97            .filter(|word| !word.is_empty())
98            .collect::<Vec<_>>();
99
100        let project_ignore = project_root
101            .map(|root| load_project_watch_ignore(root))
102            .unwrap_or_default();
103
104        Self {
105            forbidden_path_prefixes,
106            forbidden_folders,
107            banned_words,
108            global_ignore: crate::config::load_global_worktree_ignore(),
109            project_ignore,
110        }
111    }
112
113    fn with_project_root(mut self, project_root: &Path) -> Self {
114        self.project_ignore = load_project_watch_ignore(project_root);
115        self
116    }
117
118    fn ignores_relative(&self, relative: &str) -> bool {
119        let normalized = normalize_relative_watch_path(relative);
120        if normalized.is_empty() {
121            return false;
122        }
123
124        if self.global_ignore.is_ignored_relative(&normalized, false)
125            || self.global_ignore.is_ignored_relative(&normalized, true)
126        {
127            return true;
128        }
129
130        if self.project_ignore.is_ignored_relative(&normalized, false)
131            || self.project_ignore.is_ignored_relative(&normalized, true)
132        {
133            return true;
134        }
135
136        let lower = normalized.to_ascii_lowercase();
137        let components = lower
138            .split('/')
139            .filter(|component| !component.is_empty())
140            .collect::<Vec<_>>();
141
142        if components
143            .iter()
144            .any(|component| self.forbidden_folders.contains(*component))
145        {
146            return true;
147        }
148
149        for prefix in &self.forbidden_path_prefixes {
150            if lower == *prefix || lower.starts_with(&format!("{prefix}/")) {
151                return true;
152            }
153            // Also treat a single-segment prefix as a folder ban anywhere.
154            if !prefix.contains('/') && components.iter().any(|component| *component == prefix) {
155                return true;
156            }
157        }
158
159        for word in &self.banned_words {
160            if lower.contains(word) {
161                return true;
162            }
163        }
164
165        false
166    }
167
168    fn ignores_path(&self, root: &Path, path: &Path) -> bool {
169        if let Some(relative) = relative_watch_path(root, path) {
170            return self.ignores_relative(&relative);
171        }
172        false
173    }
174
175    fn skips_discovery_dir(&self, path: &Path) -> bool {
176        let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
177            return false;
178        };
179        let trimmed = name.trim();
180        if self
181            .forbidden_folders
182            .contains(&trimmed.to_ascii_lowercase())
183        {
184            return true;
185        }
186        if self.global_ignore.skips_dir_name(trimmed) {
187            return true;
188        }
189        self.project_ignore.skips_dir_name(trimmed)
190    }
191}
192
193fn load_project_watch_ignore(project_root: &Path) -> crate::utils::XbpIgnoreSet {
194    use crate::strategies::XbpConfig;
195    use crate::utils::{load_project_xbp_ignore, parse_config_with_auto_heal};
196
197    let mut extra = Vec::new();
198    // Watch-only patterns from xbp.yaml (`watch_ignore_paths` / `watch_ignore`).
199    // Service-discovery `ignore_paths` are intentionally excluded so worktree-watch
200    // still records activity under packages that are not registered as services.
201    if let Some(found) = crate::utils::find_xbp_config_upwards(project_root) {
202        if let Ok(content) = fs::read_to_string(&found.config_path) {
203            if let Ok((config, _)) = parse_config_with_auto_heal::<XbpConfig>(&content, found.kind)
204            {
205                extra = config.watch_ignore_paths;
206            }
207        }
208        return load_project_xbp_ignore(&found.project_root, &extra);
209    }
210    load_project_xbp_ignore(project_root, &extra)
211}
212
213fn built_in_forbidden_folders() -> BTreeSet<String> {
214    use crate::utils::DEFAULT_NOISE_DIR_NAMES;
215
216    DEFAULT_NOISE_DIR_NAMES
217        .iter()
218        .map(|name| name.to_ascii_lowercase())
219        .collect()
220}
221
222fn normalize_folder_token(raw: &str) -> String {
223    raw.trim()
224        .trim_matches(|ch| ch == '/' || ch == '\\')
225        .to_ascii_lowercase()
226}
227
228fn normalize_path_prefix(raw: &str) -> String {
229    let trimmed = raw.trim().replace('\\', "/");
230    let without_dot = trimmed
231        .trim_start_matches("./")
232        .trim_matches(|ch| ch == '/' || ch == '\\');
233    without_dot.to_ascii_lowercase()
234}
235
236fn normalize_relative_watch_path(raw: &str) -> String {
237    raw.trim()
238        .replace('\\', "/")
239        .trim_start_matches("./")
240        .trim_matches('/')
241        .to_string()
242}
243
244fn watch_ignore_rules() -> &'static WatchIgnoreRules {
245    static RULES: OnceLock<WatchIgnoreRules> = OnceLock::new();
246    RULES.get_or_init(WatchIgnoreRules::load)
247}
248
249/// Global config rules merged with per-repo `.xbp/.xbpignore` / `ignore_paths`.
250fn watch_ignore_rules_for_root(root: &Path) -> WatchIgnoreRules {
251    watch_ignore_rules().clone().with_project_root(root)
252}
253
254#[cfg(windows)]
255const CREATE_NO_WINDOW: u32 = 0x08000000;
256
257#[derive(Debug, Clone, Default)]
258pub struct WorktreeWatchTargetOptions {
259    pub repo: Option<PathBuf>,
260    pub parent: Option<PathBuf>,
261    /// Explicit multi-repo list (tray / advanced targeting). Ignored when `parent` is set.
262    pub repos: Vec<PathBuf>,
263}
264
265/// Prefer a running parent watcher when `status` is invoked with no target.
266///
267/// Lets `xbp worktree-watch status` from any cwd show the active parent cover
268/// (e.g. Documents/GitHub) instead of failing outside a git repo or only
269/// reporting the current checkout as stopped.
270pub fn resolve_default_worktree_watch_status_target() -> Result<WorktreeWatchTargetOptions, String> {
271    if let Some(parent) = prefer_running_parent_root() {
272        return Ok(WorktreeWatchTargetOptions {
273            repo: None,
274            parent: Some(parent),
275            repos: Vec::new(),
276        });
277    }
278    // Fall back to the most recent parent state even if the process just died,
279    // so bare `status` still shows the cover matrix instead of a git cwd error.
280    if let Some(parent) = prefer_any_parent_root() {
281        return Ok(WorktreeWatchTargetOptions {
282            repo: None,
283            parent: Some(parent),
284            repos: Vec::new(),
285        });
286    }
287    normalize_worktree_watch_target(WorktreeWatchTargetOptions {
288        repo: None,
289        parent: None,
290        repos: Vec::new(),
291    })
292}
293
294/// Resolve inventory names and pin absolute paths so detached tray/watcher children
295/// do not depend on the caller's cwd (or a bare short name like `athena`).
296pub fn normalize_worktree_watch_target(
297    target: WorktreeWatchTargetOptions,
298) -> Result<WorktreeWatchTargetOptions, String> {
299    if target.parent.is_some() && target.repo.is_some() {
300        return Err("Pass either `--repo` or `--parent`, not both.".to_string());
301    }
302    if target.parent.is_some() && !target.repos.is_empty() {
303        return Err("Pass either `--repos` or `--parent`, not both.".to_string());
304    }
305    if target.repo.is_some() && !target.repos.is_empty() {
306        return Err("Pass either `--repo` or `--repos`, not both.".to_string());
307    }
308
309    if let Some(parent) = target.parent.as_ref() {
310        let resolved = if parent.is_dir() {
311            normalize_windows_verbatim_path(
312                fs::canonicalize(parent).unwrap_or_else(|_| parent.clone()),
313            )
314        } else {
315            // Allow bare folder names under common clone roots.
316            if let Some(found) = resolve_parent_folder_hint(parent)? {
317                found
318            } else {
319                return Err(format!(
320                    "Parent folder not found: {}. Pass an absolute path or a folder under Documents/GitHub.",
321                    parent.display()
322                ));
323            }
324        };
325        return Ok(WorktreeWatchTargetOptions {
326            repo: None,
327            parent: Some(resolved),
328            repos: Vec::new(),
329        });
330    }
331
332    if !target.repos.is_empty() {
333        let mut repos = Vec::new();
334        let mut seen = BTreeSet::new();
335        for repo in &target.repos {
336            let path = resolve_repo_path_input(Some(repo.as_path()))?;
337            let absolute = normalize_windows_verbatim_path(
338                fs::canonicalize(&path).unwrap_or(path),
339            );
340            let key = path_identity_key(&absolute);
341            if seen.insert(key) {
342                repos.push(absolute);
343            }
344        }
345        return Ok(WorktreeWatchTargetOptions {
346            repo: None,
347            parent: None,
348            repos,
349        });
350    }
351
352    // Single repo (explicit or default to cwd) — always pin absolute root.
353    let identity = resolve_repo_identity(target.repo.as_deref())?;
354    Ok(WorktreeWatchTargetOptions {
355        repo: Some(identity.root),
356        parent: None,
357        repos: Vec::new(),
358    })
359}
360
361fn resolve_parent_folder_hint(parent: &Path) -> Result<Option<PathBuf>, String> {
362    if parent.is_absolute() {
363        return Ok(None);
364    }
365    let name = parent
366        .file_name()
367        .and_then(|value| value.to_str())
368        .unwrap_or_default();
369    if name.is_empty() {
370        return Ok(None);
371    }
372
373    let mut candidates: Vec<PathBuf> = Vec::new();
374    if let Some(home) = dirs::home_dir() {
375        candidates.push(home.join("Documents").join("GitHub").join(name));
376        candidates.push(home.join("Documents").join("github").join(name));
377        candidates.push(home.join("Documents").join(name));
378        candidates.push(home.join("src").join(name));
379        candidates.push(home.join(name));
380    }
381    // If the hint itself is "GitHub", resolve the common clone root.
382    if name.eq_ignore_ascii_case("github") {
383        if let Some(home) = dirs::home_dir() {
384            candidates.insert(0, home.join("Documents").join("GitHub"));
385            candidates.insert(1, home.join("Documents").join("github"));
386        }
387    }
388
389    for candidate in candidates {
390        if candidate.is_dir() {
391            return Ok(Some(normalize_windows_verbatim_path(
392                fs::canonicalize(&candidate).unwrap_or(candidate),
393            )));
394        }
395    }
396    Ok(None)
397}
398
399/// Live snapshot for the worktree-watch system tray UI.
400#[derive(Debug, Clone, Serialize)]
401#[serde(rename_all = "camelCase")]
402pub struct WorktreeWatchTraySnapshot {
403    pub target_label: String,
404    pub mode: String,
405    pub parent: Option<String>,
406    pub repository_count: usize,
407    pub running_watchers: usize,
408    pub any_running: bool,
409    pub all_running: bool,
410    pub parent_watcher_running: bool,
411    pub parent_watcher_pid: Option<u32>,
412    pub total_unsynced_files: u64,
413    pub total_unsynced_records: u64,
414    pub repositories: Vec<WorktreeWatchTrayRepoStatus>,
415    pub stats_line: Option<String>,
416}
417
418#[derive(Debug, Clone, Serialize)]
419#[serde(rename_all = "camelCase")]
420pub struct WorktreeWatchTrayRepoStatus {
421    pub root: String,
422    pub owner: String,
423    pub name: String,
424    pub branch: String,
425    pub running: bool,
426    pub pid: Option<u32>,
427    pub unsynced_files: u64,
428    pub unsynced_records: u64,
429}
430
431#[derive(Debug, Clone)]
432pub struct WorktreeWatchStartOptions {
433    pub target: WorktreeWatchTargetOptions,
434    pub detach: bool,
435    pub sync_interval_seconds: u64,
436    pub once: bool,
437}
438
439#[derive(Debug, Clone)]
440pub struct WorktreeWatchSyncOptions {
441    pub target: WorktreeWatchTargetOptions,
442    pub dry_run: bool,
443    pub resync: bool,
444}
445
446#[derive(Debug, Clone)]
447pub struct WorktreeWatchStopOptions {
448    pub target: WorktreeWatchTargetOptions,
449    pub force: bool,
450}
451
452#[derive(Debug, Clone)]
453pub struct WorktreeWatchStatusOptions {
454    pub target: WorktreeWatchTargetOptions,
455    pub json: bool,
456    pub records: bool,
457    pub record_limit: usize,
458    pub stats: bool,
459    pub repo_activity: bool,
460    pub stats_gap_minutes: u64,
461    /// Include adaptive tier / rank / process resource insight (default true).
462    pub resource: bool,
463}
464
465#[derive(Debug, Clone)]
466struct RepoIdentity {
467    owner: String,
468    name: String,
469    branch: String,
470    root: PathBuf,
471    head_sha: Option<String>,
472}
473
474#[derive(Debug, Clone)]
475struct SpoolLayout {
476    root: PathBuf,
477    events_file: PathBuf,
478    commits_file: PathBuf,
479    stats_file: PathBuf,
480    watcher_state_file: PathBuf,
481    sync_log_file: PathBuf,
482    event_dedupe_file: PathBuf,
483    event_dedupe_dir: PathBuf,
484    commit_dedupe_dir: PathBuf,
485}
486
487#[derive(Debug, Clone)]
488struct ParentSpoolLayout {
489    watcher_state_file: PathBuf,
490}
491
492/// Adaptive watch intensity for a repository.
493#[derive(Debug, Clone, Copy, PartialEq, Eq)]
494enum RepoWatchTier {
495    /// Recent activity: preferred for recursive FS watch + frequent HEAD checks.
496    Hot,
497    /// Quiet: slower HEAD checks; FS watch only if under the concurrent watch budget.
498    Warm,
499    /// Idle: no FS watch (releases OS directory locks so the folder can be deleted).
500    Cold,
501}
502
503impl RepoWatchTier {
504    fn as_str(self) -> &'static str {
505        match self {
506            Self::Hot => "hot",
507            Self::Warm => "warm",
508            Self::Cold => "cold",
509        }
510    }
511}
512
513#[derive(Debug)]
514struct WatchedRepo {
515    identity: RepoIdentity,
516    layout: SpoolLayout,
517    last_head: Option<String>,
518    event_dedupe: RecentEventDedupe,
519    /// Last FS mutation or meaningful commit change.
520    last_activity: Instant,
521    /// Decayed activity score (higher = busier).
522    activity_score: f64,
523    tier: RepoWatchTier,
524    /// Whether the native/poll watcher currently holds this repo root.
525    fs_watched: bool,
526    /// Next time we may run `git rev-parse HEAD` for this repo.
527    next_commit_check: Instant,
528}
529
530#[derive(Debug, Serialize, Deserialize, Clone)]
531#[serde(rename_all = "camelCase")]
532struct WorktreeMutationEvent {
533    id: String,
534    #[serde(default, skip_serializing_if = "Option::is_none")]
535    fingerprint: Option<String>,
536    repo_owner: String,
537    repo_name: String,
538    branch_name: String,
539    repo_root: String,
540    head_sha: Option<String>,
541    event_kind: String,
542    paths: Vec<String>,
543    primary_path: Option<String>,
544    old_path: Option<String>,
545    new_path: Option<String>,
546    added_lines: Option<u64>,
547    removed_lines: Option<u64>,
548    total_lines: Option<u64>,
549    #[serde(default, skip_serializing_if = "Vec::is_empty")]
550    code_snapshots: Vec<CodeSnapshot>,
551    file_created: bool,
552    file_removed: bool,
553    folder_created: bool,
554    folder_removed: bool,
555    renamed_or_moved: bool,
556    raw_kind: String,
557    occurred_at: DateTime<Utc>,
558}
559
560#[derive(Debug, Serialize, Deserialize, Clone)]
561#[serde(rename_all = "camelCase")]
562struct WorktreeCommitEvent {
563    id: String,
564    #[serde(default, skip_serializing_if = "Option::is_none")]
565    fingerprint: Option<String>,
566    repo_owner: String,
567    repo_name: String,
568    branch_name: String,
569    repo_root: String,
570    previous_head_sha: Option<String>,
571    head_sha: String,
572    subject: Option<String>,
573    author_name: Option<String>,
574    author_email: Option<String>,
575    committed_at: Option<String>,
576    occurred_at: DateTime<Utc>,
577    /// Paths touched by previous_head..head (capped).
578    #[serde(default, skip_serializing_if = "Option::is_none")]
579    files_changed: Option<Vec<String>>,
580    #[serde(default, skip_serializing_if = "Option::is_none")]
581    files_changed_count: Option<u64>,
582    #[serde(default, skip_serializing_if = "Option::is_none")]
583    insertions: Option<u64>,
584    #[serde(default, skip_serializing_if = "Option::is_none")]
585    deletions: Option<u64>,
586    #[serde(default, skip_serializing_if = "Option::is_none")]
587    is_merge: Option<bool>,
588    /// When HEAD jumped multiple commits, SHAs in the range (newest first, capped).
589    #[serde(default, skip_serializing_if = "Option::is_none")]
590    included_commits: Option<Vec<String>>,
591}
592
593/// Intent written before install so watchers can relaunch on the new binary.
594#[derive(Debug, Serialize, Deserialize, Clone, Default)]
595#[serde(rename_all = "camelCase")]
596struct WorktreeWatchRestartIntent {
597    #[serde(default)]
598    parents: Vec<String>,
599    #[serde(default)]
600    repos: Vec<String>,
601    #[serde(default)]
602    tray: bool,
603    #[serde(default)]
604    stopped_at: Option<String>,
605}
606
607#[derive(Debug, Serialize)]
608#[serde(rename_all = "camelCase")]
609struct WorktreeMutationIngestPayload {
610    device: WorktreeMutationDevicePayload,
611    repository: WorktreeMutationRepositoryPayload,
612    events: Vec<WorktreeMutationEvent>,
613    commits: Vec<WorktreeCommitEvent>,
614}
615
616#[derive(Debug, Serialize)]
617#[serde(rename_all = "camelCase")]
618struct WorktreeMutationDevicePayload {
619    hardware_id: String,
620    device_name: Option<String>,
621    hostname: Option<String>,
622    platform: String,
623    /// Stable runtime isolation key (e.g. `windows`, `wsl-ubuntu`, `linux`).
624    /// Lets Windows + WSL watchers coexist as distinct upload sources.
625    #[serde(skip_serializing_if = "Option::is_none")]
626    runtime_key: Option<String>,
627}
628
629#[derive(Debug, Serialize)]
630#[serde(rename_all = "camelCase")]
631struct WorktreeMutationRepositoryPayload {
632    owner: String,
633    name: String,
634    branch_name: String,
635    repo_root: String,
636}
637
638#[derive(Debug)]
639struct SyncCandidate {
640    path: PathBuf,
641    records: SyncRecords,
642}
643
644#[derive(Debug)]
645struct WorktreeMutationUploadBatch {
646    events: Vec<WorktreeMutationEvent>,
647    commits: Vec<WorktreeCommitEvent>,
648    estimated_json_bytes: usize,
649}
650
651#[derive(Debug, Clone, Copy, PartialEq, Eq)]
652enum WorktreeMutationSyncErrorKind {
653    Retryable,
654    NonRetryable,
655}
656
657#[derive(Debug)]
658struct WorktreeMutationSyncError {
659    kind: WorktreeMutationSyncErrorKind,
660    message: String,
661}
662
663struct SyncLogAppend<'a> {
664    identity: &'a RepoIdentity,
665    layout: &'a SpoolLayout,
666    endpoint: &'a str,
667    status_code: u16,
668    event_count: usize,
669    commit_count: usize,
670    candidates: &'a [SyncCandidate],
671    resync: bool,
672}
673
674#[derive(Debug, Clone, Copy)]
675enum SyncFileKind {
676    Events,
677    Commits,
678}
679
680#[derive(Debug)]
681enum SyncRecords {
682    Events(Vec<WorktreeMutationEvent>),
683    Commits(Vec<WorktreeCommitEvent>),
684}
685
686impl SyncRecords {
687    fn len(&self) -> usize {
688        match self {
689            Self::Events(records) => records.len(),
690            Self::Commits(records) => records.len(),
691        }
692    }
693
694    fn is_empty(&self) -> bool {
695        self.len() == 0
696    }
697}
698
699#[derive(Debug, Serialize, Deserialize, Clone)]
700#[serde(rename_all = "camelCase")]
701struct WorktreeWatchStatsSummary {
702    generated_at: DateTime<Utc>,
703    repository_owner: String,
704    repository_name: String,
705    branch_name: String,
706    repo_root: String,
707    spool_root: String,
708    session_gap_minutes: u64,
709    total_events: u64,
710    total_commits: u64,
711    first_event_at: Option<DateTime<Utc>>,
712    last_event_at: Option<DateTime<Utc>>,
713    session_count: u64,
714    observed_span_seconds: u64,
715    estimated_coding_seconds: u64,
716    added_lines: u64,
717    removed_lines: u64,
718    by_file: Vec<WorktreeWatchFileStats>,
719    by_filetype: Vec<WorktreeWatchBucketStats>,
720    by_event_kind: Vec<WorktreeWatchBucketStats>,
721}
722
723#[derive(Debug, Serialize, Clone)]
724#[serde(rename_all = "camelCase")]
725struct WorktreeWatchRepoActivitySummary {
726    generated_at: DateTime<Utc>,
727    repository_owner: String,
728    repository_name: String,
729    repo_root: String,
730    repository_spool_root: String,
731    stats_file: String,
732    session_gap_minutes: u64,
733    branch_count: u64,
734    total_events: u64,
735    total_commits: u64,
736    first_event_at: Option<DateTime<Utc>>,
737    last_event_at: Option<DateTime<Utc>>,
738    session_count: u64,
739    observed_span_seconds: u64,
740    estimated_coding_seconds: u64,
741    added_lines: u64,
742    removed_lines: u64,
743    branches: Vec<WorktreeWatchBranchActivityStats>,
744}
745
746#[derive(Debug, Serialize, Clone)]
747#[serde(rename_all = "camelCase")]
748struct WorktreeWatchBranchActivityStats {
749    branch_name: String,
750    spool_root: String,
751    total_events: u64,
752    total_commits: u64,
753    first_event_at: Option<DateTime<Utc>>,
754    last_event_at: Option<DateTime<Utc>>,
755    session_count: u64,
756    observed_span_seconds: u64,
757    estimated_coding_seconds: u64,
758    added_lines: u64,
759    removed_lines: u64,
760}
761
762#[derive(Debug, Serialize, Deserialize, Clone, Default)]
763#[serde(rename_all = "camelCase")]
764struct WorktreeWatchFileStats {
765    path: String,
766    filetype: String,
767    event_count: u64,
768    estimated_coding_seconds: u64,
769    added_lines: u64,
770    removed_lines: u64,
771}
772
773#[derive(Debug, Serialize, Deserialize, Clone, Default)]
774#[serde(rename_all = "camelCase")]
775struct WorktreeWatchBucketStats {
776    name: String,
777    event_count: u64,
778    estimated_coding_seconds: u64,
779    added_lines: u64,
780    removed_lines: u64,
781}
782
783#[derive(Debug, Serialize, Deserialize, Clone)]
784#[serde(rename_all = "camelCase")]
785struct WorktreeWatcherState {
786    pid: u32,
787    repo_root: String,
788    repository_owner: String,
789    repository_name: String,
790    branch_name: String,
791    executable: String,
792    started_at: DateTime<Utc>,
793    /// Present on modern state files so Windows/WSL/Linux watchers never clobber each other.
794    #[serde(default, skip_serializing_if = "Option::is_none")]
795    runtime_key: Option<String>,
796    #[serde(default, skip_serializing_if = "Option::is_none")]
797    platform: Option<String>,
798}
799
800#[derive(Debug, Serialize, Deserialize, Clone)]
801#[serde(rename_all = "camelCase")]
802struct ParentWorktreeWatcherState {
803    pid: u32,
804    parent_root: String,
805    executable: String,
806    started_at: DateTime<Utc>,
807    #[serde(default, skip_serializing_if = "Option::is_none")]
808    runtime_key: Option<String>,
809    #[serde(default, skip_serializing_if = "Option::is_none")]
810    platform: Option<String>,
811}
812
813#[derive(Debug, Serialize, Deserialize, Clone)]
814#[serde(rename_all = "camelCase")]
815struct WorktreeWatchSyncLogEntry {
816    id: String,
817    synced_at: DateTime<Utc>,
818    endpoint: String,
819    repository_owner: String,
820    repository_name: String,
821    branch_name: String,
822    repo_root: String,
823    resync: bool,
824    status_code: u16,
825    event_count: usize,
826    commit_count: usize,
827    spool_file_count: usize,
828    spool_files: Vec<String>,
829}
830
831#[derive(Debug, Clone, Copy, PartialEq, Eq)]
832enum StopWatcherOutcome {
833    Stopped(u32),
834    RemovedStaleState,
835    NoState,
836}
837
838#[derive(Debug, Serialize, Deserialize, Clone)]
839#[serde(rename_all = "camelCase")]
840struct WorktreeWatchEventDedupeRecord {
841    fingerprint: String,
842    first_seen_at: DateTime<Utc>,
843    event_kind: String,
844    primary_path: Option<String>,
845}
846
847#[derive(Debug, Default)]
848struct RecentEventDedupe {
849    fingerprints: BTreeSet<String>,
850}
851
852pub async fn run_worktree_watch_start(options: WorktreeWatchStartOptions) -> Result<(), String> {
853    if options.detach {
854        // Keep detached start free of the foreground watch future so the local
855        // HTTP API can call this without creating an async type cycle.
856        return run_worktree_watch_start_detached(options);
857    }
858
859    if let Some(parent) = options.target.parent.as_deref() {
860        let identities = resolve_target_identities(&options.target)?;
861        if identities.is_empty() {
862            println!("No git repositories found under parent folder.");
863            return Ok(());
864        }
865
866        if options.once {
867            for identity in &identities {
868                let layout = prepare_spool_layout(identity)?;
869                record_commit_snapshot(identity, &layout, None)?;
870                println!("Recorded current git state for {}", identity.root.display());
871            }
872            return Ok(());
873        }
874
875        let parent = canonical_parent_path(parent)?;
876        println!(
877            "Watching {} recursively and routing mutations for {} repo(s).",
878            parent.display(),
879            identities.len()
880        );
881        return watch_parent_foreground(parent, identities, options.sync_interval_seconds).await;
882    }
883
884    let identity = resolve_repo_identity(options.target.repo.as_deref())?;
885    let layout = prepare_spool_layout(&identity)?;
886    println!(
887        "Watching {} and spooling mutations to {} [runtime={}]",
888        identity.root.display(),
889        layout.root.display(),
890        worktree_runtime_key()
891    );
892
893    if options.once {
894        record_commit_snapshot(&identity, &layout, None)?;
895        return Ok(());
896    }
897
898    watch_foreground(identity, layout, options.sync_interval_seconds).await
899}
900
901/// Spawn detached watcher process(es) only (no foreground watch loop).
902///
903/// Used by the local HTTP API and CLI `--detach` so handlers never type-depend
904/// on the infinite `watch_foreground` future.
905pub fn run_worktree_watch_start_detached(
906    options: WorktreeWatchStartOptions,
907) -> Result<(), String> {
908    if let Some(parent) = options.target.parent.as_deref() {
909        // Fast .git root count only — full identity resolve is deferred to the child.
910        let parent_path = canonical_parent_path(parent)?;
911        let repo_count = count_git_roots_under(&parent_path)?;
912        if repo_count == 0 {
913            println!("No git repositories found under parent folder.");
914            return Ok(());
915        }
916        let path = spawn_detached_parent_worktree_watch(&parent_path)?;
917        println!(
918            "Started one background worktree watcher for {} covering {} repo(s).",
919            path.display(),
920            repo_count
921        );
922        if let Some(port) =
923            crate::commands::worktree_watch_api::resolve_worktree_watch_api_port(None)
924        {
925            println!(
926                "Local API (when free): http://127.0.0.1:{port}  — dashboard: apps/worktree-watch-app/"
927            );
928        }
929        return Ok(());
930    }
931
932    if !options.target.repos.is_empty() {
933        for repo in &options.target.repos {
934            let path = spawn_detached_worktree_watch(Some(repo.as_path()))?;
935            println!("Started background worktree watcher for {}", path.display());
936        }
937        if let Some(port) =
938            crate::commands::worktree_watch_api::resolve_worktree_watch_api_port(None)
939        {
940            println!(
941                "Local API (when free): http://127.0.0.1:{port}  — dashboard: apps/worktree-watch-app/"
942            );
943        }
944        return Ok(());
945    }
946
947    spawn_detached_worktree_watch(options.target.repo.as_deref()).map(|path| {
948        println!("Started background worktree watcher for {}", path.display());
949        if let Some(port) =
950            crate::commands::worktree_watch_api::resolve_worktree_watch_api_port(None)
951        {
952            println!(
953                "Local API (when free): http://127.0.0.1:{port}  — dashboard: apps/worktree-watch-app/"
954            );
955        }
956    })
957}
958
959pub async fn run_worktree_watch_sync(options: WorktreeWatchSyncOptions) -> Result<(), String> {
960    let identities = resolve_target_identities(&options.target)?;
961    let repo_count = identities.len();
962    let mut plans = Vec::new();
963    let mut total_files = 0usize;
964    let mut total_records = 0usize;
965
966    for identity in identities {
967        let layout = prepare_spool_layout(&identity)?;
968        let candidates = collect_spool_candidates(&layout, options.resync)?;
969        total_files += candidates.len();
970        total_records += candidates
971            .iter()
972            .map(|candidate| candidate.records.len())
973            .sum::<usize>();
974        if !candidates.is_empty() {
975            plans.push((identity, candidates));
976        }
977    }
978
979    if options.dry_run {
980        println!(
981            "Would sync {} record(s) from {} spool file(s) across {} repo(s).",
982            total_records, total_files, repo_count
983        );
984        if options.resync {
985            println!("Resync mode includes files already marked `.synced.*.jsonl`.");
986        }
987        return Ok(());
988    }
989
990    if plans.is_empty() {
991        print_no_sync_candidates_message(&options.target, options.resync)?;
992        return Ok(());
993    }
994
995    let upload_repo_count = plans.len();
996    for (identity, candidates) in plans {
997        sync_candidates(&identity, candidates, options.resync).await?;
998    }
999    println!(
1000        "Synced {} worktree mutation record(s) across {} repo(s).",
1001        total_records, upload_repo_count
1002    );
1003    Ok(())
1004}
1005
1006pub fn run_worktree_watch_stop(options: WorktreeWatchStopOptions) -> Result<(), String> {
1007    if let Some(parent) = options.target.parent.as_deref() {
1008        let parent = canonical_parent_path(parent)?;
1009        match stop_existing_parent_watcher(&parent, options.force)? {
1010            StopWatcherOutcome::Stopped(pid) => {
1011                println!(
1012                    "Stopped background parent worktree watcher {} for {}",
1013                    pid,
1014                    parent.display()
1015                );
1016            }
1017            StopWatcherOutcome::RemovedStaleState => {
1018                println!(
1019                    "Removed stale parent worktree watcher state for {}",
1020                    parent.display()
1021                );
1022            }
1023            StopWatcherOutcome::NoState => {
1024                println!("No parent worktree watcher state for {}", parent.display());
1025            }
1026        }
1027    }
1028
1029    let identities = resolve_target_identities(&options.target)?;
1030    if identities.is_empty() {
1031        println!("No git repositories found for worktree watcher stop.");
1032        return Ok(());
1033    }
1034
1035    let mut stopped = 0usize;
1036    let mut stale = 0usize;
1037    let mut missing = 0usize;
1038    for identity in identities {
1039        let layout = prepare_spool_layout(&identity)?;
1040        match stop_existing_watcher(&identity, &layout, options.force)? {
1041            StopWatcherOutcome::Stopped(pid) => {
1042                stopped += 1;
1043                println!(
1044                    "Stopped background worktree watcher {} for {}",
1045                    pid,
1046                    identity.root.display()
1047                );
1048            }
1049            StopWatcherOutcome::RemovedStaleState => {
1050                stale += 1;
1051                println!(
1052                    "Removed stale worktree watcher state for {}",
1053                    identity.root.display()
1054                );
1055            }
1056            StopWatcherOutcome::NoState => {
1057                missing += 1;
1058                println!(
1059                    "No background worktree watcher state for {}",
1060                    identity.root.display()
1061                );
1062            }
1063        }
1064    }
1065
1066    println!(
1067        "Worktree watcher stop complete: {stopped} stopped, {stale} stale state file(s) removed, {missing} without state."
1068    );
1069    Ok(())
1070}
1071
1072/// Best-effort stop of whatever worktree-watch cover is active (parent or repo(s))
1073/// so `cargo install` / `xbp update --install` can replace the XBP binary without
1074/// Windows file locks / file-claim failures.
1075///
1076/// Never fails the caller: prints what it stopped (or a soft warning) and returns.
1077/// Records a restart intent so [`restart_worktree_watch_after_update`] can relaunch.
1078pub fn stop_active_worktree_watch_before_install() {
1079    let mut intent = WorktreeWatchRestartIntent {
1080        stopped_at: Some(Utc::now().to_rfc3339()),
1081        ..Default::default()
1082    };
1083    let mut stopped_parent = false;
1084
1085    // Prefer live parent cover(s) — one process holds watches for the whole tree.
1086    // Avoid discovering every git repo under the parent (noisy + slow on large folders).
1087    if let Ok(mut candidates) = list_parent_watcher_state_files() {
1088        candidates.sort_by(|left, right| right.1.started_at.cmp(&left.1.started_at));
1089        for (_path, state) in candidates {
1090            let system = process_system_for_pids(&[state.pid]);
1091            if !is_live_parent_watcher_process_with_system(&system, &state) {
1092                continue;
1093            }
1094            let parent = parent_root_as_local_path(&state.parent_root);
1095            match stop_existing_parent_watcher(&parent, true) {
1096                Ok(StopWatcherOutcome::Stopped(pid)) => {
1097                    stopped_parent = true;
1098                    intent.parents.push(parent.display().to_string());
1099                    println!(
1100                        "Stopped worktree-watch parent watcher {} for {} before install.",
1101                        pid,
1102                        parent.display()
1103                    );
1104                }
1105                Ok(StopWatcherOutcome::RemovedStaleState) => {
1106                    println!(
1107                        "Removed stale parent worktree-watch state for {} before install.",
1108                        parent.display()
1109                    );
1110                }
1111                Ok(StopWatcherOutcome::NoState) => {}
1112                Err(error) => {
1113                    println!(
1114                        "Warning: could not stop parent worktree-watch for {}: {error}",
1115                        parent.display()
1116                    );
1117                }
1118            }
1119        }
1120    }
1121
1122    if stopped_parent {
1123        let _ = write_worktree_watch_restart_intent(&intent);
1124        return;
1125    }
1126
1127    // No live parent: stop an active per-repo / multi-repo cover when present.
1128    let Ok(target) = resolve_default_worktree_watch_status_target() else {
1129        return;
1130    };
1131    // Default status target may fall back to a stale parent path; we already
1132    // handled live parents above, so skip parent-only targets here.
1133    if target.parent.is_some() {
1134        return;
1135    }
1136
1137    let running = collect_worktree_watch_tray_snapshot(&target)
1138        .map(|snapshot| snapshot.any_running)
1139        .unwrap_or(false);
1140    if !running {
1141        return;
1142    }
1143
1144    if let Some(repo) = target.repo.as_ref() {
1145        intent.repos.push(repo.display().to_string());
1146    }
1147    for repo in &target.repos {
1148        intent.repos.push(repo.display().to_string());
1149    }
1150
1151    match run_worktree_watch_stop(WorktreeWatchStopOptions {
1152        target,
1153        force: true,
1154    }) {
1155        Ok(()) => {
1156            let _ = write_worktree_watch_restart_intent(&intent);
1157            println!("Stopped worktree-watch before install.");
1158        }
1159        Err(error) => {
1160            println!("Warning: could not stop worktree-watch before install: {error}");
1161        }
1162    }
1163}
1164
1165/// Relaunch worktree watchers recorded by [`stop_active_worktree_watch_before_install`].
1166/// Best-effort: never fails the caller.
1167pub fn restart_worktree_watch_after_update() {
1168    let Some(intent) = read_worktree_watch_restart_intent() else {
1169        return;
1170    };
1171    let _ = clear_worktree_watch_restart_intent();
1172
1173    let mut restarted = 0usize;
1174    for parent in &intent.parents {
1175        let path = PathBuf::from(parent);
1176        if !path.is_dir() {
1177            continue;
1178        }
1179        match spawn_detached_parent_worktree_watch(&path) {
1180            Ok(_) => {
1181                restarted += 1;
1182                println!(
1183                    "Restarted worktree-watch parent cover for {} after install.",
1184                    path.display()
1185                );
1186            }
1187            Err(error) => {
1188                println!(
1189                    "Warning: could not restart parent worktree-watch for {}: {error}",
1190                    path.display()
1191                );
1192            }
1193        }
1194    }
1195
1196    for repo in &intent.repos {
1197        let path = PathBuf::from(repo);
1198        if !path.is_dir() {
1199            continue;
1200        }
1201        match resolve_repo_identity(Some(&path)).and_then(|id| {
1202            spawn_detached_worktree_watch_for_identity(&id).map(|_| id.root)
1203        }) {
1204            Ok(root) => {
1205                restarted += 1;
1206                println!(
1207                    "Restarted worktree-watch for {} after install.",
1208                    root.display()
1209                );
1210            }
1211            Err(error) => {
1212                println!(
1213                    "Warning: could not restart worktree-watch for {}: {error}",
1214                    path.display()
1215                );
1216            }
1217        }
1218    }
1219
1220    if intent.tray {
1221        match crate::commands::worktree_watch_tray::spawn_detached_tray_after_update() {
1222            Ok(()) => {
1223                restarted += 1;
1224                println!("Restarted worktree-watch tray after install.");
1225            }
1226            Err(error) => {
1227                println!("Warning: could not restart worktree-watch tray: {error}");
1228            }
1229        }
1230    }
1231
1232    if restarted == 0
1233        && intent.parents.is_empty()
1234        && intent.repos.is_empty()
1235        && !intent.tray
1236    {
1237        // nothing recorded
1238    }
1239}
1240
1241fn worktree_watch_restart_intent_path() -> Result<PathBuf, String> {
1242    let paths = ensure_global_xbp_paths()?;
1243    Ok(paths
1244        .root_dir
1245        .join("worktree-watch")
1246        .join("restart-after-update.json"))
1247}
1248
1249fn write_worktree_watch_restart_intent(intent: &WorktreeWatchRestartIntent) -> Result<(), String> {
1250    let path = worktree_watch_restart_intent_path()?;
1251    if let Some(parent) = path.parent() {
1252        fs::create_dir_all(parent).map_err(|e| {
1253            format!(
1254                "Failed to create {}: {e}",
1255                parent.display()
1256            )
1257        })?;
1258    }
1259    let rendered = serde_json::to_string_pretty(intent)
1260        .map_err(|e| format!("Failed to serialize restart intent: {e}"))?;
1261    fs::write(&path, format!("{rendered}\n"))
1262        .map_err(|e| format!("Failed to write {}: {e}", path.display()))
1263}
1264
1265fn read_worktree_watch_restart_intent() -> Option<WorktreeWatchRestartIntent> {
1266    let path = worktree_watch_restart_intent_path().ok()?;
1267    let content = fs::read_to_string(path).ok()?;
1268    serde_json::from_str(&content).ok()
1269}
1270
1271fn clear_worktree_watch_restart_intent() -> Result<(), String> {
1272    let path = worktree_watch_restart_intent_path()?;
1273    if path.exists() {
1274        fs::remove_file(&path).map_err(|e| format!("Failed to remove {}: {e}", path.display()))?;
1275    }
1276    Ok(())
1277}
1278
1279/// Mark that the tray should be relaunched after the next binary replace.
1280pub fn record_tray_restart_intent() {
1281    let mut intent = read_worktree_watch_restart_intent().unwrap_or_default();
1282    intent.tray = true;
1283    if intent.stopped_at.is_none() {
1284        intent.stopped_at = Some(Utc::now().to_rfc3339());
1285    }
1286    let _ = write_worktree_watch_restart_intent(&intent);
1287}
1288
1289pub async fn run_worktree_watch_status(options: WorktreeWatchStatusOptions) -> Result<(), String> {
1290    // Lightweight overview (running detection + spool counts + last sync age).
1291    // Full JSONL decode only when --records / --stats / --repo-activity is set.
1292    let need_heavy = options.records || options.stats || options.repo_activity;
1293    let snapshot = collect_worktree_watch_tray_snapshot(&options.target)?;
1294    let watch_cfg = resolve_worktree_watch_config();
1295    let rank_index = build_repo_rank_index(&watch_cfg);
1296
1297    let mut payloads = Vec::new();
1298    let mut total_files = 0usize;
1299    let mut total_records = 0usize;
1300
1301    for tray_repo in &snapshot.repositories {
1302        let identity = identity_from_tray_repo(tray_repo);
1303        let mut status = if need_heavy {
1304            worktree_watch_status_payload(&identity, options.records, options.record_limit)?
1305        } else {
1306            // Reuse tray counts; only open sync-log for age.
1307            worktree_watch_status_from_tray_repo(tray_repo)?
1308        };
1309
1310        if let Some(object) = status.as_object_mut() {
1311            object.insert("running".to_string(), json!(tray_repo.running));
1312            object.insert("pid".to_string(), json!(tray_repo.pid));
1313            if let Some(last_sync) = object.get("lastSync") {
1314                if let Some(synced_at) = last_sync.get("syncedAt").and_then(Value::as_str) {
1315                    if let Ok(dt) = DateTime::parse_from_rfc3339(synced_at) {
1316                        let age = (Utc::now() - dt.with_timezone(&Utc))
1317                            .num_seconds()
1318                            .max(0) as u64;
1319                        object.insert("lastSyncSecondsAgo".to_string(), json!(age));
1320                    }
1321                }
1322            }
1323            if options.resource {
1324                attach_repo_resource_fields(object, &identity.root, &rank_index, &watch_cfg);
1325            }
1326        }
1327
1328        let stats = if options.stats {
1329            Some(generate_and_store_stats(
1330                &identity,
1331                options.stats_gap_minutes,
1332            )?)
1333        } else {
1334            None
1335        };
1336        let repo_activity = if options.repo_activity {
1337            Some(generate_and_store_repo_activity(
1338                &identity,
1339                options.stats_gap_minutes,
1340            )?)
1341        } else {
1342            None
1343        };
1344        // Refresh TODO→issue coding effort from this repo's mutation spool when present.
1345        if options.stats || options.repo_activity {
1346            if let Err(err) = crate::commands::todos::effort::recompute_effort(
1347                &identity.root,
1348                options.stats_gap_minutes,
1349                true,
1350            ) {
1351                eprintln!("note: todo effort recompute: {err}");
1352            }
1353        }
1354        let status = attach_optional_stats(status, stats, repo_activity)?;
1355        total_files += status
1356            .get("unsyncedFiles")
1357            .and_then(Value::as_u64)
1358            .unwrap_or(0) as usize;
1359        total_records += status
1360            .get("unsyncedRecords")
1361            .and_then(Value::as_u64)
1362            .unwrap_or(0) as usize;
1363        payloads.push(status);
1364    }
1365
1366    if options.resource {
1367        // Hot repos first so the matrix / JSON surface busiest cover.
1368        payloads.sort_by(|a, b| {
1369            let sa = a.get("activityScore").and_then(Value::as_f64).unwrap_or(0.0);
1370            let sb = b.get("activityScore").and_then(Value::as_f64).unwrap_or(0.0);
1371            sb.partial_cmp(&sa)
1372                .unwrap_or(std::cmp::Ordering::Equal)
1373                .then_with(|| {
1374                    let ra = a.get("activityRank").and_then(Value::as_u64).unwrap_or(u64::MAX);
1375                    let rb = b.get("activityRank").and_then(Value::as_u64).unwrap_or(u64::MAX);
1376                    ra.cmp(&rb)
1377                })
1378        });
1379    }
1380
1381    let resource = if options.resource {
1382        Some(build_resource_insight(&snapshot, &payloads, &watch_cfg))
1383    } else {
1384        None
1385    };
1386
1387    if options.json {
1388        let mut payload = if options.target.parent.is_some() || payloads.len() > 1 {
1389            json!({
1390                "targetLabel": snapshot.target_label,
1391                "mode": snapshot.mode,
1392                "parent": snapshot.parent,
1393                "parentWatcherRunning": snapshot.parent_watcher_running,
1394                "parentWatcherPid": snapshot.parent_watcher_pid,
1395                "anyRunning": snapshot.any_running,
1396                "allRunning": snapshot.all_running,
1397                "runningWatchers": snapshot.running_watchers,
1398                "repositories": payloads,
1399                "repositoryCount": payloads.len(),
1400                "unsyncedFiles": total_files,
1401                "unsyncedRecords": total_records,
1402            })
1403        } else {
1404            payloads.into_iter().next().unwrap_or_else(|| json!({}))
1405        };
1406        if let Some(resource) = resource {
1407            if let Some(object) = payload.as_object_mut() {
1408                object.insert("resource".to_string(), resource);
1409            }
1410        }
1411        println!(
1412            "{}",
1413            serde_json::to_string_pretty(&payload)
1414                .map_err(|error| format!("Failed to render status JSON: {error}"))?
1415        );
1416    } else {
1417        print_status_overview(&snapshot, total_files, total_records);
1418        if let Some(resource) = resource.as_ref() {
1419            print_status_resource(resource);
1420        }
1421        print_status_matrix(&payloads, &snapshot, options.resource);
1422        if need_heavy {
1423            for (index, payload) in payloads.iter().enumerate() {
1424                if index > 0 {
1425                    println!();
1426                }
1427                if options.records {
1428                    println!(
1429                        "{} {}/{}",
1430                        "records".bright_black(),
1431                        payload
1432                            .get("repositoryOwner")
1433                            .and_then(Value::as_str)
1434                            .unwrap_or("unknown"),
1435                        payload
1436                            .get("repositoryName")
1437                            .and_then(Value::as_str)
1438                            .unwrap_or("repository")
1439                    );
1440                    print_status_records(payload);
1441                }
1442                if options.stats {
1443                    print_status_stats(payload);
1444                }
1445                if options.repo_activity {
1446                    print_repo_activity(payload);
1447                }
1448            }
1449        }
1450    }
1451
1452    Ok(())
1453}
1454
1455fn print_status_overview(
1456    snapshot: &WorktreeWatchTraySnapshot,
1457    total_files: usize,
1458    total_records: usize,
1459) {
1460    let running_label = if snapshot.any_running {
1461        "RUNNING".green().bold()
1462    } else {
1463        "STOPPED".red().bold()
1464    };
1465    println!(
1466        "{} {}  {}",
1467        "worktree-watch".bright_magenta().bold(),
1468        running_label,
1469        snapshot.target_label.bright_white()
1470    );
1471
1472    if snapshot.mode == "parent" {
1473        let parent_label = if snapshot.parent_watcher_running {
1474            format!(
1475                "parent watcher {}",
1476                snapshot
1477                    .parent_watcher_pid
1478                    .map(|pid| format!("pid {pid}"))
1479                    .unwrap_or_else(|| "active".to_string())
1480            )
1481            .green()
1482            .to_string()
1483        } else {
1484            "parent watcher not detected".red().to_string()
1485        };
1486        println!(
1487            "  {}  {}  {} repo(s) covered",
1488            parent_label,
1489            "·".bright_black(),
1490            snapshot.repository_count
1491        );
1492    } else {
1493        println!(
1494            "  {} watching · {} repo(s)",
1495            snapshot.running_watchers, snapshot.repository_count
1496        );
1497    }
1498
1499    println!(
1500        "  {} unsynced record(s) in {} file(s)",
1501        total_records.to_string().yellow(),
1502        total_files
1503    );
1504    if let Some(line) = snapshot.stats_line.as_ref() {
1505        println!("  {}", line.bright_black());
1506    }
1507    println!();
1508}
1509
1510fn print_status_resource(resource: &Value) {
1511    println!("{}", "resource".bright_cyan().bold());
1512
1513    let tiers = resource.get("tiers");
1514    let hot = tiers
1515        .and_then(|t| t.get("hot"))
1516        .and_then(Value::as_u64)
1517        .unwrap_or(0);
1518    let warm = tiers
1519        .and_then(|t| t.get("warm"))
1520        .and_then(Value::as_u64)
1521        .unwrap_or(0);
1522    let cold = tiers
1523        .and_then(|t| t.get("cold"))
1524        .and_then(Value::as_u64)
1525        .unwrap_or(0);
1526    let unknown = tiers
1527        .and_then(|t| t.get("unknown"))
1528        .and_then(Value::as_u64)
1529        .unwrap_or(0);
1530    let fs_watches = resource
1531        .get("estimatedFsWatches")
1532        .and_then(Value::as_u64)
1533        .unwrap_or(hot + warm);
1534
1535    println!(
1536        "  tiers  {} hot · {} warm · {} cold{}",
1537        hot.to_string().red().bold(),
1538        warm.to_string().yellow(),
1539        cold.to_string().bright_black(),
1540        if unknown > 0 {
1541            format!(" · {unknown} unranked")
1542        } else {
1543            String::new()
1544        }
1545    );
1546    println!(
1547        "  watches  ~{} recursive FS watch(es)  {} cold unwatched (deletable)",
1548        fs_watches.to_string().cyan(),
1549        cold.to_string().bright_black()
1550    );
1551
1552    if let Some(proc) = resource.get("process") {
1553        let pid = proc.get("pid").and_then(Value::as_u64);
1554        let cpu = proc.get("cpuPercent").and_then(Value::as_f64);
1555        let rss_mb = proc.get("rssMb").and_then(Value::as_f64);
1556        let mut parts = Vec::new();
1557        if let Some(pid) = pid {
1558            parts.push(format!("pid {pid}"));
1559        }
1560        if let Some(cpu) = cpu {
1561            parts.push(format!("cpu {cpu:.1}%"));
1562        }
1563        if let Some(rss) = rss_mb {
1564            parts.push(format!("rss {rss:.1} MiB"));
1565        }
1566        if !parts.is_empty() {
1567            let cpu_style = match cpu {
1568                Some(c) if c >= 5.0 => parts.join(" · ").red().to_string(),
1569                Some(c) if c >= 2.0 => parts.join(" · ").yellow().to_string(),
1570                _ => parts.join(" · ").green().to_string(),
1571            };
1572            println!("  process  {cpu_style}");
1573        }
1574    } else {
1575        println!(
1576            "  process  {}",
1577            "no live watcher process stats".bright_black()
1578        );
1579    }
1580
1581    if let Some(sched) = resource.get("schedule") {
1582        let idle = sched
1583            .get("idleAfterSeconds")
1584            .and_then(Value::as_u64)
1585            .unwrap_or(0);
1586        let cold_after = sched
1587            .get("coldAfterSeconds")
1588            .and_then(Value::as_u64)
1589            .unwrap_or(0);
1590        let hot_c = sched
1591            .get("hotCommitCheckSeconds")
1592            .and_then(Value::as_u64)
1593            .unwrap_or(0);
1594        let warm_c = sched
1595            .get("warmCommitCheckSeconds")
1596            .and_then(Value::as_u64)
1597            .unwrap_or(0);
1598        let cold_c = sched
1599            .get("coldCommitCheckSeconds")
1600            .and_then(Value::as_u64)
1601            .unwrap_or(0);
1602        let max_hot = sched
1603            .get("maxHotRepos")
1604            .and_then(Value::as_u64)
1605            .unwrap_or(0);
1606        let max_fs = sched
1607            .get("maxFsWatches")
1608            .and_then(Value::as_u64)
1609            .unwrap_or(0);
1610        println!(
1611            "  schedule  idle→warm {idle}s · cold {cold_after}s · HEAD {hot_c}/{warm_c}/{cold_c}s · max_hot {max_hot} · max_fs {max_fs}"
1612        );
1613    }
1614
1615    if let Some(top) = resource.get("topRepos").and_then(Value::as_array) {
1616        if !top.is_empty() {
1617            println!("  top by activity score");
1618            for (i, entry) in top.iter().take(8).enumerate() {
1619                let name = entry
1620                    .get("repository")
1621                    .and_then(Value::as_str)
1622                    .unwrap_or("?");
1623                let tier = entry.get("tier").and_then(Value::as_str).unwrap_or("?");
1624                let score = entry.get("score").and_then(Value::as_f64).unwrap_or(0.0);
1625                let last = entry
1626                    .get("lastEventAt")
1627                    .and_then(Value::as_str)
1628                    .unwrap_or("—");
1629                let last_short = if last.len() >= 19 {
1630                    &last[11..19]
1631                } else {
1632                    last
1633                };
1634                let tier_col = match tier {
1635                    "hot" => tier.red().bold().to_string(),
1636                    "warm" => tier.yellow().to_string(),
1637                    "cold" => tier.bright_black().to_string(),
1638                    _ => tier.bright_black().to_string(),
1639                };
1640                println!(
1641                    "    {:>2}. {:<36}  {}  score {:>7.2}  last {}",
1642                    i + 1,
1643                    truncate_middle(name, 36),
1644                    tier_col,
1645                    score,
1646                    last_short
1647                );
1648            }
1649        }
1650    }
1651
1652    if let Some(note) = resource.get("note").and_then(Value::as_str) {
1653        println!("  {}", note.bright_black());
1654    }
1655    println!();
1656}
1657
1658fn print_status_matrix(
1659    payloads: &[Value],
1660    snapshot: &WorktreeWatchTraySnapshot,
1661    show_resource: bool,
1662) {
1663    if payloads.is_empty() {
1664        println!("{}", "No repositories found for this target.".yellow());
1665        return;
1666    }
1667
1668    let headers: Vec<&str> = if show_resource {
1669        vec![
1670            "REPO",
1671            "BRANCH",
1672            "TIER",
1673            "RANK",
1674            "SCORE",
1675            "STATE",
1676            "PID",
1677            "UNSYNCED",
1678            "AGE",
1679        ]
1680    } else {
1681        vec![
1682            "REPO", "BRANCH", "STATE", "PID", "UNSYNCED", "LAST SYNC", "AGE",
1683        ]
1684    };
1685    let mut rows = Vec::with_capacity(payloads.len());
1686
1687    for payload in payloads {
1688        let owner = payload
1689            .get("repositoryOwner")
1690            .or_else(|| payload.get("owner"))
1691            .and_then(Value::as_str)
1692            .unwrap_or("unknown");
1693        let name = payload
1694            .get("repositoryName")
1695            .or_else(|| payload.get("name"))
1696            .and_then(Value::as_str)
1697            .unwrap_or("repository");
1698        let branch = payload
1699            .get("branchName")
1700            .or_else(|| payload.get("branch"))
1701            .and_then(Value::as_str)
1702            .unwrap_or("unknown");
1703        let running = payload
1704            .get("running")
1705            .and_then(Value::as_bool)
1706            .unwrap_or(false);
1707        let pid = payload
1708            .get("pid")
1709            .and_then(Value::as_u64)
1710            .map(|value| value.to_string())
1711            .unwrap_or_else(|| {
1712                if running {
1713                    snapshot
1714                        .parent_watcher_pid
1715                        .map(|value| value.to_string())
1716                        .unwrap_or_else(|| "—".to_string())
1717                } else {
1718                    "—".to_string()
1719                }
1720            });
1721        let unsynced = payload
1722            .get("unsyncedRecords")
1723            .and_then(Value::as_u64)
1724            .unwrap_or(0);
1725        let (last_sync_at, age) = last_sync_display(payload);
1726        let state = if running {
1727            "RUN".green().bold().to_string()
1728        } else {
1729            "OFF".red().to_string()
1730        };
1731        let unsynced_cell = if unsynced > 0 {
1732            unsynced.to_string().yellow().to_string()
1733        } else {
1734            "0".bright_black().to_string()
1735        };
1736        let age_cell = if age == "never" {
1737            age.bright_black().to_string()
1738        } else {
1739            age.cyan().to_string()
1740        };
1741
1742        if show_resource {
1743            let tier = payload
1744                .get("watchTier")
1745                .and_then(Value::as_str)
1746                .unwrap_or("—");
1747            let tier_cell = match tier {
1748                "hot" => tier.red().bold().to_string(),
1749                "warm" => tier.yellow().to_string(),
1750                "cold" => tier.bright_black().to_string(),
1751                _ => tier.bright_black().to_string(),
1752            };
1753            let rank = payload
1754                .get("activityRank")
1755                .and_then(Value::as_u64)
1756                .map(|r| r.to_string())
1757                .unwrap_or_else(|| "—".to_string());
1758            let score = payload
1759                .get("activityScore")
1760                .and_then(Value::as_f64)
1761                .map(|s| format!("{s:.1}"))
1762                .unwrap_or_else(|| "—".to_string());
1763            rows.push(vec![
1764                format!("{owner}/{name}"),
1765                branch.to_string(),
1766                tier_cell,
1767                rank,
1768                score,
1769                state,
1770                pid,
1771                unsynced_cell,
1772                age_cell,
1773            ]);
1774        } else {
1775            rows.push(vec![
1776                format!("{owner}/{name}"),
1777                branch.to_string(),
1778                state,
1779                pid,
1780                unsynced_cell,
1781                last_sync_at,
1782                age_cell,
1783            ]);
1784        }
1785    }
1786
1787    print!("{}", render_table(&headers, &rows, TableStyle::Box, ""));
1788
1789    let running = snapshot.running_watchers;
1790    let stopped = snapshot
1791        .repository_count
1792        .saturating_sub(snapshot.running_watchers);
1793    println!();
1794    println!(
1795        "{} {} running · {} stopped · {} total",
1796        "summary".bright_black(),
1797        running.to_string().green().bold(),
1798        stopped.to_string().red(),
1799        snapshot.repository_count
1800    );
1801    if show_resource {
1802        println!(
1803            "{} tiers from ~/.xbp config ranks (updated while watcher runs)",
1804            "note".bright_black()
1805        );
1806    }
1807}
1808
1809fn build_repo_rank_index(cfg: &WorktreeWatchConfig) -> BTreeMap<String, WorktreeWatchRepoRank> {
1810    let mut map = BTreeMap::new();
1811    for rank in &cfg.repo_ranks {
1812        let key = path_identity_key(Path::new(&rank.root));
1813        map.insert(key, rank.clone());
1814    }
1815    map
1816}
1817
1818fn attach_repo_resource_fields(
1819    object: &mut serde_json::Map<String, Value>,
1820    root: &Path,
1821    ranks: &BTreeMap<String, WorktreeWatchRepoRank>,
1822    cfg: &WorktreeWatchConfig,
1823) {
1824    let key = path_identity_key(root);
1825    if let Some(rank) = ranks.get(&key) {
1826        let tier = rank
1827            .tier
1828            .clone()
1829            .unwrap_or_else(|| "unknown".to_string());
1830        object.insert("watchTier".to_string(), json!(tier));
1831        object.insert("activityScore".to_string(), json!(rank.score));
1832        if let Some(r) = rank.rank {
1833            object.insert("activityRank".to_string(), json!(r));
1834        }
1835        if let Some(at) = rank.last_event_at.as_ref() {
1836            object.insert("lastActivityAt".to_string(), json!(at));
1837        }
1838        // Hot is preferred for FS watches; warm only when under max_fs_watches budget.
1839        let fs_watched = matches!(tier.as_str(), "hot");
1840        object.insert("fsWatched".to_string(), json!(fs_watched));
1841        let commit_secs = match tier.as_str() {
1842            "hot" => cfg.hot_commit_check_seconds(),
1843            "warm" => cfg.warm_commit_check_seconds(),
1844            "cold" => cfg.cold_commit_check_seconds(),
1845            _ => cfg.warm_commit_check_seconds(),
1846        };
1847        object.insert("commitCheckSeconds".to_string(), json!(commit_secs));
1848    } else {
1849        object.insert("watchTier".to_string(), json!("unknown"));
1850        object.insert("activityScore".to_string(), json!(0.0));
1851        object.insert("fsWatched".to_string(), json!(false));
1852        object.insert(
1853            "commitCheckSeconds".to_string(),
1854            json!(cfg.cold_commit_check_seconds()),
1855        );
1856    }
1857}
1858
1859fn build_resource_insight(
1860    snapshot: &WorktreeWatchTraySnapshot,
1861    payloads: &[Value],
1862    cfg: &WorktreeWatchConfig,
1863) -> Value {
1864    let mut hot = 0u64;
1865    let mut warm = 0u64;
1866    let mut cold = 0u64;
1867    let mut unknown = 0u64;
1868    for payload in payloads {
1869        match payload
1870            .get("watchTier")
1871            .and_then(Value::as_str)
1872            .unwrap_or("unknown")
1873        {
1874            "hot" => hot += 1,
1875            "warm" => warm += 1,
1876            "cold" => cold += 1,
1877            _ => unknown += 1,
1878        }
1879    }
1880    // Hot is preferred; warm only fills remaining budget (see max_fs_watches).
1881    let estimated_fs_watches = hot
1882        .saturating_add(warm)
1883        .min(cfg.max_fs_watches() as u64);
1884
1885    let mut pids: Vec<u32> = Vec::new();
1886    if let Some(pid) = snapshot.parent_watcher_pid {
1887        pids.push(pid);
1888    }
1889    for repo in &snapshot.repositories {
1890        if let Some(pid) = repo.pid {
1891            if !pids.contains(&pid) {
1892                pids.push(pid);
1893            }
1894        }
1895    }
1896    let process = sample_watcher_process_stats(&pids);
1897
1898    let mut top: Vec<Value> = payloads
1899        .iter()
1900        .filter_map(|p| {
1901            let owner = p
1902                .get("repositoryOwner")
1903                .or_else(|| p.get("owner"))
1904                .and_then(Value::as_str)?;
1905            let name = p
1906                .get("repositoryName")
1907                .or_else(|| p.get("name"))
1908                .and_then(Value::as_str)?;
1909            let score = p.get("activityScore").and_then(Value::as_f64).unwrap_or(0.0);
1910            if score <= 0.0
1911                && p.get("watchTier").and_then(Value::as_str) == Some("unknown")
1912            {
1913                return None;
1914            }
1915            Some(json!({
1916                "repository": format!("{owner}/{name}"),
1917                "tier": p.get("watchTier").and_then(Value::as_str).unwrap_or("unknown"),
1918                "score": score,
1919                "rank": p.get("activityRank"),
1920                "lastEventAt": p.get("lastActivityAt"),
1921                "fsWatched": p.get("fsWatched"),
1922                "commitCheckSeconds": p.get("commitCheckSeconds"),
1923            }))
1924        })
1925        .collect();
1926    top.sort_by(|a, b| {
1927        let sa = a.get("score").and_then(Value::as_f64).unwrap_or(0.0);
1928        let sb = b.get("score").and_then(Value::as_f64).unwrap_or(0.0);
1929        sb.partial_cmp(&sa)
1930            .unwrap_or(std::cmp::Ordering::Equal)
1931    });
1932    top.truncate(12);
1933
1934    let note = if snapshot.any_running && hot + warm + cold == 0 {
1935        Some(
1936            "No repo_ranks yet — start/restart the watcher so it persists activity tiers to ~/.xbp/config.yaml"
1937                .to_string(),
1938        )
1939    } else if !snapshot.any_running {
1940        Some("Watcher not running — tier data is last known from config ranks".to_string())
1941    } else {
1942        None
1943    };
1944
1945    json!({
1946        "tiers": {
1947            "hot": hot,
1948            "warm": warm,
1949            "cold": cold,
1950            "unknown": unknown,
1951        },
1952        "estimatedFsWatches": estimated_fs_watches,
1953        "repositoryCount": payloads.len(),
1954        "process": process,
1955        "schedule": {
1956            "idleAfterSeconds": cfg.idle_after_seconds(),
1957            "coldAfterSeconds": cfg.cold_after_seconds(),
1958            "hotCommitCheckSeconds": cfg.hot_commit_check_seconds(),
1959            "warmCommitCheckSeconds": cfg.warm_commit_check_seconds(),
1960            "coldCommitCheckSeconds": cfg.cold_commit_check_seconds(),
1961            "parentRescanSeconds": cfg.parent_rescan_seconds(),
1962            "rankPersistSeconds": cfg.rank_persist_seconds(),
1963            "maxHotRepos": cfg.max_hot_repos(),
1964            "maxFsWatches": cfg.max_fs_watches(),
1965            "spoolRetentionDays": cfg.spool_retention_days(),
1966        },
1967        "topRepos": top,
1968        "note": note,
1969    })
1970}
1971
1972fn sample_watcher_process_stats(pids: &[u32]) -> Value {
1973    let filtered: Vec<u32> = pids
1974        .iter()
1975        .copied()
1976        .filter(|p| *p != 0)
1977        .collect::<BTreeSet<_>>()
1978        .into_iter()
1979        .collect();
1980    if filtered.is_empty() {
1981        return Value::Null;
1982    }
1983
1984    let pid_list: Vec<Pid> = filtered.iter().copied().map(Pid::from_u32).collect();
1985    let mut system = System::new();
1986    let refresh = ProcessRefreshKind::everything()
1987        .with_cmd(UpdateKind::Always)
1988        .with_exe(UpdateKind::Always)
1989        .with_memory();
1990    // Two samples so cpu_usage is meaningful.
1991    system.refresh_processes_specifics(ProcessesToUpdate::Some(&pid_list), true, refresh);
1992    std::thread::sleep(Duration::from_millis(200));
1993    system.refresh_processes_specifics(ProcessesToUpdate::Some(&pid_list), true, refresh);
1994
1995    let mut total_cpu = 0.0_f64;
1996    let mut total_rss: u64 = 0;
1997    let mut live = Vec::new();
1998    for pid in &filtered {
1999        let Some(proc) = system.process(Pid::from_u32(*pid)) else {
2000            continue;
2001        };
2002        if !process_looks_like_worktree_watch(proc)
2003            && *pid != std::process::id()
2004            && !process_cmdline_contains_worktree(proc)
2005        {
2006            // Still report raw stats if it matches our tracked watcher pid from state.
2007        }
2008        let cpu = proc.cpu_usage() as f64;
2009        let rss = proc.memory(); // bytes in recent sysinfo
2010        total_cpu += f64::from(cpu);
2011        total_rss = total_rss.saturating_add(rss);
2012        live.push(json!({
2013            "pid": pid,
2014            "cpuPercent": (cpu * 10.0).round() / 10.0,
2015            "rssMb": (rss as f64 / (1024.0 * 1024.0) * 10.0).round() / 10.0,
2016            "name": proc.name().to_string_lossy(),
2017        }));
2018    }
2019
2020    if live.is_empty() {
2021        return Value::Null;
2022    }
2023
2024    json!({
2025        "pid": filtered.first().copied(),
2026        "pids": filtered,
2027        "cpuPercent": (total_cpu * 10.0).round() / 10.0,
2028        "rssMb": (total_rss as f64 / (1024.0 * 1024.0) * 10.0).round() / 10.0,
2029        "processes": live,
2030    })
2031}
2032
2033fn process_cmdline_contains_worktree(process: &sysinfo::Process) -> bool {
2034    process
2035        .cmd()
2036        .iter()
2037        .any(|part| part.to_string_lossy().to_ascii_lowercase().contains("worktree-watch"))
2038}
2039
2040fn truncate_middle(s: &str, max: usize) -> String {
2041    if s.chars().count() <= max {
2042        return s.to_string();
2043    }
2044    if max < 5 {
2045        return s.chars().take(max).collect();
2046    }
2047    let keep = max.saturating_sub(1) / 2;
2048    let front: String = s.chars().take(keep).collect();
2049    let back: String = s
2050        .chars()
2051        .rev()
2052        .take(max - keep - 1)
2053        .collect::<String>()
2054        .chars()
2055        .rev()
2056        .collect();
2057    format!("{front}…{back}")
2058}
2059
2060fn last_sync_display(payload: &Value) -> (String, String) {
2061    let Some(last_sync) = payload.get("lastSync") else {
2062        return ("—".to_string(), "never".to_string());
2063    };
2064    let synced_at = last_sync
2065        .get("syncedAt")
2066        .and_then(Value::as_str)
2067        .unwrap_or("unknown");
2068    let age = if let Some(seconds) = payload
2069        .get("lastSyncSecondsAgo")
2070        .and_then(Value::as_u64)
2071    {
2072        format_age_seconds(seconds)
2073    } else if let Ok(dt) = DateTime::parse_from_rfc3339(synced_at) {
2074        let seconds = (Utc::now() - dt.with_timezone(&Utc)).num_seconds().max(0) as u64;
2075        format_age_seconds(seconds)
2076    } else {
2077        "unknown".to_string()
2078    };
2079    // Compact timestamp for the matrix column.
2080    let compact = if synced_at.len() >= 19 {
2081        synced_at[11..19].to_string()
2082    } else {
2083        synced_at.to_string()
2084    };
2085    (compact, age)
2086}
2087
2088fn format_age_seconds(seconds: u64) -> String {
2089    if seconds < 60 {
2090        format!("{seconds}s ago")
2091    } else if seconds < 3600 {
2092        format!("{}m ago", seconds / 60)
2093    } else if seconds < 86400 {
2094        format!("{}h ago", seconds / 3600)
2095    } else {
2096        format!("{}d ago", seconds / 86400)
2097    }
2098}
2099
2100fn attach_optional_stats(
2101    mut payload: Value,
2102    stats: Option<WorktreeWatchStatsSummary>,
2103    repo_activity: Option<WorktreeWatchRepoActivitySummary>,
2104) -> Result<Value, String> {
2105    if let Some(object) = payload.as_object_mut() {
2106        if let Some(stats) = stats {
2107            object.insert(
2108                "stats".to_string(),
2109                serde_json::to_value(stats)
2110                    .map_err(|error| format!("Failed to render stats payload: {error}"))?,
2111            );
2112        }
2113        if let Some(repo_activity) = repo_activity {
2114            object.insert(
2115                "repositoryActivity".to_string(),
2116                serde_json::to_value(repo_activity)
2117                    .map_err(|error| format!("Failed to render repo activity payload: {error}"))?,
2118            );
2119        }
2120    }
2121    Ok(payload)
2122}
2123
2124pub fn spawn_detached_worktree_watch_for_current_repo() -> Result<Option<PathBuf>, String> {
2125    if is_background_child() {
2126        return Ok(None);
2127    }
2128
2129    let identity = match resolve_repo_identity(None) {
2130        Ok(identity) => identity,
2131        Err(_) => return Ok(None),
2132    };
2133    spawn_detached_worktree_watch_for_identity(&identity).map(Some)
2134}
2135
2136/// Snapshot of worktree-watch storage and watcher state for `xbp diag`.
2137#[derive(Debug, Clone, Serialize, Deserialize)]
2138#[serde(rename_all = "camelCase")]
2139pub struct WorktreeWatchDiagnostic {
2140    pub global_xbp_root: String,
2141    pub mutations_root: String,
2142    pub runtime_key: String,
2143    pub platform: String,
2144    pub is_wsl: bool,
2145    pub repo_owner: Option<String>,
2146    pub repo_name: Option<String>,
2147    pub branch: Option<String>,
2148    pub branch_spool: Option<String>,
2149    pub branch_spool_exists: bool,
2150    pub watcher_state_path: Option<String>,
2151    pub watcher_state_present: bool,
2152    pub watcher_running: bool,
2153    pub event_jsonl_files: usize,
2154    pub commit_jsonl_files: usize,
2155    pub alternate_spool_roots: Vec<String>,
2156    pub notes: Vec<String>,
2157}
2158
2159/// Lightweight mutation activity for TODO→issue effort attribution.
2160#[derive(Debug, Clone)]
2161pub struct MutationActivityEvent {
2162    pub paths: Vec<String>,
2163    pub primary_path: Option<String>,
2164    pub occurred_at: DateTime<Utc>,
2165    pub added_lines: Option<u64>,
2166    pub removed_lines: Option<u64>,
2167    pub head_sha: Option<String>,
2168    pub branch_name: String,
2169}
2170
2171/// Lightweight commit activity for TODO→issue effort attribution.
2172#[derive(Debug, Clone)]
2173pub struct CommitActivityEvent {
2174    pub occurred_at: DateTime<Utc>,
2175    pub head_sha: String,
2176    pub subject: Option<String>,
2177    pub branch_name: String,
2178    pub repo_root: String,
2179}
2180
2181/// Load all spooled mutation events for a GitHub `owner/name` across branch folders.
2182pub fn load_repo_mutation_activity(
2183    owner: &str,
2184    name: &str,
2185) -> Result<Vec<MutationActivityEvent>, String> {
2186    let identity = RepoIdentity {
2187        owner: owner.to_string(),
2188        name: name.to_string(),
2189        branch: "main".to_string(),
2190        root: PathBuf::from("."),
2191        head_sha: None,
2192    };
2193    let mut events = Vec::new();
2194    for repo_root in repository_spool_search_roots(&identity)? {
2195        if !repo_root.is_dir() {
2196            continue;
2197        }
2198        for entry in fs::read_dir(&repo_root).map_err(|e| {
2199            format!(
2200                "Failed to read mutation spool {}: {e}",
2201                repo_root.display()
2202            )
2203        })? {
2204            let entry = entry.map_err(|e| format!("Failed to read spool entry: {e}"))?;
2205            if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
2206                continue;
2207            }
2208            let branch_layout = spool_layout_for_existing_root(&entry.path());
2209            let candidates = collect_spool_candidates(&branch_layout, true)?;
2210            for candidate in candidates {
2211                if let SyncRecords::Events(records) = candidate.records {
2212                    for record in records {
2213                        events.push(MutationActivityEvent {
2214                            paths: record.paths,
2215                            primary_path: record.primary_path,
2216                            occurred_at: record.occurred_at,
2217                            added_lines: record.added_lines,
2218                            removed_lines: record.removed_lines,
2219                            head_sha: record.head_sha,
2220                            branch_name: record.branch_name,
2221                        });
2222                    }
2223                }
2224            }
2225        }
2226    }
2227    events.sort_by_key(|e| e.occurred_at);
2228    Ok(events)
2229}
2230
2231/// Load all spooled commit events for a GitHub `owner/name`.
2232pub fn load_repo_commit_activity(
2233    owner: &str,
2234    name: &str,
2235) -> Result<Vec<CommitActivityEvent>, String> {
2236    let identity = RepoIdentity {
2237        owner: owner.to_string(),
2238        name: name.to_string(),
2239        branch: "main".to_string(),
2240        root: PathBuf::from("."),
2241        head_sha: None,
2242    };
2243    let mut commits = Vec::new();
2244    for repo_root in repository_spool_search_roots(&identity)? {
2245        if !repo_root.is_dir() {
2246            continue;
2247        }
2248        for entry in fs::read_dir(&repo_root).map_err(|e| {
2249            format!(
2250                "Failed to read mutation spool {}: {e}",
2251                repo_root.display()
2252            )
2253        })? {
2254            let entry = entry.map_err(|e| format!("Failed to read spool entry: {e}"))?;
2255            if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
2256                continue;
2257            }
2258            let branch_layout = spool_layout_for_existing_root(&entry.path());
2259            let candidates = collect_spool_candidates(&branch_layout, true)?;
2260            for candidate in candidates {
2261                if let SyncRecords::Commits(records) = candidate.records {
2262                    for record in records {
2263                        commits.push(CommitActivityEvent {
2264                            occurred_at: record.occurred_at,
2265                            head_sha: record.head_sha,
2266                            subject: record.subject,
2267                            branch_name: record.branch_name,
2268                            repo_root: record.repo_root,
2269                        });
2270                    }
2271                }
2272            }
2273        }
2274    }
2275    commits.sort_by_key(|c| c.occurred_at);
2276    Ok(commits)
2277}
2278
2279/// Collect home-level worktree-watch paths and optional current-repo spool status.
2280pub fn collect_worktree_watch_diagnostic() -> WorktreeWatchDiagnostic {
2281    let runtime_key = worktree_runtime_key();
2282    let platform = std::env::consts::OS.to_string();
2283    let is_wsl = is_wsl_runtime();
2284    let mut notes = Vec::new();
2285
2286    let global_xbp_root = match ensure_global_xbp_paths() {
2287        Ok(paths) => paths.root_dir,
2288        Err(error) => {
2289            notes.push(format!("Failed to resolve global XBP home: {error}"));
2290            resolve_global_xbp_root_dir()
2291        }
2292    };
2293    let mutations_root = global_xbp_root.join("mutations");
2294
2295    let identity = resolve_repo_identity(None).ok();
2296    let (
2297        repo_owner,
2298        repo_name,
2299        branch,
2300        branch_spool,
2301        branch_spool_exists,
2302        watcher_state_path,
2303        watcher_state_present,
2304        watcher_running,
2305        event_jsonl_files,
2306        commit_jsonl_files,
2307        alternate_spool_roots,
2308    ) = if let Some(identity) = identity.as_ref() {
2309        let layout = prepare_spool_layout(identity).ok();
2310        let branch_spool = layout
2311            .as_ref()
2312            .map(|value| value.root.display().to_string());
2313        let branch_spool_exists = layout
2314            .as_ref()
2315            .map(|value| value.root.exists())
2316            .unwrap_or(false);
2317        let watcher_state_path = layout
2318            .as_ref()
2319            .map(|value| value.watcher_state_file.display().to_string());
2320        let watcher_state_present = layout
2321            .as_ref()
2322            .map(|value| {
2323                watcher_state_read_candidates(&value.watcher_state_file)
2324                    .iter()
2325                    .any(|candidate| candidate.exists())
2326            })
2327            .unwrap_or(false);
2328        let watcher_running = layout
2329            .as_ref()
2330            .and_then(|value| read_watcher_state(&value.watcher_state_file).ok().flatten())
2331            .map(|state| is_matching_watcher_process(&state))
2332            .unwrap_or(false);
2333
2334        let mut event_jsonl_files = 0usize;
2335        let mut commit_jsonl_files = 0usize;
2336        if let Some(layout) = layout.as_ref() {
2337            if let Ok(entries) = fs::read_dir(&layout.root) {
2338                for entry in entries.flatten() {
2339                    let name = entry.file_name().to_string_lossy().to_string();
2340                    if name.starts_with("events-") && name.ends_with(".jsonl") {
2341                        event_jsonl_files += 1;
2342                    } else if name.starts_with("commits-") && name.ends_with(".jsonl") {
2343                        commit_jsonl_files += 1;
2344                    }
2345                }
2346            }
2347        }
2348
2349        let alternate_spool_roots = repository_spool_search_roots(identity)
2350            .unwrap_or_default()
2351            .into_iter()
2352            .map(|path| {
2353                path.join(sanitize_path_component(&identity.branch))
2354                    .display()
2355                    .to_string()
2356            })
2357            .filter(|path| {
2358                layout
2359                    .as_ref()
2360                    .map(|value| path_identity_key(Path::new(path)) != path_identity_key(&value.root))
2361                    .unwrap_or(true)
2362            })
2363            .filter(|path| Path::new(path).exists())
2364            .collect::<Vec<_>>();
2365
2366        let root_display = global_xbp_root
2367            .display()
2368            .to_string()
2369            .replace('\\', "/")
2370            .to_ascii_lowercase();
2371        if is_wsl && !root_display.starts_with("/mnt/") {
2372            notes.push(
2373                "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."
2374                    .to_string(),
2375            );
2376        }
2377        if is_wsl && root_display.starts_with("/mnt/") {
2378            notes.push(
2379                "Using a Windows-profile-backed XBP home from WSL; spoils are reconcilable with native Windows."
2380                    .to_string(),
2381            );
2382        }
2383        if !alternate_spool_roots.is_empty() {
2384            notes.push(format!(
2385                "Found {} alternate spool location(s) with historical data (legacy homes / runtime layouts).",
2386                alternate_spool_roots.len()
2387            ));
2388        }
2389
2390        (
2391            Some(identity.owner.clone()),
2392            Some(identity.name.clone()),
2393            Some(identity.branch.clone()),
2394            branch_spool,
2395            branch_spool_exists,
2396            watcher_state_path,
2397            watcher_state_present,
2398            watcher_running,
2399            event_jsonl_files,
2400            commit_jsonl_files,
2401            alternate_spool_roots,
2402        )
2403    } else {
2404        notes.push(
2405            "Not inside a git repository with a resolvable remote; showing global XBP home only."
2406                .to_string(),
2407        );
2408        (
2409            None,
2410            None,
2411            None,
2412            None,
2413            false,
2414            None,
2415            false,
2416            false,
2417            0,
2418            0,
2419            Vec::new(),
2420        )
2421    };
2422
2423    WorktreeWatchDiagnostic {
2424        global_xbp_root: global_xbp_root.display().to_string(),
2425        mutations_root: mutations_root.display().to_string(),
2426        runtime_key,
2427        platform,
2428        is_wsl,
2429        repo_owner,
2430        repo_name,
2431        branch,
2432        branch_spool,
2433        branch_spool_exists,
2434        watcher_state_path,
2435        watcher_state_present,
2436        watcher_running,
2437        event_jsonl_files,
2438        commit_jsonl_files,
2439        alternate_spool_roots,
2440        notes,
2441    }
2442}
2443
2444fn resolve_target_identities(
2445    target: &WorktreeWatchTargetOptions,
2446) -> Result<Vec<RepoIdentity>, String> {
2447    if target.repo.is_some() && target.parent.is_some() {
2448        return Err("Pass either `--repo` or `--parent`, not both.".to_string());
2449    }
2450    if !target.repos.is_empty() && target.parent.is_some() {
2451        return Err("Pass either `--repos` or `--parent`, not both.".to_string());
2452    }
2453    if target.repo.is_some() && !target.repos.is_empty() {
2454        return Err("Pass either `--repo` or `--repos`, not both.".to_string());
2455    }
2456
2457    if let Some(parent) = target.parent.as_deref() {
2458        return discover_repo_identities_under(parent);
2459    }
2460
2461    if !target.repos.is_empty() {
2462        let mut identities = Vec::new();
2463        let mut seen = BTreeSet::new();
2464        for repo in &target.repos {
2465            let identity = resolve_repo_identity(Some(repo.as_path()))?;
2466            let key = path_identity_key(&identity.root);
2467            if seen.insert(key) {
2468                identities.push(identity);
2469            }
2470        }
2471        return Ok(identities);
2472    }
2473
2474    resolve_repo_identity(target.repo.as_deref()).map(|identity| vec![identity])
2475}
2476
2477/// Collect a tray-friendly snapshot of watcher state + unsynced backlog.
2478pub fn collect_worktree_watch_tray_snapshot(
2479    target: &WorktreeWatchTargetOptions,
2480) -> Result<WorktreeWatchTraySnapshot, String> {
2481    let identities = resolve_target_identities(target)?;
2482    let mode = if target.parent.is_some() {
2483        "parent"
2484    } else if !target.repos.is_empty() {
2485        "repos"
2486    } else {
2487        "repo"
2488    }
2489    .to_string();
2490
2491    let parent_label = target
2492        .parent
2493        .as_ref()
2494        .map(|path| path.display().to_string());
2495    let target_label = if let Some(parent) = parent_label.as_ref() {
2496        format!("parent:{parent}")
2497    } else if !target.repos.is_empty() {
2498        format!("{} repo(s)", target.repos.len())
2499    } else if let Some(repo) = target.repo.as_ref() {
2500        repo.display().to_string()
2501    } else {
2502        identities
2503            .first()
2504            .map(|identity| identity.root.display().to_string())
2505            .unwrap_or_else(|| "current repo".to_string())
2506    };
2507
2508    // Collect PIDs first so we only refresh those processes once (System::new_all
2509    // per repo was freezing status for large parent folders on Windows).
2510    let mut parent_state: Option<ParentWorktreeWatcherState> = None;
2511    if let Some(parent) = target.parent.as_deref() {
2512        if let Ok(parent) = canonical_parent_path(parent) {
2513            if let Ok(layout) = prepare_parent_spool_layout(&parent) {
2514                parent_state = read_parent_watcher_state(&layout.watcher_state_file)?;
2515            }
2516        }
2517    }
2518
2519    let mut repo_states: Vec<(usize, WorktreeWatcherState)> = Vec::new();
2520    let mut layouts: Vec<SpoolLayout> = Vec::with_capacity(identities.len());
2521    for (index, identity) in identities.iter().enumerate() {
2522        let layout = prepare_spool_layout(identity)?;
2523        if let Some(state) = read_watcher_state(&layout.watcher_state_file)? {
2524            if same_repo_watcher_state(identity, &state) {
2525                repo_states.push((index, state));
2526            }
2527        }
2528        layouts.push(layout);
2529    }
2530
2531    let mut pids: Vec<u32> = repo_states.iter().map(|(_, state)| state.pid).collect();
2532    if let Some(state) = parent_state.as_ref() {
2533        pids.push(state.pid);
2534    }
2535    let process_system = process_system_for_pids(&pids);
2536
2537    let mut parent_watcher_running = false;
2538    let mut parent_watcher_pid = None;
2539    if let Some(state) = parent_state.as_ref() {
2540        // Include self: the loopback API runs inside the watcher process, so
2541        // `state.pid == std::process::id()` must count as running (the
2542        // "other process" matcher intentionally excludes self for stop/replace).
2543        if is_live_parent_watcher_process_with_system(&process_system, state) {
2544            parent_watcher_running = true;
2545            parent_watcher_pid = Some(state.pid);
2546        }
2547    }
2548
2549    let mut repositories = Vec::new();
2550    let mut running_watchers = 0usize;
2551    let mut total_unsynced_files = 0u64;
2552    let mut total_unsynced_records = 0u64;
2553
2554    for (index, identity) in identities.iter().enumerate() {
2555        let layout = &layouts[index];
2556        // Line counts only — never fully parse/sanitize JSONL on the hot path.
2557        let counts = count_spool_summary(layout)?;
2558        let unsynced_files = counts.unsynced_files;
2559        let unsynced_records = counts.unsynced_records;
2560        total_unsynced_files += unsynced_files;
2561        total_unsynced_records += unsynced_records;
2562
2563        let (running, pid) = match repo_states
2564            .iter()
2565            .find(|(repo_index, _)| *repo_index == index)
2566            .map(|(_, state)| state)
2567        {
2568            Some(state) if is_live_watcher_process_with_system(&process_system, state) => {
2569                (true, Some(state.pid))
2570            }
2571            _ => (false, None),
2572        };
2573        // Parent watcher covers repos under parent even without per-repo state.
2574        let running = running || parent_watcher_running;
2575        let pid = pid.or(if running {
2576            parent_watcher_pid
2577        } else {
2578            None
2579        });
2580        if running {
2581            running_watchers += 1;
2582        }
2583
2584        repositories.push(WorktreeWatchTrayRepoStatus {
2585            root: normalize_repo_root_for_payload(&identity.root),
2586            owner: identity.owner.clone(),
2587            name: identity.name.clone(),
2588            branch: identity.branch.clone(),
2589            running,
2590            pid,
2591            unsynced_files,
2592            unsynced_records,
2593        });
2594    }
2595
2596    let repository_count = repositories.len();
2597    let any_running = parent_watcher_running || running_watchers > 0;
2598    let all_running = repository_count > 0 && running_watchers >= repository_count;
2599
2600    // Lightweight summary for tooltips; full stats are computed on demand via the tray menu.
2601    let stats_line = Some(format!(
2602        "{repository_count} repo(s) · {running_watchers} watching · {total_unsynced_records} unsynced · {total_unsynced_files} file(s)"
2603    ));
2604
2605    Ok(WorktreeWatchTraySnapshot {
2606        target_label,
2607        mode,
2608        parent: parent_label,
2609        repository_count,
2610        running_watchers,
2611        any_running,
2612        all_running,
2613        parent_watcher_running,
2614        parent_watcher_pid,
2615        total_unsynced_files,
2616        total_unsynced_records,
2617        repositories,
2618        stats_line,
2619    })
2620}
2621
2622/// Default session gap used when the local HTTP API recomputes coding stats.
2623pub const WORKTREE_WATCH_API_DEFAULT_STATS_GAP_MINUTES: u64 = 30;
2624
2625/// Build the JSON body for `GET /api/worktree-watch/status` (tray snapshot + per-repo status).
2626pub fn collect_worktree_watch_api_status(
2627    target: &WorktreeWatchTargetOptions,
2628    last_error: Option<&str>,
2629) -> Result<Value, String> {
2630    let snapshot = collect_worktree_watch_tray_snapshot(target)?;
2631    let watch_cfg = resolve_worktree_watch_config();
2632    let rank_index = build_repo_rank_index(&watch_cfg);
2633    let mut repositories = Vec::new();
2634
2635    for tray_repo in &snapshot.repositories {
2636        // Reuse tray counts; attach lastSync without re-scanning spool JSONL bodies.
2637        let mut status = worktree_watch_status_from_tray_repo(tray_repo)?;
2638        if let Some(object) = status.as_object_mut() {
2639            object.insert("running".to_string(), json!(tray_repo.running));
2640            object.insert("pid".to_string(), json!(tray_repo.pid));
2641            object.insert("owner".to_string(), json!(tray_repo.owner));
2642            object.insert("name".to_string(), json!(tray_repo.name));
2643            object.insert("branch".to_string(), json!(tray_repo.branch));
2644            object.insert("root".to_string(), json!(tray_repo.root));
2645            if let Some(spool) = object.get("spoolRoot").cloned() {
2646                object.insert("spoolPath".to_string(), spool);
2647            }
2648            object.insert("lastError".to_string(), Value::Null);
2649            if let Some(last_sync) = object.get("lastSync") {
2650                if let Some(synced_at) = last_sync.get("syncedAt").and_then(Value::as_str) {
2651                    if let Ok(dt) = DateTime::parse_from_rfc3339(synced_at) {
2652                        let age = (Utc::now() - dt.with_timezone(&Utc))
2653                            .num_seconds()
2654                            .max(0) as u64;
2655                        object.insert("lastSyncSecondsAgo".to_string(), json!(age));
2656                    }
2657                }
2658            }
2659            let identity = identity_from_tray_repo(tray_repo);
2660            attach_repo_resource_fields(object, &identity.root, &rank_index, &watch_cfg);
2661        }
2662        repositories.push(status);
2663    }
2664
2665    repositories.sort_by(|a, b| {
2666        let sa = a.get("activityScore").and_then(Value::as_f64).unwrap_or(0.0);
2667        let sb = b.get("activityScore").and_then(Value::as_f64).unwrap_or(0.0);
2668        sb.partial_cmp(&sa)
2669            .unwrap_or(std::cmp::Ordering::Equal)
2670    });
2671
2672    let resource = build_resource_insight(&snapshot, &repositories, &watch_cfg);
2673
2674    Ok(json!({
2675        "targetLabel": snapshot.target_label,
2676        "mode": snapshot.mode,
2677        "parent": snapshot.parent,
2678        "repositoryCount": snapshot.repository_count,
2679        "runningWatchers": snapshot.running_watchers,
2680        "anyRunning": snapshot.any_running,
2681        "allRunning": snapshot.all_running,
2682        "parentWatcherRunning": snapshot.parent_watcher_running,
2683        "parentWatcherPid": snapshot.parent_watcher_pid,
2684        "totalUnsyncedFiles": snapshot.total_unsynced_files,
2685        "totalUnsyncedRecords": snapshot.total_unsynced_records,
2686        "unsyncedFiles": snapshot.total_unsynced_files,
2687        "unsyncedRecords": snapshot.total_unsynced_records,
2688        "statsLine": snapshot.stats_line,
2689        "lastError": last_error,
2690        "resource": resource,
2691        "repositories": repositories,
2692    }))
2693}
2694
2695fn identity_from_tray_repo(repo: &WorktreeWatchTrayRepoStatus) -> RepoIdentity {
2696    RepoIdentity {
2697        owner: repo.owner.clone(),
2698        name: repo.name.clone(),
2699        branch: repo.branch.clone(),
2700        root: parent_root_as_local_path(&repo.root),
2701        head_sha: None,
2702    }
2703}
2704
2705fn worktree_watch_status_from_tray_repo(
2706    repo: &WorktreeWatchTrayRepoStatus,
2707) -> Result<Value, String> {
2708    let identity = identity_from_tray_repo(repo);
2709    let layout = prepare_spool_layout(&identity)?;
2710    let mut payload = json!({
2711        "repoRoot": repo.root,
2712        "repositoryOwner": repo.owner,
2713        "repositoryName": repo.name,
2714        "branchName": repo.branch,
2715        "platform": std::env::consts::OS,
2716        "runtimeKey": worktree_runtime_key(),
2717        "spoolRoot": layout.root.display().to_string(),
2718        "unsyncedFiles": repo.unsynced_files,
2719        "unsyncedRecords": repo.unsynced_records,
2720        "syncedFiles": 0,
2721        "syncedRecords": 0,
2722        "localSpoolFiles": repo.unsynced_files,
2723        "localSpoolRecords": repo.unsynced_records,
2724        "running": repo.running,
2725        "pid": repo.pid,
2726    });
2727
2728    if let Some(last_sync) = read_last_sync_log_entry(&layout.sync_log_file)? {
2729        if let Some(object) = payload.as_object_mut() {
2730            object.insert(
2731                "lastSync".to_string(),
2732                serde_json::to_value(last_sync)
2733                    .map_err(|error| format!("Failed to render sync log payload: {error}"))?,
2734            );
2735        }
2736    }
2737
2738    Ok(payload)
2739}
2740
2741/// Build the JSON body for `GET /api/worktree-watch/repositories`.
2742pub fn collect_worktree_watch_api_repositories(
2743    target: &WorktreeWatchTargetOptions,
2744) -> Result<Value, String> {
2745    let status = collect_worktree_watch_api_status(target, None)?;
2746    let repositories = status
2747        .get("repositories")
2748        .cloned()
2749        .unwrap_or_else(|| json!([]));
2750    Ok(json!({ "repositories": repositories }))
2751}
2752
2753/// Build the JSON body for `GET /api/worktree-watch/stats`.
2754///
2755/// Prefers cached `stats.json` when fresh so the dashboard poll stays cheap;
2756/// recomputes on miss/stale (same shape as `xbp worktree-watch status --stats`).
2757pub fn collect_worktree_watch_api_stats(
2758    target: &WorktreeWatchTargetOptions,
2759    session_gap_minutes: u64,
2760) -> Result<Value, String> {
2761    let identities = resolve_target_identities(target)?;
2762    if identities.is_empty() {
2763        return Ok(json!({
2764            "generatedAt": null,
2765            "message": "no stats yet",
2766            "repositories": [],
2767        }));
2768    }
2769
2770    let gap = if session_gap_minutes == 0 {
2771        WORKTREE_WATCH_API_DEFAULT_STATS_GAP_MINUTES
2772    } else {
2773        session_gap_minutes
2774    };
2775
2776    let mut repositories = Vec::new();
2777    for identity in &identities {
2778        let stats = read_or_generate_stats(identity, gap, Duration::from_secs(120))?;
2779        repositories.push(json!({
2780            "owner": identity.owner,
2781            "name": identity.name,
2782            "branch": identity.branch,
2783            "repositoryOwner": identity.owner,
2784            "repositoryName": identity.name,
2785            "branchName": identity.branch,
2786            "repoRoot": normalize_repo_root_for_payload(&identity.root),
2787            "root": normalize_repo_root_for_payload(&identity.root),
2788            "stats": stats,
2789        }));
2790    }
2791
2792    if repositories.len() == 1 {
2793        let first = repositories.remove(0);
2794        let stats = first
2795            .get("stats")
2796            .cloned()
2797            .unwrap_or(Value::Null);
2798        // Flat WorktreeWatchStatsSummary for single-repo targets (dashboard accepts both).
2799        if let Some(mut object) = stats.as_object().cloned() {
2800            object.insert(
2801                "repositories".to_string(),
2802                json!([{
2803                    "owner": first.get("owner"),
2804                    "name": first.get("name"),
2805                    "branch": first.get("branch"),
2806                    "stats": first.get("stats"),
2807                }]),
2808            );
2809            return Ok(Value::Object(object));
2810        }
2811        return Ok(first);
2812    }
2813
2814    Ok(json!({
2815        "repositories": repositories,
2816        "repositoryCount": repositories.len(),
2817    }))
2818}
2819
2820/// Fast status payload: spool file/line counts + lastSync, no full JSONL decode.
2821fn worktree_watch_status_payload_light(identity: &RepoIdentity) -> Result<Value, String> {
2822    let layout = prepare_spool_layout(identity)?;
2823    let counts = count_spool_summary(&layout)?;
2824
2825    let mut payload = json!({
2826        "repoRoot": normalize_repo_root_for_payload(&identity.root),
2827        "repositoryOwner": identity.owner.clone(),
2828        "repositoryName": identity.name.clone(),
2829        "branchName": identity.branch.clone(),
2830        "platform": std::env::consts::OS,
2831        "runtimeKey": worktree_runtime_key(),
2832        "spoolRoot": layout.root.display().to_string(),
2833        "unsyncedFiles": counts.unsynced_files,
2834        "unsyncedRecords": counts.unsynced_records,
2835        "syncedFiles": counts.synced_files,
2836        "syncedRecords": counts.synced_records,
2837        "localSpoolFiles": counts.unsynced_files + counts.synced_files,
2838        "localSpoolRecords": counts.unsynced_records + counts.synced_records,
2839    });
2840
2841    if let Some(last_sync) = read_last_sync_log_entry(&layout.sync_log_file)? {
2842        if let Some(object) = payload.as_object_mut() {
2843            object.insert(
2844                "lastSync".to_string(),
2845                serde_json::to_value(last_sync)
2846                    .map_err(|error| format!("Failed to render sync log payload: {error}"))?,
2847            );
2848        }
2849    }
2850
2851    Ok(payload)
2852}
2853
2854fn worktree_watch_status_payload(
2855    identity: &RepoIdentity,
2856    include_records: bool,
2857    record_limit: usize,
2858) -> Result<Value, String> {
2859    // Counts are always available via the light path; only decode JSONL when
2860    // the caller needs individual records.
2861    if !include_records {
2862        return worktree_watch_status_payload_light(identity);
2863    }
2864
2865    let layout = prepare_spool_layout(identity)?;
2866    let candidates = collect_sync_candidates(&layout)?;
2867    let all_candidates = collect_spool_candidates(&layout, true)?;
2868    let record_count: usize = candidates
2869        .iter()
2870        .map(|candidate| candidate.records.len())
2871        .sum();
2872    let all_record_count: usize = all_candidates
2873        .iter()
2874        .map(|candidate| candidate.records.len())
2875        .sum();
2876    let synced_file_count = all_candidates.len().saturating_sub(candidates.len());
2877    let synced_record_count = all_record_count.saturating_sub(record_count);
2878
2879    let mut payload = json!({
2880        "repoRoot": normalize_repo_root_for_payload(&identity.root),
2881        "repositoryOwner": identity.owner.clone(),
2882        "repositoryName": identity.name.clone(),
2883        "branchName": identity.branch.clone(),
2884        "platform": std::env::consts::OS,
2885        "runtimeKey": worktree_runtime_key(),
2886        "spoolRoot": layout.root.display().to_string(),
2887        "unsyncedFiles": candidates.len(),
2888        "unsyncedRecords": record_count,
2889        "syncedFiles": synced_file_count,
2890        "syncedRecords": synced_record_count,
2891        "localSpoolFiles": all_candidates.len(),
2892        "localSpoolRecords": all_record_count,
2893    });
2894
2895    if let Some(last_sync) = read_last_sync_log_entry(&layout.sync_log_file)? {
2896        if let Some(object) = payload.as_object_mut() {
2897            object.insert(
2898                "lastSync".to_string(),
2899                serde_json::to_value(last_sync)
2900                    .map_err(|error| format!("Failed to render sync log payload: {error}"))?,
2901            );
2902        }
2903    }
2904
2905    if let Some(object) = payload.as_object_mut() {
2906        object.insert(
2907            "records".to_string(),
2908            collect_status_records(&candidates, record_limit)?,
2909        );
2910    }
2911
2912    Ok(payload)
2913}
2914
2915#[derive(Debug, Default, Clone, Copy)]
2916struct SpoolCountSummary {
2917    unsynced_files: u64,
2918    unsynced_records: u64,
2919    synced_files: u64,
2920    synced_records: u64,
2921}
2922
2923/// Count spool JSONL files and non-empty lines without parsing or rewriting.
2924fn count_spool_summary(layout: &SpoolLayout) -> Result<SpoolCountSummary, String> {
2925    let mut roots = vec![layout.root.clone()];
2926    if let Some(branch) = layout.root.file_name().and_then(|name| name.to_str()) {
2927        if let Some(repo_root) = layout.root.parent() {
2928            if let (Some(name), Some(owner)) = (
2929                repo_root.file_name().and_then(|n| n.to_str()),
2930                repo_root
2931                    .parent()
2932                    .and_then(|p| p.file_name())
2933                    .and_then(|n| n.to_str()),
2934            ) {
2935                let identity = RepoIdentity {
2936                    owner: owner.to_string(),
2937                    name: name.to_string(),
2938                    branch: branch.to_string(),
2939                    root: PathBuf::new(),
2940                    head_sha: None,
2941                };
2942                if let Ok(search_roots) = repository_spool_search_roots(&identity) {
2943                    for search_root in search_roots {
2944                        let branch_root = search_root.join(branch);
2945                        if !roots.iter().any(|existing| {
2946                            path_identity_key(existing) == path_identity_key(&branch_root)
2947                        }) {
2948                            roots.push(branch_root);
2949                        }
2950                    }
2951                }
2952            }
2953        }
2954    }
2955
2956    let mut summary = SpoolCountSummary::default();
2957    let mut seen_files = BTreeSet::new();
2958    for root in roots {
2959        if !root.exists() {
2960            continue;
2961        }
2962        for entry in fs::read_dir(&root).map_err(|error| {
2963            format!(
2964                "Failed to read spool directory {}: {error}",
2965                root.display()
2966            )
2967        })? {
2968            let path = entry
2969                .map_err(|error| format!("Failed to read spool entry: {error}"))?
2970                .path();
2971            let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
2972                continue;
2973            };
2974            if !file_name.ends_with(".jsonl") {
2975                continue;
2976            }
2977            if !(file_name.starts_with("events-") || file_name.starts_with("commits-")) {
2978                continue;
2979            }
2980            let identity_key = path_identity_key(&path);
2981            if !seen_files.insert(identity_key) {
2982                continue;
2983            }
2984            let lines = count_nonempty_lines(&path)?;
2985            if file_name.contains(".synced.") {
2986                summary.synced_files += 1;
2987                summary.synced_records += lines;
2988            } else {
2989                summary.unsynced_files += 1;
2990                summary.unsynced_records += lines;
2991            }
2992        }
2993    }
2994    Ok(summary)
2995}
2996
2997fn count_nonempty_lines(path: &Path) -> Result<u64, String> {
2998    let file = File::open(path)
2999        .map_err(|error| format!("Failed to open spool file {}: {error}", path.display()))?;
3000    let reader = BufReader::new(file);
3001    let mut count = 0u64;
3002    for line in reader.lines() {
3003        let line =
3004            line.map_err(|error| format!("Failed to read spool file {}: {error}", path.display()))?;
3005        if !line.trim().is_empty() {
3006            count += 1;
3007        }
3008    }
3009    Ok(count)
3010}
3011
3012fn read_or_generate_stats(
3013    identity: &RepoIdentity,
3014    session_gap_minutes: u64,
3015    max_age: Duration,
3016) -> Result<WorktreeWatchStatsSummary, String> {
3017    let layout = prepare_spool_layout(identity)?;
3018    if layout.stats_file.exists() {
3019        if let Ok(raw) = fs::read_to_string(&layout.stats_file) {
3020            if let Ok(stats) = serde_json::from_str::<WorktreeWatchStatsSummary>(&raw) {
3021                let age = Utc::now()
3022                    .signed_duration_since(stats.generated_at)
3023                    .to_std()
3024                    .unwrap_or(Duration::from_secs(u64::MAX));
3025                if age <= max_age {
3026                    return Ok(stats);
3027                }
3028            }
3029        }
3030    }
3031    generate_and_store_stats(identity, session_gap_minutes)
3032}
3033
3034fn collect_status_records(candidates: &[SyncCandidate], limit: usize) -> Result<Value, String> {
3035    let available: usize = candidates
3036        .iter()
3037        .map(|candidate| candidate.records.len())
3038        .sum();
3039    let mut shown = 0usize;
3040    let mut events = Vec::new();
3041    let mut commits = Vec::new();
3042
3043    'candidates: for candidate in candidates {
3044        match &candidate.records {
3045            SyncRecords::Events(records) => {
3046                for event in records {
3047                    if shown >= limit {
3048                        break 'candidates;
3049                    }
3050                    events.push(json!({
3051                        "sourceFile": candidate.path.display().to_string(),
3052                        "occurredAt": event.occurred_at,
3053                        "eventKind": event.event_kind.clone(),
3054                        "primaryPath": event.primary_path.clone(),
3055                        "oldPath": event.old_path.clone(),
3056                        "newPath": event.new_path.clone(),
3057                        "paths": event.paths.clone(),
3058                        "addedLines": event.added_lines,
3059                        "removedLines": event.removed_lines,
3060                        "totalLines": event.total_lines,
3061                        "headSha": event.head_sha.clone(),
3062                        "rawKind": event.raw_kind.clone(),
3063                    }));
3064                    shown += 1;
3065                }
3066            }
3067            SyncRecords::Commits(records) => {
3068                for commit in records {
3069                    if shown >= limit {
3070                        break 'candidates;
3071                    }
3072                    commits.push(json!({
3073                        "sourceFile": candidate.path.display().to_string(),
3074                        "occurredAt": commit.occurred_at,
3075                        "headSha": commit.head_sha.clone(),
3076                        "previousHeadSha": commit.previous_head_sha.clone(),
3077                        "subject": commit.subject.clone(),
3078                        "authorName": commit.author_name.clone(),
3079                        "authorEmail": commit.author_email.clone(),
3080                        "committedAt": commit.committed_at.clone(),
3081                        "filesChanged": commit.files_changed.clone(),
3082                        "filesChangedCount": commit.files_changed_count,
3083                        "insertions": commit.insertions,
3084                        "deletions": commit.deletions,
3085                        "isMerge": commit.is_merge,
3086                        "includedCommits": commit.included_commits.clone(),
3087                    }));
3088                    shown += 1;
3089                }
3090            }
3091        }
3092    }
3093
3094    Ok(json!({
3095        "available": available,
3096        "shown": shown,
3097        "truncated": shown < available,
3098        "events": events,
3099        "commits": commits,
3100    }))
3101}
3102
3103fn print_status_records(payload: &Value) {
3104    let Some(records) = payload.get("records") else {
3105        return;
3106    };
3107    let shown = records.get("shown").and_then(Value::as_u64).unwrap_or(0);
3108    if shown == 0 {
3109        println!("records: none");
3110        return;
3111    }
3112
3113    println!("records:");
3114    if let Some(events) = records.get("events").and_then(Value::as_array) {
3115        if !events.is_empty() {
3116            println!("  mutations:");
3117            for event in events {
3118                print_status_event_record(event);
3119            }
3120        }
3121    }
3122    if let Some(commits) = records.get("commits").and_then(Value::as_array) {
3123        if !commits.is_empty() {
3124            println!("  commits:");
3125            for commit in commits {
3126                print_status_commit_record(commit);
3127            }
3128        }
3129    }
3130    if records
3131        .get("truncated")
3132        .and_then(Value::as_bool)
3133        .unwrap_or(false)
3134    {
3135        let available = records
3136            .get("available")
3137            .and_then(Value::as_u64)
3138            .unwrap_or(shown);
3139        println!("  showing {shown} of {available} record(s)");
3140    }
3141}
3142
3143fn print_status_stats(payload: &Value) {
3144    let Some(stats) = payload.get("stats") else {
3145        return;
3146    };
3147    println!("stats:");
3148    println!(
3149        "  coding time: {}",
3150        format_duration(
3151            stats
3152                .get("estimatedCodingSeconds")
3153                .and_then(Value::as_u64)
3154                .unwrap_or(0)
3155        )
3156    );
3157    println!(
3158        "  sessions: {}",
3159        stats
3160            .get("sessionCount")
3161            .and_then(Value::as_u64)
3162            .unwrap_or(0)
3163    );
3164    println!(
3165        "  observed span: {}",
3166        format_duration(
3167            stats
3168                .get("observedSpanSeconds")
3169                .and_then(Value::as_u64)
3170                .unwrap_or(0)
3171        )
3172    );
3173    println!(
3174        "  events: {}",
3175        stats
3176            .get("totalEvents")
3177            .and_then(Value::as_u64)
3178            .unwrap_or(0)
3179    );
3180    println!(
3181        "  commits: {}",
3182        stats
3183            .get("totalCommits")
3184            .and_then(Value::as_u64)
3185            .unwrap_or(0)
3186    );
3187    println!(
3188        "  lines: +{} -{}",
3189        stats.get("addedLines").and_then(Value::as_u64).unwrap_or(0),
3190        stats
3191            .get("removedLines")
3192            .and_then(Value::as_u64)
3193            .unwrap_or(0)
3194    );
3195    if let Some(spool_root) = value_str(stats, "spoolRoot") {
3196        println!(
3197            "  stored: {}",
3198            Path::new(spool_root).join("stats.json").display()
3199        );
3200    }
3201    print_stats_bucket_section(stats, "byFiletype", "  by filetype:", 10);
3202    print_stats_bucket_section(stats, "byEventKind", "  by event kind:", 10);
3203    print_stats_file_section(stats, 10);
3204}
3205
3206fn print_repo_activity(payload: &Value) {
3207    let Some(activity) = payload.get("repositoryActivity") else {
3208        return;
3209    };
3210    println!("repo activity:");
3211    println!(
3212        "  coding time: {}",
3213        format_duration(
3214            activity
3215                .get("estimatedCodingSeconds")
3216                .and_then(Value::as_u64)
3217                .unwrap_or(0)
3218        )
3219    );
3220    println!(
3221        "  observed span: {}",
3222        format_duration(
3223            activity
3224                .get("observedSpanSeconds")
3225                .and_then(Value::as_u64)
3226                .unwrap_or(0)
3227        )
3228    );
3229    println!(
3230        "  branches: {}",
3231        activity
3232            .get("branchCount")
3233            .and_then(Value::as_u64)
3234            .unwrap_or(0)
3235    );
3236    println!(
3237        "  sessions: {}, events: {}, commits: {}, lines: +{} -{}",
3238        activity
3239            .get("sessionCount")
3240            .and_then(Value::as_u64)
3241            .unwrap_or(0),
3242        activity
3243            .get("totalEvents")
3244            .and_then(Value::as_u64)
3245            .unwrap_or(0),
3246        activity
3247            .get("totalCommits")
3248            .and_then(Value::as_u64)
3249            .unwrap_or(0),
3250        activity
3251            .get("addedLines")
3252            .and_then(Value::as_u64)
3253            .unwrap_or(0),
3254        activity
3255            .get("removedLines")
3256            .and_then(Value::as_u64)
3257            .unwrap_or(0)
3258    );
3259    if let Some(stats_file) = value_str(activity, "statsFile") {
3260        println!("  stored: {stats_file}");
3261    }
3262    let Some(branches) = activity.get("branches").and_then(Value::as_array) else {
3263        return;
3264    };
3265    if branches.is_empty() {
3266        return;
3267    }
3268    println!("  by branch:");
3269    for branch in branches.iter().take(20) {
3270        let name = value_str(branch, "branchName").unwrap_or("unknown");
3271        let coding_seconds = branch
3272            .get("estimatedCodingSeconds")
3273            .and_then(Value::as_u64)
3274            .unwrap_or(0);
3275        let observed_seconds = branch
3276            .get("observedSpanSeconds")
3277            .and_then(Value::as_u64)
3278            .unwrap_or(0);
3279        let sessions = branch
3280            .get("sessionCount")
3281            .and_then(Value::as_u64)
3282            .unwrap_or(0);
3283        let events = branch
3284            .get("totalEvents")
3285            .and_then(Value::as_u64)
3286            .unwrap_or(0);
3287        let commits = branch
3288            .get("totalCommits")
3289            .and_then(Value::as_u64)
3290            .unwrap_or(0);
3291        println!(
3292            "    {name}: {} coding, {} span, {} session(s), {} event(s), {} commit(s)",
3293            format_duration(coding_seconds),
3294            format_duration(observed_seconds),
3295            sessions,
3296            events,
3297            commits
3298        );
3299    }
3300}
3301
3302#[allow(dead_code)]
3303fn print_last_sync(payload: &Value) {
3304    let Some(last_sync) = payload.get("lastSync") else {
3305        return;
3306    };
3307    let synced_at = value_str(last_sync, "syncedAt").unwrap_or("unknown-time");
3308    let status = last_sync
3309        .get("statusCode")
3310        .and_then(Value::as_u64)
3311        .unwrap_or(0);
3312    let events = last_sync
3313        .get("eventCount")
3314        .and_then(Value::as_u64)
3315        .unwrap_or(0);
3316    let commits = last_sync
3317        .get("commitCount")
3318        .and_then(Value::as_u64)
3319        .unwrap_or(0);
3320    let files = last_sync
3321        .get("spoolFileCount")
3322        .and_then(Value::as_u64)
3323        .unwrap_or(0);
3324    let resync = last_sync
3325        .get("resync")
3326        .and_then(Value::as_bool)
3327        .unwrap_or(false);
3328    println!(
3329        "last sync: {synced_at}, HTTP {status}, {events} event(s), {commits} commit(s), {files} file(s), resync={resync}"
3330    );
3331}
3332
3333fn print_stats_bucket_section(stats: &Value, key: &str, title: &str, limit: usize) {
3334    let Some(items) = stats.get(key).and_then(Value::as_array) else {
3335        return;
3336    };
3337    if items.is_empty() {
3338        return;
3339    }
3340    println!("{title}");
3341    for item in items.iter().take(limit) {
3342        let name = value_str(item, "name").unwrap_or("unknown");
3343        let events = item.get("eventCount").and_then(Value::as_u64).unwrap_or(0);
3344        let seconds = item
3345            .get("estimatedCodingSeconds")
3346            .and_then(Value::as_u64)
3347            .unwrap_or(0);
3348        let added = item.get("addedLines").and_then(Value::as_u64).unwrap_or(0);
3349        let removed = item
3350            .get("removedLines")
3351            .and_then(Value::as_u64)
3352            .unwrap_or(0);
3353        println!(
3354            "    {name}: {}, {} event(s), +{} -{}",
3355            format_duration(seconds),
3356            events,
3357            added,
3358            removed
3359        );
3360    }
3361}
3362
3363fn print_stats_file_section(stats: &Value, limit: usize) {
3364    let Some(items) = stats.get("byFile").and_then(Value::as_array) else {
3365        return;
3366    };
3367    if items.is_empty() {
3368        return;
3369    }
3370    println!("  by file:");
3371    for item in items.iter().take(limit) {
3372        let path = value_str(item, "path").unwrap_or("(no path)");
3373        let filetype = value_str(item, "filetype").unwrap_or("(none)");
3374        let events = item.get("eventCount").and_then(Value::as_u64).unwrap_or(0);
3375        let seconds = item
3376            .get("estimatedCodingSeconds")
3377            .and_then(Value::as_u64)
3378            .unwrap_or(0);
3379        let added = item.get("addedLines").and_then(Value::as_u64).unwrap_or(0);
3380        let removed = item
3381            .get("removedLines")
3382            .and_then(Value::as_u64)
3383            .unwrap_or(0);
3384        println!(
3385            "    {path} [{filetype}]: {}, {} event(s), +{} -{}",
3386            format_duration(seconds),
3387            events,
3388            added,
3389            removed
3390        );
3391    }
3392}
3393
3394fn print_status_event_record(event: &Value) {
3395    let occurred_at = value_str(event, "occurredAt").unwrap_or("unknown-time");
3396    let kind = value_str(event, "eventKind").unwrap_or("event");
3397    let primary_path = event_display_path(event);
3398    let added = event.get("addedLines").and_then(Value::as_u64).unwrap_or(0);
3399    let removed = event
3400        .get("removedLines")
3401        .and_then(Value::as_u64)
3402        .unwrap_or(0);
3403    let total_lines = event.get("totalLines").and_then(Value::as_u64);
3404    if let Some(total_lines) = total_lines {
3405        println!(
3406            "    {occurred_at} {kind} {primary_path} (+{added} -{removed}, {total_lines} lines)"
3407        );
3408    } else {
3409        println!("    {occurred_at} {kind} {primary_path} (+{added} -{removed})");
3410    }
3411
3412    if let Some(head_sha) = value_str(event, "headSha") {
3413        println!("      head: {}", short_sha(head_sha));
3414    }
3415    if let Some(paths) = event.get("paths").and_then(Value::as_array) {
3416        if paths.len() > 1 {
3417            let rendered = paths
3418                .iter()
3419                .filter_map(Value::as_str)
3420                .collect::<Vec<_>>()
3421                .join(", ");
3422            println!("      paths: {rendered}");
3423        }
3424    }
3425}
3426
3427fn print_status_commit_record(commit: &Value) {
3428    let occurred_at = value_str(commit, "occurredAt").unwrap_or("unknown-time");
3429    let head_sha = value_str(commit, "headSha")
3430        .map(short_sha)
3431        .unwrap_or_else(|| "unknown".to_string());
3432    let subject = value_str(commit, "subject").unwrap_or("(no subject)");
3433    println!("    {occurred_at} {head_sha} {subject}");
3434
3435    if let Some(author) = value_str(commit, "authorName") {
3436        println!("      author: {author}");
3437    }
3438    let files = commit
3439        .get("filesChangedCount")
3440        .and_then(Value::as_u64)
3441        .or_else(|| {
3442            commit
3443                .get("filesChanged")
3444                .and_then(Value::as_array)
3445                .map(|a| a.len() as u64)
3446        });
3447    let ins = commit.get("insertions").and_then(Value::as_u64);
3448    let del = commit.get("deletions").and_then(Value::as_u64);
3449    match (files, ins, del) {
3450        (Some(f), Some(i), Some(d)) => {
3451            println!("      changes: {f} file(s), +{i} -{d}");
3452        }
3453        (Some(f), _, _) => println!("      changes: {f} file(s)"),
3454        _ => {}
3455    }
3456    if commit.get("isMerge").and_then(Value::as_bool) == Some(true) {
3457        println!("      merge: true");
3458    }
3459}
3460
3461fn event_display_path(event: &Value) -> String {
3462    let old_path = value_str(event, "oldPath");
3463    let new_path = value_str(event, "newPath");
3464    if let (Some(old_path), Some(new_path)) = (old_path, new_path) {
3465        return format!("{old_path} -> {new_path}");
3466    }
3467    value_str(event, "primaryPath")
3468        .map(str::to_string)
3469        .or_else(|| {
3470            event
3471                .get("paths")
3472                .and_then(Value::as_array)
3473                .and_then(|paths| paths.first())
3474                .and_then(Value::as_str)
3475                .map(str::to_string)
3476        })
3477        .unwrap_or_else(|| "(no path)".to_string())
3478}
3479
3480fn value_str<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
3481    value.get(key).and_then(Value::as_str)
3482}
3483
3484fn short_sha(value: &str) -> String {
3485    value.chars().take(12).collect()
3486}
3487
3488fn generate_and_store_stats(
3489    identity: &RepoIdentity,
3490    session_gap_minutes: u64,
3491) -> Result<WorktreeWatchStatsSummary, String> {
3492    let layout = prepare_spool_layout(identity)?;
3493    let candidates = collect_spool_candidates(&layout, true)?;
3494    let summary = build_stats_summary(identity, &layout, &candidates, session_gap_minutes)?;
3495    let rendered = serde_json::to_string_pretty(&summary)
3496        .map_err(|error| format!("Failed to serialize stats: {error}"))?;
3497    fs::write(&layout.stats_file, format!("{rendered}\n")).map_err(|error| {
3498        format!(
3499            "Failed to write stats file {}: {error}",
3500            layout.stats_file.display()
3501        )
3502    })?;
3503    Ok(summary)
3504}
3505
3506fn generate_and_store_repo_activity(
3507    identity: &RepoIdentity,
3508    session_gap_minutes: u64,
3509) -> Result<WorktreeWatchRepoActivitySummary, String> {
3510    let repository_spool_root = repository_spool_root(identity)?;
3511    let summary =
3512        build_repo_activity_summary(identity, &repository_spool_root, session_gap_minutes)?;
3513    let rendered = serde_json::to_string_pretty(&summary)
3514        .map_err(|error| format!("Failed to serialize repo activity: {error}"))?;
3515    fs::create_dir_all(&repository_spool_root).map_err(|error| {
3516        format!(
3517            "Failed to create repository spool root {}: {error}",
3518            repository_spool_root.display()
3519        )
3520    })?;
3521    let stats_file = repository_spool_root.join("repo-activity.json");
3522    fs::write(&stats_file, format!("{rendered}\n")).map_err(|error| {
3523        format!(
3524            "Failed to write repo activity file {}: {error}",
3525            stats_file.display()
3526        )
3527    })?;
3528    Ok(summary)
3529}
3530
3531fn build_repo_activity_summary(
3532    identity: &RepoIdentity,
3533    repository_spool_root: &Path,
3534    session_gap_minutes: u64,
3535) -> Result<WorktreeWatchRepoActivitySummary, String> {
3536    flush_jsonl_write_buffers();
3537    let mut branches = Vec::new();
3538    if repository_spool_root.exists() {
3539        for entry in fs::read_dir(repository_spool_root).map_err(|error| {
3540            format!(
3541                "Failed to read repository spool root {}: {error}",
3542                repository_spool_root.display()
3543            )
3544        })? {
3545            let entry =
3546                entry.map_err(|error| format!("Failed to read repository spool entry: {error}"))?;
3547            let file_type = entry.file_type().map_err(|error| {
3548                format!("Failed to inspect {}: {error}", entry.path().display())
3549            })?;
3550            if !file_type.is_dir() {
3551                continue;
3552            }
3553            let branch_name = entry.file_name().to_string_lossy().to_string();
3554            let branch_root = entry.path();
3555            let branch_layout = spool_layout_for_existing_root(&branch_root);
3556            let branch_identity = RepoIdentity {
3557                branch: branch_name.clone(),
3558                ..identity.clone()
3559            };
3560            let candidates = collect_spool_candidates(&branch_layout, true)?;
3561            let stats = build_stats_summary(
3562                &branch_identity,
3563                &branch_layout,
3564                &candidates,
3565                session_gap_minutes,
3566            )?;
3567            if stats.total_events == 0 && stats.total_commits == 0 {
3568                continue;
3569            }
3570            branches.push(WorktreeWatchBranchActivityStats {
3571                branch_name,
3572                spool_root: branch_root.display().to_string(),
3573                total_events: stats.total_events,
3574                total_commits: stats.total_commits,
3575                first_event_at: stats.first_event_at,
3576                last_event_at: stats.last_event_at,
3577                session_count: stats.session_count,
3578                observed_span_seconds: stats.observed_span_seconds,
3579                estimated_coding_seconds: stats.estimated_coding_seconds,
3580                added_lines: stats.added_lines,
3581                removed_lines: stats.removed_lines,
3582            });
3583        }
3584    }
3585
3586    branches.sort_by(|left, right| {
3587        right
3588            .estimated_coding_seconds
3589            .cmp(&left.estimated_coding_seconds)
3590            .then_with(|| right.total_events.cmp(&left.total_events))
3591            .then_with(|| left.branch_name.cmp(&right.branch_name))
3592    });
3593
3594    let first_event_at = branches
3595        .iter()
3596        .filter_map(|branch| branch.first_event_at)
3597        .min();
3598    let last_event_at = branches
3599        .iter()
3600        .filter_map(|branch| branch.last_event_at)
3601        .max();
3602    let observed_span_seconds = observed_span_seconds(first_event_at, last_event_at);
3603    let stats_file = repository_spool_root.join("repo-activity.json");
3604
3605    Ok(WorktreeWatchRepoActivitySummary {
3606        generated_at: Utc::now(),
3607        repository_owner: identity.owner.clone(),
3608        repository_name: identity.name.clone(),
3609        repo_root: identity.root.display().to_string(),
3610        repository_spool_root: repository_spool_root.display().to_string(),
3611        stats_file: stats_file.display().to_string(),
3612        session_gap_minutes,
3613        branch_count: branches.len() as u64,
3614        total_events: branches.iter().map(|branch| branch.total_events).sum(),
3615        total_commits: branches.iter().map(|branch| branch.total_commits).sum(),
3616        first_event_at,
3617        last_event_at,
3618        session_count: branches.iter().map(|branch| branch.session_count).sum(),
3619        observed_span_seconds,
3620        estimated_coding_seconds: branches
3621            .iter()
3622            .map(|branch| branch.estimated_coding_seconds)
3623            .sum(),
3624        added_lines: branches.iter().map(|branch| branch.added_lines).sum(),
3625        removed_lines: branches.iter().map(|branch| branch.removed_lines).sum(),
3626        branches,
3627    })
3628}
3629
3630fn build_stats_summary(
3631    identity: &RepoIdentity,
3632    layout: &SpoolLayout,
3633    candidates: &[SyncCandidate],
3634    session_gap_minutes: u64,
3635) -> Result<WorktreeWatchStatsSummary, String> {
3636    let mut events = Vec::new();
3637    let mut total_commits = 0u64;
3638    for candidate in candidates {
3639        match &candidate.records {
3640            SyncRecords::Events(records) => events.extend(records.iter().cloned()),
3641            SyncRecords::Commits(records) => total_commits += records.len() as u64,
3642        }
3643    }
3644    events.sort_by_key(|event| event.occurred_at);
3645
3646    let gap_seconds = session_gap_minutes.saturating_mul(60).max(60);
3647    let mut previous_at: Option<DateTime<Utc>> = None;
3648    let mut session_count = 0u64;
3649    let mut total_seconds = 0u64;
3650    let mut total_added = 0u64;
3651    let mut total_removed = 0u64;
3652    let mut by_file: BTreeMap<String, WorktreeWatchFileStats> = BTreeMap::new();
3653    let mut by_filetype: BTreeMap<String, WorktreeWatchBucketStats> = BTreeMap::new();
3654    let mut by_event_kind: BTreeMap<String, WorktreeWatchBucketStats> = BTreeMap::new();
3655
3656    for event in &events {
3657        if starts_new_coding_session(previous_at, event.occurred_at, gap_seconds) {
3658            session_count += 1;
3659        }
3660        let seconds = coding_seconds_for_event(previous_at, event.occurred_at, gap_seconds);
3661        previous_at = Some(event.occurred_at);
3662        let added = event.added_lines.unwrap_or(0);
3663        let removed = event.removed_lines.unwrap_or(0);
3664        let path = stats_event_path(event);
3665        let filetype = filetype_for_path(&path);
3666
3667        total_seconds += seconds;
3668        total_added += added;
3669        total_removed += removed;
3670        update_file_stats(&mut by_file, &path, &filetype, seconds, added, removed);
3671        update_bucket_stats(&mut by_filetype, &filetype, seconds, added, removed);
3672        update_bucket_stats(
3673            &mut by_event_kind,
3674            &event.event_kind,
3675            seconds,
3676            added,
3677            removed,
3678        );
3679    }
3680
3681    Ok(WorktreeWatchStatsSummary {
3682        generated_at: Utc::now(),
3683        repository_owner: identity.owner.clone(),
3684        repository_name: identity.name.clone(),
3685        branch_name: identity.branch.clone(),
3686        repo_root: identity.root.display().to_string(),
3687        spool_root: layout.root.display().to_string(),
3688        session_gap_minutes,
3689        total_events: events.len() as u64,
3690        total_commits,
3691        first_event_at: events.first().map(|event| event.occurred_at),
3692        last_event_at: events.last().map(|event| event.occurred_at),
3693        session_count,
3694        observed_span_seconds: observed_span_seconds(
3695            events.first().map(|event| event.occurred_at),
3696            events.last().map(|event| event.occurred_at),
3697        ),
3698        estimated_coding_seconds: total_seconds,
3699        added_lines: total_added,
3700        removed_lines: total_removed,
3701        by_file: sorted_file_stats(by_file),
3702        by_filetype: sorted_bucket_stats(by_filetype),
3703        by_event_kind: sorted_bucket_stats(by_event_kind),
3704    })
3705}
3706
3707fn coding_seconds_for_event(
3708    previous_at: Option<DateTime<Utc>>,
3709    occurred_at: DateTime<Utc>,
3710    gap_seconds: u64,
3711) -> u64 {
3712    let Some(previous_at) = previous_at else {
3713        return 60;
3714    };
3715    let delta = occurred_at.signed_duration_since(previous_at).num_seconds();
3716    if delta <= 0 {
3717        0
3718    } else {
3719        (delta as u64).min(gap_seconds)
3720    }
3721}
3722
3723fn starts_new_coding_session(
3724    previous_at: Option<DateTime<Utc>>,
3725    occurred_at: DateTime<Utc>,
3726    gap_seconds: u64,
3727) -> bool {
3728    let Some(previous_at) = previous_at else {
3729        return true;
3730    };
3731    let delta = occurred_at.signed_duration_since(previous_at).num_seconds();
3732    delta > gap_seconds as i64
3733}
3734
3735fn observed_span_seconds(
3736    first_event_at: Option<DateTime<Utc>>,
3737    last_event_at: Option<DateTime<Utc>>,
3738) -> u64 {
3739    let (Some(first_event_at), Some(last_event_at)) = (first_event_at, last_event_at) else {
3740        return 0;
3741    };
3742    last_event_at
3743        .signed_duration_since(first_event_at)
3744        .num_seconds()
3745        .max(0) as u64
3746}
3747
3748fn update_file_stats(
3749    by_file: &mut BTreeMap<String, WorktreeWatchFileStats>,
3750    path: &str,
3751    filetype: &str,
3752    seconds: u64,
3753    added: u64,
3754    removed: u64,
3755) {
3756    let stats = by_file
3757        .entry(path.to_string())
3758        .or_insert_with(|| WorktreeWatchFileStats {
3759            path: path.to_string(),
3760            filetype: filetype.to_string(),
3761            ..WorktreeWatchFileStats::default()
3762        });
3763    stats.event_count += 1;
3764    stats.estimated_coding_seconds += seconds;
3765    stats.added_lines += added;
3766    stats.removed_lines += removed;
3767}
3768
3769fn update_bucket_stats(
3770    buckets: &mut BTreeMap<String, WorktreeWatchBucketStats>,
3771    name: &str,
3772    seconds: u64,
3773    added: u64,
3774    removed: u64,
3775) {
3776    let stats = buckets
3777        .entry(name.to_string())
3778        .or_insert_with(|| WorktreeWatchBucketStats {
3779            name: name.to_string(),
3780            ..WorktreeWatchBucketStats::default()
3781        });
3782    stats.event_count += 1;
3783    stats.estimated_coding_seconds += seconds;
3784    stats.added_lines += added;
3785    stats.removed_lines += removed;
3786}
3787
3788fn sorted_file_stats(
3789    by_file: BTreeMap<String, WorktreeWatchFileStats>,
3790) -> Vec<WorktreeWatchFileStats> {
3791    let mut stats = by_file.into_values().collect::<Vec<_>>();
3792    stats.sort_by(|left, right| {
3793        right
3794            .estimated_coding_seconds
3795            .cmp(&left.estimated_coding_seconds)
3796            .then_with(|| right.event_count.cmp(&left.event_count))
3797            .then_with(|| left.path.cmp(&right.path))
3798    });
3799    stats
3800}
3801
3802fn sorted_bucket_stats(
3803    buckets: BTreeMap<String, WorktreeWatchBucketStats>,
3804) -> Vec<WorktreeWatchBucketStats> {
3805    let mut stats = buckets.into_values().collect::<Vec<_>>();
3806    stats.sort_by(|left, right| {
3807        right
3808            .estimated_coding_seconds
3809            .cmp(&left.estimated_coding_seconds)
3810            .then_with(|| right.event_count.cmp(&left.event_count))
3811            .then_with(|| left.name.cmp(&right.name))
3812    });
3813    stats
3814}
3815
3816fn stats_event_path(event: &WorktreeMutationEvent) -> String {
3817    event
3818        .new_path
3819        .as_deref()
3820        .or(event.primary_path.as_deref())
3821        .or_else(|| event.paths.first().map(String::as_str))
3822        .unwrap_or("(no path)")
3823        .to_string()
3824}
3825
3826fn filetype_for_path(path: &str) -> String {
3827    Path::new(path)
3828        .extension()
3829        .and_then(|value| value.to_str())
3830        .map(|value| value.to_ascii_lowercase())
3831        .filter(|value| !value.is_empty())
3832        .unwrap_or_else(|| "(none)".to_string())
3833}
3834
3835fn format_duration(seconds: u64) -> String {
3836    let hours = seconds / 3600;
3837    let minutes = (seconds % 3600) / 60;
3838    let remaining_seconds = seconds % 60;
3839    if hours > 0 {
3840        format!("{hours}h {minutes}m")
3841    } else if minutes > 0 {
3842        format!("{minutes}m {remaining_seconds}s")
3843    } else {
3844        format!("{remaining_seconds}s")
3845    }
3846}
3847
3848async fn watch_foreground(
3849    identity: RepoIdentity,
3850    layout: SpoolLayout,
3851    sync_interval_seconds: u64,
3852) -> Result<(), String> {
3853    // Refresh watcher state so status sees this process (foreground + detach child).
3854    if let Ok(executable) = std::env::current_exe() {
3855        let _ = write_watcher_state(&identity, &layout, std::process::id(), &executable);
3856    }
3857
3858    // Local dashboard API (loopback). Background thread avoids an async type
3859    // cycle with POST /start handlers that call detached start.
3860    let api_target = WorktreeWatchTargetOptions {
3861        repo: Some(identity.root.clone()),
3862        parent: None,
3863        repos: Vec::new(),
3864    };
3865    if let Some(port) = crate::commands::worktree_watch_api::spawn_worktree_watch_api_in_background(
3866        api_target,
3867        sync_interval_seconds,
3868        None,
3869    ) {
3870        if !is_background_child() {
3871            crate::commands::worktree_watch_api::print_api_listen_hint(port);
3872        } else {
3873            tracing::info!(
3874                "worktree-watch local API listening on http://127.0.0.1:{port}"
3875            );
3876        }
3877    }
3878
3879    let watch_cfg = resolve_worktree_watch_config();
3880    let (tx, rx) = mpsc::channel();
3881    let mut watcher = create_path_watcher(&identity.root, tx)?;
3882    watcher
3883        .watch(&identity.root, RecursiveMode::Recursive)
3884        .map_err(|error| {
3885            format!(
3886                "Failed to watch repository root {}: {error}",
3887                identity.root.display()
3888            )
3889        })?;
3890    log_watcher_backend(&identity.root, "repository");
3891
3892    let mut last_head = identity.head_sha.clone();
3893    let mut last_activity = Instant::now();
3894    let mut last_commit_check = Instant::now();
3895    let mut last_sync = Instant::now();
3896    let mut last_rank_persist = Instant::now();
3897    let mut last_housekeeping = Instant::now();
3898    let mut activity_score = seed_activity_score(&watch_cfg, &identity.root);
3899    let mut fs_watched = true;
3900    let mut event_dedupe = RecentEventDedupe {
3901        fingerprints: load_event_dedupe_fingerprints(&layout.event_dedupe_file)?,
3902    };
3903    let retention =
3904        Duration::from_secs(watch_cfg.spool_retention_days().saturating_mul(86_400));
3905    let _ = prune_spool_layout(&layout, retention);
3906
3907    loop {
3908        let idle_secs = last_activity.elapsed().as_secs();
3909        let tier = tier_from_idle(idle_secs, &watch_cfg);
3910        let timeout = event_loop_timeout(tier);
3911
3912        // Cold single-repo: release FS watch so the directory can be deleted/moved.
3913        if tier == RepoWatchTier::Cold && fs_watched {
3914            let _ = watcher.unwatch(&identity.root);
3915            fs_watched = false;
3916            tracing::info!(
3917                "worktree-watch: unwatched idle repo {} (releases file locks)",
3918                identity.root.display()
3919            );
3920        } else if tier != RepoWatchTier::Cold && !fs_watched {
3921            if identity.root.is_dir() {
3922                if watcher
3923                    .watch(&identity.root, RecursiveMode::Recursive)
3924                    .is_ok()
3925                {
3926                    fs_watched = true;
3927                    tracing::info!(
3928                        "worktree-watch: re-watched active repo {}",
3929                        identity.root.display()
3930                    );
3931                }
3932            }
3933        }
3934
3935        match rx.recv_timeout(timeout) {
3936            Ok(first) => {
3937                for item in drain_notify_batch(&rx, first) {
3938                    match item {
3939                        Ok(event) => {
3940                            if let Some(mutation) = mutation_event_from_notify(&identity, event) {
3941                                if append_unique_mutation_event(
3942                                    &layout,
3943                                    &mutation,
3944                                    &mut event_dedupe,
3945                                )? {
3946                                    last_activity = Instant::now();
3947                                    activity_score = bump_activity_score(activity_score);
3948                                }
3949                            }
3950                        }
3951                        Err(error) => {
3952                            eprintln!("worktree watcher error: {error}");
3953                        }
3954                    }
3955                }
3956            }
3957            Err(mpsc::RecvTimeoutError::Timeout) => {}
3958            Err(mpsc::RecvTimeoutError::Disconnected) => {
3959                return Err("Filesystem watcher disconnected.".to_string());
3960            }
3961        }
3962
3963        // Coalesce buffered events/claims to disk once per tick (not per FS event).
3964        flush_jsonl_write_buffers();
3965
3966        let commit_every = commit_check_interval(tier, &watch_cfg);
3967        if last_commit_check.elapsed() >= commit_every {
3968            let prev = last_head.clone();
3969            match record_commit_snapshot(&identity, &layout, last_head.as_deref()) {
3970                Ok(head) => {
3971                    if head.as_deref() != prev.as_deref() {
3972                        last_activity = Instant::now();
3973                        activity_score = bump_activity_score(activity_score);
3974                    }
3975                    last_head = head;
3976                }
3977                Err(error) => {
3978                    // Repo may have been deleted while cold/unwatched.
3979                    if !identity.root.is_dir() {
3980                        eprintln!(
3981                            "worktree-watch: repo {} vanished; exiting watch loop",
3982                            identity.root.display()
3983                        );
3984                        flush_jsonl_write_buffers();
3985                        return Ok(());
3986                    }
3987                    eprintln!("worktree commit check failed: {error}");
3988                }
3989            }
3990            last_commit_check = Instant::now();
3991        }
3992
3993        if sync_interval_seconds > 0
3994            && last_sync.elapsed() >= Duration::from_secs(sync_interval_seconds)
3995        {
3996            // Ensure spool lines hit disk before upload reads them.
3997            flush_jsonl_write_buffers();
3998            if let Err(error) = sync_spool_for_identity(&identity, &layout).await {
3999                eprintln!("worktree mutation sync failed: {error}");
4000            }
4001            last_sync = Instant::now();
4002        }
4003
4004        if last_rank_persist.elapsed() >= Duration::from_secs(watch_cfg.rank_persist_seconds()) {
4005            persist_single_repo_rank(&identity, activity_score, last_activity, tier);
4006            last_rank_persist = Instant::now();
4007        }
4008
4009        if last_housekeeping.elapsed()
4010            >= Duration::from_secs(SPOOL_HOUSEKEEPING_INTERVAL_SECONDS)
4011        {
4012            let _ = prune_spool_layout(&layout, retention);
4013            last_housekeeping = Instant::now();
4014        }
4015    }
4016}
4017
4018async fn watch_parent_foreground(
4019    parent_root: PathBuf,
4020    identities: Vec<RepoIdentity>,
4021    sync_interval_seconds: u64,
4022) -> Result<(), String> {
4023    // Ensure status/CLI can detect this process even for non-detach starts
4024    // (and refresh state if the detached parent wrote a different PID).
4025    if let Ok(layout) = prepare_parent_spool_layout(&parent_root) {
4026        if let Ok(executable) = std::env::current_exe() {
4027            let _ = write_parent_watcher_state(
4028                &parent_root,
4029                &layout,
4030                std::process::id(),
4031                &executable,
4032            );
4033        }
4034    }
4035
4036    let api_target = WorktreeWatchTargetOptions {
4037        repo: None,
4038        parent: Some(parent_root.clone()),
4039        repos: Vec::new(),
4040    };
4041    if let Some(port) = crate::commands::worktree_watch_api::spawn_worktree_watch_api_in_background(
4042        api_target,
4043        sync_interval_seconds,
4044        None,
4045    ) {
4046        if !is_background_child() {
4047            crate::commands::worktree_watch_api::print_api_listen_hint(port);
4048        } else {
4049            tracing::info!(
4050                "worktree-watch local API listening on http://127.0.0.1:{port}"
4051            );
4052        }
4053    }
4054
4055    let watch_cfg = resolve_worktree_watch_config();
4056    // Watch each repo root individually — NOT the entire parent tree. Recursive
4057    // parent watches pin every nested directory handle on Windows (file-claim /
4058    // delete blocked) and burn CPU on unrelated folder noise.
4059    let (tx, rx) = mpsc::channel();
4060    let mut watcher = create_path_watcher(&parent_root, tx)?;
4061    log_watcher_backend(&parent_root, "parent-per-repo");
4062
4063    let mut repos = prepare_watched_repos(identities, &watch_cfg)?;
4064    apply_initial_repo_ranks(&mut repos, &watch_cfg);
4065    enforce_hot_cap(&mut repos, &watch_cfg);
4066    apply_fs_watch_budget(&mut watcher, &mut repos, &watch_cfg);
4067    let initial_fs = repos.iter().filter(|r| r.fs_watched).count();
4068    let retention = Duration::from_secs(watch_cfg.spool_retention_days().saturating_mul(86_400));
4069    for repo in &repos {
4070        let _ = prune_spool_layout(&repo.layout, retention);
4071    }
4072
4073    let mut last_sync = Instant::now();
4074    let mut last_rescan = Instant::now();
4075    let mut last_rank_persist = Instant::now();
4076    let mut last_tier_pass = Instant::now();
4077    let mut last_housekeeping = Instant::now();
4078
4079    eprintln!(
4080        "worktree-watch parent: {} repo(s) under {} ({} FS watch(es), max {}; idle→cold unwatch)",
4081        repos.len(),
4082        parent_root.display(),
4083        initial_fs,
4084        watch_cfg.max_fs_watches(),
4085    );
4086
4087    loop {
4088        let any_hot = repos.iter().any(|r| r.tier == RepoWatchTier::Hot);
4089        let timeout = if any_hot {
4090            Duration::from_millis(1000)
4091        } else if repos.iter().any(|r| r.tier == RepoWatchTier::Warm) {
4092            Duration::from_millis(2000)
4093        } else {
4094            Duration::from_millis(5000)
4095        };
4096
4097        match rx.recv_timeout(timeout) {
4098            Ok(first) => {
4099                for item in drain_notify_batch(&rx, first) {
4100                    match item {
4101                        Ok(event) => {
4102                            for (repo_index, routed_event) in route_parent_event(&repos, &event) {
4103                                let repo = &mut repos[repo_index];
4104                                if let Some(mutation) =
4105                                    mutation_event_from_notify(&repo.identity, routed_event)
4106                                {
4107                                    if append_unique_mutation_event(
4108                                        &repo.layout,
4109                                        &mutation,
4110                                        &mut repo.event_dedupe,
4111                                    )? {
4112                                        note_repo_activity(repo);
4113                                    }
4114                                }
4115                            }
4116                        }
4117                        Err(error) => {
4118                            eprintln!("parent worktree watcher error: {error}");
4119                        }
4120                    }
4121                }
4122            }
4123            Err(mpsc::RecvTimeoutError::Timeout) => {}
4124            Err(mpsc::RecvTimeoutError::Disconnected) => {
4125                return Err("Parent filesystem watcher disconnected.".to_string());
4126            }
4127        }
4128
4129        // Per-repo adaptive commit checks — budgeted so 60+ warm repos never all
4130        // spawn `git` on the same tick (that was ~continuous single-digit % CPU).
4131        let now = Instant::now();
4132        let mut due: Vec<usize> = repos
4133            .iter()
4134            .enumerate()
4135            .filter(|(_, r)| now >= r.next_commit_check && r.identity.root.is_dir())
4136            .map(|(i, _)| i)
4137            .collect();
4138        due.sort_by(|&a, &b| {
4139            let tier_rank = |t: RepoWatchTier| match t {
4140                RepoWatchTier::Hot => 0u8,
4141                RepoWatchTier::Warm => 1,
4142                RepoWatchTier::Cold => 2,
4143            };
4144            tier_rank(repos[a].tier)
4145                .cmp(&tier_rank(repos[b].tier))
4146                .then_with(|| {
4147                    repos[b]
4148                        .activity_score
4149                        .partial_cmp(&repos[a].activity_score)
4150                        .unwrap_or(std::cmp::Ordering::Equal)
4151                })
4152        });
4153        let budget = watch_cfg.max_commit_checks_per_tick();
4154        for idx in due.into_iter().take(budget) {
4155            let repo = &mut repos[idx];
4156            // Warm repos without an FS watch only need rare HEAD probes (cold cadence).
4157            let interval = if repo.tier == RepoWatchTier::Warm && !repo.fs_watched {
4158                Duration::from_secs(watch_cfg.cold_commit_check_seconds())
4159            } else {
4160                commit_check_interval(repo.tier, &watch_cfg)
4161            };
4162            let prev = repo.last_head.clone();
4163            match record_commit_snapshot(
4164                &repo.identity,
4165                &repo.layout,
4166                repo.last_head.as_deref(),
4167            ) {
4168                Ok(head) => {
4169                    if head.as_deref() != prev.as_deref() {
4170                        note_repo_activity(repo);
4171                    }
4172                    repo.last_head = head;
4173                }
4174                Err(error) => {
4175                    tracing::debug!(
4176                        "commit check {}: {error}",
4177                        repo.identity.root.display()
4178                    );
4179                }
4180            }
4181            repo.next_commit_check = Instant::now() + interval;
4182        }
4183
4184        // Coalesce buffered events/claims to disk once per tick (not per FS event).
4185        flush_jsonl_write_buffers();
4186
4187        // Tier transitions + unwatch cold (frees Windows file locks).
4188        if last_tier_pass.elapsed() >= Duration::from_secs(15) {
4189            refresh_repo_tiers(&mut watcher, &mut repos, &watch_cfg);
4190            last_tier_pass = Instant::now();
4191        }
4192
4193        if last_rescan.elapsed() >= Duration::from_secs(watch_cfg.parent_rescan_seconds()) {
4194            if let Err(error) =
4195                rescan_parent_repos(&parent_root, &mut watcher, &mut repos, &watch_cfg)
4196            {
4197                eprintln!("parent worktree rescan failed: {error}");
4198            }
4199            // Cold repos have no FS watch — probe mtime/HEAD so activity can reheat them.
4200            probe_cold_repo_activity(&mut watcher, &mut repos, &watch_cfg);
4201            last_rescan = Instant::now();
4202        }
4203
4204        if sync_interval_seconds > 0
4205            && last_sync.elapsed() >= Duration::from_secs(sync_interval_seconds)
4206        {
4207            // Ensure spool lines hit disk before upload reads them.
4208            flush_jsonl_write_buffers();
4209            // Sync hot repos first, then warm; skip cold unless they still have spool backlog.
4210            let mut order: Vec<usize> = (0..repos.len()).collect();
4211            order.sort_by(|&a, &b| {
4212                repos[b]
4213                    .activity_score
4214                    .partial_cmp(&repos[a].activity_score)
4215                    .unwrap_or(std::cmp::Ordering::Equal)
4216            });
4217            for idx in order {
4218                let repo = &repos[idx];
4219                if repo.tier == RepoWatchTier::Cold {
4220                    // Still drain spool occasionally so idle data is not stuck forever.
4221                    // Cheap when empty.
4222                }
4223                if let Err(error) = sync_spool_for_identity(&repo.identity, &repo.layout).await {
4224                    eprintln!(
4225                        "worktree mutation sync failed for {}: {error}",
4226                        repo.identity.root.display()
4227                    );
4228                }
4229            }
4230            last_sync = Instant::now();
4231        }
4232
4233        if last_rank_persist.elapsed() >= Duration::from_secs(watch_cfg.rank_persist_seconds()) {
4234            persist_repo_ranks(&repos);
4235            last_rank_persist = Instant::now();
4236        }
4237
4238        if last_housekeeping.elapsed()
4239            >= Duration::from_secs(SPOOL_HOUSEKEEPING_INTERVAL_SECONDS)
4240        {
4241            let retention =
4242                Duration::from_secs(watch_cfg.spool_retention_days().saturating_mul(86_400));
4243            // Spread work: hot first, then a slice of the rest each pass.
4244            let mut order: Vec<usize> = (0..repos.len()).collect();
4245            order.sort_by_key(|&i| match repos[i].tier {
4246                RepoWatchTier::Hot => 0u8,
4247                RepoWatchTier::Warm => 1,
4248                RepoWatchTier::Cold => 2,
4249            });
4250            for &idx in order.iter().take(16) {
4251                let _ = prune_spool_layout(&repos[idx].layout, retention);
4252            }
4253            last_housekeeping = Instant::now();
4254        }
4255    }
4256}
4257
4258/// Prune durable spool clutter that otherwise grows forever on busy machines:
4259/// - `.synced.*.jsonl` older than retention
4260/// - `event-dedupe/*.seen` / `commit-dedupe/*.seen` older than retention
4261/// - rotate oversized `event-dedupe.jsonl` (markers remain the source of truth)
4262fn prune_spool_layout(layout: &SpoolLayout, retention: Duration) -> Result<u64, String> {
4263    let mut removed = 0u64;
4264    removed += prune_old_files_in_dir(&layout.event_dedupe_dir, retention, |name| {
4265        name.ends_with(".seen")
4266    })?;
4267    removed += prune_old_files_in_dir(&layout.commit_dedupe_dir, retention, |name| {
4268        name.ends_with(".seen")
4269    })?;
4270    removed += prune_old_files_in_dir(&layout.root, retention, |name| {
4271        name.ends_with(".jsonl") && name.contains(".synced.")
4272    })?;
4273    removed += rotate_oversized_jsonl(&layout.event_dedupe_file, MAX_EVENT_DEDUPE_JSONL_BYTES)?;
4274    Ok(removed)
4275}
4276
4277fn prune_old_files_in_dir(
4278    dir: &Path,
4279    retention: Duration,
4280    mut name_ok: impl FnMut(&str) -> bool,
4281) -> Result<u64, String> {
4282    if !dir.is_dir() {
4283        return Ok(0);
4284    }
4285    let cutoff = std::time::SystemTime::now()
4286        .checked_sub(retention)
4287        .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
4288    let mut removed = 0u64;
4289    let entries = match fs::read_dir(dir) {
4290        Ok(entries) => entries,
4291        Err(_) => return Ok(0),
4292    };
4293    for entry in entries.flatten() {
4294        let path = entry.path();
4295        if !path.is_file() {
4296            continue;
4297        }
4298        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
4299            continue;
4300        };
4301        if !name_ok(name) {
4302            continue;
4303        }
4304        let Ok(meta) = entry.metadata() else {
4305            continue;
4306        };
4307        let Ok(modified) = meta.modified() else {
4308            continue;
4309        };
4310        if modified < cutoff {
4311            if fs::remove_file(&path).is_ok() {
4312                removed += 1;
4313            }
4314        }
4315    }
4316    Ok(removed)
4317}
4318
4319fn rotate_oversized_jsonl(path: &Path, max_bytes: u64) -> Result<u64, String> {
4320    let Ok(meta) = fs::metadata(path) else {
4321        return Ok(0);
4322    };
4323    if meta.len() <= max_bytes {
4324        return Ok(0);
4325    }
4326    // Drop the bloated telemetry log; `.seen` markers keep idempotency.
4327    let backup = path.with_extension("jsonl.pruned");
4328    let _ = fs::remove_file(&backup);
4329    match fs::rename(path, &backup) {
4330        Ok(()) => {
4331            let _ = fs::remove_file(&backup);
4332            Ok(1)
4333        }
4334        Err(_) => {
4335            // Fallback: truncate in place.
4336            match OpenOptions::new().write(true).truncate(true).open(path) {
4337                Ok(_) => Ok(1),
4338                Err(error) => Err(format!(
4339                    "Failed to rotate oversized dedupe log {}: {error}",
4340                    path.display()
4341                )),
4342            }
4343        }
4344    }
4345}
4346
4347fn note_repo_activity(repo: &mut WatchedRepo) {
4348    repo.last_activity = Instant::now();
4349    repo.activity_score = bump_activity_score(repo.activity_score);
4350    if repo.tier != RepoWatchTier::Hot {
4351        repo.tier = RepoWatchTier::Hot;
4352        repo.next_commit_check = Instant::now();
4353    }
4354}
4355
4356fn tier_from_idle(idle_secs: u64, cfg: &WorktreeWatchConfig) -> RepoWatchTier {
4357    if idle_secs >= cfg.cold_after_seconds() {
4358        RepoWatchTier::Cold
4359    } else if idle_secs >= cfg.idle_after_seconds() {
4360        RepoWatchTier::Warm
4361    } else {
4362        RepoWatchTier::Hot
4363    }
4364}
4365
4366fn commit_check_interval(tier: RepoWatchTier, cfg: &WorktreeWatchConfig) -> Duration {
4367    let secs = match tier {
4368        RepoWatchTier::Hot => cfg.hot_commit_check_seconds(),
4369        RepoWatchTier::Warm => cfg.warm_commit_check_seconds(),
4370        RepoWatchTier::Cold => cfg.cold_commit_check_seconds(),
4371    };
4372    Duration::from_secs(secs)
4373}
4374
4375fn event_loop_timeout(tier: RepoWatchTier) -> Duration {
4376    match tier {
4377        RepoWatchTier::Hot => Duration::from_millis(1000),
4378        RepoWatchTier::Warm => Duration::from_millis(2500),
4379        RepoWatchTier::Cold => Duration::from_millis(5000),
4380    }
4381}
4382
4383fn bump_activity_score(score: f64) -> f64 {
4384    // Simple growth with soft cap so ranks stay comparable.
4385    (score * 0.98 + 1.0).min(10_000.0)
4386}
4387
4388fn seed_activity_score(cfg: &WorktreeWatchConfig, root: &Path) -> f64 {
4389    let key = path_identity_key(root);
4390    cfg.repo_ranks
4391        .iter()
4392        .find(|r| path_identity_key(Path::new(&r.root)) == key)
4393        .map(|r| r.score)
4394        .unwrap_or(0.0)
4395}
4396
4397fn apply_initial_repo_ranks(repos: &mut [WatchedRepo], cfg: &WorktreeWatchConfig) {
4398    for repo in repos.iter_mut() {
4399        repo.activity_score = seed_activity_score(cfg, &repo.identity.root);
4400        // Seed last_activity so cold ranks don't all start hot.
4401        if let Some(rank) = cfg.repo_ranks.iter().find(|r| {
4402            path_identity_key(Path::new(&r.root)) == path_identity_key(&repo.identity.root)
4403        }) {
4404            if let Some(ts) = rank.last_event_at.as_deref() {
4405                if let Ok(dt) = DateTime::parse_from_rfc3339(ts) {
4406                    let age = (Utc::now() - dt.with_timezone(&Utc))
4407                        .num_seconds()
4408                        .max(0) as u64;
4409                    repo.tier = tier_from_idle(age, cfg);
4410                    // Approximate Instant: use now - age when age is small enough for Instant.
4411                    if age < 24 * 3600 {
4412                        repo.last_activity =
4413                            Instant::now() - Duration::from_secs(age.min(23 * 3600));
4414                    } else {
4415                        repo.last_activity =
4416                            Instant::now() - Duration::from_secs(cfg.cold_after_seconds());
4417                    }
4418                }
4419            }
4420        }
4421    }
4422}
4423
4424fn enforce_hot_cap(repos: &mut [WatchedRepo], cfg: &WorktreeWatchConfig) {
4425    let max_hot = cfg.max_hot_repos();
4426    let mut hot_idx: Vec<usize> = repos
4427        .iter()
4428        .enumerate()
4429        .filter(|(_, r)| r.tier == RepoWatchTier::Hot)
4430        .map(|(i, _)| i)
4431        .collect();
4432    if hot_idx.len() <= max_hot {
4433        return;
4434    }
4435    hot_idx.sort_by(|&a, &b| {
4436        repos[b]
4437            .activity_score
4438            .partial_cmp(&repos[a].activity_score)
4439            .unwrap_or(std::cmp::Ordering::Equal)
4440    });
4441    for &idx in hot_idx.iter().skip(max_hot) {
4442        repos[idx].tier = RepoWatchTier::Warm;
4443    }
4444}
4445
4446fn refresh_repo_tiers(
4447    watcher: &mut PathWatcher,
4448    repos: &mut [WatchedRepo],
4449    cfg: &WorktreeWatchConfig,
4450) {
4451    for repo in repos.iter_mut() {
4452        // Decay score slowly while idle so ranks fall over time.
4453        let idle = repo.last_activity.elapsed().as_secs();
4454        if idle > 60 {
4455            repo.activity_score *= 0.995;
4456        }
4457        let desired = tier_from_idle(idle, cfg);
4458        if desired != repo.tier {
4459            tracing::debug!(
4460                "worktree-watch tier {} → {} for {}",
4461                repo.tier.as_str(),
4462                desired.as_str(),
4463                repo.identity.root.display()
4464            );
4465            repo.tier = desired;
4466            repo.next_commit_check = Instant::now() + commit_check_interval(desired, cfg);
4467        }
4468    }
4469    enforce_hot_cap(repos, cfg);
4470    apply_fs_watch_budget(watcher, repos, cfg);
4471}
4472
4473/// Cap concurrent recursive FS watches. Prefer hot, then highest activity score.
4474fn apply_fs_watch_budget(
4475    watcher: &mut PathWatcher,
4476    repos: &mut [WatchedRepo],
4477    cfg: &WorktreeWatchConfig,
4478) {
4479    let max = cfg.max_fs_watches();
4480    let want = fs_watch_want_mask(repos, max);
4481    for (index, repo) in repos.iter_mut().enumerate() {
4482        let _ = set_repo_fs_watch(watcher, repo, want[index]);
4483    }
4484}
4485
4486/// Which repos should hold a recursive FS watch under the budget.
4487///
4488/// Prefer hot over warm, then higher activity score. Cold never watches.
4489/// Non-existent roots are skipped (production always has real dirs).
4490fn fs_watch_want_mask(repos: &[WatchedRepo], max: usize) -> Vec<bool> {
4491    let mut eligible: Vec<usize> = repos
4492        .iter()
4493        .enumerate()
4494        .filter(|(_, repo)| repo.tier != RepoWatchTier::Cold && repo.identity.root.is_dir())
4495        .map(|(index, _)| index)
4496        .collect();
4497    eligible.sort_by(|&a, &b| {
4498        let hot_a = repos[a].tier == RepoWatchTier::Hot;
4499        let hot_b = repos[b].tier == RepoWatchTier::Hot;
4500        hot_b
4501            .cmp(&hot_a)
4502            .then_with(|| {
4503                repos[b]
4504                    .activity_score
4505                    .partial_cmp(&repos[a].activity_score)
4506                    .unwrap_or(std::cmp::Ordering::Equal)
4507            })
4508            .then_with(|| {
4509                path_identity_key(&repos[a].identity.root)
4510                    .cmp(&path_identity_key(&repos[b].identity.root))
4511            })
4512    });
4513
4514    let mut want = vec![false; repos.len()];
4515    for &index in eligible.iter().take(max) {
4516        want[index] = true;
4517    }
4518    want
4519}
4520
4521fn set_repo_fs_watch(
4522    watcher: &mut PathWatcher,
4523    repo: &mut WatchedRepo,
4524    want_watch: bool,
4525) -> Result<(), String> {
4526    if want_watch && !repo.fs_watched {
4527        match watcher.watch(&repo.identity.root, RecursiveMode::Recursive) {
4528            Ok(()) => {
4529                repo.fs_watched = true;
4530                tracing::info!(
4531                    "worktree-watch: watching {} ({})",
4532                    repo.identity.root.display(),
4533                    repo.tier.as_str()
4534                );
4535            }
4536            Err(error) => {
4537                // Path may be mid-delete; leave cold.
4538                tracing::warn!(
4539                    "worktree-watch: watch {} failed: {error}",
4540                    repo.identity.root.display()
4541                );
4542                repo.tier = RepoWatchTier::Cold;
4543                repo.fs_watched = false;
4544            }
4545        }
4546    } else if !want_watch && repo.fs_watched {
4547        let _ = watcher.unwatch(&repo.identity.root);
4548        repo.fs_watched = false;
4549        tracing::info!(
4550            "worktree-watch: unwatched {} (budget/cold — directory locks released)",
4551            repo.identity.root.display()
4552        );
4553    }
4554    Ok(())
4555}
4556
4557/// Cheap reheating for cold (unwatched) repos: if `.git/index` / HEAD moved, go hot.
4558fn probe_cold_repo_activity(
4559    watcher: &mut PathWatcher,
4560    repos: &mut [WatchedRepo],
4561    cfg: &WorktreeWatchConfig,
4562) {
4563    for repo in repos.iter_mut() {
4564        if repo.tier != RepoWatchTier::Cold {
4565            continue;
4566        }
4567        if !repo.identity.root.is_dir() {
4568            continue;
4569        }
4570        if cold_repo_looks_active(repo) {
4571            note_repo_activity(repo);
4572            tracing::info!(
4573                "worktree-watch: reheated cold repo {}",
4574                repo.identity.root.display()
4575            );
4576        }
4577    }
4578    enforce_hot_cap(repos, cfg);
4579    apply_fs_watch_budget(watcher, repos, cfg);
4580}
4581
4582fn cold_repo_looks_active(repo: &WatchedRepo) -> bool {
4583    use std::time::SystemTime;
4584    let root = &repo.identity.root;
4585    // Approximate wall-clock of last_activity for mtime comparison.
4586    let last_activity_wall = SystemTime::now()
4587        .checked_sub(repo.last_activity.elapsed())
4588        .unwrap_or(SystemTime::UNIX_EPOCH);
4589    // Prefer index/HEAD mtimes — cheap; updates on commits and many git operations.
4590    for rel in [".git/index", ".git/HEAD", ".git/logs/HEAD"] {
4591        let path = root.join(rel);
4592        if let Ok(meta) = fs::metadata(&path) {
4593            if let Ok(modified) = meta.modified() {
4594                if modified > last_activity_wall {
4595                    return true;
4596                }
4597            }
4598        }
4599    }
4600    // Fallback: HEAD sha changed since last snapshot.
4601    match current_head(root) {
4602        Ok(Some(head)) if Some(head.as_str()) != repo.last_head.as_deref() => true,
4603        _ => false,
4604    }
4605}
4606
4607fn rescan_parent_repos(
4608    parent_root: &Path,
4609    watcher: &mut PathWatcher,
4610    repos: &mut Vec<WatchedRepo>,
4611    cfg: &WorktreeWatchConfig,
4612) -> Result<(), String> {
4613    let discovered = discover_repo_identities_under(parent_root)?;
4614    let live_keys: BTreeSet<String> = discovered
4615        .iter()
4616        .map(|id| path_identity_key(&id.root))
4617        .collect();
4618    let existing_keys: BTreeSet<String> = repos
4619        .iter()
4620        .map(|r| path_identity_key(&r.identity.root))
4621        .collect();
4622
4623    for identity in discovered {
4624        let key = path_identity_key(&identity.root);
4625        if existing_keys.contains(&key) {
4626            continue;
4627        }
4628        let prepared = prepare_watched_repos(vec![identity], cfg)?;
4629        for mut repo in prepared {
4630            tracing::info!(
4631                "worktree-watch parent: added repo {} ({})",
4632                repo.identity.root.display(),
4633                repo.tier.as_str()
4634            );
4635            // Newly discovered roots stay cold until edited (or ranked hot later).
4636            // Avoid pinning FS watches / Windows file locks on brand-new clones.
4637            repo.tier = RepoWatchTier::Cold;
4638            repo.fs_watched = false;
4639            repo.last_activity = Instant::now() - Duration::from_secs(cfg.cold_after_seconds());
4640            repos.push(repo);
4641        }
4642    }
4643
4644    // Drop vanished roots (unwatch first so Windows can finish deletes).
4645    let mut keep = Vec::with_capacity(repos.len());
4646    for mut repo in repos.drain(..) {
4647        let key = path_identity_key(&repo.identity.root);
4648        if live_keys.contains(&key) && repo.identity.root.is_dir() {
4649            keep.push(repo);
4650        } else {
4651            if repo.fs_watched {
4652                let _ = watcher.unwatch(&repo.identity.root);
4653                repo.fs_watched = false;
4654            }
4655            tracing::info!(
4656                "worktree-watch parent: removed vanished repo {}",
4657                repo.identity.root.display()
4658            );
4659        }
4660    }
4661    *repos = keep;
4662    repos.sort_by_key(|repo| std::cmp::Reverse(repo.identity.root.components().count()));
4663    enforce_hot_cap(repos, cfg);
4664    apply_fs_watch_budget(watcher, repos, cfg);
4665    Ok(())
4666}
4667
4668fn persist_repo_ranks(repos: &[WatchedRepo]) {
4669    let mut ranks: Vec<WorktreeWatchRepoRank> = repos
4670        .iter()
4671        .map(|repo| WorktreeWatchRepoRank {
4672            root: repo.identity.root.display().to_string(),
4673            owner: Some(repo.identity.owner.clone()),
4674            name: Some(repo.identity.name.clone()),
4675            score: (repo.activity_score * 1000.0).round() / 1000.0,
4676            last_event_at: Some(
4677                (Utc::now()
4678                    - chrono::Duration::from_std(repo.last_activity.elapsed())
4679                        .unwrap_or_else(|_| chrono::Duration::seconds(0)))
4680                .to_rfc3339(),
4681            ),
4682            rank: None,
4683            tier: Some(repo.tier.as_str().to_string()),
4684        })
4685        .collect();
4686    ranks.sort_by(|a, b| {
4687        b.score
4688            .partial_cmp(&a.score)
4689            .unwrap_or(std::cmp::Ordering::Equal)
4690    });
4691    for (i, rank) in ranks.iter_mut().enumerate() {
4692        rank.rank = Some((i + 1) as u32);
4693    }
4694    // Keep config bounded.
4695    ranks.truncate(200);
4696
4697    let mut config = match SshConfig::load() {
4698        Ok(c) => c,
4699        Err(error) => {
4700            tracing::warn!("worktree-watch: could not load config to persist ranks: {error}");
4701            return;
4702        }
4703    };
4704    let mut ww = config.worktree_watch.unwrap_or_default();
4705    ww.repo_ranks = ranks;
4706    config.worktree_watch = Some(ww);
4707    if let Err(error) = config.save() {
4708        tracing::warn!("worktree-watch: could not persist repo ranks: {error}");
4709    }
4710}
4711
4712fn persist_single_repo_rank(
4713    identity: &RepoIdentity,
4714    score: f64,
4715    last_activity: Instant,
4716    tier: RepoWatchTier,
4717) {
4718    let mut config = match SshConfig::load() {
4719        Ok(c) => c,
4720        Err(_) => return,
4721    };
4722    let mut ww = config.worktree_watch.unwrap_or_default();
4723    let key = path_identity_key(&identity.root);
4724    let entry = WorktreeWatchRepoRank {
4725        root: identity.root.display().to_string(),
4726        owner: Some(identity.owner.clone()),
4727        name: Some(identity.name.clone()),
4728        score: (score * 1000.0).round() / 1000.0,
4729        last_event_at: Some(
4730            (Utc::now()
4731                - chrono::Duration::from_std(last_activity.elapsed())
4732                    .unwrap_or_else(|_| chrono::Duration::seconds(0)))
4733            .to_rfc3339(),
4734        ),
4735        rank: None,
4736        tier: Some(tier.as_str().to_string()),
4737    };
4738    if let Some(existing) = ww
4739        .repo_ranks
4740        .iter_mut()
4741        .find(|r| path_identity_key(Path::new(&r.root)) == key)
4742    {
4743        *existing = entry;
4744    } else {
4745        ww.repo_ranks.push(entry);
4746    }
4747    ww.repo_ranks.sort_by(|a, b| {
4748        b.score
4749            .partial_cmp(&a.score)
4750            .unwrap_or(std::cmp::Ordering::Equal)
4751    });
4752    for (i, rank) in ww.repo_ranks.iter_mut().enumerate() {
4753        rank.rank = Some((i + 1) as u32);
4754    }
4755    ww.repo_ranks.truncate(200);
4756    config.worktree_watch = Some(ww);
4757    let _ = config.save();
4758}
4759
4760/// Filesystem watcher handle that can be either native (inotify/FSEvents/ReadDirectoryChanges)
4761/// or polling (needed for WSL mounts of Windows drives where inotify is unreliable).
4762enum PathWatcher {
4763    Recommended(RecommendedWatcher),
4764    Poll(notify::PollWatcher),
4765}
4766
4767impl PathWatcher {
4768    fn watch(&mut self, path: &Path, mode: RecursiveMode) -> notify::Result<()> {
4769        match self {
4770            Self::Recommended(watcher) => watcher.watch(path, mode),
4771            Self::Poll(watcher) => watcher.watch(path, mode),
4772        }
4773    }
4774
4775    fn unwatch(&mut self, path: &Path) -> notify::Result<()> {
4776        match self {
4777            Self::Recommended(watcher) => watcher.unwatch(path),
4778            Self::Poll(watcher) => watcher.unwatch(path),
4779        }
4780    }
4781}
4782
4783fn create_path_watcher(
4784    path: &Path,
4785    tx: mpsc::Sender<notify::Result<Event>>,
4786) -> Result<PathWatcher, String> {
4787    let config = Config::default();
4788    if should_use_poll_watcher(path) {
4789        // Slower poll: adaptive cold/hot layers handle responsiveness.
4790        let poll_config = config.with_poll_interval(Duration::from_secs(5));
4791        notify::PollWatcher::new(
4792            move |result| {
4793                let _ = tx.send(result);
4794            },
4795            poll_config,
4796        )
4797        .map(PathWatcher::Poll)
4798        .map_err(|error| format!("Failed to create poll filesystem watcher: {error}"))
4799    } else {
4800        RecommendedWatcher::new(
4801            move |result| {
4802                let _ = tx.send(result);
4803            },
4804            config,
4805        )
4806        .map(PathWatcher::Recommended)
4807        .map_err(|error| format!("Failed to create filesystem watcher: {error}"))
4808    }
4809}
4810
4811fn should_use_poll_watcher(path: &Path) -> bool {
4812    // WSL inotify does not reliably cover 9p/drvfs mounts under /mnt/*.
4813    if !is_wsl_runtime() {
4814        return false;
4815    }
4816    let key = path_identity_key(path).replace('\\', "/");
4817    key.starts_with("/mnt/") || key.contains("/mnt/")
4818}
4819
4820fn log_watcher_backend(path: &Path, label: &str) {
4821    if should_use_poll_watcher(path) {
4822        eprintln!(
4823            "worktree-watch: using poll watcher for {label} {} (WSL mount; inotify is unreliable)",
4824            path.display()
4825        );
4826    } else {
4827        tracing::info!(
4828            "worktree-watch: native watcher for {label} {}",
4829            path.display()
4830        );
4831    }
4832}
4833
4834fn prepare_watched_repos(
4835    identities: Vec<RepoIdentity>,
4836    cfg: &WorktreeWatchConfig,
4837) -> Result<Vec<WatchedRepo>, String> {
4838    let mut repos = Vec::with_capacity(identities.len());
4839    let now = Instant::now();
4840    for identity in identities {
4841        let layout = prepare_spool_layout(&identity)?;
4842        let event_dedupe = RecentEventDedupe {
4843            fingerprints: load_event_dedupe_fingerprints(&layout.event_dedupe_file)?,
4844        };
4845        let score = seed_activity_score(cfg, &identity.root);
4846        // Prefer cold for unknown/idle repos so parent covers do not pin dozens of
4847        // directory handles. Ranked / previously active repos start warmer.
4848        // Low residual scores (1–2 from old ranks) stay cold — they were flooding
4849        // warm-tier git HEAD polls across 60+ clones.
4850        let tier = if score >= 5.0 {
4851            RepoWatchTier::Hot
4852        } else if score >= 3.0 {
4853            RepoWatchTier::Warm
4854        } else {
4855            RepoWatchTier::Cold
4856        };
4857        // Cold start: pretend idle long enough that we do not immediately re-hot.
4858        let last_activity = if matches!(tier, RepoWatchTier::Cold) {
4859            now - Duration::from_secs(cfg.cold_after_seconds())
4860        } else if matches!(tier, RepoWatchTier::Warm) {
4861            now - Duration::from_secs(cfg.idle_after_seconds())
4862        } else {
4863            now
4864        };
4865        // Stagger first HEAD probe so a 70-repo parent does not spawn 70 gits at t=0.
4866        let stagger_ms = (repos.len() as u64).saturating_mul(400) % 45_000;
4867        repos.push(WatchedRepo {
4868            last_head: identity.head_sha.clone(),
4869            identity,
4870            layout,
4871            event_dedupe,
4872            last_activity,
4873            activity_score: score,
4874            tier,
4875            fs_watched: false,
4876            next_commit_check: now + Duration::from_millis(stagger_ms),
4877        });
4878    }
4879    repos.sort_by_key(|repo| std::cmp::Reverse(repo.identity.root.components().count()));
4880    Ok(repos)
4881}
4882
4883fn route_parent_event(repos: &[WatchedRepo], event: &Event) -> Vec<(usize, Event)> {
4884    let mut paths_by_repo: BTreeMap<usize, Vec<PathBuf>> = BTreeMap::new();
4885    for path in &event.paths {
4886        for (index, repo) in repos.iter().enumerate() {
4887            if path_belongs_to_repo(path, &repo.identity.root) {
4888                let paths = paths_by_repo.entry(index).or_default();
4889                if !paths.iter().any(|existing| existing == path) {
4890                    paths.push(path.clone());
4891                }
4892                break;
4893            }
4894        }
4895    }
4896
4897    let mut routed = Vec::new();
4898    for (index, paths) in paths_by_repo {
4899        let mut repo_event = event.clone();
4900        repo_event.paths = paths;
4901        routed.push((index, repo_event));
4902    }
4903    routed
4904}
4905
4906fn path_belongs_to_repo(path: &Path, repo_root: &Path) -> bool {
4907    let path_key = path_identity_key(path).replace('\\', "/");
4908    let root_key = path_identity_key(repo_root).replace('\\', "/");
4909    path_key == root_key || path_key.starts_with(&format!("{root_key}/"))
4910}
4911
4912fn mutation_event_from_notify(
4913    identity: &RepoIdentity,
4914    event: Event,
4915) -> Option<WorktreeMutationEvent> {
4916    if event.kind.is_access() || event.kind.is_other() {
4917        return None;
4918    }
4919
4920    let paths: Vec<String> = event
4921        .paths
4922        .iter()
4923        .filter(|path| !is_ignored_path(&identity.root, path))
4924        .filter_map(|path| relative_slash_path(&identity.root, path))
4925        .fold(Vec::new(), |mut paths, path| {
4926            if !paths.iter().any(|existing| existing == &path) {
4927                paths.push(path);
4928            }
4929            paths
4930        });
4931
4932    if paths.is_empty() {
4933        return None;
4934    }
4935
4936    let primary_path = paths.first().cloned();
4937    let (old_path, new_path) = rename_paths(&event.kind, &paths);
4938    // Expensive git/file scans only for content modifies — not every create/remove/noise event.
4939    let want_line_stats = matches!(
4940        event.kind,
4941        EventKind::Modify(ModifyKind::Data(_)) | EventKind::Modify(ModifyKind::Any)
4942    );
4943    let line_counts = if want_line_stats {
4944        primary_path
4945            .as_deref()
4946            .and_then(|path| git_numstat_for_path(&identity.root, path).ok())
4947    } else {
4948        None
4949    };
4950    let total_lines = if want_line_stats {
4951        primary_path
4952            .as_deref()
4953            .and_then(|path| file_line_count_for_path(&identity.root, path).ok())
4954    } else {
4955        None
4956    };
4957    let code_snapshots = primary_path
4958        .as_deref()
4959        .and_then(|path| fs::read_to_string(identity.root.join(path)).ok())
4960        .map(|source| {
4961            snapshots_for_file(primary_path.as_deref().unwrap_or_default(), &source)
4962                .into_iter()
4963                .take(32)
4964                .collect()
4965        })
4966        .unwrap_or_default();
4967    let event_kind = classify_event_kind(&event.kind);
4968    let is_dir = event
4969        .paths
4970        .first()
4971        .and_then(|path| fs::metadata(path).ok())
4972        .map(|metadata| metadata.is_dir())
4973        .unwrap_or(false);
4974
4975    let occurred_at = Utc::now();
4976    let mut mutation = WorktreeMutationEvent {
4977        id: String::new(),
4978        fingerprint: None,
4979        repo_owner: identity.owner.clone(),
4980        repo_name: identity.name.clone(),
4981        branch_name: identity.branch.clone(),
4982        repo_root: normalize_repo_root_for_payload(&identity.root),
4983        // Avoid git spawn on every FS event (HEAD is refreshed on the adaptive commit timer).
4984        head_sha: identity.head_sha.clone(),
4985        event_kind: event_kind.to_string(),
4986        paths,
4987        primary_path,
4988        old_path,
4989        new_path,
4990        added_lines: line_counts.map(|counts| counts.0),
4991        removed_lines: line_counts.map(|counts| counts.1),
4992        total_lines,
4993        code_snapshots,
4994        file_created: matches!(
4995            event.kind,
4996            EventKind::Create(CreateKind::File | CreateKind::Any)
4997        ) && !is_dir,
4998        file_removed: matches!(
4999            event.kind,
5000            EventKind::Remove(RemoveKind::File | RemoveKind::Any)
5001        ) && !is_dir,
5002        folder_created: matches!(
5003            event.kind,
5004            EventKind::Create(CreateKind::Folder | CreateKind::Any)
5005        ) && is_dir,
5006        folder_removed: matches!(
5007            event.kind,
5008            EventKind::Remove(RemoveKind::Folder | RemoveKind::Any)
5009        ) && is_dir,
5010        renamed_or_moved: is_rename_or_move(&event.kind),
5011        raw_kind: format!("{:?}", event.kind),
5012        occurred_at,
5013    };
5014    // Stable id/fingerprint for idempotent spool + upload (same logical mutation
5015    // within the dedupe window always maps to the same identity).
5016    let fingerprint = mutation_event_fingerprint(&mutation);
5017    mutation.fingerprint = Some(fingerprint.clone());
5018    mutation.id = stable_event_id(&fingerprint);
5019    Some(mutation)
5020}
5021
5022fn append_unique_mutation_event(
5023    layout: &SpoolLayout,
5024    mutation: &WorktreeMutationEvent,
5025    dedupe: &mut RecentEventDedupe,
5026) -> Result<bool, String> {
5027    let fingerprint = mutation
5028        .fingerprint
5029        .clone()
5030        .unwrap_or_else(|| mutation_event_fingerprint(mutation));
5031    // In-memory cache + `.seen` markers only. Scanning event-dedupe.jsonl was
5032    // O(file size) per event and loaded multi‑10MB sets at process start.
5033    if dedupe.fingerprints.contains(&fingerprint) {
5034        return Ok(false);
5035    }
5036
5037    if !claim_event_fingerprint(layout, &fingerprint)? {
5038        dedupe_remember(dedupe, fingerprint);
5039        return Ok(false);
5040    }
5041
5042    let mut mutation = mutation.clone();
5043    mutation.fingerprint = Some(fingerprint.clone());
5044    if mutation.id.is_empty() {
5045        mutation.id = stable_event_id(&fingerprint);
5046    }
5047    if let Err(error) = append_json_line(&layout.events_file, &mutation) {
5048        release_event_fingerprint_claim(layout, &fingerprint);
5049        return Err(error);
5050    }
5051    // Claims journal already records the fingerprint (see claim_event_fingerprint).
5052    // Skip a second per-event write to event-dedupe.jsonl — that doubled IO for
5053    // every mutation and grew multi‑10MB logs that were never loaded at start.
5054    dedupe_remember(dedupe, fingerprint);
5055    Ok(true)
5056}
5057
5058fn dedupe_remember(dedupe: &mut RecentEventDedupe, fingerprint: String) {
5059    if dedupe.fingerprints.len() >= MAX_RECENT_EVENT_DEDUPE_IN_MEMORY {
5060        dedupe.fingerprints.clear();
5061    }
5062    dedupe.fingerprints.insert(fingerprint);
5063}
5064
5065fn claim_event_fingerprint(layout: &SpoolLayout, fingerprint: &str) -> Result<bool, String> {
5066    // Legacy per-fingerprint markers (one file each → 10k–100k files under
5067    // ~/.xbp/mutations). Honor them for older spools, but do not create new ones.
5068    let marker = event_dedupe_marker_path(layout, fingerprint);
5069    if marker.is_file() {
5070        return Ok(false);
5071    }
5072    // Process-wide claim set: covers multi-dedupe-instance races in one OS process
5073    // without creating per-fingerprint files. Journal is durable telemetry only.
5074    let claim_key = format!("{}|{fingerprint}", layout.root.display());
5075    {
5076        let mut claimed = event_claims_in_process()
5077            .lock()
5078            .map_err(|_| "event claim lock poisoned".to_string())?;
5079        if !claimed.insert(claim_key) {
5080            return Ok(false);
5081        }
5082    }
5083    let claims = layout.root.join("event-claims.jsonl");
5084    append_json_line(
5085        &claims,
5086        &json!({
5087            "fingerprint": fingerprint,
5088            "firstSeenAt": Utc::now().to_rfc3339(),
5089        }),
5090    )?;
5091    Ok(true)
5092}
5093
5094fn release_event_fingerprint_claim(layout: &SpoolLayout, fingerprint: &str) {
5095    // Best-effort: remove legacy marker if present. Journal lines are append-only
5096    // (failed event writes are rare after claim).
5097    let _ = fs::remove_file(event_dedupe_marker_path(layout, fingerprint));
5098    let claim_key = format!("{}|{fingerprint}", layout.root.display());
5099    if let Ok(mut claimed) = event_claims_in_process().lock() {
5100        claimed.remove(&claim_key);
5101    }
5102}
5103
5104fn event_claims_in_process() -> &'static Mutex<BTreeSet<String>> {
5105    static CLAIMS: OnceLock<Mutex<BTreeSet<String>>> = OnceLock::new();
5106    CLAIMS.get_or_init(|| Mutex::new(BTreeSet::new()))
5107}
5108
5109fn event_dedupe_marker_path(layout: &SpoolLayout, fingerprint: &str) -> PathBuf {
5110    layout.event_dedupe_dir.join(format!("{fingerprint}.seen"))
5111}
5112
5113fn mutation_event_fingerprint(mutation: &WorktreeMutationEvent) -> String {
5114    let bucket = mutation.occurred_at.timestamp() / EVENT_DEDUPE_WINDOW_SECONDS;
5115    // Omit absolute `repoRoot` so Windows `C:\...` and WSL `/mnt/c/...` spellings
5116    // of the same tree share one fingerprint. Platform is kept only as telemetry
5117    // on the upload device payload — not in the local idempotency key — so both
5118    // environments can share one home-level spool safely.
5119    let payload = json!({
5120        "schema": 3,
5121        "repoOwner": mutation.repo_owner,
5122        "repoName": mutation.repo_name,
5123        "branchName": mutation.branch_name,
5124        "headSha": mutation.head_sha,
5125        "eventKind": mutation.event_kind,
5126        "paths": normalize_path_list_for_fingerprint(&mutation.paths),
5127        "primaryPath": mutation.primary_path.as_deref().map(normalize_relative_watch_path),
5128        "oldPath": mutation.old_path.as_deref().map(normalize_relative_watch_path),
5129        "newPath": mutation.new_path.as_deref().map(normalize_relative_watch_path),
5130        "addedLines": mutation.added_lines,
5131        "removedLines": mutation.removed_lines,
5132        "fileCreated": mutation.file_created,
5133        "fileRemoved": mutation.file_removed,
5134        "folderCreated": mutation.folder_created,
5135        "folderRemoved": mutation.folder_removed,
5136        "renamedOrMoved": mutation.renamed_or_moved,
5137        "timeBucket": bucket,
5138    });
5139    let encoded = serde_json::to_vec(&payload).unwrap_or_default();
5140    let mut hasher = Sha256::new();
5141    hasher.update(encoded);
5142    format!("{:x}", hasher.finalize())
5143}
5144
5145fn normalize_path_list_for_fingerprint(paths: &[String]) -> Vec<String> {
5146    let mut normalized = paths
5147        .iter()
5148        .map(|path| normalize_relative_watch_path(path))
5149        .filter(|path| !path.is_empty())
5150        .collect::<Vec<_>>();
5151    normalized.sort();
5152    normalized.dedup();
5153    normalized
5154}
5155
5156fn stable_event_id(fingerprint: &str) -> String {
5157    // Deterministic id derived from fingerprint so resync / multi-process claims
5158    // never invent a second identity for the same logical mutation.
5159    format!("wtmut_{fingerprint}")
5160}
5161
5162fn normalize_repo_root_for_payload(root: &Path) -> String {
5163    path_identity_key(root).replace('\\', "/")
5164}
5165
5166fn load_event_dedupe_fingerprints(_path: &Path) -> Result<BTreeSet<String>, String> {
5167    // Intentionally empty. Durable cross-process dedupe uses `event-dedupe/*.seen`
5168    // markers. Loading multi‑10MB `event-dedupe.jsonl` files for every branch under
5169    // a parent cover was a primary cause of ~1 GiB RSS.
5170    Ok(BTreeSet::new())
5171}
5172
5173fn classify_event_kind(kind: &EventKind) -> &'static str {
5174    match kind {
5175        EventKind::Create(CreateKind::File) => "file_create",
5176        EventKind::Create(CreateKind::Folder) => "folder_create",
5177        EventKind::Create(_) => "create",
5178        EventKind::Remove(RemoveKind::File) => "file_remove",
5179        EventKind::Remove(RemoveKind::Folder) => "folder_remove",
5180        EventKind::Remove(_) => "remove",
5181        EventKind::Modify(ModifyKind::Name(
5182            RenameMode::From | RenameMode::To | RenameMode::Both,
5183        )) => "rename_or_move",
5184        EventKind::Modify(ModifyKind::Data(_)) => "file_modify",
5185        EventKind::Modify(ModifyKind::Metadata(_)) => "metadata_modify",
5186        EventKind::Modify(_) => "modify",
5187        _ => "other",
5188    }
5189}
5190
5191fn is_rename_or_move(kind: &EventKind) -> bool {
5192    matches!(
5193        kind,
5194        EventKind::Modify(ModifyKind::Name(
5195            RenameMode::From | RenameMode::To | RenameMode::Both
5196        ))
5197    )
5198}
5199
5200fn rename_paths(kind: &EventKind, paths: &[String]) -> (Option<String>, Option<String>) {
5201    if !is_rename_or_move(kind) {
5202        return (None, None);
5203    }
5204
5205    match paths {
5206        [old_path, new_path, ..] => (Some(old_path.clone()), Some(new_path.clone())),
5207        [path] => match kind {
5208            EventKind::Modify(ModifyKind::Name(RenameMode::From)) => (Some(path.clone()), None),
5209            EventKind::Modify(ModifyKind::Name(RenameMode::To)) => (None, Some(path.clone())),
5210            _ => (None, Some(path.clone())),
5211        },
5212        _ => (None, None),
5213    }
5214}
5215
5216fn record_commit_snapshot(
5217    identity: &RepoIdentity,
5218    layout: &SpoolLayout,
5219    previous_head: Option<&str>,
5220) -> Result<Option<String>, String> {
5221    let head = current_head(&identity.root)?;
5222    let Some(head_sha) = head else {
5223        return Ok(None);
5224    };
5225
5226    if previous_head == Some(head_sha.as_str()) {
5227        return Ok(Some(head_sha));
5228    }
5229
5230    // One git log for parents + subject/author/email/time (was rev-list + separate log).
5231    let enrich = enrich_commit_range(&identity.root, previous_head, &head_sha);
5232
5233    let mut commit = WorktreeCommitEvent {
5234        id: String::new(),
5235        fingerprint: None,
5236        repo_owner: identity.owner.clone(),
5237        repo_name: identity.name.clone(),
5238        branch_name: identity.branch.clone(),
5239        repo_root: normalize_repo_root_for_payload(&identity.root),
5240        previous_head_sha: previous_head.map(str::to_string),
5241        head_sha: head_sha.clone(),
5242        subject: enrich.subject,
5243        author_name: enrich.author_name,
5244        author_email: enrich.author_email,
5245        committed_at: enrich.committed_at,
5246        occurred_at: Utc::now(),
5247        files_changed: enrich.files_changed,
5248        files_changed_count: enrich.files_changed_count,
5249        insertions: enrich.insertions,
5250        deletions: enrich.deletions,
5251        is_merge: enrich.is_merge,
5252        included_commits: enrich.included_commits,
5253    };
5254    let fingerprint = commit_event_fingerprint(&commit);
5255    commit.fingerprint = Some(fingerprint.clone());
5256    commit.id = stable_event_id(&fingerprint);
5257    append_unique_commit_event(layout, &commit)?;
5258    Ok(Some(head_sha))
5259}
5260
5261const COMMIT_FILES_CAP: usize = 200;
5262const INCLUDED_COMMITS_CAP: usize = 32;
5263
5264#[derive(Default)]
5265struct CommitRangeEnrichment {
5266    subject: Option<String>,
5267    author_name: Option<String>,
5268    author_email: Option<String>,
5269    committed_at: Option<String>,
5270    files_changed: Option<Vec<String>>,
5271    files_changed_count: Option<u64>,
5272    insertions: Option<u64>,
5273    deletions: Option<u64>,
5274    is_merge: Option<bool>,
5275    included_commits: Option<Vec<String>>,
5276}
5277
5278fn enrich_commit_range(
5279    repo_root: &Path,
5280    previous_head: Option<&str>,
5281    head_sha: &str,
5282) -> CommitRangeEnrichment {
5283    let mut out = CommitRangeEnrichment::default();
5284
5285    let range = match previous_head {
5286        Some(prev) if !prev.is_empty() && prev != head_sha => format!("{prev}..{head_sha}"),
5287        _ => format!("{head_sha}^!"),
5288    };
5289
5290    // Bundle merge detection + commit meta into one spawn:
5291    // %P = parent SHAs, then subject/author/email/time.
5292    // Remaining: numstat (+ optional multi-commit log).
5293    if let Ok(meta) = git_output(
5294        repo_root,
5295        &[
5296            "log",
5297            "-1",
5298            "--pretty=%P%x00%s%x00%an%x00%ae%x00%cI",
5299            head_sha,
5300        ],
5301    ) {
5302        let mut parts = meta.split('\0').map(str::trim);
5303        let parents = parts.next().unwrap_or("");
5304        let parent_count = if parents.is_empty() {
5305            0
5306        } else {
5307            parents.split_whitespace().count()
5308        };
5309        out.is_merge = Some(parent_count > 1);
5310        out.subject = non_empty_owned(parts.next());
5311        out.author_name = non_empty_owned(parts.next());
5312        out.author_email = non_empty_owned(parts.next());
5313        out.committed_at = non_empty_owned(parts.next());
5314    }
5315
5316    if let Ok(numstat) = git_output(repo_root, &["diff", "--numstat", &range]) {
5317        let mut files = Vec::new();
5318        let mut insertions = 0u64;
5319        let mut deletions = 0u64;
5320        let mut full_count = 0u64;
5321        for line in numstat.lines() {
5322            let mut parts = line.split('\t');
5323            let add = parts.next().unwrap_or("0");
5324            let del = parts.next().unwrap_or("0");
5325            let path = parts.next().unwrap_or("").trim();
5326            if path.is_empty() {
5327                continue;
5328            }
5329            full_count += 1;
5330            if add != "-" {
5331                insertions += add.parse::<u64>().unwrap_or(0);
5332            }
5333            if del != "-" {
5334                deletions += del.parse::<u64>().unwrap_or(0);
5335            }
5336            if files.len() < COMMIT_FILES_CAP {
5337                files.push(path.to_string());
5338            }
5339        }
5340        // Dropped separate `diff --name-only` spawn; numstat already lists paths.
5341        out.files_changed_count = Some(full_count);
5342        if !files.is_empty() {
5343            out.files_changed = Some(files);
5344        }
5345        out.insertions = Some(insertions);
5346        out.deletions = Some(deletions);
5347    }
5348
5349    if let Some(prev) = previous_head.filter(|p| !p.is_empty() && *p != head_sha) {
5350        if let Ok(log) = git_output(
5351            repo_root,
5352            &["log", "--format=%H", &format!("{prev}..{head_sha}")],
5353        ) {
5354            let shas: Vec<String> = log
5355                .lines()
5356                .map(str::trim)
5357                .filter(|l| !l.is_empty())
5358                .take(INCLUDED_COMMITS_CAP)
5359                .map(str::to_string)
5360                .collect();
5361            if shas.len() > 1 {
5362                out.included_commits = Some(shas);
5363            }
5364        }
5365    }
5366
5367    out
5368}
5369
5370fn non_empty_owned(value: Option<&str>) -> Option<String> {
5371    value
5372        .map(str::trim)
5373        .filter(|s| !s.is_empty())
5374        .map(str::to_string)
5375}
5376
5377/// Drain a burst of notify events so one tick batches many FS writes into one
5378/// jsonl flush instead of open/append per event under heavy editors.
5379fn drain_notify_batch(
5380    rx: &mpsc::Receiver<Result<Event, notify::Error>>,
5381    first: Result<Event, notify::Error>,
5382) -> Vec<Result<Event, notify::Error>> {
5383    let mut batch = Vec::with_capacity(8);
5384    batch.push(first);
5385    while batch.len() < NOTIFY_EVENT_DRAIN_CAP {
5386        match rx.try_recv() {
5387            Ok(next) => batch.push(next),
5388            Err(_) => break,
5389        }
5390    }
5391    batch
5392}
5393
5394fn append_unique_commit_event(
5395    layout: &SpoolLayout,
5396    commit: &WorktreeCommitEvent,
5397) -> Result<bool, String> {
5398    let fingerprint = commit
5399        .fingerprint
5400        .clone()
5401        .unwrap_or_else(|| commit_event_fingerprint(commit));
5402    if !claim_commit_fingerprint(layout, &fingerprint)? {
5403        return Ok(false);
5404    }
5405
5406    let mut commit = commit.clone();
5407    commit.fingerprint = Some(fingerprint.clone());
5408    if commit.id.is_empty() {
5409        commit.id = stable_event_id(&fingerprint);
5410    }
5411    if let Err(error) = append_json_line(&layout.commits_file, &commit) {
5412        release_commit_fingerprint_claim(layout, &fingerprint);
5413        return Err(error);
5414    }
5415    Ok(true)
5416}
5417
5418fn commit_event_fingerprint(commit: &WorktreeCommitEvent) -> String {
5419    let payload = json!({
5420        "schema": 3,
5421        "repoOwner": commit.repo_owner,
5422        "repoName": commit.repo_name,
5423        "branchName": commit.branch_name,
5424        "previousHeadSha": commit.previous_head_sha,
5425        "headSha": commit.head_sha,
5426    });
5427    let encoded = serde_json::to_vec(&payload).unwrap_or_default();
5428    let mut hasher = Sha256::new();
5429    hasher.update(encoded);
5430    format!("{:x}", hasher.finalize())
5431}
5432
5433fn claim_commit_fingerprint(layout: &SpoolLayout, fingerprint: &str) -> Result<bool, String> {
5434    let marker = commit_dedupe_marker_path(layout, fingerprint);
5435    if marker.is_file() {
5436        return Ok(false);
5437    }
5438    let claim_key = format!("{}|{fingerprint}", layout.root.display());
5439    {
5440        let mut claimed = commit_claims_in_process()
5441            .lock()
5442            .map_err(|_| "commit claim lock poisoned".to_string())?;
5443        if !claimed.insert(claim_key) {
5444            return Ok(false);
5445        }
5446    }
5447    let claims = layout.root.join("commit-claims.jsonl");
5448    append_json_line(
5449        &claims,
5450        &json!({
5451            "fingerprint": fingerprint,
5452            "firstSeenAt": Utc::now().to_rfc3339(),
5453        }),
5454    )?;
5455    Ok(true)
5456}
5457
5458fn release_commit_fingerprint_claim(layout: &SpoolLayout, fingerprint: &str) {
5459    let _ = fs::remove_file(commit_dedupe_marker_path(layout, fingerprint));
5460    let claim_key = format!("{}|{fingerprint}", layout.root.display());
5461    if let Ok(mut claimed) = commit_claims_in_process().lock() {
5462        claimed.remove(&claim_key);
5463    }
5464}
5465
5466fn commit_claims_in_process() -> &'static Mutex<BTreeSet<String>> {
5467    static CLAIMS: OnceLock<Mutex<BTreeSet<String>>> = OnceLock::new();
5468    CLAIMS.get_or_init(|| Mutex::new(BTreeSet::new()))
5469}
5470
5471fn commit_dedupe_marker_path(layout: &SpoolLayout, fingerprint: &str) -> PathBuf {
5472    layout.commit_dedupe_dir.join(format!("{fingerprint}.seen"))
5473}
5474
5475async fn sync_spool_for_identity(
5476    identity: &RepoIdentity,
5477    layout: &SpoolLayout,
5478) -> Result<(), String> {
5479    let candidates = collect_sync_candidates(layout)?;
5480    if candidates.is_empty() {
5481        return Ok(());
5482    }
5483
5484    sync_candidates(identity, candidates, false).await
5485}
5486
5487async fn sync_candidates(
5488    identity: &RepoIdentity,
5489    candidates: Vec<SyncCandidate>,
5490    resync: bool,
5491) -> Result<(), String> {
5492    let mut events = Vec::new();
5493    let mut commits = Vec::new();
5494    for candidate in &candidates {
5495        match &candidate.records {
5496            SyncRecords::Events(records) => events.extend(records.iter().cloned()),
5497            SyncRecords::Commits(records) => commits.extend(records.iter().cloned()),
5498        }
5499    }
5500
5501    let token = resolve_cli_access_token()?;
5502    let device = resolve_device_identity()?;
5503    let client = cli_request_client()?;
5504    let api = ApiConfig::from_env();
5505    let endpoint = api.cli_worktree_mutations_endpoint();
5506    let event_count = events.len();
5507    let commit_count = commits.len();
5508    let hardware_id = worktree_device_hardware_id(&device.hardware_id);
5509    let hostname = current_hostname();
5510    let platform = std::env::consts::OS.to_string();
5511    let runtime_key = worktree_runtime_key();
5512    let repo_root = normalize_repo_root_for_payload(&identity.root);
5513    // Ensure every record has a stable fingerprint/id before upload (legacy spool rows).
5514    let events = events
5515        .into_iter()
5516        .map(ensure_mutation_event_identity)
5517        .collect::<Vec<_>>();
5518    let commits = commits
5519        .into_iter()
5520        .map(ensure_commit_event_identity)
5521        .collect::<Vec<_>>();
5522    let batches = split_worktree_mutation_upload_batches(events.clone(), commits.clone());
5523    let batch_count = batches.len();
5524
5525    if batch_count > 1 {
5526        println!(
5527            "Uploading {} worktree mutation record(s) in {} batch(es) for {}/{} on {} [{}].",
5528            event_count + commit_count,
5529            batch_count,
5530            identity.owner,
5531            identity.name,
5532            identity.branch,
5533            runtime_key
5534        );
5535    }
5536
5537    let mut status_code = 202;
5538    for attempt in 1..=WORKTREE_MUTATION_SYNC_MAX_ATTEMPTS {
5539        let attempt_batches =
5540            split_worktree_mutation_upload_batches(events.clone(), commits.clone());
5541        match upload_worktree_mutation_batches(
5542            &client,
5543            &endpoint,
5544            &token,
5545            &hardware_id,
5546            hostname.as_deref(),
5547            &platform,
5548            &runtime_key,
5549            identity,
5550            &repo_root,
5551            attempt_batches,
5552        )
5553        .await
5554        {
5555            Ok(code) => {
5556                status_code = code;
5557                break;
5558            }
5559            Err(error)
5560                if error.kind == WorktreeMutationSyncErrorKind::Retryable
5561                    && attempt < WORKTREE_MUTATION_SYNC_MAX_ATTEMPTS =>
5562            {
5563                eprintln!(
5564                    "Worktree mutation sync attempt {attempt}/{} failed for {}/{} on {}: {} Restarting from the first batch in {} second(s).",
5565                    WORKTREE_MUTATION_SYNC_MAX_ATTEMPTS,
5566                    identity.owner,
5567                    identity.name,
5568                    identity.branch,
5569                    error.message,
5570                    WORKTREE_MUTATION_SYNC_RETRY_DELAY_SECONDS
5571                );
5572                tokio::time::sleep(Duration::from_secs(
5573                    WORKTREE_MUTATION_SYNC_RETRY_DELAY_SECONDS,
5574                ))
5575                .await;
5576            }
5577            Err(error) => return Err(error.message),
5578        }
5579    }
5580
5581    let layout = prepare_spool_layout(identity)?;
5582    append_sync_log(SyncLogAppend {
5583        identity,
5584        layout: &layout,
5585        endpoint: &endpoint,
5586        status_code,
5587        event_count,
5588        commit_count,
5589        candidates: &candidates,
5590        resync,
5591    })?;
5592
5593    for candidate in candidates {
5594        if !is_synced_spool_path(&candidate.path) {
5595            mark_synced(&candidate.path)?;
5596        }
5597    }
5598
5599    Ok(())
5600}
5601
5602async fn upload_worktree_mutation_batches(
5603    client: &reqwest::Client,
5604    endpoint: &str,
5605    token: &str,
5606    hardware_id: &str,
5607    hostname: Option<&str>,
5608    platform: &str,
5609    runtime_key: &str,
5610    identity: &RepoIdentity,
5611    repo_root: &str,
5612    batches: Vec<WorktreeMutationUploadBatch>,
5613) -> Result<u16, WorktreeMutationSyncError> {
5614    let batch_count = batches.len();
5615    let mut status_code = 202;
5616
5617    for (batch_index, batch) in batches.into_iter().enumerate() {
5618        let batch_event_count = batch.events.len();
5619        let batch_commit_count = batch.commits.len();
5620        let payload = WorktreeMutationIngestPayload {
5621            device: WorktreeMutationDevicePayload {
5622                hardware_id: hardware_id.to_string(),
5623                device_name: hostname.map(str::to_string),
5624                hostname: hostname.map(str::to_string),
5625                platform: platform.to_string(),
5626                runtime_key: Some(runtime_key.to_string()),
5627            },
5628            repository: WorktreeMutationRepositoryPayload {
5629                owner: identity.owner.clone(),
5630                name: identity.name.clone(),
5631                branch_name: identity.branch.clone(),
5632                repo_root: repo_root.to_string(),
5633            },
5634            events: batch.events,
5635            commits: batch.commits,
5636        };
5637
5638        let response = client
5639            .post(endpoint)
5640            .bearer_auth(token)
5641            .json(&payload)
5642            .send()
5643            .await
5644            .map_err(|error| WorktreeMutationSyncError {
5645                kind: WorktreeMutationSyncErrorKind::Retryable,
5646                message: format!(
5647                    "Failed to upload worktree mutation batch {}/{}: {error}",
5648                    batch_index + 1,
5649                    batch_count
5650                ),
5651            })?;
5652
5653        if response.status() == StatusCode::UNAUTHORIZED {
5654            return Err(WorktreeMutationSyncError {
5655                kind: WorktreeMutationSyncErrorKind::NonRetryable,
5656                message: "Your stored CLI session is no longer valid. Run `xbp login` again."
5657                    .to_string(),
5658            });
5659        }
5660
5661        if !response.status().is_success() {
5662            let status = response.status();
5663            let body = response.text().await.unwrap_or_default();
5664            return Err(WorktreeMutationSyncError {
5665                kind: if should_retry_worktree_mutation_sync_status(status) {
5666                    WorktreeMutationSyncErrorKind::Retryable
5667                } else {
5668                    WorktreeMutationSyncErrorKind::NonRetryable
5669                },
5670                message: format!(
5671                    "Worktree mutation upload batch {}/{} failed with {status}: {body}",
5672                    batch_index + 1,
5673                    batch_count
5674                ),
5675            });
5676        }
5677
5678        status_code = response.status().as_u16();
5679        if batch_count > 1 {
5680            println!(
5681                "Uploaded worktree mutation batch {}/{} ({} event(s), {} commit(s)).",
5682                batch_index + 1,
5683                batch_count,
5684                batch_event_count,
5685                batch_commit_count
5686            );
5687        }
5688    }
5689
5690    Ok(status_code)
5691}
5692
5693fn should_retry_worktree_mutation_sync_status(status: StatusCode) -> bool {
5694    status.is_server_error()
5695        || status == StatusCode::REQUEST_TIMEOUT
5696        || status == StatusCode::TOO_MANY_REQUESTS
5697}
5698
5699fn split_worktree_mutation_upload_batches(
5700    events: Vec<WorktreeMutationEvent>,
5701    commits: Vec<WorktreeCommitEvent>,
5702) -> Vec<WorktreeMutationUploadBatch> {
5703    let mut batches = Vec::new();
5704    let mut current = empty_worktree_mutation_upload_batch();
5705
5706    for event in events {
5707        let estimated_json_bytes = serialized_json_len(&event);
5708        if should_start_next_worktree_mutation_upload_batch(&current, estimated_json_bytes) {
5709            batches.push(current);
5710            current = empty_worktree_mutation_upload_batch();
5711        }
5712        current.estimated_json_bytes += estimated_json_bytes;
5713        current.events.push(event);
5714    }
5715
5716    for commit in commits {
5717        let estimated_json_bytes = serialized_json_len(&commit);
5718        if should_start_next_worktree_mutation_upload_batch(&current, estimated_json_bytes) {
5719            batches.push(current);
5720            current = empty_worktree_mutation_upload_batch();
5721        }
5722        current.estimated_json_bytes += estimated_json_bytes;
5723        current.commits.push(commit);
5724    }
5725
5726    if current_record_count(&current) > 0 {
5727        batches.push(current);
5728    }
5729
5730    batches
5731}
5732
5733fn empty_worktree_mutation_upload_batch() -> WorktreeMutationUploadBatch {
5734    WorktreeMutationUploadBatch {
5735        events: Vec::new(),
5736        commits: Vec::new(),
5737        estimated_json_bytes: 0,
5738    }
5739}
5740
5741fn should_start_next_worktree_mutation_upload_batch(
5742    batch: &WorktreeMutationUploadBatch,
5743    next_record_json_bytes: usize,
5744) -> bool {
5745    if current_record_count(batch) == 0 {
5746        return false;
5747    }
5748
5749    current_record_count(batch) >= WORKTREE_MUTATION_SYNC_BATCH_RECORD_LIMIT
5750        || batch.estimated_json_bytes + next_record_json_bytes
5751            > WORKTREE_MUTATION_SYNC_BATCH_JSON_BYTES
5752}
5753
5754fn current_record_count(batch: &WorktreeMutationUploadBatch) -> usize {
5755    batch.events.len() + batch.commits.len()
5756}
5757
5758fn serialized_json_len<T: Serialize>(value: &T) -> usize {
5759    serde_json::to_vec(value)
5760        .map(|encoded| encoded.len())
5761        .unwrap_or(0)
5762}
5763
5764fn collect_sync_candidates(layout: &SpoolLayout) -> Result<Vec<SyncCandidate>, String> {
5765    // Readers must see buffered appends (batch flush is tick-based).
5766    flush_jsonl_write_buffers();
5767    collect_spool_candidates(layout, false)
5768}
5769
5770fn print_no_sync_candidates_message(
5771    target: &WorktreeWatchTargetOptions,
5772    resync: bool,
5773) -> Result<(), String> {
5774    if resync {
5775        println!("No local worktree mutation JSONL files found.");
5776        return Ok(());
5777    }
5778
5779    let identities = resolve_target_identities(target)?;
5780    let mut synced_files = 0usize;
5781    let mut synced_records = 0usize;
5782    for identity in identities {
5783        let layout = prepare_spool_layout(&identity)?;
5784        let all_candidates = collect_spool_candidates(&layout, true)?;
5785        let unsynced_candidates = collect_sync_candidates(&layout)?;
5786        synced_files += all_candidates
5787            .len()
5788            .saturating_sub(unsynced_candidates.len());
5789        let all_records = all_candidates
5790            .iter()
5791            .map(|candidate| candidate.records.len())
5792            .sum::<usize>();
5793        let unsynced_records = unsynced_candidates
5794            .iter()
5795            .map(|candidate| candidate.records.len())
5796            .sum::<usize>();
5797        synced_records += all_records.saturating_sub(unsynced_records);
5798    }
5799
5800    if synced_files > 0 {
5801        println!(
5802            "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."
5803        );
5804    } else {
5805        println!("No unsynced worktree mutation files found.");
5806    }
5807    Ok(())
5808}
5809
5810fn collect_spool_candidates(
5811    layout: &SpoolLayout,
5812    include_synced: bool,
5813) -> Result<Vec<SyncCandidate>, String> {
5814    // Directory scans only see paths that already exist on disk.
5815    flush_jsonl_write_buffers();
5816    // When the layout root is a branch spool, also scan sibling legacy roots for
5817    // the same owner/repo/branch so WSL can see Windows-written files and vice versa.
5818    let mut roots = vec![layout.root.clone()];
5819    if let Some(branch) = layout.root.file_name().and_then(|name| name.to_str()) {
5820        if let Some(repo_root) = layout.root.parent() {
5821            if let (Some(name), Some(owner)) = (
5822                repo_root.file_name().and_then(|n| n.to_str()),
5823                repo_root.parent().and_then(|p| p.file_name()).and_then(|n| n.to_str()),
5824            ) {
5825                let identity = RepoIdentity {
5826                    owner: owner.to_string(),
5827                    name: name.to_string(),
5828                    branch: branch.to_string(),
5829                    root: PathBuf::new(),
5830                    head_sha: None,
5831                };
5832                if let Ok(search_roots) = repository_spool_search_roots(&identity) {
5833                    for search_root in search_roots {
5834                        let branch_root = search_root.join(branch);
5835                        if !roots.iter().any(|existing| {
5836                            path_identity_key(existing) == path_identity_key(&branch_root)
5837                        }) {
5838                            roots.push(branch_root);
5839                        }
5840                    }
5841                }
5842            }
5843        }
5844    }
5845
5846    let mut candidates = Vec::new();
5847    let mut seen_files = BTreeSet::new();
5848    for root in roots {
5849        collect_spool_candidates_from_root(&root, include_synced, &mut candidates, &mut seen_files)?;
5850    }
5851    Ok(candidates)
5852}
5853
5854fn collect_spool_candidates_from_root(
5855    root: &Path,
5856    include_synced: bool,
5857    candidates: &mut Vec<SyncCandidate>,
5858    seen_files: &mut BTreeSet<String>,
5859) -> Result<(), String> {
5860    if !root.exists() {
5861        return Ok(());
5862    }
5863
5864    for entry in fs::read_dir(root).map_err(|error| {
5865        format!(
5866            "Failed to read spool directory {}: {error}",
5867            root.display()
5868        )
5869    })? {
5870        let path = entry
5871            .map_err(|error| format!("Failed to read spool entry: {error}"))?
5872            .path();
5873        let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
5874            continue;
5875        };
5876        if !file_name.ends_with(".jsonl") || (!include_synced && file_name.contains(".synced.")) {
5877            continue;
5878        }
5879
5880        let kind = if file_name.starts_with("events-") {
5881            SyncFileKind::Events
5882        } else if file_name.starts_with("commits-") {
5883            SyncFileKind::Commits
5884        } else {
5885            continue;
5886        };
5887
5888        let identity_key = path_identity_key(&path);
5889        if !seen_files.insert(identity_key) {
5890            continue;
5891        }
5892
5893        let records = sanitize_spool_records(&path, kind, read_spool_records(&path, kind)?)?;
5894        if records.is_empty() {
5895            continue;
5896        }
5897        candidates.push(SyncCandidate { path, records });
5898    }
5899
5900    Ok(())
5901}
5902
5903fn append_sync_log(request: SyncLogAppend<'_>) -> Result<(), String> {
5904    let entry = WorktreeWatchSyncLogEntry {
5905        id: Uuid::new_v4().to_string(),
5906        synced_at: Utc::now(),
5907        endpoint: request.endpoint.to_string(),
5908        repository_owner: request.identity.owner.clone(),
5909        repository_name: request.identity.name.clone(),
5910        branch_name: request.identity.branch.clone(),
5911        repo_root: request.identity.root.display().to_string(),
5912        resync: request.resync,
5913        status_code: request.status_code,
5914        event_count: request.event_count,
5915        commit_count: request.commit_count,
5916        spool_file_count: request.candidates.len(),
5917        spool_files: request
5918            .candidates
5919            .iter()
5920            .map(|candidate| candidate.path.display().to_string())
5921            .collect(),
5922    };
5923    append_json_line(&request.layout.sync_log_file, &entry)
5924}
5925
5926fn sanitize_spool_records(
5927    path: &Path,
5928    kind: SyncFileKind,
5929    records: SyncRecords,
5930) -> Result<SyncRecords, String> {
5931    match (kind, records) {
5932        (SyncFileKind::Events, SyncRecords::Events(records)) => {
5933            let mut sanitized = Vec::with_capacity(records.len());
5934            let mut changed = false;
5935
5936            for record in records {
5937                let original = serde_json::to_value(&record).ok();
5938                let Some(record) = sanitize_spooled_event(record) else {
5939                    changed = true;
5940                    continue;
5941                };
5942                if !changed {
5943                    changed = serde_json::to_value(&record).ok() != original;
5944                }
5945                sanitized.push(record);
5946            }
5947
5948            if changed {
5949                rewrite_jsonl_records(path, &sanitized)?;
5950            }
5951
5952            Ok(SyncRecords::Events(sanitized))
5953        }
5954        (_, records) => Ok(records),
5955    }
5956}
5957
5958fn sanitize_spooled_event(mut event: WorktreeMutationEvent) -> Option<WorktreeMutationEvent> {
5959    event.paths = dedupe_filtered_spool_paths(event.paths);
5960    event.primary_path = sanitize_optional_spool_path(event.primary_path);
5961    event.old_path = sanitize_optional_spool_path(event.old_path);
5962    event.new_path = sanitize_optional_spool_path(event.new_path);
5963
5964    if event.paths.is_empty() {
5965        if let Some(path) = event.primary_path.clone() {
5966            event.paths.push(path);
5967        }
5968        if let Some(path) = event.old_path.clone() {
5969            if !event.paths.iter().any(|existing| existing == &path) {
5970                event.paths.push(path);
5971            }
5972        }
5973        if let Some(path) = event.new_path.clone() {
5974            if !event.paths.iter().any(|existing| existing == &path) {
5975                event.paths.push(path);
5976            }
5977        }
5978    }
5979
5980    if event.primary_path.is_none() {
5981        event.primary_path = event.paths.first().cloned();
5982    }
5983
5984    if event.paths.is_empty() {
5985        return None;
5986    }
5987
5988    Some(event)
5989}
5990
5991fn sanitize_optional_spool_path(path: Option<String>) -> Option<String> {
5992    path.and_then(sanitize_spool_path)
5993}
5994
5995fn sanitize_spool_path(path: String) -> Option<String> {
5996    let trimmed = path.trim();
5997    if trimmed.is_empty() || watch_ignore_rules().ignores_relative(trimmed) {
5998        return None;
5999    }
6000    Some(trimmed.replace('\\', "/"))
6001}
6002
6003fn dedupe_filtered_spool_paths(paths: Vec<String>) -> Vec<String> {
6004    let mut sanitized = Vec::with_capacity(paths.len());
6005    for path in paths {
6006        let Some(path) = sanitize_spool_path(path) else {
6007            continue;
6008        };
6009        if !sanitized.iter().any(|existing| existing == &path) {
6010            sanitized.push(path);
6011        }
6012    }
6013    sanitized
6014}
6015
6016fn rewrite_jsonl_records<T: Serialize>(path: &Path, records: &[T]) -> Result<(), String> {
6017    if records.is_empty() {
6018        if path.exists() {
6019            fs::remove_file(path).map_err(|error| {
6020                format!(
6021                    "Failed to remove emptied spool file {}: {error}",
6022                    path.display()
6023                )
6024            })?;
6025        }
6026        return Ok(());
6027    }
6028
6029    let temp_path = path.with_extension(format!(
6030        "{}.{}.tmp",
6031        path.extension()
6032            .and_then(|value| value.to_str())
6033            .unwrap_or("jsonl"),
6034        Uuid::new_v4()
6035    ));
6036    {
6037        let mut file = File::create(&temp_path).map_err(|error| {
6038            format!(
6039                "Failed to create temporary spool file {}: {error}",
6040                temp_path.display()
6041            )
6042        })?;
6043        for record in records {
6044            let line = serde_json::to_string(record)
6045                .map_err(|error| format!("Failed to serialize worktree event: {error}"))?;
6046            writeln!(file, "{line}").map_err(|error| {
6047                format!(
6048                    "Failed to write temporary spool file {}: {error}",
6049                    temp_path.display()
6050                )
6051            })?;
6052        }
6053    }
6054
6055    if path.exists() {
6056        fs::remove_file(path).map_err(|error| {
6057            format!(
6058                "Failed to replace rewritten spool file {}: {error}",
6059                path.display()
6060            )
6061        })?;
6062    }
6063
6064    fs::rename(&temp_path, path)
6065        .map_err(|error| format!("Failed to rewrite spool file {}: {error}", path.display()))
6066}
6067
6068fn read_last_sync_log_entry(path: &Path) -> Result<Option<WorktreeWatchSyncLogEntry>, String> {
6069    if !path.exists() {
6070        return Ok(None);
6071    }
6072    let values = read_jsonl_values(path)?;
6073    let Some(value) = values.into_iter().last() else {
6074        return Ok(None);
6075    };
6076    serde_json::from_value(value)
6077        .map(Some)
6078        .map_err(|error| format!("Failed to decode sync log {}: {error}", path.display()))
6079}
6080
6081fn is_synced_spool_path(path: &Path) -> bool {
6082    path.file_name()
6083        .and_then(|value| value.to_str())
6084        .map(|value| value.contains(".synced."))
6085        .unwrap_or(false)
6086}
6087
6088fn mark_synced(path: &Path) -> Result<(), String> {
6089    let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
6090        return Ok(());
6091    };
6092    let synced_name = file_name.replace(
6093        ".jsonl",
6094        &format!(".synced.{}.jsonl", Utc::now().timestamp()),
6095    );
6096    let synced_path = path.with_file_name(synced_name);
6097    fs::rename(path, &synced_path).map_err(|error| {
6098        format!(
6099            "Failed to mark spool file {} as synced: {error}",
6100            path.display()
6101        )
6102    })
6103}
6104
6105fn read_spool_records(path: &Path, kind: SyncFileKind) -> Result<SyncRecords, String> {
6106    flush_jsonl_write_buffers();
6107    match kind {
6108        SyncFileKind::Events => read_jsonl_records::<WorktreeMutationEvent>(path)
6109            .map(SyncRecords::Events)
6110            .map_err(|error| format!("Failed to decode event from {}: {error}", path.display())),
6111        SyncFileKind::Commits => read_jsonl_records::<WorktreeCommitEvent>(path)
6112            .map(SyncRecords::Commits)
6113            .map_err(|error| format!("Failed to decode commit from {}: {error}", path.display())),
6114    }
6115}
6116
6117/// Strip whitespace and sparse-file / torn-write NUL padding before JSON parse.
6118/// Windows concurrent appends have produced lines that start with long `\0` runs
6119/// then valid JSON; parsing the raw line yields "expected value at line 1 column 1".
6120fn sanitize_jsonl_line(line: &str) -> Option<&str> {
6121    let trimmed = line.trim().trim_matches('\0').trim();
6122    if trimmed.is_empty() {
6123        None
6124    } else {
6125        Some(trimmed)
6126    }
6127}
6128
6129fn read_jsonl_records<T>(path: &Path) -> Result<Vec<T>, String>
6130where
6131    T: for<'de> Deserialize<'de>,
6132{
6133    flush_jsonl_write_buffers();
6134    let file = File::open(path)
6135        .map_err(|error| format!("Failed to open spool file {}: {error}", path.display()))?;
6136    let reader = BufReader::new(file);
6137    let mut records = Vec::new();
6138    for (line_no, line) in reader.lines().enumerate() {
6139        let line =
6140            line.map_err(|error| format!("Failed to read spool file {}: {error}", path.display()))?;
6141        let Some(payload) = sanitize_jsonl_line(&line) else {
6142            continue;
6143        };
6144        match serde_json::from_str(payload) {
6145            Ok(record) => records.push(record),
6146            Err(error) => {
6147                // Skip unparseable lines (partial writes, encoding glitches) rather than
6148                // killing worktree-watch / dedupe load for the whole repo.
6149                eprintln!(
6150                    "warning: skipping unparseable JSONL record in {} at line {}: {error}",
6151                    path.display(),
6152                    line_no + 1
6153                );
6154            }
6155        }
6156    }
6157    Ok(records)
6158}
6159
6160fn read_jsonl_values(path: &Path) -> Result<Vec<Value>, String> {
6161    read_jsonl_records(path)
6162}
6163
6164fn spawn_detached_worktree_watch(repo: Option<&Path>) -> Result<PathBuf, String> {
6165    let identity = resolve_repo_identity(repo)?;
6166    spawn_detached_worktree_watch_for_identity(&identity)
6167}
6168
6169fn spawn_detached_parent_worktree_watch(parent: &Path) -> Result<PathBuf, String> {
6170    let parent = canonical_parent_path(parent)?;
6171    let layout = prepare_parent_spool_layout(&parent)?;
6172    replace_existing_parent_watcher(&parent, &layout)?;
6173    let executable = std::env::current_exe()
6174        .map_err(|error| format!("Failed to resolve current XBP executable: {error}"))?;
6175    let mut command = Command::new(&executable);
6176    command
6177        .arg("worktree-watch")
6178        .arg("start")
6179        .arg("--parent")
6180        .arg(&parent)
6181        .env(BACKGROUND_CHILD_ENV, "1")
6182        .current_dir(&parent)
6183        .stdin(Stdio::null())
6184        .stdout(Stdio::null())
6185        .stderr(Stdio::null());
6186    configure_detached_child_process(&mut command);
6187
6188    let child = command
6189        .spawn()
6190        .map_err(|error| format!("Failed to spawn background parent worktree watcher: {error}"))?;
6191    write_parent_watcher_state(&parent, &layout, child.id(), &executable)?;
6192    Ok(parent)
6193}
6194
6195fn spawn_detached_worktree_watch_for_identity(identity: &RepoIdentity) -> Result<PathBuf, String> {
6196    let layout = prepare_spool_layout(identity)?;
6197    replace_existing_watcher(identity, &layout)?;
6198    let executable = std::env::current_exe()
6199        .map_err(|error| format!("Failed to resolve current XBP executable: {error}"))?;
6200    let mut command = Command::new(&executable);
6201    command
6202        .arg("worktree-watch")
6203        .arg("start")
6204        .arg("--repo")
6205        .arg(&identity.root)
6206        .env(BACKGROUND_CHILD_ENV, "1")
6207        .current_dir(&identity.root)
6208        .stdin(Stdio::null())
6209        .stdout(Stdio::null())
6210        .stderr(Stdio::null());
6211    configure_detached_child_process(&mut command);
6212
6213    let child: std::process::Child = command
6214        .spawn()
6215        .map_err(|error| format!("Failed to spawn background worktree watcher: {error}"))?;
6216    write_watcher_state(identity, &layout, child.id(), &executable)?;
6217    Ok(identity.root.clone())
6218}
6219
6220fn configure_detached_child_process(command: &mut Command) {
6221    #[cfg(windows)]
6222    {
6223        use std::os::windows::process::CommandExt;
6224        command.creation_flags(CREATE_NO_WINDOW);
6225    }
6226
6227    // On Unix (including WSL), start a new session so the watcher survives the
6228    // launching shell and does not receive SIGHUP when the parent exits.
6229    #[cfg(unix)]
6230    {
6231        use std::os::unix::process::CommandExt;
6232        // SAFETY: setsid is called only in the child just after fork, before exec.
6233        unsafe {
6234            command.pre_exec(|| {
6235                extern "C" {
6236                    fn setsid() -> i32;
6237                }
6238                let _ = setsid();
6239                Ok(())
6240            });
6241        }
6242    }
6243}
6244
6245fn replace_existing_parent_watcher(
6246    parent: &Path,
6247    layout: &ParentSpoolLayout,
6248) -> Result<(), String> {
6249    let Some(state) = read_parent_watcher_state_any(&layout.watcher_state_file)? else {
6250        return Ok(());
6251    };
6252
6253    if path_identity_key(parent) != path_identity_key(Path::new(&state.parent_root)) {
6254        return Ok(());
6255    }
6256
6257    if is_matching_parent_watcher_process(&state) {
6258        stop_parent_process(&state, false)?;
6259        println!(
6260            "Replaced existing background parent worktree watcher {} for {}",
6261            state.pid,
6262            parent.display()
6263        );
6264        remove_all_watcher_state_files(&layout.watcher_state_file);
6265        return Ok(());
6266    }
6267
6268    if parent_watcher_state_pid_exists_with_same_executable(&state) {
6269        return Err(format!(
6270            "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.",
6271            parent.display(),
6272            state.pid,
6273            parent.display()
6274        ));
6275    }
6276
6277    remove_all_watcher_state_files(&layout.watcher_state_file);
6278    Ok(())
6279}
6280
6281fn replace_existing_watcher(identity: &RepoIdentity, layout: &SpoolLayout) -> Result<(), String> {
6282    let Some(state) = read_watcher_state(&layout.watcher_state_file)? else {
6283        return Ok(());
6284    };
6285
6286    if !same_repo_watcher_state(identity, &state) {
6287        return Ok(());
6288    }
6289
6290    if is_matching_watcher_process(&state) {
6291        stop_process(&state, false)?;
6292        println!(
6293            "Replaced existing background worktree watcher {} for {}",
6294            state.pid,
6295            identity.root.display()
6296        );
6297        remove_all_watcher_state_files(&layout.watcher_state_file);
6298        return Ok(());
6299    }
6300
6301    if watcher_state_pid_exists_with_same_executable(&state) {
6302        return Err(format!(
6303            "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.",
6304            identity.root.display(),
6305            state.pid,
6306            identity.root.display()
6307        ));
6308    }
6309
6310    remove_all_watcher_state_files(&layout.watcher_state_file);
6311    Ok(())
6312}
6313
6314fn stop_existing_watcher(
6315    identity: &RepoIdentity,
6316    layout: &SpoolLayout,
6317    force: bool,
6318) -> Result<StopWatcherOutcome, String> {
6319    let Some(state) = read_watcher_state(&layout.watcher_state_file)? else {
6320        // Still purge orphaned legacy/runtime state files so stop is idempotent.
6321        if remove_all_watcher_state_files(&layout.watcher_state_file) > 0 {
6322            return Ok(StopWatcherOutcome::RemovedStaleState);
6323        }
6324        return Ok(StopWatcherOutcome::NoState);
6325    };
6326
6327    if !same_repo_watcher_state(identity, &state) {
6328        return Ok(StopWatcherOutcome::NoState);
6329    }
6330
6331    if is_matching_watcher_process(&state) || force {
6332        let pid = state.pid;
6333        stop_process(&state, force)?;
6334        remove_all_watcher_state_files(&layout.watcher_state_file);
6335        return Ok(StopWatcherOutcome::Stopped(pid));
6336    }
6337
6338    if watcher_state_pid_exists_with_same_executable(&state) {
6339        return Err(format!(
6340            "Watcher state for {} points to running XBP process {}, but its command line could not be verified. Re-run with `--force` to stop that PID.",
6341            identity.root.display(),
6342            state.pid
6343        ));
6344    }
6345
6346    // Stale PID: remove legacy watcher-state.json AND runtime-scoped copies.
6347    remove_all_watcher_state_files(&layout.watcher_state_file);
6348    Ok(StopWatcherOutcome::RemovedStaleState)
6349}
6350
6351fn stop_existing_parent_watcher(parent: &Path, force: bool) -> Result<StopWatcherOutcome, String> {
6352    let layout = prepare_parent_spool_layout(parent)?;
6353    let Some(state) = read_parent_watcher_state_any(&layout.watcher_state_file)? else {
6354        if remove_all_watcher_state_files(&layout.watcher_state_file) > 0 {
6355            return Ok(StopWatcherOutcome::RemovedStaleState);
6356        }
6357        return Ok(StopWatcherOutcome::NoState);
6358    };
6359
6360    if path_identity_key(parent) != path_identity_key(Path::new(&state.parent_root)) {
6361        return Ok(StopWatcherOutcome::NoState);
6362    }
6363
6364    if is_matching_parent_watcher_process(&state) || force {
6365        let pid = state.pid;
6366        stop_parent_process(&state, force)?;
6367        remove_all_watcher_state_files(&layout.watcher_state_file);
6368        return Ok(StopWatcherOutcome::Stopped(pid));
6369    }
6370
6371    if parent_watcher_state_pid_exists_with_same_executable(&state) {
6372        return Err(format!(
6373            "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.",
6374            parent.display(),
6375            state.pid
6376        ));
6377    }
6378
6379    remove_all_watcher_state_files(&layout.watcher_state_file);
6380    Ok(StopWatcherOutcome::RemovedStaleState)
6381}
6382
6383/// Remove every `watcher-state*.json` next to the primary state path.
6384///
6385/// Historical bug: stop only deleted the runtime-scoped file
6386/// (`watcher-state-windows.json`) while `read_watcher_state` still fell back to
6387/// legacy `watcher-state.json`, so every subsequent `stop` reported
6388/// "Removed stale …" forever.
6389fn remove_all_watcher_state_files(primary: &Path) -> usize {
6390    let mut removed = 0usize;
6391    let mut seen = BTreeSet::new();
6392
6393    for candidate in watcher_state_read_candidates(primary) {
6394        if !seen.insert(candidate.clone()) {
6395            continue;
6396        }
6397        if candidate.is_file() && fs::remove_file(&candidate).is_ok() {
6398            removed += 1;
6399        }
6400    }
6401
6402    if let Some(dir) = primary.parent() {
6403        if let Ok(entries) = fs::read_dir(dir) {
6404            for entry in entries.flatten() {
6405                let path = entry.path();
6406                let Some(name) = path.file_name().and_then(|v| v.to_str()) else {
6407                    continue;
6408                };
6409                if !(name.starts_with("watcher-state") && name.ends_with(".json")) {
6410                    continue;
6411                }
6412                if !seen.insert(path.clone()) {
6413                    continue;
6414                }
6415                if path.is_file() && fs::remove_file(&path).is_ok() {
6416                    removed += 1;
6417                }
6418            }
6419        }
6420    }
6421
6422    removed
6423}
6424
6425fn read_watcher_state(path: &Path) -> Result<Option<WorktreeWatcherState>, String> {
6426    // Prefer runtime-scoped state; fall back to legacy shared watcher-state.json.
6427    for candidate in watcher_state_read_candidates(path) {
6428        if !candidate.exists() {
6429            continue;
6430        }
6431        let raw = fs::read_to_string(&candidate).map_err(|error| {
6432            format!(
6433                "Failed to read watcher state {}: {error}",
6434                candidate.display()
6435            )
6436        })?;
6437        return serde_json::from_str(&raw).map(Some).map_err(|error| {
6438            format!(
6439                "Failed to parse watcher state {}: {error}",
6440                candidate.display()
6441            )
6442        });
6443    }
6444    Ok(None)
6445}
6446
6447fn watcher_state_read_candidates(path: &Path) -> Vec<PathBuf> {
6448    let mut candidates = vec![path.to_path_buf()];
6449    if let Some(parent) = path.parent() {
6450        let legacy = parent.join("watcher-state.json");
6451        if legacy != path {
6452            candidates.push(legacy);
6453        }
6454    }
6455    candidates
6456}
6457
6458fn read_parent_watcher_state(path: &Path) -> Result<Option<ParentWorktreeWatcherState>, String> {
6459    if !path.exists() {
6460        return Ok(None);
6461    }
6462    let raw = fs::read_to_string(path).map_err(|error| {
6463        format!(
6464            "Failed to read parent watcher state {}: {error}",
6465            path.display()
6466        )
6467    })?;
6468    serde_json::from_str(&raw).map(Some).map_err(|error| {
6469        format!(
6470            "Failed to parse parent watcher state {}: {error}",
6471            path.display()
6472        )
6473    })
6474}
6475
6476/// Read parent state from runtime-scoped path or legacy fallback (same candidates as repo watchers).
6477fn read_parent_watcher_state_any(
6478    primary: &Path,
6479) -> Result<Option<ParentWorktreeWatcherState>, String> {
6480    for candidate in watcher_state_read_candidates(primary) {
6481        if let Some(state) = read_parent_watcher_state(&candidate)? {
6482            return Ok(Some(state));
6483        }
6484    }
6485    Ok(None)
6486}
6487
6488fn write_watcher_state(
6489    identity: &RepoIdentity,
6490    layout: &SpoolLayout,
6491    pid: u32,
6492    executable: &Path,
6493) -> Result<(), String> {
6494    let state = WorktreeWatcherState {
6495        pid,
6496        repo_root: normalize_repo_root_for_payload(&identity.root),
6497        repository_owner: identity.owner.clone(),
6498        repository_name: identity.name.clone(),
6499        branch_name: identity.branch.clone(),
6500        executable: executable.display().to_string(),
6501        started_at: Utc::now(),
6502        runtime_key: Some(worktree_runtime_key()),
6503        platform: Some(std::env::consts::OS.to_string()),
6504    };
6505    let rendered = serde_json::to_string_pretty(&state)
6506        .map_err(|error| format!("Failed to serialize watcher state: {error}"))?;
6507    fs::write(&layout.watcher_state_file, format!("{rendered}\n")).map_err(|error| {
6508        format!(
6509            "Failed to write watcher state {}: {error}",
6510            layout.watcher_state_file.display()
6511        )
6512    })
6513}
6514
6515fn write_parent_watcher_state(
6516    parent: &Path,
6517    layout: &ParentSpoolLayout,
6518    pid: u32,
6519    executable: &Path,
6520) -> Result<(), String> {
6521    let state = ParentWorktreeWatcherState {
6522        pid,
6523        parent_root: normalize_repo_root_for_payload(parent),
6524        executable: executable.display().to_string(),
6525        started_at: Utc::now(),
6526        runtime_key: Some(worktree_runtime_key()),
6527        platform: Some(std::env::consts::OS.to_string()),
6528    };
6529    let rendered = serde_json::to_string_pretty(&state)
6530        .map_err(|error| format!("Failed to serialize parent watcher state: {error}"))?;
6531    fs::write(&layout.watcher_state_file, format!("{rendered}\n")).map_err(|error| {
6532        format!(
6533            "Failed to write parent watcher state {}: {error}",
6534            layout.watcher_state_file.display()
6535        )
6536    })
6537}
6538
6539fn same_repo_watcher_state(identity: &RepoIdentity, state: &WorktreeWatcherState) -> bool {
6540    if !runtime_keys_compatible(state.runtime_key.as_deref()) {
6541        return false;
6542    }
6543    path_identity_key(&identity.root) == path_identity_key(Path::new(&state.repo_root))
6544        && identity.owner == state.repository_owner
6545        && identity.name == state.repository_name
6546        && identity.branch == state.branch_name
6547}
6548
6549fn runtime_keys_compatible(state_runtime: Option<&str>) -> bool {
6550    let current = worktree_runtime_key();
6551    match state_runtime {
6552        Some(key) => key == current,
6553        // Legacy state files (pre-runtime isolation) only match native Windows/Linux
6554        // paths that still live outside the runtimes/ spool tree — never cross WSL.
6555        None => !is_wsl_runtime(),
6556    }
6557}
6558
6559fn process_system_for_pids(pids: &[u32]) -> System {
6560    let mut system = System::new();
6561    if pids.is_empty() {
6562        return system;
6563    }
6564    let pid_list: Vec<Pid> = pids
6565        .iter()
6566        .copied()
6567        .filter(|pid| *pid != 0 && *pid != std::process::id())
6568        .map(Pid::from_u32)
6569        .collect();
6570    if pid_list.is_empty() {
6571        return system;
6572    }
6573    // Refresh only the candidate watcher PIDs (not the entire process table).
6574    system.refresh_processes_specifics(
6575        ProcessesToUpdate::Some(&pid_list),
6576        true,
6577        ProcessRefreshKind::everything()
6578            .with_cmd(UpdateKind::Always)
6579            .with_exe(UpdateKind::Always),
6580    );
6581    system
6582}
6583
6584fn is_matching_watcher_process(state: &WorktreeWatcherState) -> bool {
6585    let system = process_system_for_pids(&[state.pid]);
6586    // "Matching other process" — used by stop/replace; never treats self as match.
6587    is_matching_watcher_process_with_system(&system, state)
6588}
6589
6590/// True when the state PID is a live watcher, including this process (status/API).
6591fn is_live_watcher_process_with_system(system: &System, state: &WorktreeWatcherState) -> bool {
6592    if state.pid == std::process::id() {
6593        return runtime_keys_compatible(state.runtime_key.as_deref());
6594    }
6595    is_matching_watcher_process_with_system(system, state)
6596}
6597
6598fn is_matching_watcher_process_with_system(
6599    system: &System,
6600    state: &WorktreeWatcherState,
6601) -> bool {
6602    if state.pid == std::process::id() {
6603        return false;
6604    }
6605    let Some(process) = system.process(Pid::from_u32(state.pid)) else {
6606        return false;
6607    };
6608    let command_line = process
6609        .cmd()
6610        .iter()
6611        .map(|part| part.to_string_lossy())
6612        .collect::<Vec<_>>()
6613        .join(" ");
6614    let command_matches = command_line_contains_ignore_case(&command_line, "worktree-watch")
6615        && command_line_contains_ignore_case(&command_line, "start")
6616        && command_line_mentions_path(&command_line, &state.repo_root);
6617    command_matches || watcher_state_process_identity_matches(process, state)
6618}
6619
6620fn is_matching_parent_watcher_process(state: &ParentWorktreeWatcherState) -> bool {
6621    let system = process_system_for_pids(&[state.pid]);
6622    is_matching_parent_watcher_process_with_system(&system, state)
6623}
6624
6625/// True when the parent state PID is a live watcher, including this process.
6626fn is_live_parent_watcher_process_with_system(
6627    system: &System,
6628    state: &ParentWorktreeWatcherState,
6629) -> bool {
6630    if state.pid == std::process::id() {
6631        return runtime_keys_compatible(state.runtime_key.as_deref());
6632    }
6633    is_matching_parent_watcher_process_with_system(system, state)
6634}
6635
6636fn is_matching_parent_watcher_process_with_system(
6637    system: &System,
6638    state: &ParentWorktreeWatcherState,
6639) -> bool {
6640    if state.pid == std::process::id() {
6641        return false;
6642    }
6643    if !runtime_keys_compatible(state.runtime_key.as_deref()) {
6644        return false;
6645    }
6646    let Some(process) = system.process(Pid::from_u32(state.pid)) else {
6647        return false;
6648    };
6649    let command_line = process
6650        .cmd()
6651        .iter()
6652        .map(|part| part.to_string_lossy())
6653        .collect::<Vec<_>>()
6654        .join(" ");
6655    let command_matches = command_line_contains_ignore_case(&command_line, "worktree-watch")
6656        && command_line_contains_ignore_case(&command_line, "start")
6657        && command_line_contains_ignore_case(&command_line, "--parent")
6658        && command_line_mentions_path(&command_line, &state.parent_root);
6659    // Accept: verified worktree-watch cmdline, or same executable at this PID
6660    // (Windows often omits args / start_time can be coarse after CREATE_NO_WINDOW).
6661    command_matches
6662        || parent_watcher_state_process_identity_matches(process, state)
6663        || (process_executable_matches_path(process, Path::new(&state.executable))
6664            && process_looks_like_worktree_watch(process))
6665}
6666
6667fn process_looks_like_worktree_watch(process: &sysinfo::Process) -> bool {
6668    let command_line = process
6669        .cmd()
6670        .iter()
6671        .map(|part| part.to_string_lossy())
6672        .collect::<Vec<_>>()
6673        .join(" ");
6674    if command_line_contains_ignore_case(&command_line, "worktree-watch") {
6675        return true;
6676    }
6677    // Empty cmd() is common for some Windows query modes — exe match + state file is enough.
6678    command_line.trim().is_empty()
6679}
6680
6681fn command_line_contains_ignore_case(haystack: &str, needle: &str) -> bool {
6682    haystack.to_ascii_lowercase().contains(&needle.to_ascii_lowercase())
6683}
6684
6685fn command_line_mentions_path(command_line: &str, path: &str) -> bool {
6686    if path.is_empty() {
6687        return false;
6688    }
6689    if command_line.contains(path) {
6690        return true;
6691    }
6692    let command_lower = command_line.to_ascii_lowercase().replace('\\', "/");
6693    let path_lower = path.to_ascii_lowercase().replace('\\', "/");
6694    if command_lower.contains(&path_lower) {
6695        return true;
6696    }
6697    // Cross-platform: C:\Users\… vs /mnt/c/users/…
6698    let path_key = path_identity_key(Path::new(path));
6699    if !path_key.is_empty() && command_lower.contains(&path_key) {
6700        return true;
6701    }
6702    for token in command_line.split_whitespace() {
6703        let cleaned = token.trim_matches('"').trim_matches('\'');
6704        if cleaned.is_empty() {
6705            continue;
6706        }
6707        if path_identity_key(Path::new(cleaned)) == path_key {
6708            return true;
6709        }
6710    }
6711    // Final path segment (GitHub / github) case-insensitive.
6712    Path::new(path)
6713        .file_name()
6714        .and_then(|name| name.to_str())
6715        .map(|name| command_line_contains_ignore_case(command_line, name))
6716        .unwrap_or(false)
6717}
6718
6719fn watcher_state_pid_exists_with_same_executable(state: &WorktreeWatcherState) -> bool {
6720    if state.pid == std::process::id() {
6721        return false;
6722    }
6723    let system = process_system_for_pids(&[state.pid]);
6724    let Some(process) = system.process(Pid::from_u32(state.pid)) else {
6725        return false;
6726    };
6727    process_executable_matches_state(process, state)
6728}
6729
6730fn parent_watcher_state_pid_exists_with_same_executable(
6731    state: &ParentWorktreeWatcherState,
6732) -> bool {
6733    if state.pid == std::process::id() {
6734        return false;
6735    }
6736    let system = process_system_for_pids(&[state.pid]);
6737    let Some(process) = system.process(Pid::from_u32(state.pid)) else {
6738        return false;
6739    };
6740    process_executable_matches_path(process, Path::new(&state.executable))
6741}
6742
6743fn watcher_state_process_identity_matches(
6744    process: &sysinfo::Process,
6745    state: &WorktreeWatcherState,
6746) -> bool {
6747    process_executable_matches_state(process, state)
6748        && process_start_time_matches(process.start_time(), state.started_at)
6749}
6750
6751fn parent_watcher_state_process_identity_matches(
6752    process: &sysinfo::Process,
6753    state: &ParentWorktreeWatcherState,
6754) -> bool {
6755    process_executable_matches_path(process, Path::new(&state.executable))
6756        && process_start_time_matches(process.start_time(), state.started_at)
6757}
6758
6759fn process_executable_matches_state(
6760    process: &sysinfo::Process,
6761    state: &WorktreeWatcherState,
6762) -> bool {
6763    process_executable_matches_path(process, Path::new(&state.executable))
6764}
6765
6766fn process_executable_matches_path(process: &sysinfo::Process, executable: &Path) -> bool {
6767    let expected = path_identity_key(executable);
6768    process
6769        .exe()
6770        .map(|path| path_identity_key(path) == expected)
6771        .unwrap_or(false)
6772}
6773
6774fn process_start_time_matches(process_started: u64, state_started: DateTime<Utc>) -> bool {
6775    // 0 means "unknown" on some platforms / query modes — don't reject the match.
6776    if process_started == 0 {
6777        return true;
6778    }
6779    let process_started = process_started as i64;
6780    let state_started = state_started.timestamp();
6781    // Wider window: spawn → write state can lag, and Windows clocks are coarse.
6782    (process_started - state_started).abs() <= 120
6783}
6784
6785fn stop_process(state: &WorktreeWatcherState, force: bool) -> Result<(), String> {
6786    let system = process_system_for_pids(&[state.pid]);
6787    let Some(process) = system.process(Pid::from_u32(state.pid)) else {
6788        return Ok(());
6789    };
6790    if !force && !is_matching_watcher_process_with_system(&system, state) {
6791        return Ok(());
6792    }
6793    if process.kill() {
6794        Ok(())
6795    } else {
6796        Err(format!(
6797            "Failed to stop existing watcher process {}",
6798            state.pid
6799        ))
6800    }
6801}
6802
6803fn stop_parent_process(state: &ParentWorktreeWatcherState, force: bool) -> Result<(), String> {
6804    let system = process_system_for_pids(&[state.pid]);
6805    let Some(process) = system.process(Pid::from_u32(state.pid)) else {
6806        return Ok(());
6807    };
6808    if !force && !is_matching_parent_watcher_process_with_system(&system, state) {
6809        return Ok(());
6810    }
6811    if process.kill() {
6812        Ok(())
6813    } else {
6814        Err(format!(
6815            "Failed to stop existing parent watcher process {}",
6816            state.pid
6817        ))
6818    }
6819}
6820
6821/// Prefer a live parent watcher so bare `status` reports the active cover.
6822fn prefer_running_parent_root() -> Option<PathBuf> {
6823    let mut candidates = list_parent_watcher_state_files().ok()?;
6824    // Newest state first.
6825    candidates.sort_by(|left, right| right.1.started_at.cmp(&left.1.started_at));
6826    for (_path, state) in candidates {
6827        let system = process_system_for_pids(&[state.pid]);
6828        if !is_live_parent_watcher_process_with_system(&system, &state) {
6829            continue;
6830        }
6831        let root = parent_root_as_local_path(&state.parent_root);
6832        if root.is_dir() {
6833            return Some(root);
6834        }
6835        return Some(root);
6836    }
6837    None
6838}
6839
6840/// Most recent parent cover path (even if the watcher process is not live).
6841fn prefer_any_parent_root() -> Option<PathBuf> {
6842    let mut candidates = list_parent_watcher_state_files().ok()?;
6843    candidates.sort_by(|left, right| right.1.started_at.cmp(&left.1.started_at));
6844    for (_path, state) in candidates {
6845        let root = parent_root_as_local_path(&state.parent_root);
6846        if root.is_dir() {
6847            return Some(root);
6848        }
6849    }
6850    None
6851}
6852
6853fn count_git_roots_under(parent: &Path) -> Result<usize, String> {
6854    let mut roots = Vec::new();
6855    let mut seen = BTreeSet::new();
6856    collect_git_roots_in_dir(parent, parent, &mut seen, &mut roots)?;
6857    Ok(roots.len())
6858}
6859
6860fn list_parent_watcher_state_files() -> Result<Vec<(PathBuf, ParentWorktreeWatcherState)>, String> {
6861    let parents_root = global_mutations_root()?.join("parents");
6862    if !parents_root.is_dir() {
6863        return Ok(Vec::new());
6864    }
6865    let mut out = Vec::new();
6866    for entry in fs::read_dir(&parents_root)
6867        .map_err(|error| format!("Failed to read {}: {error}", parents_root.display()))?
6868    {
6869        let entry = entry.map_err(|error| format!("Failed to read parent spool entry: {error}"))?;
6870        if !entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
6871            continue;
6872        }
6873        for file in fs::read_dir(entry.path()).map_err(|error| {
6874            format!(
6875                "Failed to read parent spool {}: {error}",
6876                entry.path().display()
6877            )
6878        })? {
6879            let file = file.map_err(|error| format!("Failed to read parent state entry: {error}"))?;
6880            let path = file.path();
6881            let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
6882                continue;
6883            };
6884            if !(name.starts_with("watcher-state") && name.ends_with(".json")) {
6885                continue;
6886            }
6887            if let Ok(Some(state)) = read_parent_watcher_state(&path) {
6888                // Prefer runtime-scoped files for this runtime.
6889                if let Some(runtime) = state.runtime_key.as_deref() {
6890                    if runtime != worktree_runtime_key() {
6891                        // Still allow legacy files without runtime_key via runtime_keys_compatible.
6892                        if !runtime_keys_compatible(Some(runtime)) {
6893                            continue;
6894                        }
6895                    }
6896                } else if !runtime_keys_compatible(None) {
6897                    continue;
6898                }
6899                out.push((path, state));
6900            }
6901        }
6902    }
6903    Ok(out)
6904}
6905
6906fn parent_root_as_local_path(parent_root: &str) -> PathBuf {
6907    let trimmed = parent_root.trim();
6908    if trimmed.is_empty() {
6909        return PathBuf::new();
6910    }
6911    if let Some(windows) = map_wsl_mnt_path_to_windows(trimmed) {
6912        return PathBuf::from(windows);
6913    }
6914    let as_path = PathBuf::from(trimmed);
6915    if as_path.is_dir() {
6916        return as_path;
6917    }
6918    // path_identity_key form is lowercase /mnt/c/... even on Windows storage.
6919    if let Some(windows) = map_wsl_mnt_path_to_windows(&trimmed.replace('\\', "/")) {
6920        return PathBuf::from(windows);
6921    }
6922    as_path
6923}
6924
6925fn discover_repo_identities_under(parent: &Path) -> Result<Vec<RepoIdentity>, String> {
6926    let parent = canonical_parent_path(parent)?;
6927
6928    let mut roots = Vec::new();
6929    let mut seen = BTreeSet::new();
6930    collect_git_roots_in_dir(&parent, &parent, &mut seen, &mut roots)?;
6931
6932    // Resolve identities in parallel — sequential git spawns for 60+ repos
6933    // made status/API unusable on Windows.
6934    let identities = resolve_repo_identities_parallel(&roots);
6935    let mut identities = identities.into_iter().flatten().collect::<Vec<_>>();
6936    identities.sort_by(|left, right| left.root.cmp(&right.root));
6937    Ok(identities)
6938}
6939
6940fn collect_git_roots_in_dir(
6941    parent: &Path,
6942    dir: &Path,
6943    seen: &mut BTreeSet<String>,
6944    roots: &mut Vec<PathBuf>,
6945) -> Result<(), String> {
6946    if dir != parent && is_skipped_discovery_dir(dir) {
6947        return Ok(());
6948    }
6949
6950    if dir.join(".git").exists() {
6951        let key = path_identity_key(dir);
6952        if seen.insert(key) {
6953            roots.push(dir.to_path_buf());
6954        }
6955        return Ok(());
6956    }
6957
6958    let entries = fs::read_dir(dir)
6959        .map_err(|error| format!("Failed to read folder {}: {error}", dir.display()))?;
6960    for entry in entries {
6961        let entry = entry.map_err(|error| {
6962            format!(
6963                "Failed to read folder entry under {}: {error}",
6964                dir.display()
6965            )
6966        })?;
6967        let file_type = entry
6968            .file_type()
6969            .map_err(|error| format!("Failed to inspect {}: {error}", entry.path().display()))?;
6970        if file_type.is_dir() {
6971            collect_git_roots_in_dir(parent, &entry.path(), seen, roots)?;
6972        }
6973    }
6974
6975    Ok(())
6976}
6977
6978fn resolve_repo_identities_parallel(roots: &[PathBuf]) -> Vec<Option<RepoIdentity>> {
6979    if roots.is_empty() {
6980        return Vec::new();
6981    }
6982    if roots.len() == 1 {
6983        return vec![resolve_repo_identity_light(Some(&roots[0])).ok()];
6984    }
6985
6986    let thread_count = roots.len().min(8).max(1);
6987    let chunk_size = (roots.len() + thread_count - 1) / thread_count;
6988    let mut handles = Vec::new();
6989    for chunk in roots.chunks(chunk_size) {
6990        let chunk = chunk.to_vec();
6991        handles.push(std::thread::spawn(move || {
6992            chunk
6993                .iter()
6994                .map(|root| resolve_repo_identity_light(Some(root)).ok())
6995                .collect::<Vec<_>>()
6996        }));
6997    }
6998
6999    let mut out = Vec::with_capacity(roots.len());
7000    for handle in handles {
7001        match handle.join() {
7002            Ok(part) => out.extend(part),
7003            Err(_) => {
7004                // Fallback sequential if a worker panics.
7005            }
7006        }
7007    }
7008    // If a worker panicked, fill remaining via sequential so we never under-report silently.
7009    if out.len() < roots.len() {
7010        for root in roots.iter().skip(out.len()) {
7011            out.push(resolve_repo_identity_light(Some(root)).ok());
7012        }
7013    }
7014    out
7015}
7016
7017/// Status/tray identity resolve: skip HEAD SHA (one less git spawn per repo).
7018fn resolve_repo_identity_light(repo: Option<&Path>) -> Result<RepoIdentity, String> {
7019    let start = resolve_repo_path_input(repo)?;
7020    let root_raw = git_output(&start, &["rev-parse", "--show-toplevel"]).map_err(|error| {
7021        if repo.is_some() {
7022            format!(
7023                "Failed to run git rev-parse --show-toplevel in {}: {error}",
7024                start.display()
7025            )
7026        } else {
7027            format!("Failed to run git rev-parse --show-toplevel: {error}")
7028        }
7029    })?;
7030    let root = normalize_windows_verbatim_path(
7031        fs::canonicalize(root_raw.trim()).unwrap_or_else(|_| PathBuf::from(root_raw.trim())),
7032    );
7033    let branch = git_output(&root, &["rev-parse", "--abbrev-ref", "HEAD"])
7034        .unwrap_or_else(|_| "unknown".to_string());
7035    let remote = git_output(&root, &["remote", "get-url", DEFAULT_REMOTE]).unwrap_or_default();
7036    let (owner, name) = parse_remote_owner_repo(&remote).unwrap_or_else(|| {
7037        let name = root
7038            .file_name()
7039            .and_then(|value| value.to_str())
7040            .unwrap_or("repository")
7041            .to_string();
7042        ("unknown".to_string(), name)
7043    });
7044
7045    Ok(RepoIdentity {
7046        owner,
7047        name,
7048        branch: sanitize_path_component(branch.trim()),
7049        root,
7050        head_sha: None,
7051    })
7052}
7053
7054fn resolve_repo_identity(repo: Option<&Path>) -> Result<RepoIdentity, String> {
7055    let start = resolve_repo_path_input(repo)?;
7056    let root_raw = git_output(&start, &["rev-parse", "--show-toplevel"]).map_err(|error| {
7057        if repo.is_some() {
7058            format!(
7059                "Failed to run git rev-parse --show-toplevel in {}: {error}",
7060                start.display()
7061            )
7062        } else {
7063            format!("Failed to run git rev-parse --show-toplevel: {error}")
7064        }
7065    })?;
7066    let root = normalize_windows_verbatim_path(
7067        fs::canonicalize(root_raw.trim()).unwrap_or_else(|_| PathBuf::from(root_raw.trim())),
7068    );
7069    let branch = git_output(&root, &["rev-parse", "--abbrev-ref", "HEAD"])
7070        .unwrap_or_else(|_| "unknown".to_string());
7071    let remote = git_output(&root, &["remote", "get-url", DEFAULT_REMOTE]).unwrap_or_default();
7072    let (owner, name) = parse_remote_owner_repo(&remote).unwrap_or_else(|| {
7073        let name = root
7074            .file_name()
7075            .and_then(|value| value.to_str())
7076            .unwrap_or("repository")
7077            .to_string();
7078        ("unknown".to_string(), name)
7079    });
7080    let head_sha = current_head(&root)?;
7081
7082    Ok(RepoIdentity {
7083        owner,
7084        name,
7085        branch: sanitize_path_component(branch.trim()),
7086        root,
7087        head_sha,
7088    })
7089}
7090
7091/// Resolve `--repo` / `--repos` values to a filesystem path.
7092///
7093/// Accepts:
7094/// - absolute or relative git roots that exist on disk
7095/// - short repo names / `owner/repo` / full paths from
7096///   `~/.xbp/config.yaml` → `system_inventory.github_repos`
7097fn resolve_repo_path_input(repo: Option<&Path>) -> Result<PathBuf, String> {
7098    let Some(input) = repo else {
7099        return std::env::current_dir()
7100            .map_err(|error| format!("Failed to resolve current directory: {error}"));
7101    };
7102
7103    if path_looks_like_existing_dir(input) {
7104        return Ok(input.to_path_buf());
7105    }
7106
7107    let token = input.to_string_lossy();
7108    let token = token.trim();
7109    if token.is_empty() {
7110        return Err("--repo value is empty".to_string());
7111    }
7112
7113    // Bare names (athena) and owner/repo (xylex-group/athena) never contain path
7114    // separators on disk until inventory expands them; try inventory first.
7115    if let Some(path) = lookup_repo_path_from_system_inventory(token)? {
7116        return Ok(path);
7117    }
7118
7119    // Keep original so callers can surface a path-oriented git error for real paths.
7120    if input.components().count() > 1
7121        || token.contains('/')
7122        || token.contains('\\')
7123        || token.contains(':')
7124    {
7125        return Ok(input.to_path_buf());
7126    }
7127
7128    Err(format!(
7129        "Could not resolve `--repo {token}` as a filesystem path or inventory name. \
7130Refresh with `xbp diag --refresh-system-inventory`, pass a full path, or use `--parent`."
7131    ))
7132}
7133
7134fn path_looks_like_existing_dir(path: &Path) -> bool {
7135    path.is_dir()
7136        || fs::metadata(path)
7137            .map(|meta| meta.is_dir())
7138            .unwrap_or(false)
7139}
7140
7141fn lookup_repo_path_from_system_inventory(token: &str) -> Result<Option<PathBuf>, String> {
7142    let config = match SshConfig::load() {
7143        Ok(config) => config,
7144        Err(_) => return Ok(None),
7145    };
7146    let Some(inventory) = config.system_inventory.as_ref() else {
7147        return Ok(None);
7148    };
7149
7150    if let Some(path) = match_github_repo_inventory(&inventory.github_repos, token)? {
7151        return Ok(Some(path));
7152    }
7153
7154    // Fall back to discovered XBP project names (folder / project name).
7155    let needle = normalize_repo_lookup_token(token);
7156    let mut project_hits: Vec<PathBuf> = Vec::new();
7157    for project in &inventory.xbp_projects {
7158        let name = normalize_repo_lookup_token(&project.name);
7159        let root_name = Path::new(&project.root)
7160            .file_name()
7161            .and_then(|value| value.to_str())
7162            .map(normalize_repo_lookup_token)
7163            .unwrap_or_default();
7164        if name == needle || root_name == needle {
7165            let path = PathBuf::from(&project.root);
7166            if path_looks_like_existing_dir(&path)
7167                && !project_hits.iter().any(|existing| {
7168                    path_identity_key(existing.as_path()) == path_identity_key(path.as_path())
7169                })
7170            {
7171                project_hits.push(path);
7172            }
7173        }
7174    }
7175    match project_hits.len() {
7176        0 => Ok(None),
7177        1 => Ok(Some(project_hits.remove(0))),
7178        _ => Err(format_ambiguous_repo_matches(token, &project_hits)),
7179    }
7180}
7181
7182fn match_github_repo_inventory(
7183    repos: &[GithubRepoRecord],
7184    token: &str,
7185) -> Result<Option<PathBuf>, String> {
7186    let needle = normalize_repo_lookup_token(token);
7187    if needle.is_empty() {
7188        return Ok(None);
7189    }
7190
7191    let mut exact_path: Vec<PathBuf> = Vec::new();
7192    let mut full_name_hits: Vec<PathBuf> = Vec::new();
7193    let mut short_name_hits: Vec<PathBuf> = Vec::new();
7194
7195    for record in repos {
7196        let path = PathBuf::from(record.path.trim());
7197        if record.path.trim().is_empty() || !path_looks_like_existing_dir(&path) {
7198            continue;
7199        }
7200
7201        let path_key = path_identity_key(&path);
7202        let full_name = normalize_repo_lookup_token(&record.full_name);
7203        let short = normalize_repo_lookup_token(&record.repo);
7204        let owner_repo = normalize_repo_lookup_token(&format!("{}/{}", record.owner, record.repo));
7205        let folder = path
7206            .file_name()
7207            .and_then(|value| value.to_str())
7208            .map(normalize_repo_lookup_token)
7209            .unwrap_or_default();
7210
7211        let push_unique = |bucket: &mut Vec<PathBuf>| {
7212            if !bucket
7213                .iter()
7214                .any(|existing| path_identity_key(existing.as_path()) == path_key)
7215            {
7216                bucket.push(path.clone());
7217            }
7218        };
7219
7220        if path_identity_key(Path::new(record.path.trim())) == path_identity_key(Path::new(token))
7221            || normalize_repo_lookup_token(&record.path) == needle
7222        {
7223            push_unique(&mut exact_path);
7224        }
7225        if full_name == needle || owner_repo == needle {
7226            push_unique(&mut full_name_hits);
7227        }
7228        if short == needle || folder == needle {
7229            push_unique(&mut short_name_hits);
7230        }
7231    }
7232
7233    if let Some(path) = exact_path.into_iter().next() {
7234        return Ok(Some(path));
7235    }
7236    match full_name_hits.len() {
7237        0 => {}
7238        1 => return Ok(Some(full_name_hits.remove(0))),
7239        _ => return Err(format_ambiguous_repo_matches(token, &full_name_hits)),
7240    }
7241    match short_name_hits.len() {
7242        0 => Ok(None),
7243        1 => Ok(Some(short_name_hits.remove(0))),
7244        _ => Err(format_ambiguous_repo_matches(token, &short_name_hits)),
7245    }
7246}
7247
7248fn normalize_repo_lookup_token(value: &str) -> String {
7249    value
7250        .trim()
7251        .trim_end_matches('/')
7252        .trim_end_matches('\\')
7253        .trim_end_matches(".git")
7254        .replace('\\', "/")
7255        .to_ascii_lowercase()
7256}
7257
7258fn format_ambiguous_repo_matches(token: &str, paths: &[PathBuf]) -> String {
7259    let list = paths
7260        .iter()
7261        .map(|path| format!("  - {}", path.display()))
7262        .collect::<Vec<_>>()
7263        .join("\n");
7264    format!(
7265        "Ambiguous `--repo {token}` matches multiple inventory paths:\n{list}\n\
7266Use a full path or `owner/repo` (for example `xylex-group/{token}`)."
7267    )
7268}
7269
7270fn prepare_spool_layout(identity: &RepoIdentity) -> Result<SpoolLayout, String> {
7271    let root = repository_spool_root(identity)?.join(sanitize_path_component(&identity.branch));
7272    fs::create_dir_all(&root).map_err(|error| {
7273        format!(
7274            "Failed to create worktree mutation spool {}: {error}",
7275            root.display()
7276        )
7277    })?;
7278    let event_dedupe_dir = root.join("event-dedupe");
7279    fs::create_dir_all(&event_dedupe_dir).map_err(|error| {
7280        format!(
7281            "Failed to create worktree mutation dedupe dir {}: {error}",
7282            event_dedupe_dir.display()
7283        )
7284    })?;
7285    let commit_dedupe_dir = root.join("commit-dedupe");
7286    fs::create_dir_all(&commit_dedupe_dir).map_err(|error| {
7287        format!(
7288            "Failed to create worktree commit dedupe dir {}: {error}",
7289            commit_dedupe_dir.display()
7290        )
7291    })?;
7292    Ok(spool_layout_for_root(
7293        root,
7294        Some(Uuid::new_v4().to_string()),
7295    ))
7296}
7297
7298/// Canonical mutations root under the established global XBP home
7299/// (`XBP_HOME` / Windows profile `.xbp` / `~/.xbp`), not the repo.
7300fn global_mutations_root() -> Result<PathBuf, String> {
7301    let paths = ensure_global_xbp_paths()?;
7302    Ok(paths.root_dir.join("mutations"))
7303}
7304
7305fn repository_spool_root(identity: &RepoIdentity) -> Result<PathBuf, String> {
7306    Ok(global_mutations_root()?
7307        .join(sanitize_path_component(&identity.owner))
7308        .join(sanitize_path_component(&identity.name)))
7309}
7310
7311/// All mutation roots that may already hold data for this identity.
7312///
7313/// Primary root is the established global XBP home; legacy/alternate homes and
7314/// older `runtimes/*` layouts are included so WSL/Windows can still read each
7315/// other's historical spoils while new writes go to the primary path.
7316fn repository_spool_search_roots(identity: &RepoIdentity) -> Result<Vec<PathBuf>, String> {
7317    let owner = sanitize_path_component(&identity.owner);
7318    let name = sanitize_path_component(&identity.name);
7319    let mut roots: Vec<PathBuf> = Vec::new();
7320    let mut push = |path: PathBuf| {
7321        if roots.iter().any(|existing| {
7322            path_identity_key(existing.as_path()) == path_identity_key(path.as_path())
7323        }) {
7324            return;
7325        }
7326        roots.push(path);
7327    };
7328
7329    push(repository_spool_root(identity)?);
7330
7331    for base in legacy_mutations_base_candidates() {
7332        push(base.join(&owner).join(&name));
7333        // Previous runtime-isolated layout.
7334        let runtimes = base.join("runtimes");
7335        if runtimes.is_dir() {
7336            if let Ok(entries) = fs::read_dir(&runtimes) {
7337                for entry in entries.flatten() {
7338                    if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
7339                        push(entry.path().join(&owner).join(&name));
7340                    }
7341                }
7342            }
7343        }
7344    }
7345
7346    Ok(roots)
7347}
7348
7349fn legacy_mutations_base_candidates() -> Vec<PathBuf> {
7350    let mut bases: Vec<PathBuf> = Vec::new();
7351    let mut push = |path: PathBuf| {
7352        if bases
7353            .iter()
7354            .any(|existing| path_identity_key(existing.as_path()) == path_identity_key(path.as_path()))
7355        {
7356            return;
7357        }
7358        bases.push(path);
7359    };
7360
7361    if let Ok(primary) = global_mutations_root() {
7362        push(primary);
7363    }
7364    // Also surface the raw resolved root without create-side effects for alternate spellings.
7365    push(resolve_global_xbp_root_dir().join("mutations"));
7366
7367    if let Some(home) = dirs::home_dir() {
7368        push(home.join(".xbp").join("mutations"));
7369        push(home.join(".config").join("xbp").join("mutations"));
7370    }
7371
7372    // Dual-map the primary mutations root across WSL/Windows path spellings.
7373    if let Ok(primary) = global_mutations_root() {
7374        let rendered = primary.to_string_lossy().replace('\\', "/");
7375        if let Some(mnt) = map_windows_path_to_wsl_mnt(&rendered) {
7376            push(PathBuf::from(mnt));
7377        }
7378        if let Some(windows) = map_wsl_mnt_path_to_windows(&rendered) {
7379            if let Some(mnt) = map_windows_path_to_wsl_mnt(&windows) {
7380                push(PathBuf::from(mnt));
7381            }
7382            push(PathBuf::from(windows));
7383        }
7384    }
7385
7386    bases
7387}
7388
7389fn spool_layout_for_existing_root(root: &Path) -> SpoolLayout {
7390    spool_layout_for_root(root.to_path_buf(), None)
7391}
7392
7393fn spool_layout_for_root(root: PathBuf, run_id: Option<String>) -> SpoolLayout {
7394    let run_id = run_id.unwrap_or_else(|| Uuid::new_v4().to_string());
7395    let event_dedupe_dir = root.join("event-dedupe");
7396    let commit_dedupe_dir = root.join("commit-dedupe");
7397    // Events/stats/dedupe are shared across Windows+WSL on the established home.
7398    // Watcher PID state is runtime-scoped so both can run without clobbering stop/start.
7399    let watcher_state_file = root.join(format!(
7400        "watcher-state-{}.json",
7401        sanitize_path_component(&worktree_runtime_key())
7402    ));
7403    SpoolLayout {
7404        events_file: root.join(format!("events-{run_id}.jsonl")),
7405        commits_file: root.join(format!("commits-{run_id}.jsonl")),
7406        stats_file: root.join("stats.json"),
7407        watcher_state_file,
7408        sync_log_file: root.join("sync-log.jsonl"),
7409        event_dedupe_file: root.join("event-dedupe.jsonl"),
7410        event_dedupe_dir,
7411        commit_dedupe_dir,
7412        root,
7413    }
7414}
7415
7416fn prepare_parent_spool_layout(parent: &Path) -> Result<ParentSpoolLayout, String> {
7417    let parent = canonical_parent_path(parent)?;
7418    // Prefer a path-identity that is stable across WSL `/mnt/c/...` and Windows `C:\...`.
7419    let parent_key = cross_platform_path_identity_key(&parent);
7420    let mut hasher = Sha256::new();
7421    hasher.update(parent_key.as_bytes());
7422    let digest = format!("{:x}", hasher.finalize());
7423    let label = parent
7424        .file_name()
7425        .and_then(|value| value.to_str())
7426        .map(sanitize_path_component)
7427        .filter(|value| !value.is_empty())
7428        .unwrap_or_else(|| "parent".to_string());
7429    let root = global_mutations_root()?
7430        .join("parents")
7431        .join(format!("{}-{}", label, &digest[..16]));
7432    fs::create_dir_all(&root).map_err(|error| {
7433        format!(
7434            "Failed to create parent worktree watcher state dir {}: {error}",
7435            root.display()
7436        )
7437    })?;
7438    Ok(ParentSpoolLayout {
7439        watcher_state_file: root.join(format!(
7440            "watcher-state-{}.json",
7441            sanitize_path_component(&worktree_runtime_key())
7442        )),
7443    })
7444}
7445
7446fn canonical_parent_path(parent: &Path) -> Result<PathBuf, String> {
7447    let normalized = normalize_windows_verbatim_path(
7448        fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf()),
7449    );
7450    if !normalized.is_dir() {
7451        return Err(format!(
7452            "Parent folder {} does not exist or is not a directory.",
7453            normalized.display()
7454        ));
7455    }
7456    Ok(normalized)
7457}
7458
7459/// Process-wide buffer so bursty FS events open each jsonl file once per batch
7460/// instead of once per event (major IO win under editors that write many times).
7461struct JsonlWriteBuffers {
7462    lines_by_path: HashMap<PathBuf, Vec<String>>,
7463    line_count: usize,
7464    last_flush: Instant,
7465}
7466
7467impl JsonlWriteBuffers {
7468    fn new() -> Self {
7469        Self {
7470            lines_by_path: HashMap::new(),
7471            line_count: 0,
7472            last_flush: Instant::now(),
7473        }
7474    }
7475
7476    fn push(&mut self, path: PathBuf, line: String) {
7477        self.lines_by_path.entry(path).or_default().push(line);
7478        self.line_count = self.line_count.saturating_add(1);
7479    }
7480
7481    fn should_flush(&self) -> bool {
7482        self.line_count >= JSONL_FLUSH_MAX_LINES
7483            || self.last_flush.elapsed() >= Duration::from_millis(JSONL_FLUSH_MAX_WAIT_MS)
7484    }
7485
7486    fn flush(&mut self) -> Result<(), String> {
7487        if self.lines_by_path.is_empty() {
7488            return Ok(());
7489        }
7490        let batch = std::mem::take(&mut self.lines_by_path);
7491        self.line_count = 0;
7492        self.last_flush = Instant::now();
7493        for (path, lines) in batch {
7494            if let Some(parent) = path.parent() {
7495                let _ = fs::create_dir_all(parent);
7496            }
7497            let mut file = OpenOptions::new()
7498                .create(true)
7499                .append(true)
7500                .open(&path)
7501                .map_err(|error| {
7502                    format!("Failed to open spool file {}: {error}", path.display())
7503                })?;
7504            for line in lines {
7505                writeln!(file, "{line}").map_err(|error| {
7506                    format!("Failed to append spool file {}: {error}", path.display())
7507                })?;
7508            }
7509            // One flush per file per batch (not per line).
7510            let _ = file.flush();
7511        }
7512        Ok(())
7513    }
7514}
7515
7516fn jsonl_buffers() -> &'static Mutex<JsonlWriteBuffers> {
7517    static BUFFERS: OnceLock<Mutex<JsonlWriteBuffers>> = OnceLock::new();
7518    BUFFERS.get_or_init(|| Mutex::new(JsonlWriteBuffers::new()))
7519}
7520
7521fn append_json_line<T: Serialize>(path: &Path, value: &T) -> Result<(), String> {
7522    let line = serde_json::to_string(value)
7523        .map_err(|error| format!("Failed to serialize worktree event: {error}"))?;
7524    let mut guard = jsonl_buffers()
7525        .lock()
7526        .map_err(|_| "jsonl write buffer lock poisoned".to_string())?;
7527    guard.push(path.to_path_buf(), line);
7528    if guard.should_flush() {
7529        guard.flush()?;
7530    }
7531    Ok(())
7532}
7533
7534/// Force pending spool lines to disk (call each watch-loop tick and on shutdown paths).
7535fn flush_jsonl_write_buffers() {
7536    if let Ok(mut guard) = jsonl_buffers().lock() {
7537        let _ = guard.flush();
7538    }
7539}
7540
7541fn git_numstat_for_path(repo_root: &Path, relative_path: &str) -> Result<(u64, u64), String> {
7542    let output = Command::new("git")
7543        .args(["diff", "--numstat", "--"])
7544        .arg(relative_path)
7545        .current_dir(repo_root)
7546        .output()
7547        .map_err(|error| format!("Failed to run git diff --numstat: {error}"))?;
7548    if !output.status.success() {
7549        return Ok((0, 0));
7550    }
7551    let stdout = String::from_utf8_lossy(&output.stdout);
7552    let mut added = 0;
7553    let mut removed = 0;
7554    for line in stdout.lines() {
7555        let mut parts = line.split_whitespace();
7556        added += parse_numstat_count(parts.next());
7557        removed += parse_numstat_count(parts.next());
7558    }
7559    Ok((added, removed))
7560}
7561
7562fn file_line_count_for_path(repo_root: &Path, relative_path: &str) -> Result<u64, String> {
7563    let path = repo_root.join(relative_path);
7564    let meta = fs::metadata(&path)
7565        .map_err(|error| format!("Failed to stat file {}: {error}", path.display()))?;
7566    // Skip huge files — full reads were a major CPU/IO cost on hot trees.
7567    if meta.len() > 512 * 1024 {
7568        return Ok(0);
7569    }
7570    let content = fs::read_to_string(&path)
7571        .map_err(|error| format!("Failed to read file {}: {error}", path.display()))?;
7572    Ok(content.lines().count() as u64)
7573}
7574
7575fn parse_numstat_count(value: Option<&str>) -> u64 {
7576    value.and_then(|raw| raw.parse::<u64>().ok()).unwrap_or(0)
7577}
7578
7579fn current_head(repo_root: &Path) -> Result<Option<String>, String> {
7580    match git_output(repo_root, &["rev-parse", "HEAD"]) {
7581        Ok(value) => Ok(Some(value)),
7582        Err(error)
7583            if error.contains("unknown revision") || error.contains("ambiguous argument") =>
7584        {
7585            Ok(None)
7586        }
7587        Err(error) => Err(error),
7588    }
7589}
7590
7591fn git_output(repo_root: &Path, args: &[&str]) -> Result<String, String> {
7592    let output = Command::new("git")
7593        .args(args)
7594        .current_dir(repo_root)
7595        .output()
7596        .map_err(|error| format!("Failed to run git {}: {error}", args.join(" ")))?;
7597    if !output.status.success() {
7598        return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
7599    }
7600    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
7601}
7602
7603fn parse_remote_owner_repo(remote: &str) -> Option<(String, String)> {
7604    let trimmed = remote.trim().trim_end_matches(".git");
7605    if trimmed.is_empty() {
7606        return None;
7607    }
7608
7609    let path_part = if !trimmed.contains("://") {
7610        if let Some((_, path)) = trimmed.rsplit_once(':') {
7611            path
7612        } else {
7613            trimmed
7614        }
7615    } else {
7616        trimmed
7617            .trim_start_matches("https://")
7618            .trim_start_matches("http://")
7619            .trim_start_matches("ssh://")
7620            .split_once('/')
7621            .map(|(_, path)| path)
7622            .unwrap_or(trimmed)
7623    };
7624    let mut parts = path_part.rsplitn(2, '/');
7625    let name = parts.next()?.trim();
7626    let owner = parts.next()?.trim();
7627    if owner.is_empty() || name.is_empty() {
7628        return None;
7629    }
7630    Some((
7631        sanitize_path_component(owner),
7632        sanitize_path_component(name.trim_end_matches(".git")),
7633    ))
7634}
7635
7636fn sanitize_path_component(value: &str) -> String {
7637    let sanitized: String = value
7638        .chars()
7639        .map(|ch| {
7640            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
7641                ch
7642            } else {
7643                '-'
7644            }
7645        })
7646        .collect();
7647    sanitized
7648        .trim_matches('-')
7649        .chars()
7650        .take(160)
7651        .collect::<String>()
7652}
7653
7654fn normalize_windows_verbatim_path(path: PathBuf) -> PathBuf {
7655    PathBuf::from(strip_windows_verbatim_prefix(&path.to_string_lossy()))
7656}
7657
7658fn strip_windows_verbatim_prefix(input: &str) -> &str {
7659    input.strip_prefix(r"\\?\").unwrap_or(input)
7660}
7661
7662fn path_identity_key(path: &Path) -> String {
7663    cross_platform_path_identity_key(path)
7664}
7665
7666/// Path identity that treats `C:\foo` and `/mnt/c/foo` as the same location.
7667fn cross_platform_path_identity_key(path: &Path) -> String {
7668    let mut value = path.to_string_lossy().replace('\\', "/");
7669    if let Some(mnt) = map_windows_path_to_wsl_mnt(&value) {
7670        value = mnt;
7671    } else if let Some(windows) = map_wsl_mnt_path_to_windows(&value) {
7672        if let Some(mnt) = map_windows_path_to_wsl_mnt(&windows) {
7673            value = mnt;
7674        } else {
7675            value = windows.replace('\\', "/");
7676        }
7677    }
7678    // Drive-backed paths are case-insensitive on Windows; always fold for stable keys.
7679    value.to_ascii_lowercase()
7680}
7681
7682/// Runtime isolation key for spoils + device identity.
7683///
7684/// Examples: `windows`, `linux`, `macos`, `wsl-ubuntu`, `wsl-debian`.
7685fn worktree_runtime_key() -> String {
7686    static KEY: OnceLock<String> = OnceLock::new();
7687    KEY.get_or_init(|| {
7688        if is_wsl_runtime() {
7689            let distro = std::env::var("WSL_DISTRO_NAME")
7690                .ok()
7691                .map(|value| value.trim().to_string())
7692                .filter(|value| !value.is_empty())
7693                .unwrap_or_else(|| "wsl".to_string());
7694            format!("wsl-{}", sanitize_path_component(&distro).to_ascii_lowercase())
7695        } else {
7696            std::env::consts::OS.to_string()
7697        }
7698    })
7699    .clone()
7700}
7701
7702fn is_wsl_runtime() -> bool {
7703    if !cfg!(target_os = "linux") {
7704        return false;
7705    }
7706    if std::env::var_os("WSL_DISTRO_NAME").is_some() || std::env::var_os("WSL_INTEROP").is_some() {
7707        return true;
7708    }
7709    // Fall back to /proc/version marker used by Microsoft WSL kernels.
7710    fs::read_to_string("/proc/version")
7711        .map(|content| {
7712            let lower = content.to_ascii_lowercase();
7713            lower.contains("microsoft") || lower.contains("wsl")
7714        })
7715        .unwrap_or(false)
7716}
7717
7718/// Device id scoped by runtime so Windows and WSL can share one xbp config home
7719/// without looking like the same upload source.
7720fn worktree_device_hardware_id(base_hardware_id: &str) -> String {
7721    format!("{base_hardware_id}@{}", worktree_runtime_key())
7722}
7723
7724fn ensure_mutation_event_identity(mut event: WorktreeMutationEvent) -> WorktreeMutationEvent {
7725    if event.fingerprint.as_ref().is_none_or(|value| value.is_empty()) {
7726        let fingerprint = mutation_event_fingerprint(&event);
7727        event.fingerprint = Some(fingerprint.clone());
7728        if event.id.is_empty() || !event.id.starts_with("wtmut_") {
7729            event.id = stable_event_id(&fingerprint);
7730        }
7731    } else if event.id.is_empty() {
7732        event.id = stable_event_id(event.fingerprint.as_deref().unwrap_or_default());
7733    }
7734    event
7735}
7736
7737fn ensure_commit_event_identity(mut commit: WorktreeCommitEvent) -> WorktreeCommitEvent {
7738    if commit.fingerprint.as_ref().is_none_or(|value| value.is_empty()) {
7739        let fingerprint = commit_event_fingerprint(&commit);
7740        commit.fingerprint = Some(fingerprint.clone());
7741        if commit.id.is_empty() || !commit.id.starts_with("wtmut_") {
7742            commit.id = stable_event_id(&fingerprint);
7743        }
7744    } else if commit.id.is_empty() {
7745        commit.id = stable_event_id(commit.fingerprint.as_deref().unwrap_or_default());
7746    }
7747    commit
7748}
7749
7750fn path_string_has_ignored_component(path: &str) -> bool {
7751    // No project root available; apply global + built-in rules only.
7752    watch_ignore_rules().ignores_relative(path)
7753}
7754
7755fn is_skipped_discovery_dir(path: &Path) -> bool {
7756    watch_ignore_rules().skips_discovery_dir(path)
7757}
7758
7759/// Relative path from `root` to `path` with `/` separators.
7760///
7761/// Uses `Path::strip_prefix` when possible, then falls back to slash-normalized
7762/// string prefix matching so Windows-style absolute paths still work when the
7763/// binary runs on Unix (WSL tests / mixed path sources from notify).
7764fn relative_watch_path(root: &Path, path: &Path) -> Option<String> {
7765    if let Ok(relative) = path.strip_prefix(root) {
7766        return Some(normalize_relative_watch_path(&relative.to_string_lossy()));
7767    }
7768
7769    let root_norm = normalize_absolute_watch_path(root);
7770    let path_norm = normalize_absolute_watch_path(path);
7771    if path_norm == root_norm {
7772        return Some(String::new());
7773    }
7774    let prefix = format!("{root_norm}/");
7775    path_norm
7776        .strip_prefix(&prefix)
7777        .map(|relative| normalize_relative_watch_path(relative))
7778}
7779
7780fn normalize_absolute_watch_path(path: &Path) -> String {
7781    let mut value = path.to_string_lossy().replace('\\', "/");
7782    // Drop Windows verbatim prefix if present after slash normalization.
7783    if let Some(stripped) = value.strip_prefix("//?/") {
7784        value = stripped.to_string();
7785    }
7786    // Case-fold drive letter for stable prefix matching (C: vs c:).
7787    if value.len() >= 2 {
7788        let bytes = value.as_bytes();
7789        if bytes[1] == b':' && bytes[0].is_ascii_alphabetic() {
7790            let mut chars = value.chars();
7791            let drive = chars.next().unwrap().to_ascii_lowercase();
7792            value = format!("{drive}{}", chars.as_str());
7793        }
7794    }
7795    value.trim_end_matches('/').to_string()
7796}
7797
7798fn relative_slash_path(root: &Path, path: &Path) -> Option<String> {
7799    relative_watch_path(root, path).or_else(|| {
7800        Some(normalize_relative_watch_path(
7801            &path.to_string_lossy().replace('\\', "/"),
7802        ))
7803    })
7804}
7805
7806fn is_ignored_path(root: &Path, path: &Path) -> bool {
7807    watch_ignore_rules_for_root(root).ignores_path(root, path)
7808}
7809
7810fn current_hostname() -> Option<String> {
7811    for key in ["COMPUTERNAME", "HOSTNAME", "NAME"] {
7812        if let Ok(value) = std::env::var(key) {
7813            let trimmed = value.trim();
7814            if !trimmed.is_empty() {
7815                return Some(trimmed.to_string());
7816            }
7817        }
7818    }
7819
7820    if let Ok(content) = fs::read_to_string("/etc/hostname") {
7821        let trimmed = content.trim();
7822        if !trimmed.is_empty() {
7823            return Some(trimmed.to_string());
7824        }
7825    }
7826
7827    // WSL often has a Linux hostname distinct from the Windows host; prefer it when set.
7828    if let Ok(output) = Command::new("hostname").output() {
7829        if output.status.success() {
7830            let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
7831            if !value.is_empty() {
7832                return Some(value);
7833            }
7834        }
7835    }
7836
7837    None
7838}
7839
7840fn is_background_child() -> bool {
7841    std::env::var(BACKGROUND_CHILD_ENV)
7842        .ok()
7843        .map(|value| value == "1")
7844        .unwrap_or(false)
7845}
7846
7847#[allow(dead_code)]
7848fn content_sha256(path: &Path) -> Option<String> {
7849    let bytes = fs::read(path).ok()?;
7850    let mut hasher = Sha256::new();
7851    hasher.update(bytes);
7852    Some(format!("{:x}", hasher.finalize()))
7853}
7854
7855#[cfg(test)]
7856mod tests {
7857    use super::*;
7858    use std::io::Write;
7859
7860    #[test]
7861    fn remove_all_watcher_state_files_clears_legacy_and_runtime() {
7862        let dir = std::env::temp_dir().join(format!(
7863            "xbp-watcher-state-test-{}",
7864            std::process::id()
7865        ));
7866        let _ = fs::remove_dir_all(&dir);
7867        fs::create_dir_all(&dir).expect("temp dir");
7868        let primary = dir.join("watcher-state-windows.json");
7869        let legacy = dir.join("watcher-state.json");
7870        let wsl = dir.join("watcher-state-wsl-ubuntu.json");
7871        for path in [&primary, &legacy, &wsl] {
7872            let mut f = fs::File::create(path).expect("create");
7873            writeln!(f, "{{}}").expect("write");
7874        }
7875        let removed = remove_all_watcher_state_files(&primary);
7876        assert!(removed >= 3, "removed={removed}");
7877        assert!(!primary.exists());
7878        assert!(!legacy.exists());
7879        assert!(!wsl.exists());
7880        assert_eq!(remove_all_watcher_state_files(&primary), 0);
7881        let _ = fs::remove_dir_all(&dir);
7882    }
7883
7884    #[test]
7885    fn parses_https_and_ssh_remote_urls() {
7886        assert_eq!(
7887            parse_remote_owner_repo("https://github.com/xylex-group/xbp.git"),
7888            Some(("xylex-group".to_string(), "xbp".to_string()))
7889        );
7890        assert_eq!(
7891            parse_remote_owner_repo("git@github.com:xylex-group/xbp.git"),
7892            Some(("xylex-group".to_string(), "xbp".to_string()))
7893        );
7894    }
7895
7896    #[test]
7897    fn sanitizes_branch_for_storage_path() {
7898        assert_eq!(
7899            sanitize_path_component("feature/worktree watcher"),
7900            "feature-worktree-watcher"
7901        );
7902    }
7903
7904    #[test]
7905    fn normalizes_windows_verbatim_repo_roots() {
7906        assert_eq!(
7907            normalize_windows_verbatim_path(PathBuf::from(
7908                r"\\?\C:\Users\floris\Documents\GitHub\mollie-api-rust"
7909            )),
7910            PathBuf::from(r"C:\Users\floris\Documents\GitHub\mollie-api-rust")
7911        );
7912    }
7913
7914    #[test]
7915    fn parent_path_matching_uses_repo_boundaries() {
7916        let repo = Path::new(r"C:\Users\floris\Documents\GitHub\xbp");
7917        assert!(path_belongs_to_repo(
7918            Path::new(r"C:\Users\floris\Documents\GitHub\xbp\src\main.rs"),
7919            repo
7920        ));
7921        assert!(!path_belongs_to_repo(
7922            Path::new(r"C:\Users\floris\Documents\GitHub\xbp-other\src\main.rs"),
7923            repo
7924        ));
7925    }
7926
7927    #[test]
7928    fn adaptive_tiers_follow_idle_thresholds() {
7929        let cfg = WorktreeWatchConfig {
7930            idle_after_seconds: Some(300),
7931            cold_after_seconds: Some(1800),
7932            ..Default::default()
7933        };
7934        assert_eq!(tier_from_idle(0, &cfg), RepoWatchTier::Hot);
7935        assert_eq!(tier_from_idle(299, &cfg), RepoWatchTier::Hot);
7936        assert_eq!(tier_from_idle(300, &cfg), RepoWatchTier::Warm);
7937        assert_eq!(tier_from_idle(1799, &cfg), RepoWatchTier::Warm);
7938        assert_eq!(tier_from_idle(1800, &cfg), RepoWatchTier::Cold);
7939        assert!(cfg.hot_commit_check_seconds() < cfg.warm_commit_check_seconds());
7940        assert!(cfg.warm_commit_check_seconds() < cfg.cold_commit_check_seconds());
7941    }
7942
7943    #[test]
7944    fn default_resource_knobs_are_tighter() {
7945        let cfg = WorktreeWatchConfig::default();
7946        assert_eq!(cfg.idle_after_seconds(), 180);
7947        assert_eq!(cfg.cold_after_seconds(), 600);
7948        assert_eq!(cfg.max_hot_repos(), 4);
7949        assert_eq!(cfg.max_fs_watches(), 6);
7950        assert_eq!(cfg.max_commit_checks_per_tick(), 6);
7951        assert_eq!(cfg.warm_commit_check_seconds(), 120);
7952        assert_eq!(cfg.cold_commit_check_seconds(), 300);
7953        assert_eq!(cfg.spool_retention_days(), 7);
7954        assert_eq!(cfg.parent_rescan_seconds(), 300);
7955    }
7956
7957    #[test]
7958    fn fs_watch_budget_prefers_hot_and_caps() {
7959        let base = std::env::temp_dir().join(format!("xbp-fs-budget-{}", Uuid::new_v4()));
7960        let dir_a = base.join("a");
7961        let dir_b = base.join("b");
7962        let dir_c = base.join("c");
7963        let dir_d = base.join("d");
7964        for dir in [&dir_a, &dir_b, &dir_c, &dir_d] {
7965            fs::create_dir_all(dir).unwrap();
7966        }
7967        let now = Instant::now();
7968        let mk = |root: &Path, tier: RepoWatchTier, score: f64| -> WatchedRepo {
7969            WatchedRepo {
7970                identity: RepoIdentity {
7971                    owner: "o".into(),
7972                    name: root
7973                        .file_name()
7974                        .and_then(|n| n.to_str())
7975                        .unwrap_or("r")
7976                        .to_string(),
7977                    branch: "main".into(),
7978                    root: root.to_path_buf(),
7979                    head_sha: None,
7980                },
7981                layout: spool_layout_for_root(root.join(".xbp-spool-test"), None),
7982                last_head: None,
7983                event_dedupe: RecentEventDedupe::default(),
7984                last_activity: now,
7985                activity_score: score,
7986                tier,
7987                fs_watched: false,
7988                next_commit_check: now,
7989            }
7990        };
7991        let repos = vec![
7992            mk(&dir_a, RepoWatchTier::Hot, 1.0),
7993            mk(&dir_b, RepoWatchTier::Warm, 50.0),
7994            mk(&dir_c, RepoWatchTier::Warm, 40.0),
7995            mk(&dir_d, RepoWatchTier::Cold, 99.0),
7996        ];
7997        let want = fs_watch_want_mask(&repos, 2);
7998        let _ = fs::remove_dir_all(&base);
7999        assert!(want[0], "hot always preferred");
8000        assert!(want[1], "highest warm fills remaining budget");
8001        assert!(!want[2], "third non-cold loses budget");
8002        assert!(!want[3], "cold never watches");
8003    }
8004
8005    #[test]
8006    fn prune_spool_removes_old_seen_and_synced() {
8007        let temp = std::env::temp_dir().join(format!("xbp-prune-{}", Uuid::new_v4()));
8008        let _ = fs::remove_dir_all(&temp);
8009        fs::create_dir_all(&temp).unwrap();
8010        let layout = spool_layout_for_root(temp.clone(), None);
8011        fs::create_dir_all(&layout.event_dedupe_dir).unwrap();
8012        fs::create_dir_all(&layout.commit_dedupe_dir).unwrap();
8013        let old_seen = layout.event_dedupe_dir.join("abc.seen");
8014        let fresh_seen = layout.event_dedupe_dir.join("def.seen");
8015        fs::write(&old_seen, "old").unwrap();
8016        fs::write(&fresh_seen, "new").unwrap();
8017        let synced = layout.root.join("events-1.synced.1.jsonl");
8018        fs::write(&synced, "{}\n").unwrap();
8019        let fat_dedupe = layout.event_dedupe_file.clone();
8020        fs::write(
8021            &fat_dedupe,
8022            vec![b'x'; (MAX_EVENT_DEDUPE_JSONL_BYTES as usize) + 8],
8023        )
8024        .unwrap();
8025
8026        // retention=0: any file with mtime <= now is eligible for removal.
8027        let removed = prune_spool_layout(&layout, Duration::from_secs(0)).unwrap();
8028        let fat_gone =
8029            !fat_dedupe.exists() || fs::metadata(&fat_dedupe).map(|m| m.len()).unwrap_or(0) == 0;
8030        let _ = fs::remove_dir_all(&temp);
8031        assert!(removed >= 3, "expected seen+synced+rotate, got {removed}");
8032        assert!(fat_gone, "oversized dedupe log should be rotated or truncated");
8033    }
8034
8035    #[test]
8036    fn load_event_dedupe_does_not_read_file() {
8037        let temp = std::env::temp_dir().join(format!("xbp-dedupe-load-{}", Uuid::new_v4()));
8038        let _ = fs::remove_dir_all(&temp);
8039        fs::create_dir_all(&temp).unwrap();
8040        let path = temp.join("event-dedupe.jsonl");
8041        // Would be expensive if fully parsed into a set at start.
8042        let line = r#"{"fingerprint":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}"#;
8043        let mut body = String::new();
8044        for _ in 0..20_000 {
8045            body.push_str(line);
8046            body.push('\n');
8047        }
8048        fs::write(&path, body).unwrap();
8049        let set = load_event_dedupe_fingerprints(&path).unwrap();
8050        let _ = fs::remove_dir_all(&temp);
8051        assert!(set.is_empty());
8052    }
8053
8054    #[test]
8055    fn activity_score_bumps_and_caps() {
8056        let mut score = 0.0;
8057        for _ in 0..20 {
8058            score = bump_activity_score(score);
8059        }
8060        assert!(score > 5.0);
8061        let capped = bump_activity_score(10_000.0);
8062        assert!(capped <= 10_000.0);
8063    }
8064
8065    #[test]
8066    fn truncate_middle_keeps_ends() {
8067        let s = truncate_middle("owner/very-long-repository-name-here", 20);
8068        assert!(s.chars().count() <= 20);
8069        assert!(s.contains('…') || s.len() <= 20);
8070    }
8071
8072    #[test]
8073    fn rank_index_keys_by_path_identity() {
8074        let cfg = WorktreeWatchConfig {
8075            repo_ranks: vec![WorktreeWatchRepoRank {
8076                root: r"C:\Users\floris\Documents\GitHub\xbp".to_string(),
8077                owner: Some("x".into()),
8078                name: Some("xbp".into()),
8079                score: 3.5,
8080                last_event_at: None,
8081                rank: Some(1),
8082                tier: Some("hot".into()),
8083            }],
8084            ..Default::default()
8085        };
8086        let index = build_repo_rank_index(&cfg);
8087        let key = path_identity_key(Path::new(r"C:\Users\floris\Documents\GitHub\xbp"));
8088        assert!(index.contains_key(&key));
8089        assert_eq!(index.get(&key).map(|r| r.score), Some(3.5));
8090    }
8091
8092    #[test]
8093    fn skips_heavy_discovery_directories() {
8094        assert!(is_skipped_discovery_dir(Path::new("node_modules")));
8095        assert!(is_skipped_discovery_dir(Path::new("target")));
8096        assert!(is_skipped_discovery_dir(Path::new("dist")));
8097        assert!(is_skipped_discovery_dir(Path::new(".next")));
8098        assert!(is_skipped_discovery_dir(Path::new(".nx")));
8099        assert!(is_skipped_discovery_dir(Path::new(".xbp")));
8100        assert!(is_skipped_discovery_dir(Path::new(".cargo")));
8101        assert!(is_skipped_discovery_dir(Path::new(".codex")));
8102        assert!(is_skipped_discovery_dir(Path::new(".cursor")));
8103        assert!(is_skipped_discovery_dir(Path::new(".agents")));
8104        assert!(is_skipped_discovery_dir(Path::new(".idea")));
8105        assert!(is_skipped_discovery_dir(Path::new(".github")));
8106        assert!(is_skipped_discovery_dir(Path::new(".postman")));
8107        assert!(is_skipped_discovery_dir(Path::new("mcps")));
8108        assert!(is_skipped_discovery_dir(Path::new("agent-tools")));
8109        assert!(is_skipped_discovery_dir(Path::new("target-publish")));
8110        assert!(is_skipped_discovery_dir(Path::new("__pycache__")));
8111        assert!(is_skipped_discovery_dir(Path::new("terminals")));
8112        assert!(is_skipped_discovery_dir(Path::new(".open-next")));
8113        assert!(!is_skipped_discovery_dir(Path::new("mollie-api-rust")));
8114    }
8115
8116    #[test]
8117    fn ignores_nested_generated_mutation_paths() {
8118        // Windows-style absolute paths must work even when tests run on Unix/WSL
8119        // (Path::strip_prefix does not treat `\` as a separator on Unix).
8120        let root = Path::new(r"C:\Users\floris\Documents\GitHub\athena");
8121        assert!(is_ignored_path(
8122            root,
8123            Path::new(
8124                r"C:\Users\floris\Documents\GitHub\athena\apps\docs\node_modules\@aws-sdk\client-lambda\dist-types\schemas"
8125            )
8126        ));
8127        assert!(is_ignored_path(
8128            root,
8129            Path::new(r"C:\Users\floris\Documents\GitHub\athena\target\debug\build")
8130        ));
8131        // Nx cache and OpenNext build output anywhere under the repo.
8132        assert!(is_ignored_path(
8133            root,
8134            Path::new(r"C:\Users\floris\Documents\GitHub\athena\.nx\cache\run.json")
8135        ));
8136        assert!(is_ignored_path(
8137            root,
8138            Path::new(
8139                r"C:\Users\floris\Documents\GitHub\athena\apps\docs\.open-next\server-functions\default\handler.mjs"
8140            )
8141        ));
8142        assert!(!is_ignored_path(
8143            root,
8144            Path::new(r"C:\Users\floris\Documents\GitHub\athena\apps\web\src\main.ts")
8145        ));
8146
8147        // Forward-slash form (notify / mixed hosts) and relative component checks.
8148        let root_slash = Path::new("C:/Users/floris/Documents/GitHub/athena");
8149        assert!(is_ignored_path(
8150            root_slash,
8151            Path::new(
8152                "C:/Users/floris/Documents/GitHub/athena/apps/docs/node_modules/@aws-sdk/client-lambda/dist-types/schemas"
8153            )
8154        ));
8155        assert!(path_string_has_ignored_component(
8156            "apps/docs/node_modules/@aws-sdk/client-lambda/dist-types/schemas"
8157        ));
8158    }
8159
8160    #[test]
8161    fn global_config_forbidden_paths_folders_and_banned_words() {
8162        let rules = WatchIgnoreRules::from_config(&WorktreeWatchConfig {
8163            forbidden_paths: vec!["secrets/".to_string(), "apps/web/.env.local".to_string()],
8164            forbidden_folders: vec![".cache".to_string(), "coverage".to_string()],
8165            banned_words: vec!["private-key".to_string(), "CREDENTIAL".to_string()],
8166            ..Default::default()
8167        });
8168
8169        // Nested under a watched tree still blocked by prefix.
8170        assert!(rules.ignores_relative("secrets/prod/api.key"));
8171        assert!(rules.ignores_relative("apps/web/.env.local"));
8172        // Exact file ban is not a string-prefix of other filenames.
8173        assert!(!rules.ignores_relative("apps/web/.env.local.bak"));
8174
8175        // Folder name anywhere
8176        assert!(rules.ignores_relative("apps/web/.cache/tmp"));
8177        assert!(rules.ignores_relative("packages/foo/coverage/index.html"));
8178
8179        // Banned word (case-insensitive substring)
8180        assert!(rules.ignores_relative("docs/private-key-notes.md"));
8181        assert!(rules.ignores_relative("config/my-credential-store.json"));
8182
8183        // Still allow normal source
8184        assert!(!rules.ignores_relative("apps/web/src/main.ts"));
8185        assert!(!rules.skips_discovery_dir(Path::new("mollie-api-rust")));
8186        assert!(rules.skips_discovery_dir(Path::new(".cache")));
8187        assert!(rules.skips_discovery_dir(Path::new("node_modules"))); // built-in
8188    }
8189
8190    #[test]
8191    fn global_worktree_ignore_file_honors_mcps_patterns() {
8192        let root = std::env::temp_dir().join(format!(
8193            "xbp-global-watch-ignore-{}-{}",
8194            std::process::id(),
8195            std::time::SystemTime::now()
8196                .duration_since(std::time::UNIX_EPOCH)
8197                .expect("time")
8198                .as_nanos()
8199        ));
8200        let _ = fs::remove_dir_all(&root);
8201        fs::create_dir_all(&root).expect("global root");
8202
8203        let ignore_path = root.join(crate::utils::GLOBAL_WORKTREE_IGNORE_FILENAME);
8204        fs::write(&ignore_path, "mcps/\n/custom-global/\n").expect("write global ignore");
8205
8206        let rules = WatchIgnoreRules {
8207            forbidden_path_prefixes: Vec::new(),
8208            forbidden_folders: BTreeSet::new(),
8209            banned_words: Vec::new(),
8210            global_ignore: crate::utils::load_global_worktree_ignore_from_path(&ignore_path),
8211            project_ignore: crate::utils::XbpIgnoreSet::empty(),
8212        };
8213
8214        assert!(rules.ignores_relative("mcps/github/catalog.json"));
8215        assert!(rules.ignores_relative("apps/api/mcps/local"));
8216        assert!(rules.ignores_relative("custom-global/notes.md"));
8217        assert!(!rules.ignores_relative("apps/web/src/main.ts"));
8218        assert!(rules.skips_discovery_dir(Path::new("mcps")));
8219
8220        let _ = fs::remove_dir_all(root);
8221    }
8222
8223    #[test]
8224    fn project_xbpignore_is_honored_by_worktree_watch_rules() {
8225        let root = std::env::temp_dir().join(format!(
8226            "xbp-watch-ignore-{}-{}",
8227            std::process::id(),
8228            std::time::SystemTime::now()
8229                .duration_since(std::time::UNIX_EPOCH)
8230                .expect("time")
8231                .as_nanos()
8232        ));
8233        let _ = fs::remove_dir_all(&root);
8234        fs::create_dir_all(root.join(".xbp")).expect("xbp dir");
8235        fs::write(
8236            root.join(".xbp").join(".xbpignore"),
8237            "packages/icons/\n*.generated.ts\n",
8238        )
8239        .expect("write ignore");
8240        fs::write(
8241            root.join(".xbp").join("xbp.yaml"),
8242            "project_name: demo\nversion: 0.1.0\nport: 3000\nbuild_dir: ./\nignore_paths:\n  - e2e/\nwatch_ignore_paths:\n  - tmp-watch/\n",
8243        )
8244        .expect("write config");
8245
8246        let rules = WatchIgnoreRules::from_config_and_project(
8247            &WorktreeWatchConfig::default(),
8248            Some(&root),
8249        );
8250        // `.xbpignore` still applies to worktree-watch
8251        assert!(rules.ignores_relative("packages/icons/src/index.ts"));
8252        assert!(rules.ignores_relative("apps/web/foo.generated.ts"));
8253        // Discovery-only `ignore_paths` must NOT silence worktree-watch
8254        assert!(!rules.ignores_relative("e2e/login.spec.ts"));
8255        // Explicit `watch_ignore_paths` do apply
8256        assert!(rules.ignores_relative("tmp-watch/file.ts"));
8257        assert!(!rules.ignores_relative("apps/web/src/main.ts"));
8258
8259        let _ = fs::remove_dir_all(root);
8260    }
8261
8262    #[test]
8263    fn builds_coding_stats_by_file_and_filetype() {
8264        let identity = RepoIdentity {
8265            owner: "xylex-group".to_string(),
8266            name: "xbp".to_string(),
8267            branch: "main".to_string(),
8268            root: PathBuf::from(r"C:\Users\floris\Documents\GitHub\xbp"),
8269            head_sha: None,
8270        };
8271        let layout = SpoolLayout {
8272            root: PathBuf::from(r"C:\Users\floris\.xbp\mutations\xylex-group\xbp\main"),
8273            events_file: PathBuf::from("events.jsonl"),
8274            commits_file: PathBuf::from("commits.jsonl"),
8275            stats_file: PathBuf::from("stats.json"),
8276            watcher_state_file: PathBuf::from("watcher-state.json"),
8277            sync_log_file: PathBuf::from("sync-log.jsonl"),
8278            event_dedupe_file: PathBuf::from("event-dedupe.jsonl"),
8279            event_dedupe_dir: PathBuf::from("event-dedupe"),
8280            commit_dedupe_dir: PathBuf::from("commit-dedupe"),
8281        };
8282        let candidates = vec![SyncCandidate {
8283            path: PathBuf::from("events.jsonl"),
8284            records: SyncRecords::Events(vec![
8285                stats_test_event_record("2026-07-08T10:00:00Z", "src/main.rs", 4, 1),
8286                stats_test_event_record("2026-07-08T10:05:00Z", "src/main.rs", 2, 0),
8287                stats_test_event_record("2026-07-08T11:00:00Z", "README.md", 1, 3),
8288            ]),
8289        }];
8290
8291        let summary = build_stats_summary(&identity, &layout, &candidates, 15).unwrap();
8292
8293        assert_eq!(summary.total_events, 3);
8294        assert_eq!(summary.estimated_coding_seconds, 60 + 300 + 900);
8295        assert_eq!(summary.session_count, 2);
8296        assert_eq!(summary.observed_span_seconds, 60 * 60);
8297        assert_eq!(summary.added_lines, 7);
8298        assert_eq!(summary.removed_lines, 4);
8299        assert_eq!(summary.by_file[0].path, "README.md");
8300        assert_eq!(summary.by_file[0].estimated_coding_seconds, 900);
8301        assert_eq!(summary.by_filetype[0].name, "md");
8302        assert_eq!(summary.by_filetype[0].estimated_coding_seconds, 900);
8303        assert_eq!(summary.by_filetype[1].name, "rs");
8304        assert_eq!(summary.by_filetype[1].estimated_coding_seconds, 360);
8305    }
8306
8307    #[test]
8308    fn builds_repo_activity_across_branch_spools() {
8309        let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
8310        let main_root = temp_root.join("main");
8311        let feature_root = temp_root.join("feature-login");
8312        fs::create_dir_all(&main_root).unwrap();
8313        fs::create_dir_all(&feature_root).unwrap();
8314
8315        append_json_line(
8316            &main_root.join("events-main.jsonl"),
8317            &stats_test_event_record("2026-07-08T10:00:00Z", "src/main.rs", 4, 1),
8318        )
8319        .unwrap();
8320        append_json_line(
8321            &main_root.join("events-main.jsonl"),
8322            &stats_test_event_record("2026-07-08T10:10:00Z", "src/lib.rs", 2, 0),
8323        )
8324        .unwrap();
8325        append_json_line(
8326            &feature_root.join("events-feature.jsonl"),
8327            &stats_test_event_record("2026-07-08T11:00:00Z", "src/auth.rs", 10, 2),
8328        )
8329        .unwrap();
8330        append_json_line(
8331            &feature_root.join("events-feature.jsonl"),
8332            &stats_test_event_record("2026-07-08T11:30:00Z", "src/auth.rs", 3, 1),
8333        )
8334        .unwrap();
8335        append_json_line(
8336            &feature_root.join("commits-feature.jsonl"),
8337            &worktree_commit_test_event("previous", "current"),
8338        )
8339        .unwrap();
8340
8341        let identity = RepoIdentity {
8342            owner: "xylex-group".to_string(),
8343            name: "xbp".to_string(),
8344            branch: "main".to_string(),
8345            root: PathBuf::from(r"C:\Users\floris\Documents\GitHub\xbp"),
8346            head_sha: None,
8347        };
8348
8349        let summary = build_repo_activity_summary(&identity, &temp_root, 15).unwrap();
8350
8351        assert_eq!(summary.branch_count, 2);
8352        assert_eq!(summary.total_events, 4);
8353        assert_eq!(summary.total_commits, 1);
8354        assert_eq!(summary.session_count, 3);
8355        assert_eq!(summary.estimated_coding_seconds, 60 + 600 + 60 + 900);
8356        assert_eq!(summary.observed_span_seconds, 90 * 60);
8357        assert_eq!(summary.branches[0].branch_name, "feature-login");
8358        assert_eq!(summary.branches[0].session_count, 2);
8359        assert_eq!(summary.branches[0].observed_span_seconds, 30 * 60);
8360        assert_eq!(summary.branches[1].branch_name, "main");
8361
8362        let _ = fs::remove_dir_all(temp_root);
8363    }
8364
8365    #[test]
8366    fn mutation_fingerprint_ignores_random_event_id_but_keeps_time_bucket() {
8367        let first: WorktreeMutationEvent = serde_json::from_value(stats_test_event(
8368            "2026-07-08T10:00:00Z",
8369            "src/main.rs",
8370            4,
8371            1,
8372        ))
8373        .unwrap();
8374        let same_bucket: WorktreeMutationEvent = serde_json::from_value(stats_test_event(
8375            "2026-07-08T10:00:00Z",
8376            "src/main.rs",
8377            4,
8378            1,
8379        ))
8380        .unwrap();
8381        let next_bucket: WorktreeMutationEvent = serde_json::from_value(stats_test_event(
8382            "2026-07-08T10:00:06Z",
8383            "src/main.rs",
8384            4,
8385            1,
8386        ))
8387        .unwrap();
8388
8389        assert_eq!(
8390            mutation_event_fingerprint(&first),
8391            mutation_event_fingerprint(&same_bucket)
8392        );
8393        assert_ne!(
8394            mutation_event_fingerprint(&first),
8395            mutation_event_fingerprint(&next_bucket)
8396        );
8397
8398        // Fingerprint is path-root independent: absolute Windows/WSL spellings must not diverge.
8399        let mut windows_root = first.clone();
8400        windows_root.repo_root = r"C:\Users\floris\Documents\GitHub\xbp".to_string();
8401        let mut wsl_root = first.clone();
8402        wsl_root.repo_root = "/mnt/c/Users/floris/Documents/GitHub/xbp".to_string();
8403        assert_eq!(
8404            mutation_event_fingerprint(&windows_root),
8405            mutation_event_fingerprint(&wsl_root)
8406        );
8407
8408        let fingerprint = mutation_event_fingerprint(&first);
8409        assert_eq!(stable_event_id(&fingerprint), format!("wtmut_{fingerprint}"));
8410        assert_eq!(
8411            ensure_mutation_event_identity(first.clone()).id,
8412            stable_event_id(&fingerprint)
8413        );
8414    }
8415
8416    #[test]
8417    fn runtime_key_and_device_id_are_stable_and_scoped() {
8418        let key = worktree_runtime_key();
8419        assert!(!key.is_empty());
8420        assert_eq!(key, worktree_runtime_key());
8421        // On this host: windows | linux | macos | wsl-*
8422        assert!(
8423            key == "windows"
8424                || key == "linux"
8425                || key == "macos"
8426                || key.starts_with("wsl-")
8427                || key == "wsl",
8428            "unexpected runtime key {key}"
8429        );
8430        let device = worktree_device_hardware_id("xbp_hw_test");
8431        assert_eq!(device, format!("xbp_hw_test@{key}"));
8432    }
8433
8434    #[test]
8435    fn should_poll_only_for_wsl_mnt_paths() {
8436        // On non-WSL CI/dev hosts this is always false; the path heuristic itself is cheap.
8437        let mnt = Path::new("/mnt/c/Users/floris/Documents/GitHub/xbp");
8438        let home = Path::new("/home/floris/src/xbp");
8439        if is_wsl_runtime() {
8440            assert!(should_use_poll_watcher(mnt));
8441            assert!(!should_use_poll_watcher(home));
8442        } else {
8443            assert!(!should_use_poll_watcher(mnt));
8444            assert!(!should_use_poll_watcher(home));
8445        }
8446    }
8447
8448    #[test]
8449    fn repository_spool_root_uses_global_xbp_home_mutations() {
8450        let identity = RepoIdentity {
8451            owner: "xylex-group".to_string(),
8452            name: "xbp".to_string(),
8453            branch: "main".to_string(),
8454            root: PathBuf::from("/tmp/xbp"),
8455            head_sha: None,
8456        };
8457        let root = repository_spool_root(&identity).expect("global xbp home");
8458        let rendered = root.to_string_lossy().replace('\\', "/");
8459        assert!(
8460            rendered.contains("/mutations/"),
8461            "expected home-level mutations path, got {rendered}"
8462        );
8463        assert!(
8464            !rendered.contains("/mutations/runtimes/"),
8465            "spool must not be runtime-split when sharing one established home: {rendered}"
8466        );
8467        assert!(
8468            rendered.ends_with("xylex-group/xbp") || rendered.contains("xylex-group/xbp"),
8469            "expected owner/name suffix, got {rendered}"
8470        );
8471        // Must live under the established global root, never the repo path.
8472        assert!(!rendered.contains("/tmp/xbp/"));
8473    }
8474
8475    #[test]
8476    fn cross_platform_path_identity_reconciles_wsl_and_windows() {
8477        let windows = Path::new(r"C:\Users\szymon\.xbp\mutations\SuitsFinance\speedrun-formations\main");
8478        let wsl = Path::new(
8479            "/mnt/c/Users/szymon/.xbp/mutations/SuitsFinance/speedrun-formations/main",
8480        );
8481        assert_eq!(
8482            cross_platform_path_identity_key(windows),
8483            cross_platform_path_identity_key(wsl)
8484        );
8485    }
8486
8487    #[test]
8488    fn command_line_mentions_path_is_case_and_slash_insensitive() {
8489        let cmdline = r#""C:\xbp.exe" worktree-watch start --parent C:\Users\floris\Documents\GitHub"#;
8490        assert!(command_line_mentions_path(
8491            cmdline,
8492            "/mnt/c/users/floris/documents/github"
8493        ));
8494        assert!(command_line_mentions_path(
8495            cmdline,
8496            r"C:\Users\floris\Documents\GitHub"
8497        ));
8498        assert!(command_line_mentions_path(cmdline, "GitHub"));
8499    }
8500
8501    #[test]
8502    fn append_unique_mutation_event_is_idempotent_across_dedupe_instances() {
8503        let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
8504        fs::create_dir_all(&temp_root).unwrap();
8505        let layout = SpoolLayout {
8506            root: temp_root.clone(),
8507            events_file: temp_root.join("events.jsonl"),
8508            commits_file: temp_root.join("commits.jsonl"),
8509            stats_file: temp_root.join("stats.json"),
8510            watcher_state_file: temp_root.join("watcher-state.json"),
8511            sync_log_file: temp_root.join("sync-log.jsonl"),
8512            event_dedupe_file: temp_root.join("event-dedupe.jsonl"),
8513            event_dedupe_dir: temp_root.join("event-dedupe"),
8514            commit_dedupe_dir: temp_root.join("commit-dedupe"),
8515        };
8516        let mutation: WorktreeMutationEvent = serde_json::from_value(stats_test_event(
8517            "2026-07-08T10:00:00Z",
8518            "src/main.rs",
8519            4,
8520            1,
8521        ))
8522        .unwrap();
8523
8524        let first =
8525            append_unique_mutation_event(&layout, &mutation, &mut RecentEventDedupe::default())
8526                .unwrap();
8527        let second =
8528            append_unique_mutation_event(&layout, &mutation, &mut RecentEventDedupe::default())
8529                .unwrap();
8530
8531        assert!(first);
8532        assert!(!second);
8533        let events = read_jsonl_values(&layout.events_file).unwrap();
8534        assert_eq!(events.len(), 1);
8535        assert_eq!(
8536            events[0].get("fingerprint").and_then(Value::as_str),
8537            Some(mutation_event_fingerprint(&mutation).as_str())
8538        );
8539        let _ = fs::remove_dir_all(temp_root);
8540    }
8541
8542    #[test]
8543    fn reads_spool_records_as_typed_events() {
8544        let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
8545        fs::create_dir_all(&temp_root).unwrap();
8546        let events_file = temp_root.join("events.jsonl");
8547        append_json_line(
8548            &events_file,
8549            &stats_test_event_record("2026-07-08T10:00:00Z", "src/main.rs", 4, 1),
8550        )
8551        .unwrap();
8552
8553        let records = read_spool_records(&events_file, SyncFileKind::Events).unwrap();
8554
8555        match records {
8556            SyncRecords::Events(events) => {
8557                assert_eq!(events.len(), 1);
8558                assert_eq!(events[0].primary_path.as_deref(), Some("src/main.rs"));
8559            }
8560            SyncRecords::Commits(_) => panic!("expected typed event records"),
8561        }
8562        let _ = fs::remove_dir_all(temp_root);
8563    }
8564
8565    #[test]
8566    fn read_spool_records_skips_nul_only_padding() {
8567        let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
8568        fs::create_dir_all(&temp_root).unwrap();
8569        let events_file = temp_root.join("events.jsonl");
8570        fs::write(&events_file, vec![0; 4096]).unwrap();
8571
8572        let records = read_spool_records(&events_file, SyncFileKind::Events).unwrap();
8573
8574        match records {
8575            SyncRecords::Events(events) => assert!(events.is_empty()),
8576            SyncRecords::Commits(_) => panic!("expected event records"),
8577        }
8578        let _ = fs::remove_dir_all(temp_root);
8579    }
8580
8581    #[test]
8582    fn read_jsonl_strips_leading_nuls_before_json() {
8583        let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
8584        fs::create_dir_all(&temp_root).unwrap();
8585        let path = temp_root.join("event-dedupe.jsonl");
8586        let mut bytes = vec![0u8; 64];
8587        bytes.extend_from_slice(
8588            br#"{"fingerprint":"abc","firstSeenAt":"2026-07-09T00:00:00Z","eventKind":"modify","primaryPath":".xbp"}"#,
8589        );
8590        bytes.push(b'\n');
8591        fs::write(&path, bytes).unwrap();
8592
8593        let values = read_jsonl_values(&path).unwrap();
8594        assert_eq!(values.len(), 1);
8595        assert_eq!(
8596            values[0].get("fingerprint").and_then(Value::as_str),
8597            Some("abc")
8598        );
8599        let _ = fs::remove_dir_all(temp_root);
8600    }
8601
8602    #[test]
8603    fn read_jsonl_skips_garbage_lines() {
8604        let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
8605        fs::create_dir_all(&temp_root).unwrap();
8606        let path = temp_root.join("mixed.jsonl");
8607        fs::write(
8608            &path,
8609            "not-json\n{\"fingerprint\":\"ok\"}\n\n{\"broken\":\n",
8610        )
8611        .unwrap();
8612
8613        let values = read_jsonl_values(&path).unwrap();
8614        assert_eq!(values.len(), 1);
8615        assert_eq!(
8616            values[0].get("fingerprint").and_then(Value::as_str),
8617            Some("ok")
8618        );
8619        let _ = fs::remove_dir_all(temp_root);
8620    }
8621
8622    #[test]
8623    fn collect_spool_candidates_rewrites_legacy_generated_events() {
8624        let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
8625        fs::create_dir_all(&temp_root).unwrap();
8626        let events_file = temp_root.join("events-legacy.jsonl");
8627        append_json_line(
8628            &events_file,
8629            &stats_test_event_record(
8630                "2026-07-08T10:00:00Z",
8631                "apps/docs/node_modules/@aws-sdk/client-lambda/dist-types/schemas",
8632                0,
8633                0,
8634            ),
8635        )
8636        .unwrap();
8637        append_json_line(
8638            &events_file,
8639            &stats_test_event_record("2026-07-08T10:01:00Z", "apps/web/src/main.ts", 4, 1),
8640        )
8641        .unwrap();
8642        let layout = SpoolLayout {
8643            root: temp_root.clone(),
8644            events_file: temp_root.join("events.jsonl"),
8645            commits_file: temp_root.join("commits.jsonl"),
8646            stats_file: temp_root.join("stats.json"),
8647            watcher_state_file: temp_root.join("watcher-state.json"),
8648            sync_log_file: temp_root.join("sync-log.jsonl"),
8649            event_dedupe_file: temp_root.join("event-dedupe.jsonl"),
8650            event_dedupe_dir: temp_root.join("event-dedupe"),
8651            commit_dedupe_dir: temp_root.join("commit-dedupe"),
8652        };
8653
8654        let candidates = collect_spool_candidates(&layout, false).unwrap();
8655
8656        assert_eq!(candidates.len(), 1);
8657        match &candidates[0].records {
8658            SyncRecords::Events(events) => {
8659                assert_eq!(events.len(), 1);
8660                assert_eq!(
8661                    events[0].primary_path.as_deref(),
8662                    Some("apps/web/src/main.ts")
8663                );
8664            }
8665            SyncRecords::Commits(_) => panic!("expected event spool candidate"),
8666        }
8667        let rewritten = read_jsonl_records::<WorktreeMutationEvent>(&events_file).unwrap();
8668        assert_eq!(rewritten.len(), 1);
8669        assert_eq!(
8670            rewritten[0].primary_path.as_deref(),
8671            Some("apps/web/src/main.ts")
8672        );
8673        let _ = fs::remove_dir_all(temp_root);
8674    }
8675
8676    #[test]
8677    fn collect_spool_candidates_deletes_fully_ignored_event_files() {
8678        let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
8679        fs::create_dir_all(&temp_root).unwrap();
8680        let events_file = temp_root.join("events-legacy.jsonl");
8681        append_json_line(
8682            &events_file,
8683            &stats_test_event_record(
8684                "2026-07-08T10:00:00Z",
8685                "apps/docs/node_modules/@aws-sdk/client-lambda/dist-types/schemas",
8686                0,
8687                0,
8688            ),
8689        )
8690        .unwrap();
8691        let layout = SpoolLayout {
8692            root: temp_root.clone(),
8693            events_file: temp_root.join("events.jsonl"),
8694            commits_file: temp_root.join("commits.jsonl"),
8695            stats_file: temp_root.join("stats.json"),
8696            watcher_state_file: temp_root.join("watcher-state.json"),
8697            sync_log_file: temp_root.join("sync-log.jsonl"),
8698            event_dedupe_file: temp_root.join("event-dedupe.jsonl"),
8699            event_dedupe_dir: temp_root.join("event-dedupe"),
8700            commit_dedupe_dir: temp_root.join("commit-dedupe"),
8701        };
8702
8703        let candidates = collect_spool_candidates(&layout, false).unwrap();
8704
8705        assert!(candidates.is_empty());
8706        assert!(!events_file.exists());
8707        let _ = fs::remove_dir_all(temp_root);
8708    }
8709
8710    #[test]
8711    fn append_unique_commit_event_is_idempotent_across_watcher_instances() {
8712        let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
8713        fs::create_dir_all(&temp_root).unwrap();
8714        let layout = SpoolLayout {
8715            root: temp_root.clone(),
8716            events_file: temp_root.join("events.jsonl"),
8717            commits_file: temp_root.join("commits.jsonl"),
8718            stats_file: temp_root.join("stats.json"),
8719            watcher_state_file: temp_root.join("watcher-state.json"),
8720            sync_log_file: temp_root.join("sync-log.jsonl"),
8721            event_dedupe_file: temp_root.join("event-dedupe.jsonl"),
8722            event_dedupe_dir: temp_root.join("event-dedupe"),
8723            commit_dedupe_dir: temp_root.join("commit-dedupe"),
8724        };
8725        let commit = WorktreeCommitEvent {
8726            id: Uuid::new_v4().to_string(),
8727            fingerprint: None,
8728            repo_owner: "xylex-group".to_string(),
8729            repo_name: "xbp".to_string(),
8730            branch_name: "main".to_string(),
8731            repo_root: r"C:\Users\floris\Documents\GitHub\xbp".to_string(),
8732            previous_head_sha: Some("previous".to_string()),
8733            head_sha: "current".to_string(),
8734            subject: Some("test commit".to_string()),
8735            author_name: Some("Floris".to_string()),
8736            author_email: Some("floris@xylex.group".to_string()),
8737            committed_at: Some("2026-07-08T10:00:00Z".to_string()),
8738            occurred_at: Utc::now(),
8739            files_changed: None,
8740            files_changed_count: None,
8741            insertions: None,
8742            deletions: None,
8743            is_merge: None,
8744            included_commits: None,
8745        };
8746
8747        assert!(append_unique_commit_event(&layout, &commit).unwrap());
8748        assert!(!append_unique_commit_event(&layout, &commit).unwrap());
8749        let commits = read_jsonl_values(&layout.commits_file).unwrap();
8750        assert_eq!(commits.len(), 1);
8751        assert_eq!(
8752            commits[0].get("fingerprint").and_then(Value::as_str),
8753            Some(commit_event_fingerprint(&commit).as_str())
8754        );
8755        let _ = fs::remove_dir_all(temp_root);
8756    }
8757
8758    #[test]
8759    fn splits_worktree_mutation_uploads_into_bounded_batches() {
8760        let events = (0..(WORKTREE_MUTATION_SYNC_BATCH_RECORD_LIMIT + 1))
8761            .map(|index| {
8762                serde_json::from_value::<WorktreeMutationEvent>(stats_test_event(
8763                    "2026-07-08T10:00:00Z",
8764                    &format!("src/file-{index}.rs"),
8765                    1,
8766                    0,
8767                ))
8768                .unwrap()
8769            })
8770            .collect::<Vec<_>>();
8771        let commits = vec![
8772            worktree_commit_test_event("previous-a", "current-a"),
8773            worktree_commit_test_event("previous-b", "current-b"),
8774        ];
8775
8776        let batches = split_worktree_mutation_upload_batches(events, commits);
8777
8778        assert_eq!(batches.len(), 2);
8779        assert_eq!(
8780            batches[0].events.len() + batches[0].commits.len(),
8781            WORKTREE_MUTATION_SYNC_BATCH_RECORD_LIMIT
8782        );
8783        assert_eq!(batches[1].events.len(), 1);
8784        assert_eq!(batches[1].commits.len(), 2);
8785    }
8786
8787    #[test]
8788    fn splits_worktree_mutation_uploads_by_estimated_json_bytes() {
8789        let long_path = format!("src/{}.rs", "x".repeat(8_000));
8790        let events = (0..200)
8791            .map(|index| {
8792                serde_json::from_value::<WorktreeMutationEvent>(stats_test_event(
8793                    "2026-07-08T10:00:00Z",
8794                    &format!("{long_path}-{index}"),
8795                    1,
8796                    0,
8797                ))
8798                .unwrap()
8799            })
8800            .collect::<Vec<_>>();
8801
8802        let batches = split_worktree_mutation_upload_batches(events, Vec::new());
8803
8804        assert!(batches.len() > 1);
8805        for batch in batches {
8806            assert!(
8807                batch.estimated_json_bytes <= WORKTREE_MUTATION_SYNC_BATCH_JSON_BYTES,
8808                "batch estimated JSON bytes exceeded limit: {}",
8809                batch.estimated_json_bytes
8810            );
8811        }
8812    }
8813
8814    #[test]
8815    fn classifies_create_remove_and_rename_events() {
8816        assert_eq!(
8817            classify_event_kind(&EventKind::Create(CreateKind::File)),
8818            "file_create"
8819        );
8820        assert_eq!(
8821            classify_event_kind(&EventKind::Remove(RemoveKind::Folder)),
8822            "folder_remove"
8823        );
8824        assert_eq!(
8825            classify_event_kind(&EventKind::Modify(ModifyKind::Name(RenameMode::Both))),
8826            "rename_or_move"
8827        );
8828    }
8829
8830    #[test]
8831    fn retries_only_retryable_worktree_sync_statuses() {
8832        assert!(should_retry_worktree_mutation_sync_status(
8833            StatusCode::INTERNAL_SERVER_ERROR
8834        ));
8835        assert!(should_retry_worktree_mutation_sync_status(
8836            StatusCode::REQUEST_TIMEOUT
8837        ));
8838        assert!(should_retry_worktree_mutation_sync_status(
8839            StatusCode::TOO_MANY_REQUESTS
8840        ));
8841        assert!(!should_retry_worktree_mutation_sync_status(
8842            StatusCode::BAD_REQUEST
8843        ));
8844        assert!(!should_retry_worktree_mutation_sync_status(
8845            StatusCode::UNAUTHORIZED
8846        ));
8847        assert!(!should_retry_worktree_mutation_sync_status(
8848            StatusCode::PAYLOAD_TOO_LARGE
8849        ));
8850    }
8851
8852    #[test]
8853    fn match_github_repo_inventory_resolves_short_name_and_owner_repo() {
8854        let temp = std::env::temp_dir().join(format!("xbp-repo-lookup-{}", Uuid::new_v4()));
8855        let athena = temp.join("athena");
8856        let other = temp.join("other-athena");
8857        fs::create_dir_all(&athena).unwrap();
8858        fs::create_dir_all(&other).unwrap();
8859
8860        let repos = vec![
8861            GithubRepoRecord {
8862                owner: "xylex-group".to_string(),
8863                repo: "athena".to_string(),
8864                full_name: "xylex-group/athena".to_string(),
8865                path: athena.to_string_lossy().to_string(),
8866                remote_url: "https://github.com/xylex-group/athena.git".to_string(),
8867            },
8868            GithubRepoRecord {
8869                owner: "someone".to_string(),
8870                repo: "other".to_string(),
8871                full_name: "someone/other".to_string(),
8872                path: other.to_string_lossy().to_string(),
8873                remote_url: "https://github.com/someone/other.git".to_string(),
8874            },
8875        ];
8876
8877        let by_short = match_github_repo_inventory(&repos, "athena").unwrap().unwrap();
8878        assert_eq!(
8879            path_identity_key(&by_short),
8880            path_identity_key(athena.as_path())
8881        );
8882
8883        let by_full = match_github_repo_inventory(&repos, "xylex-group/athena")
8884            .unwrap()
8885            .unwrap();
8886        assert_eq!(
8887            path_identity_key(&by_full),
8888            path_identity_key(athena.as_path())
8889        );
8890
8891        assert!(match_github_repo_inventory(&repos, "missing")
8892            .unwrap()
8893            .is_none());
8894
8895        let _ = fs::remove_dir_all(temp);
8896    }
8897
8898    #[test]
8899    fn match_github_repo_inventory_errors_on_ambiguous_short_name() {
8900        let temp = std::env::temp_dir().join(format!("xbp-repo-lookup-ambig-{}", Uuid::new_v4()));
8901        let a = temp.join("a");
8902        let b = temp.join("b");
8903        fs::create_dir_all(&a).unwrap();
8904        fs::create_dir_all(&b).unwrap();
8905
8906        let repos = vec![
8907            GithubRepoRecord {
8908                owner: "one".to_string(),
8909                repo: "dup".to_string(),
8910                full_name: "one/dup".to_string(),
8911                path: a.to_string_lossy().to_string(),
8912                remote_url: "https://github.com/one/dup.git".to_string(),
8913            },
8914            GithubRepoRecord {
8915                owner: "two".to_string(),
8916                repo: "dup".to_string(),
8917                full_name: "two/dup".to_string(),
8918                path: b.to_string_lossy().to_string(),
8919                remote_url: "https://github.com/two/dup.git".to_string(),
8920            },
8921        ];
8922
8923        let err = match_github_repo_inventory(&repos, "dup").unwrap_err();
8924        assert!(err.contains("Ambiguous"));
8925        assert!(err.contains("one/dup") || err.contains(a.to_string_lossy().as_ref()));
8926
8927        let unique = match_github_repo_inventory(&repos, "two/dup").unwrap().unwrap();
8928        assert_eq!(path_identity_key(&unique), path_identity_key(b.as_path()));
8929
8930        let _ = fs::remove_dir_all(temp);
8931    }
8932
8933    fn stats_test_event(
8934        occurred_at: &str,
8935        path: &str,
8936        added_lines: u64,
8937        removed_lines: u64,
8938    ) -> Value {
8939        json!({
8940            "id": Uuid::new_v4().to_string(),
8941            "repoOwner": "xylex-group",
8942            "repoName": "xbp",
8943            "branchName": "main",
8944            "repoRoot": r"C:\Users\floris\Documents\GitHub\xbp",
8945            "headSha": null,
8946            "eventKind": "file_modify",
8947            "paths": [path],
8948            "primaryPath": path,
8949            "oldPath": null,
8950            "newPath": null,
8951            "addedLines": added_lines,
8952            "removedLines": removed_lines,
8953            "totalLines": added_lines + 42,
8954            "fileCreated": false,
8955            "fileRemoved": false,
8956            "folderCreated": false,
8957            "folderRemoved": false,
8958            "renamedOrMoved": false,
8959            "rawKind": "Modify(Data(Content))",
8960            "occurredAt": occurred_at,
8961        })
8962    }
8963
8964    fn stats_test_event_record(
8965        occurred_at: &str,
8966        path: &str,
8967        added_lines: u64,
8968        removed_lines: u64,
8969    ) -> WorktreeMutationEvent {
8970        serde_json::from_value(stats_test_event(
8971            occurred_at,
8972            path,
8973            added_lines,
8974            removed_lines,
8975        ))
8976        .unwrap()
8977    }
8978
8979    fn worktree_commit_test_event(previous_head_sha: &str, head_sha: &str) -> WorktreeCommitEvent {
8980        WorktreeCommitEvent {
8981            id: Uuid::new_v4().to_string(),
8982            fingerprint: None,
8983            repo_owner: "xylex-group".to_string(),
8984            repo_name: "xbp".to_string(),
8985            branch_name: "main".to_string(),
8986            repo_root: r"C:\Users\floris\Documents\GitHub\xbp".to_string(),
8987            previous_head_sha: Some(previous_head_sha.to_string()),
8988            head_sha: head_sha.to_string(),
8989            subject: Some("test commit".to_string()),
8990            author_name: Some("Floris".to_string()),
8991            author_email: Some("floris@xylex.group".to_string()),
8992            committed_at: Some("2026-07-08T10:00:00Z".to_string()),
8993            occurred_at: Utc::now(),
8994            files_changed: None,
8995            files_changed_count: None,
8996            insertions: None,
8997            deletions: None,
8998            is_merge: None,
8999            included_commits: None,
9000        }
9001    }
9002
9003    #[test]
9004    fn enrich_commit_range_parses_numstat_lines() {
9005        // Pure unit coverage of the numstat parser path via a synthetic range
9006        // is environment-dependent; ensure the enrichment struct defaults cleanly.
9007        let empty = CommitRangeEnrichment::default();
9008        assert!(empty.files_changed.is_none());
9009        assert!(empty.insertions.is_none());
9010    }
9011}