Skip to main content

xbp_cli/commands/
worktree_watch.rs

1use crate::commands::cli_session::{cli_request_client, resolve_cli_access_token};
2use crate::config::{resolve_device_identity, ApiConfig};
3use chrono::{DateTime, Utc};
4use notify::event::{CreateKind, ModifyKind, RemoveKind, RenameMode};
5use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
6use reqwest::StatusCode;
7use serde::{Deserialize, Serialize};
8use serde_json::{json, Value};
9use sha2::{Digest, Sha256};
10use std::collections::{BTreeMap, BTreeSet};
11use std::fs::{self, File, OpenOptions};
12use std::io::{BufRead, BufReader, Write};
13use std::path::{Path, PathBuf};
14use std::process::{Command, Stdio};
15use std::sync::mpsc;
16use std::time::{Duration, Instant};
17use sysinfo::{Pid, System};
18use uuid::Uuid;
19
20const BACKGROUND_CHILD_ENV: &str = "XBP_WORKTREE_WATCH_BACKGROUND_CHILD";
21const DEFAULT_REMOTE: &str = "origin";
22const EVENT_DEDUPE_WINDOW_SECONDS: i64 = 5;
23const WORKTREE_MUTATION_SYNC_BATCH_RECORD_LIMIT: usize = 250;
24const WORKTREE_MUTATION_SYNC_BATCH_JSON_BYTES: usize = 768 * 1024;
25const WORKTREE_MUTATION_SYNC_MAX_ATTEMPTS: usize = 3;
26const WORKTREE_MUTATION_SYNC_RETRY_DELAY_SECONDS: u64 = 2;
27
28#[cfg(windows)]
29const CREATE_NO_WINDOW: u32 = 0x08000000;
30
31#[derive(Debug, Clone)]
32pub struct WorktreeWatchTargetOptions {
33    pub repo: Option<PathBuf>,
34    pub parent: Option<PathBuf>,
35}
36
37#[derive(Debug, Clone)]
38pub struct WorktreeWatchStartOptions {
39    pub target: WorktreeWatchTargetOptions,
40    pub detach: bool,
41    pub sync_interval_seconds: u64,
42    pub once: bool,
43}
44
45#[derive(Debug, Clone)]
46pub struct WorktreeWatchSyncOptions {
47    pub target: WorktreeWatchTargetOptions,
48    pub dry_run: bool,
49    pub resync: bool,
50}
51
52#[derive(Debug, Clone)]
53pub struct WorktreeWatchStopOptions {
54    pub target: WorktreeWatchTargetOptions,
55    pub force: bool,
56}
57
58#[derive(Debug, Clone)]
59pub struct WorktreeWatchStatusOptions {
60    pub target: WorktreeWatchTargetOptions,
61    pub json: bool,
62    pub records: bool,
63    pub record_limit: usize,
64    pub stats: bool,
65    pub stats_gap_minutes: u64,
66}
67
68#[derive(Debug, Clone)]
69struct RepoIdentity {
70    owner: String,
71    name: String,
72    branch: String,
73    root: PathBuf,
74    head_sha: Option<String>,
75}
76
77#[derive(Debug, Clone)]
78struct SpoolLayout {
79    root: PathBuf,
80    events_file: PathBuf,
81    commits_file: PathBuf,
82    stats_file: PathBuf,
83    watcher_state_file: PathBuf,
84    sync_log_file: PathBuf,
85    event_dedupe_file: PathBuf,
86    event_dedupe_dir: PathBuf,
87    commit_dedupe_dir: PathBuf,
88}
89
90#[derive(Debug, Clone)]
91struct ParentSpoolLayout {
92    watcher_state_file: PathBuf,
93}
94
95#[derive(Debug)]
96struct WatchedRepo {
97    identity: RepoIdentity,
98    layout: SpoolLayout,
99    last_head: Option<String>,
100    event_dedupe: RecentEventDedupe,
101}
102
103#[derive(Debug, Serialize, Deserialize, Clone)]
104#[serde(rename_all = "camelCase")]
105struct WorktreeMutationEvent {
106    id: String,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    fingerprint: Option<String>,
109    repo_owner: String,
110    repo_name: String,
111    branch_name: String,
112    repo_root: String,
113    head_sha: Option<String>,
114    event_kind: String,
115    paths: Vec<String>,
116    primary_path: Option<String>,
117    old_path: Option<String>,
118    new_path: Option<String>,
119    added_lines: Option<u64>,
120    removed_lines: Option<u64>,
121    total_lines: Option<u64>,
122    file_created: bool,
123    file_removed: bool,
124    folder_created: bool,
125    folder_removed: bool,
126    renamed_or_moved: bool,
127    raw_kind: String,
128    occurred_at: DateTime<Utc>,
129}
130
131#[derive(Debug, Serialize, Deserialize, Clone)]
132#[serde(rename_all = "camelCase")]
133struct WorktreeCommitEvent {
134    id: String,
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    fingerprint: Option<String>,
137    repo_owner: String,
138    repo_name: String,
139    branch_name: String,
140    repo_root: String,
141    previous_head_sha: Option<String>,
142    head_sha: String,
143    subject: Option<String>,
144    author_name: Option<String>,
145    author_email: Option<String>,
146    committed_at: Option<String>,
147    occurred_at: DateTime<Utc>,
148}
149
150#[derive(Debug, Serialize)]
151#[serde(rename_all = "camelCase")]
152struct WorktreeMutationIngestPayload {
153    device: WorktreeMutationDevicePayload,
154    repository: WorktreeMutationRepositoryPayload,
155    events: Vec<WorktreeMutationEvent>,
156    commits: Vec<WorktreeCommitEvent>,
157}
158
159#[derive(Debug, Serialize)]
160#[serde(rename_all = "camelCase")]
161struct WorktreeMutationDevicePayload {
162    hardware_id: String,
163    device_name: Option<String>,
164    hostname: Option<String>,
165    platform: String,
166}
167
168#[derive(Debug, Serialize)]
169#[serde(rename_all = "camelCase")]
170struct WorktreeMutationRepositoryPayload {
171    owner: String,
172    name: String,
173    branch_name: String,
174    repo_root: String,
175}
176
177#[derive(Debug)]
178struct SyncCandidate {
179    path: PathBuf,
180    records: SyncRecords,
181}
182
183#[derive(Debug)]
184struct WorktreeMutationUploadBatch {
185    events: Vec<WorktreeMutationEvent>,
186    commits: Vec<WorktreeCommitEvent>,
187    estimated_json_bytes: usize,
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191enum WorktreeMutationSyncErrorKind {
192    Retryable,
193    NonRetryable,
194}
195
196#[derive(Debug)]
197struct WorktreeMutationSyncError {
198    kind: WorktreeMutationSyncErrorKind,
199    message: String,
200}
201
202struct SyncLogAppend<'a> {
203    identity: &'a RepoIdentity,
204    layout: &'a SpoolLayout,
205    endpoint: &'a str,
206    status_code: u16,
207    event_count: usize,
208    commit_count: usize,
209    candidates: &'a [SyncCandidate],
210    resync: bool,
211}
212
213#[derive(Debug, Clone, Copy)]
214enum SyncFileKind {
215    Events,
216    Commits,
217}
218
219#[derive(Debug)]
220enum SyncRecords {
221    Events(Vec<WorktreeMutationEvent>),
222    Commits(Vec<WorktreeCommitEvent>),
223}
224
225impl SyncRecords {
226    fn len(&self) -> usize {
227        match self {
228            Self::Events(records) => records.len(),
229            Self::Commits(records) => records.len(),
230        }
231    }
232
233    fn is_empty(&self) -> bool {
234        self.len() == 0
235    }
236}
237
238#[derive(Debug, Serialize, Clone)]
239#[serde(rename_all = "camelCase")]
240struct WorktreeWatchStatsSummary {
241    generated_at: DateTime<Utc>,
242    repository_owner: String,
243    repository_name: String,
244    branch_name: String,
245    repo_root: String,
246    spool_root: String,
247    session_gap_minutes: u64,
248    total_events: u64,
249    total_commits: u64,
250    first_event_at: Option<DateTime<Utc>>,
251    last_event_at: Option<DateTime<Utc>>,
252    estimated_coding_seconds: u64,
253    added_lines: u64,
254    removed_lines: u64,
255    by_file: Vec<WorktreeWatchFileStats>,
256    by_filetype: Vec<WorktreeWatchBucketStats>,
257    by_event_kind: Vec<WorktreeWatchBucketStats>,
258}
259
260#[derive(Debug, Serialize, Clone, Default)]
261#[serde(rename_all = "camelCase")]
262struct WorktreeWatchFileStats {
263    path: String,
264    filetype: String,
265    event_count: u64,
266    estimated_coding_seconds: u64,
267    added_lines: u64,
268    removed_lines: u64,
269}
270
271#[derive(Debug, Serialize, Clone, Default)]
272#[serde(rename_all = "camelCase")]
273struct WorktreeWatchBucketStats {
274    name: String,
275    event_count: u64,
276    estimated_coding_seconds: u64,
277    added_lines: u64,
278    removed_lines: u64,
279}
280
281#[derive(Debug, Serialize, Deserialize, Clone)]
282#[serde(rename_all = "camelCase")]
283struct WorktreeWatcherState {
284    pid: u32,
285    repo_root: String,
286    repository_owner: String,
287    repository_name: String,
288    branch_name: String,
289    executable: String,
290    started_at: DateTime<Utc>,
291}
292
293#[derive(Debug, Serialize, Deserialize, Clone)]
294#[serde(rename_all = "camelCase")]
295struct ParentWorktreeWatcherState {
296    pid: u32,
297    parent_root: String,
298    executable: String,
299    started_at: DateTime<Utc>,
300}
301
302#[derive(Debug, Serialize, Deserialize, Clone)]
303#[serde(rename_all = "camelCase")]
304struct WorktreeWatchSyncLogEntry {
305    id: String,
306    synced_at: DateTime<Utc>,
307    endpoint: String,
308    repository_owner: String,
309    repository_name: String,
310    branch_name: String,
311    repo_root: String,
312    resync: bool,
313    status_code: u16,
314    event_count: usize,
315    commit_count: usize,
316    spool_file_count: usize,
317    spool_files: Vec<String>,
318}
319
320#[derive(Debug, Clone, Copy, PartialEq, Eq)]
321enum StopWatcherOutcome {
322    Stopped(u32),
323    RemovedStaleState,
324    NoState,
325}
326
327#[derive(Debug, Serialize, Deserialize, Clone)]
328#[serde(rename_all = "camelCase")]
329struct WorktreeWatchEventDedupeRecord {
330    fingerprint: String,
331    first_seen_at: DateTime<Utc>,
332    event_kind: String,
333    primary_path: Option<String>,
334}
335
336#[derive(Debug, Default)]
337struct RecentEventDedupe {
338    fingerprints: BTreeSet<String>,
339}
340
341pub async fn run_worktree_watch_start(options: WorktreeWatchStartOptions) -> Result<(), String> {
342    if let Some(parent) = options.target.parent.as_deref() {
343        let identities = resolve_target_identities(&options.target)?;
344        if identities.is_empty() {
345            println!("No git repositories found under parent folder.");
346            return Ok(());
347        }
348
349        if options.detach {
350            let path = spawn_detached_parent_worktree_watch(parent)?;
351            println!(
352                "Started one background worktree watcher for {} covering {} repo(s).",
353                path.display(),
354                identities.len()
355            );
356            return Ok(());
357        }
358
359        if options.once {
360            for identity in &identities {
361                let layout = prepare_spool_layout(identity)?;
362                record_commit_snapshot(identity, &layout, None)?;
363                println!("Recorded current git state for {}", identity.root.display());
364            }
365            return Ok(());
366        }
367
368        let parent = canonical_parent_path(parent)?;
369        println!(
370            "Watching {} recursively and routing mutations for {} repo(s).",
371            parent.display(),
372            identities.len()
373        );
374        return watch_parent_foreground(parent, identities, options.sync_interval_seconds).await;
375    }
376
377    if options.detach {
378        return spawn_detached_worktree_watch(options.target.repo.as_deref()).map(|path| {
379            println!("Started background worktree watcher for {}", path.display());
380        });
381    }
382
383    let identity = resolve_repo_identity(options.target.repo.as_deref())?;
384    let layout = prepare_spool_layout(&identity)?;
385    println!(
386        "Watching {} and spooling mutations to {}",
387        identity.root.display(),
388        layout.root.display()
389    );
390
391    if options.once {
392        record_commit_snapshot(&identity, &layout, None)?;
393        return Ok(());
394    }
395
396    watch_foreground(identity, layout, options.sync_interval_seconds).await
397}
398
399pub async fn run_worktree_watch_sync(options: WorktreeWatchSyncOptions) -> Result<(), String> {
400    let identities = resolve_target_identities(&options.target)?;
401    let repo_count = identities.len();
402    let mut plans = Vec::new();
403    let mut total_files = 0usize;
404    let mut total_records = 0usize;
405
406    for identity in identities {
407        let layout = prepare_spool_layout(&identity)?;
408        let candidates = collect_spool_candidates(&layout, options.resync)?;
409        total_files += candidates.len();
410        total_records += candidates
411            .iter()
412            .map(|candidate| candidate.records.len())
413            .sum::<usize>();
414        if !candidates.is_empty() {
415            plans.push((identity, candidates));
416        }
417    }
418
419    if options.dry_run {
420        println!(
421            "Would sync {} record(s) from {} spool file(s) across {} repo(s).",
422            total_records, total_files, repo_count
423        );
424        if options.resync {
425            println!("Resync mode includes files already marked `.synced.*.jsonl`.");
426        }
427        return Ok(());
428    }
429
430    if plans.is_empty() {
431        print_no_sync_candidates_message(&options.target, options.resync)?;
432        return Ok(());
433    }
434
435    let upload_repo_count = plans.len();
436    for (identity, candidates) in plans {
437        sync_candidates(&identity, candidates, options.resync).await?;
438    }
439    println!(
440        "Synced {} worktree mutation record(s) across {} repo(s).",
441        total_records, upload_repo_count
442    );
443    Ok(())
444}
445
446pub fn run_worktree_watch_stop(options: WorktreeWatchStopOptions) -> Result<(), String> {
447    if let Some(parent) = options.target.parent.as_deref() {
448        let parent = canonical_parent_path(parent)?;
449        match stop_existing_parent_watcher(&parent, options.force)? {
450            StopWatcherOutcome::Stopped(pid) => {
451                println!(
452                    "Stopped background parent worktree watcher {} for {}",
453                    pid,
454                    parent.display()
455                );
456            }
457            StopWatcherOutcome::RemovedStaleState => {
458                println!(
459                    "Removed stale parent worktree watcher state for {}",
460                    parent.display()
461                );
462            }
463            StopWatcherOutcome::NoState => {
464                println!("No parent worktree watcher state for {}", parent.display());
465            }
466        }
467    }
468
469    let identities = resolve_target_identities(&options.target)?;
470    if identities.is_empty() {
471        println!("No git repositories found for worktree watcher stop.");
472        return Ok(());
473    }
474
475    let mut stopped = 0usize;
476    let mut stale = 0usize;
477    let mut missing = 0usize;
478    for identity in identities {
479        let layout = prepare_spool_layout(&identity)?;
480        match stop_existing_watcher(&identity, &layout, options.force)? {
481            StopWatcherOutcome::Stopped(pid) => {
482                stopped += 1;
483                println!(
484                    "Stopped background worktree watcher {} for {}",
485                    pid,
486                    identity.root.display()
487                );
488            }
489            StopWatcherOutcome::RemovedStaleState => {
490                stale += 1;
491                println!(
492                    "Removed stale worktree watcher state for {}",
493                    identity.root.display()
494                );
495            }
496            StopWatcherOutcome::NoState => {
497                missing += 1;
498                println!(
499                    "No background worktree watcher state for {}",
500                    identity.root.display()
501                );
502            }
503        }
504    }
505
506    println!(
507        "Worktree watcher stop complete: {stopped} stopped, {stale} stale state file(s) removed, {missing} without state."
508    );
509    Ok(())
510}
511
512pub async fn run_worktree_watch_status(options: WorktreeWatchStatusOptions) -> Result<(), String> {
513    let identities = resolve_target_identities(&options.target)?;
514    let mut payloads = Vec::new();
515    let mut total_files = 0usize;
516    let mut total_records = 0usize;
517
518    for identity in identities {
519        let status =
520            worktree_watch_status_payload(&identity, options.records, options.record_limit)?;
521        let stats = if options.stats {
522            Some(generate_and_store_stats(
523                &identity,
524                options.stats_gap_minutes,
525            )?)
526        } else {
527            None
528        };
529        let status = attach_optional_stats(status, stats)?;
530        total_files += status
531            .get("unsyncedFiles")
532            .and_then(Value::as_u64)
533            .unwrap_or(0) as usize;
534        total_records += status
535            .get("unsyncedRecords")
536            .and_then(Value::as_u64)
537            .unwrap_or(0) as usize;
538        payloads.push(status);
539    }
540
541    if options.json {
542        let payload = if options.target.parent.is_some() {
543            json!({
544                "repositories": payloads,
545                "repositoryCount": payloads.len(),
546                "unsyncedFiles": total_files,
547                "unsyncedRecords": total_records,
548            })
549        } else {
550            payloads.into_iter().next().unwrap_or_else(|| json!({}))
551        };
552        println!(
553            "{}",
554            serde_json::to_string_pretty(&payload)
555                .map_err(|error| format!("Failed to render status JSON: {error}"))?
556        );
557    } else {
558        for (index, payload) in payloads.iter().enumerate() {
559            if index > 0 {
560                println!();
561            }
562            println!(
563                "repo: {}/{}",
564                payload
565                    .get("repositoryOwner")
566                    .and_then(Value::as_str)
567                    .unwrap_or("unknown"),
568                payload
569                    .get("repositoryName")
570                    .and_then(Value::as_str)
571                    .unwrap_or("repository")
572            );
573            println!(
574                "branch: {}",
575                payload
576                    .get("branchName")
577                    .and_then(Value::as_str)
578                    .unwrap_or("unknown")
579            );
580            println!(
581                "root: {}",
582                payload
583                    .get("repoRoot")
584                    .and_then(Value::as_str)
585                    .unwrap_or("")
586            );
587            println!(
588                "spool: {}",
589                payload
590                    .get("spoolRoot")
591                    .and_then(Value::as_str)
592                    .unwrap_or("")
593            );
594            println!(
595                "unsynced files: {}",
596                payload
597                    .get("unsyncedFiles")
598                    .and_then(Value::as_u64)
599                    .unwrap_or(0)
600            );
601            println!(
602                "unsynced records: {}",
603                payload
604                    .get("unsyncedRecords")
605                    .and_then(Value::as_u64)
606                    .unwrap_or(0)
607            );
608            println!(
609                "synced files: {}",
610                payload
611                    .get("syncedFiles")
612                    .and_then(Value::as_u64)
613                    .unwrap_or(0)
614            );
615            println!(
616                "synced records: {}",
617                payload
618                    .get("syncedRecords")
619                    .and_then(Value::as_u64)
620                    .unwrap_or(0)
621            );
622            print_last_sync(payload);
623            if options.records {
624                print_status_records(payload);
625            }
626            if options.stats {
627                print_status_stats(payload);
628            }
629        }
630        if options.target.parent.is_some() {
631            println!();
632            println!(
633                "total: {} repo(s), {} unsynced file(s), {} unsynced record(s)",
634                payloads.len(),
635                total_files,
636                total_records
637            );
638        }
639    }
640
641    Ok(())
642}
643
644fn attach_optional_stats(
645    mut payload: Value,
646    stats: Option<WorktreeWatchStatsSummary>,
647) -> Result<Value, String> {
648    if let Some(stats) = stats {
649        if let Some(object) = payload.as_object_mut() {
650            object.insert(
651                "stats".to_string(),
652                serde_json::to_value(stats)
653                    .map_err(|error| format!("Failed to render stats payload: {error}"))?,
654            );
655        }
656    }
657    Ok(payload)
658}
659
660pub fn spawn_detached_worktree_watch_for_current_repo() -> Result<Option<PathBuf>, String> {
661    if is_background_child() {
662        return Ok(None);
663    }
664
665    let identity = match resolve_repo_identity(None) {
666        Ok(identity) => identity,
667        Err(_) => return Ok(None),
668    };
669    spawn_detached_worktree_watch_for_identity(&identity).map(Some)
670}
671
672fn resolve_target_identities(
673    target: &WorktreeWatchTargetOptions,
674) -> Result<Vec<RepoIdentity>, String> {
675    if target.repo.is_some() && target.parent.is_some() {
676        return Err("Pass either `--repo` or `--parent`, not both.".to_string());
677    }
678
679    if let Some(parent) = target.parent.as_deref() {
680        return discover_repo_identities_under(parent);
681    }
682
683    resolve_repo_identity(target.repo.as_deref()).map(|identity| vec![identity])
684}
685
686fn worktree_watch_status_payload(
687    identity: &RepoIdentity,
688    include_records: bool,
689    record_limit: usize,
690) -> Result<Value, String> {
691    let layout = prepare_spool_layout(identity)?;
692    let candidates = collect_sync_candidates(&layout)?;
693    let all_candidates = collect_spool_candidates(&layout, true)?;
694    let record_count: usize = candidates
695        .iter()
696        .map(|candidate| candidate.records.len())
697        .sum();
698    let all_record_count: usize = all_candidates
699        .iter()
700        .map(|candidate| candidate.records.len())
701        .sum();
702    let synced_file_count = all_candidates.len().saturating_sub(candidates.len());
703    let synced_record_count = all_record_count.saturating_sub(record_count);
704
705    let mut payload = json!({
706        "repoRoot": identity.root.display().to_string(),
707        "repositoryOwner": identity.owner.clone(),
708        "repositoryName": identity.name.clone(),
709        "branchName": identity.branch.clone(),
710        "spoolRoot": layout.root.display().to_string(),
711        "unsyncedFiles": candidates.len(),
712        "unsyncedRecords": record_count,
713        "syncedFiles": synced_file_count,
714        "syncedRecords": synced_record_count,
715        "localSpoolFiles": all_candidates.len(),
716        "localSpoolRecords": all_record_count,
717    });
718
719    if let Some(last_sync) = read_last_sync_log_entry(&layout.sync_log_file)? {
720        if let Some(object) = payload.as_object_mut() {
721            object.insert(
722                "lastSync".to_string(),
723                serde_json::to_value(last_sync)
724                    .map_err(|error| format!("Failed to render sync log payload: {error}"))?,
725            );
726        }
727    }
728
729    if include_records {
730        if let Some(object) = payload.as_object_mut() {
731            object.insert(
732                "records".to_string(),
733                collect_status_records(&candidates, record_limit)?,
734            );
735        }
736    }
737
738    Ok(payload)
739}
740
741fn collect_status_records(candidates: &[SyncCandidate], limit: usize) -> Result<Value, String> {
742    let available: usize = candidates
743        .iter()
744        .map(|candidate| candidate.records.len())
745        .sum();
746    let mut shown = 0usize;
747    let mut events = Vec::new();
748    let mut commits = Vec::new();
749
750    'candidates: for candidate in candidates {
751        match &candidate.records {
752            SyncRecords::Events(records) => {
753                for event in records {
754                    if shown >= limit {
755                        break 'candidates;
756                    }
757                    events.push(json!({
758                        "sourceFile": candidate.path.display().to_string(),
759                        "occurredAt": event.occurred_at,
760                        "eventKind": event.event_kind.clone(),
761                        "primaryPath": event.primary_path.clone(),
762                        "oldPath": event.old_path.clone(),
763                        "newPath": event.new_path.clone(),
764                        "paths": event.paths.clone(),
765                        "addedLines": event.added_lines,
766                        "removedLines": event.removed_lines,
767                        "totalLines": event.total_lines,
768                        "headSha": event.head_sha.clone(),
769                        "rawKind": event.raw_kind.clone(),
770                    }));
771                    shown += 1;
772                }
773            }
774            SyncRecords::Commits(records) => {
775                for commit in records {
776                    if shown >= limit {
777                        break 'candidates;
778                    }
779                    commits.push(json!({
780                        "sourceFile": candidate.path.display().to_string(),
781                        "occurredAt": commit.occurred_at,
782                        "headSha": commit.head_sha.clone(),
783                        "previousHeadSha": commit.previous_head_sha.clone(),
784                        "subject": commit.subject.clone(),
785                        "authorName": commit.author_name.clone(),
786                        "authorEmail": commit.author_email.clone(),
787                        "committedAt": commit.committed_at.clone(),
788                    }));
789                    shown += 1;
790                }
791            }
792        }
793    }
794
795    Ok(json!({
796        "available": available,
797        "shown": shown,
798        "truncated": shown < available,
799        "events": events,
800        "commits": commits,
801    }))
802}
803
804fn print_status_records(payload: &Value) {
805    let Some(records) = payload.get("records") else {
806        return;
807    };
808    let shown = records.get("shown").and_then(Value::as_u64).unwrap_or(0);
809    if shown == 0 {
810        println!("records: none");
811        return;
812    }
813
814    println!("records:");
815    if let Some(events) = records.get("events").and_then(Value::as_array) {
816        if !events.is_empty() {
817            println!("  mutations:");
818            for event in events {
819                print_status_event_record(event);
820            }
821        }
822    }
823    if let Some(commits) = records.get("commits").and_then(Value::as_array) {
824        if !commits.is_empty() {
825            println!("  commits:");
826            for commit in commits {
827                print_status_commit_record(commit);
828            }
829        }
830    }
831    if records
832        .get("truncated")
833        .and_then(Value::as_bool)
834        .unwrap_or(false)
835    {
836        let available = records
837            .get("available")
838            .and_then(Value::as_u64)
839            .unwrap_or(shown);
840        println!("  showing {shown} of {available} record(s)");
841    }
842}
843
844fn print_status_stats(payload: &Value) {
845    let Some(stats) = payload.get("stats") else {
846        return;
847    };
848    println!("stats:");
849    println!(
850        "  coding time: {}",
851        format_duration(
852            stats
853                .get("estimatedCodingSeconds")
854                .and_then(Value::as_u64)
855                .unwrap_or(0)
856        )
857    );
858    println!(
859        "  events: {}",
860        stats
861            .get("totalEvents")
862            .and_then(Value::as_u64)
863            .unwrap_or(0)
864    );
865    println!(
866        "  commits: {}",
867        stats
868            .get("totalCommits")
869            .and_then(Value::as_u64)
870            .unwrap_or(0)
871    );
872    println!(
873        "  lines: +{} -{}",
874        stats.get("addedLines").and_then(Value::as_u64).unwrap_or(0),
875        stats
876            .get("removedLines")
877            .and_then(Value::as_u64)
878            .unwrap_or(0)
879    );
880    if let Some(spool_root) = value_str(stats, "spoolRoot") {
881        println!(
882            "  stored: {}",
883            Path::new(spool_root).join("stats.json").display()
884        );
885    }
886    print_stats_bucket_section(stats, "byFiletype", "  by filetype:", 10);
887    print_stats_bucket_section(stats, "byEventKind", "  by event kind:", 10);
888    print_stats_file_section(stats, 10);
889}
890
891fn print_last_sync(payload: &Value) {
892    let Some(last_sync) = payload.get("lastSync") else {
893        return;
894    };
895    let synced_at = value_str(last_sync, "syncedAt").unwrap_or("unknown-time");
896    let status = last_sync
897        .get("statusCode")
898        .and_then(Value::as_u64)
899        .unwrap_or(0);
900    let events = last_sync
901        .get("eventCount")
902        .and_then(Value::as_u64)
903        .unwrap_or(0);
904    let commits = last_sync
905        .get("commitCount")
906        .and_then(Value::as_u64)
907        .unwrap_or(0);
908    let files = last_sync
909        .get("spoolFileCount")
910        .and_then(Value::as_u64)
911        .unwrap_or(0);
912    let resync = last_sync
913        .get("resync")
914        .and_then(Value::as_bool)
915        .unwrap_or(false);
916    println!(
917        "last sync: {synced_at}, HTTP {status}, {events} event(s), {commits} commit(s), {files} file(s), resync={resync}"
918    );
919}
920
921fn print_stats_bucket_section(stats: &Value, key: &str, title: &str, limit: usize) {
922    let Some(items) = stats.get(key).and_then(Value::as_array) else {
923        return;
924    };
925    if items.is_empty() {
926        return;
927    }
928    println!("{title}");
929    for item in items.iter().take(limit) {
930        let name = value_str(item, "name").unwrap_or("unknown");
931        let events = item.get("eventCount").and_then(Value::as_u64).unwrap_or(0);
932        let seconds = item
933            .get("estimatedCodingSeconds")
934            .and_then(Value::as_u64)
935            .unwrap_or(0);
936        let added = item.get("addedLines").and_then(Value::as_u64).unwrap_or(0);
937        let removed = item
938            .get("removedLines")
939            .and_then(Value::as_u64)
940            .unwrap_or(0);
941        println!(
942            "    {name}: {}, {} event(s), +{} -{}",
943            format_duration(seconds),
944            events,
945            added,
946            removed
947        );
948    }
949}
950
951fn print_stats_file_section(stats: &Value, limit: usize) {
952    let Some(items) = stats.get("byFile").and_then(Value::as_array) else {
953        return;
954    };
955    if items.is_empty() {
956        return;
957    }
958    println!("  by file:");
959    for item in items.iter().take(limit) {
960        let path = value_str(item, "path").unwrap_or("(no path)");
961        let filetype = value_str(item, "filetype").unwrap_or("(none)");
962        let events = item.get("eventCount").and_then(Value::as_u64).unwrap_or(0);
963        let seconds = item
964            .get("estimatedCodingSeconds")
965            .and_then(Value::as_u64)
966            .unwrap_or(0);
967        let added = item.get("addedLines").and_then(Value::as_u64).unwrap_or(0);
968        let removed = item
969            .get("removedLines")
970            .and_then(Value::as_u64)
971            .unwrap_or(0);
972        println!(
973            "    {path} [{filetype}]: {}, {} event(s), +{} -{}",
974            format_duration(seconds),
975            events,
976            added,
977            removed
978        );
979    }
980}
981
982fn print_status_event_record(event: &Value) {
983    let occurred_at = value_str(event, "occurredAt").unwrap_or("unknown-time");
984    let kind = value_str(event, "eventKind").unwrap_or("event");
985    let primary_path = event_display_path(event);
986    let added = event.get("addedLines").and_then(Value::as_u64).unwrap_or(0);
987    let removed = event
988        .get("removedLines")
989        .and_then(Value::as_u64)
990        .unwrap_or(0);
991    let total_lines = event.get("totalLines").and_then(Value::as_u64);
992    if let Some(total_lines) = total_lines {
993        println!(
994            "    {occurred_at} {kind} {primary_path} (+{added} -{removed}, {total_lines} lines)"
995        );
996    } else {
997        println!("    {occurred_at} {kind} {primary_path} (+{added} -{removed})");
998    }
999
1000    if let Some(head_sha) = value_str(event, "headSha") {
1001        println!("      head: {}", short_sha(head_sha));
1002    }
1003    if let Some(paths) = event.get("paths").and_then(Value::as_array) {
1004        if paths.len() > 1 {
1005            let rendered = paths
1006                .iter()
1007                .filter_map(Value::as_str)
1008                .collect::<Vec<_>>()
1009                .join(", ");
1010            println!("      paths: {rendered}");
1011        }
1012    }
1013}
1014
1015fn print_status_commit_record(commit: &Value) {
1016    let occurred_at = value_str(commit, "occurredAt").unwrap_or("unknown-time");
1017    let head_sha = value_str(commit, "headSha")
1018        .map(short_sha)
1019        .unwrap_or_else(|| "unknown".to_string());
1020    let subject = value_str(commit, "subject").unwrap_or("(no subject)");
1021    println!("    {occurred_at} {head_sha} {subject}");
1022
1023    if let Some(author) = value_str(commit, "authorName") {
1024        println!("      author: {author}");
1025    }
1026}
1027
1028fn event_display_path(event: &Value) -> String {
1029    let old_path = value_str(event, "oldPath");
1030    let new_path = value_str(event, "newPath");
1031    if let (Some(old_path), Some(new_path)) = (old_path, new_path) {
1032        return format!("{old_path} -> {new_path}");
1033    }
1034    value_str(event, "primaryPath")
1035        .map(str::to_string)
1036        .or_else(|| {
1037            event
1038                .get("paths")
1039                .and_then(Value::as_array)
1040                .and_then(|paths| paths.first())
1041                .and_then(Value::as_str)
1042                .map(str::to_string)
1043        })
1044        .unwrap_or_else(|| "(no path)".to_string())
1045}
1046
1047fn value_str<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
1048    value.get(key).and_then(Value::as_str)
1049}
1050
1051fn short_sha(value: &str) -> String {
1052    value.chars().take(12).collect()
1053}
1054
1055fn generate_and_store_stats(
1056    identity: &RepoIdentity,
1057    session_gap_minutes: u64,
1058) -> Result<WorktreeWatchStatsSummary, String> {
1059    let layout = prepare_spool_layout(identity)?;
1060    let candidates = collect_spool_candidates(&layout, true)?;
1061    let summary = build_stats_summary(identity, &layout, &candidates, session_gap_minutes)?;
1062    let rendered = serde_json::to_string_pretty(&summary)
1063        .map_err(|error| format!("Failed to serialize stats: {error}"))?;
1064    fs::write(&layout.stats_file, format!("{rendered}\n")).map_err(|error| {
1065        format!(
1066            "Failed to write stats file {}: {error}",
1067            layout.stats_file.display()
1068        )
1069    })?;
1070    Ok(summary)
1071}
1072
1073fn build_stats_summary(
1074    identity: &RepoIdentity,
1075    layout: &SpoolLayout,
1076    candidates: &[SyncCandidate],
1077    session_gap_minutes: u64,
1078) -> Result<WorktreeWatchStatsSummary, String> {
1079    let mut events = Vec::new();
1080    let mut total_commits = 0u64;
1081    for candidate in candidates {
1082        match &candidate.records {
1083            SyncRecords::Events(records) => events.extend(records.iter().cloned()),
1084            SyncRecords::Commits(records) => total_commits += records.len() as u64,
1085        }
1086    }
1087    events.sort_by_key(|event| event.occurred_at);
1088
1089    let gap_seconds = session_gap_minutes.saturating_mul(60).max(60);
1090    let mut previous_at: Option<DateTime<Utc>> = None;
1091    let mut total_seconds = 0u64;
1092    let mut total_added = 0u64;
1093    let mut total_removed = 0u64;
1094    let mut by_file: BTreeMap<String, WorktreeWatchFileStats> = BTreeMap::new();
1095    let mut by_filetype: BTreeMap<String, WorktreeWatchBucketStats> = BTreeMap::new();
1096    let mut by_event_kind: BTreeMap<String, WorktreeWatchBucketStats> = BTreeMap::new();
1097
1098    for event in &events {
1099        let seconds = coding_seconds_for_event(previous_at, event.occurred_at, gap_seconds);
1100        previous_at = Some(event.occurred_at);
1101        let added = event.added_lines.unwrap_or(0);
1102        let removed = event.removed_lines.unwrap_or(0);
1103        let path = stats_event_path(event);
1104        let filetype = filetype_for_path(&path);
1105
1106        total_seconds += seconds;
1107        total_added += added;
1108        total_removed += removed;
1109        update_file_stats(&mut by_file, &path, &filetype, seconds, added, removed);
1110        update_bucket_stats(&mut by_filetype, &filetype, seconds, added, removed);
1111        update_bucket_stats(
1112            &mut by_event_kind,
1113            &event.event_kind,
1114            seconds,
1115            added,
1116            removed,
1117        );
1118    }
1119
1120    Ok(WorktreeWatchStatsSummary {
1121        generated_at: Utc::now(),
1122        repository_owner: identity.owner.clone(),
1123        repository_name: identity.name.clone(),
1124        branch_name: identity.branch.clone(),
1125        repo_root: identity.root.display().to_string(),
1126        spool_root: layout.root.display().to_string(),
1127        session_gap_minutes,
1128        total_events: events.len() as u64,
1129        total_commits,
1130        first_event_at: events.first().map(|event| event.occurred_at),
1131        last_event_at: events.last().map(|event| event.occurred_at),
1132        estimated_coding_seconds: total_seconds,
1133        added_lines: total_added,
1134        removed_lines: total_removed,
1135        by_file: sorted_file_stats(by_file),
1136        by_filetype: sorted_bucket_stats(by_filetype),
1137        by_event_kind: sorted_bucket_stats(by_event_kind),
1138    })
1139}
1140
1141fn coding_seconds_for_event(
1142    previous_at: Option<DateTime<Utc>>,
1143    occurred_at: DateTime<Utc>,
1144    gap_seconds: u64,
1145) -> u64 {
1146    let Some(previous_at) = previous_at else {
1147        return 60;
1148    };
1149    let delta = occurred_at.signed_duration_since(previous_at).num_seconds();
1150    if delta <= 0 {
1151        0
1152    } else {
1153        (delta as u64).min(gap_seconds)
1154    }
1155}
1156
1157fn update_file_stats(
1158    by_file: &mut BTreeMap<String, WorktreeWatchFileStats>,
1159    path: &str,
1160    filetype: &str,
1161    seconds: u64,
1162    added: u64,
1163    removed: u64,
1164) {
1165    let stats = by_file
1166        .entry(path.to_string())
1167        .or_insert_with(|| WorktreeWatchFileStats {
1168            path: path.to_string(),
1169            filetype: filetype.to_string(),
1170            ..WorktreeWatchFileStats::default()
1171        });
1172    stats.event_count += 1;
1173    stats.estimated_coding_seconds += seconds;
1174    stats.added_lines += added;
1175    stats.removed_lines += removed;
1176}
1177
1178fn update_bucket_stats(
1179    buckets: &mut BTreeMap<String, WorktreeWatchBucketStats>,
1180    name: &str,
1181    seconds: u64,
1182    added: u64,
1183    removed: u64,
1184) {
1185    let stats = buckets
1186        .entry(name.to_string())
1187        .or_insert_with(|| WorktreeWatchBucketStats {
1188            name: name.to_string(),
1189            ..WorktreeWatchBucketStats::default()
1190        });
1191    stats.event_count += 1;
1192    stats.estimated_coding_seconds += seconds;
1193    stats.added_lines += added;
1194    stats.removed_lines += removed;
1195}
1196
1197fn sorted_file_stats(
1198    by_file: BTreeMap<String, WorktreeWatchFileStats>,
1199) -> Vec<WorktreeWatchFileStats> {
1200    let mut stats = by_file.into_values().collect::<Vec<_>>();
1201    stats.sort_by(|left, right| {
1202        right
1203            .estimated_coding_seconds
1204            .cmp(&left.estimated_coding_seconds)
1205            .then_with(|| right.event_count.cmp(&left.event_count))
1206            .then_with(|| left.path.cmp(&right.path))
1207    });
1208    stats
1209}
1210
1211fn sorted_bucket_stats(
1212    buckets: BTreeMap<String, WorktreeWatchBucketStats>,
1213) -> Vec<WorktreeWatchBucketStats> {
1214    let mut stats = buckets.into_values().collect::<Vec<_>>();
1215    stats.sort_by(|left, right| {
1216        right
1217            .estimated_coding_seconds
1218            .cmp(&left.estimated_coding_seconds)
1219            .then_with(|| right.event_count.cmp(&left.event_count))
1220            .then_with(|| left.name.cmp(&right.name))
1221    });
1222    stats
1223}
1224
1225fn stats_event_path(event: &WorktreeMutationEvent) -> String {
1226    event
1227        .new_path
1228        .as_deref()
1229        .or(event.primary_path.as_deref())
1230        .or_else(|| event.paths.first().map(String::as_str))
1231        .unwrap_or("(no path)")
1232        .to_string()
1233}
1234
1235fn filetype_for_path(path: &str) -> String {
1236    Path::new(path)
1237        .extension()
1238        .and_then(|value| value.to_str())
1239        .map(|value| value.to_ascii_lowercase())
1240        .filter(|value| !value.is_empty())
1241        .unwrap_or_else(|| "(none)".to_string())
1242}
1243
1244fn format_duration(seconds: u64) -> String {
1245    let hours = seconds / 3600;
1246    let minutes = (seconds % 3600) / 60;
1247    let remaining_seconds = seconds % 60;
1248    if hours > 0 {
1249        format!("{hours}h {minutes}m")
1250    } else if minutes > 0 {
1251        format!("{minutes}m {remaining_seconds}s")
1252    } else {
1253        format!("{remaining_seconds}s")
1254    }
1255}
1256
1257async fn watch_foreground(
1258    identity: RepoIdentity,
1259    layout: SpoolLayout,
1260    sync_interval_seconds: u64,
1261) -> Result<(), String> {
1262    let (tx, rx) = mpsc::channel();
1263    let mut watcher = RecommendedWatcher::new(
1264        move |result| {
1265            let _ = tx.send(result);
1266        },
1267        Config::default(),
1268    )
1269    .map_err(|error| format!("Failed to create filesystem watcher: {error}"))?;
1270
1271    watcher
1272        .watch(&identity.root, RecursiveMode::Recursive)
1273        .map_err(|error| {
1274            format!(
1275                "Failed to watch repository root {}: {error}",
1276                identity.root.display()
1277            )
1278        })?;
1279
1280    let mut last_head = identity.head_sha.clone();
1281    let mut last_commit_check = Instant::now();
1282    let mut last_sync = Instant::now();
1283    let mut event_dedupe = RecentEventDedupe {
1284        fingerprints: load_event_dedupe_fingerprints(&layout.event_dedupe_file)?,
1285    };
1286
1287    loop {
1288        match rx.recv_timeout(Duration::from_millis(750)) {
1289            Ok(Ok(event)) => {
1290                if let Some(mutation) = mutation_event_from_notify(&identity, event) {
1291                    append_unique_mutation_event(&layout, &mutation, &mut event_dedupe)?;
1292                }
1293            }
1294            Ok(Err(error)) => {
1295                eprintln!("worktree watcher error: {error}");
1296            }
1297            Err(mpsc::RecvTimeoutError::Timeout) => {}
1298            Err(mpsc::RecvTimeoutError::Disconnected) => {
1299                return Err("Filesystem watcher disconnected.".to_string());
1300            }
1301        }
1302
1303        if last_commit_check.elapsed() >= Duration::from_secs(3) {
1304            last_head = record_commit_snapshot(&identity, &layout, last_head.as_deref())?;
1305            last_commit_check = Instant::now();
1306        }
1307
1308        if sync_interval_seconds > 0
1309            && last_sync.elapsed() >= Duration::from_secs(sync_interval_seconds)
1310        {
1311            if let Err(error) = sync_spool_for_identity(&identity, &layout).await {
1312                eprintln!("worktree mutation sync failed: {error}");
1313            }
1314            last_sync = Instant::now();
1315        }
1316    }
1317}
1318
1319async fn watch_parent_foreground(
1320    parent_root: PathBuf,
1321    identities: Vec<RepoIdentity>,
1322    sync_interval_seconds: u64,
1323) -> Result<(), String> {
1324    let (tx, rx) = mpsc::channel();
1325    let mut watcher = RecommendedWatcher::new(
1326        move |result| {
1327            let _ = tx.send(result);
1328        },
1329        Config::default(),
1330    )
1331    .map_err(|error| format!("Failed to create parent filesystem watcher: {error}"))?;
1332
1333    watcher
1334        .watch(&parent_root, RecursiveMode::Recursive)
1335        .map_err(|error| {
1336            format!(
1337                "Failed to watch parent folder {}: {error}",
1338                parent_root.display()
1339            )
1340        })?;
1341
1342    let mut repos = prepare_watched_repos(identities)?;
1343    let mut last_commit_check = Instant::now();
1344    let mut last_sync = Instant::now();
1345
1346    loop {
1347        match rx.recv_timeout(Duration::from_millis(750)) {
1348            Ok(Ok(event)) => {
1349                for (repo_index, routed_event) in route_parent_event(&repos, &event) {
1350                    let repo = &mut repos[repo_index];
1351                    if let Some(mutation) = mutation_event_from_notify(&repo.identity, routed_event)
1352                    {
1353                        append_unique_mutation_event(
1354                            &repo.layout,
1355                            &mutation,
1356                            &mut repo.event_dedupe,
1357                        )?;
1358                    }
1359                }
1360            }
1361            Ok(Err(error)) => {
1362                eprintln!("parent worktree watcher error: {error}");
1363            }
1364            Err(mpsc::RecvTimeoutError::Timeout) => {}
1365            Err(mpsc::RecvTimeoutError::Disconnected) => {
1366                return Err("Parent filesystem watcher disconnected.".to_string());
1367            }
1368        }
1369
1370        if last_commit_check.elapsed() >= Duration::from_secs(3) {
1371            for repo in &mut repos {
1372                repo.last_head = record_commit_snapshot(
1373                    &repo.identity,
1374                    &repo.layout,
1375                    repo.last_head.as_deref(),
1376                )?;
1377            }
1378            last_commit_check = Instant::now();
1379        }
1380
1381        if sync_interval_seconds > 0
1382            && last_sync.elapsed() >= Duration::from_secs(sync_interval_seconds)
1383        {
1384            for repo in &repos {
1385                if let Err(error) = sync_spool_for_identity(&repo.identity, &repo.layout).await {
1386                    eprintln!(
1387                        "worktree mutation sync failed for {}: {error}",
1388                        repo.identity.root.display()
1389                    );
1390                }
1391            }
1392            last_sync = Instant::now();
1393        }
1394    }
1395}
1396
1397fn prepare_watched_repos(identities: Vec<RepoIdentity>) -> Result<Vec<WatchedRepo>, String> {
1398    let mut repos = Vec::with_capacity(identities.len());
1399    for identity in identities {
1400        let layout = prepare_spool_layout(&identity)?;
1401        let event_dedupe = RecentEventDedupe {
1402            fingerprints: load_event_dedupe_fingerprints(&layout.event_dedupe_file)?,
1403        };
1404        repos.push(WatchedRepo {
1405            last_head: identity.head_sha.clone(),
1406            identity,
1407            layout,
1408            event_dedupe,
1409        });
1410    }
1411    repos.sort_by_key(|repo| std::cmp::Reverse(repo.identity.root.components().count()));
1412    Ok(repos)
1413}
1414
1415fn route_parent_event(repos: &[WatchedRepo], event: &Event) -> Vec<(usize, Event)> {
1416    let mut paths_by_repo: BTreeMap<usize, Vec<PathBuf>> = BTreeMap::new();
1417    for path in &event.paths {
1418        for (index, repo) in repos.iter().enumerate() {
1419            if path_belongs_to_repo(path, &repo.identity.root) {
1420                let paths = paths_by_repo.entry(index).or_default();
1421                if !paths.iter().any(|existing| existing == path) {
1422                    paths.push(path.clone());
1423                }
1424                break;
1425            }
1426        }
1427    }
1428
1429    let mut routed = Vec::new();
1430    for (index, paths) in paths_by_repo {
1431        let mut repo_event = event.clone();
1432        repo_event.paths = paths;
1433        routed.push((index, repo_event));
1434    }
1435    routed
1436}
1437
1438fn path_belongs_to_repo(path: &Path, repo_root: &Path) -> bool {
1439    let path_key = path_identity_key(path).replace('\\', "/");
1440    let root_key = path_identity_key(repo_root).replace('\\', "/");
1441    path_key == root_key || path_key.starts_with(&format!("{root_key}/"))
1442}
1443
1444fn mutation_event_from_notify(
1445    identity: &RepoIdentity,
1446    event: Event,
1447) -> Option<WorktreeMutationEvent> {
1448    if event.kind.is_access() || event.kind.is_other() {
1449        return None;
1450    }
1451
1452    let paths: Vec<String> = event
1453        .paths
1454        .iter()
1455        .filter(|path| !is_ignored_path(&identity.root, path))
1456        .filter_map(|path| relative_slash_path(&identity.root, path))
1457        .fold(Vec::new(), |mut paths, path| {
1458            if !paths.iter().any(|existing| existing == &path) {
1459                paths.push(path);
1460            }
1461            paths
1462        });
1463
1464    if paths.is_empty() {
1465        return None;
1466    }
1467
1468    let primary_path = paths.first().cloned();
1469    let (old_path, new_path) = rename_paths(&event.kind, &paths);
1470    let line_counts = primary_path
1471        .as_deref()
1472        .and_then(|path| git_numstat_for_path(&identity.root, path).ok());
1473    let total_lines = primary_path
1474        .as_deref()
1475        .and_then(|path| file_line_count_for_path(&identity.root, path).ok());
1476    let event_kind = classify_event_kind(&event.kind);
1477    let is_dir = event
1478        .paths
1479        .first()
1480        .and_then(|path| fs::metadata(path).ok())
1481        .map(|metadata| metadata.is_dir())
1482        .unwrap_or(false);
1483
1484    Some(WorktreeMutationEvent {
1485        id: Uuid::new_v4().to_string(),
1486        fingerprint: None,
1487        repo_owner: identity.owner.clone(),
1488        repo_name: identity.name.clone(),
1489        branch_name: identity.branch.clone(),
1490        repo_root: identity.root.display().to_string(),
1491        head_sha: current_head(&identity.root).ok().flatten(),
1492        event_kind: event_kind.to_string(),
1493        paths,
1494        primary_path,
1495        old_path,
1496        new_path,
1497        added_lines: line_counts.map(|counts| counts.0),
1498        removed_lines: line_counts.map(|counts| counts.1),
1499        total_lines,
1500        file_created: matches!(
1501            event.kind,
1502            EventKind::Create(CreateKind::File | CreateKind::Any)
1503        ) && !is_dir,
1504        file_removed: matches!(
1505            event.kind,
1506            EventKind::Remove(RemoveKind::File | RemoveKind::Any)
1507        ) && !is_dir,
1508        folder_created: matches!(
1509            event.kind,
1510            EventKind::Create(CreateKind::Folder | CreateKind::Any)
1511        ) && is_dir,
1512        folder_removed: matches!(
1513            event.kind,
1514            EventKind::Remove(RemoveKind::Folder | RemoveKind::Any)
1515        ) && is_dir,
1516        renamed_or_moved: is_rename_or_move(&event.kind),
1517        raw_kind: format!("{:?}", event.kind),
1518        occurred_at: Utc::now(),
1519    })
1520}
1521
1522fn append_unique_mutation_event(
1523    layout: &SpoolLayout,
1524    mutation: &WorktreeMutationEvent,
1525    dedupe: &mut RecentEventDedupe,
1526) -> Result<bool, String> {
1527    let fingerprint = mutation_event_fingerprint(mutation);
1528    if dedupe.fingerprints.contains(&fingerprint)
1529        || event_dedupe_file_contains(&layout.event_dedupe_file, &fingerprint)?
1530    {
1531        dedupe.fingerprints.insert(fingerprint);
1532        return Ok(false);
1533    }
1534
1535    if !claim_event_fingerprint(layout, &fingerprint)? {
1536        dedupe.fingerprints.insert(fingerprint);
1537        return Ok(false);
1538    }
1539
1540    let mut mutation = mutation.clone();
1541    mutation.fingerprint = Some(fingerprint.clone());
1542    if let Err(error) = append_json_line(&layout.events_file, &mutation) {
1543        release_event_fingerprint_claim(layout, &fingerprint);
1544        return Err(error);
1545    }
1546    append_json_line(
1547        &layout.event_dedupe_file,
1548        &WorktreeWatchEventDedupeRecord {
1549            fingerprint: fingerprint.clone(),
1550            first_seen_at: Utc::now(),
1551            event_kind: mutation.event_kind.clone(),
1552            primary_path: mutation.primary_path.clone(),
1553        },
1554    )?;
1555    dedupe.fingerprints.insert(fingerprint);
1556    Ok(true)
1557}
1558
1559fn claim_event_fingerprint(layout: &SpoolLayout, fingerprint: &str) -> Result<bool, String> {
1560    fs::create_dir_all(&layout.event_dedupe_dir).map_err(|error| {
1561        format!(
1562            "Failed to create event dedupe directory {}: {error}",
1563            layout.event_dedupe_dir.display()
1564        )
1565    })?;
1566    let marker = event_dedupe_marker_path(layout, fingerprint);
1567    match OpenOptions::new()
1568        .write(true)
1569        .create_new(true)
1570        .open(&marker)
1571    {
1572        Ok(mut file) => {
1573            writeln!(file, "{}", Utc::now().to_rfc3339()).map_err(|error| {
1574                format!(
1575                    "Failed to write event dedupe marker {}: {error}",
1576                    marker.display()
1577                )
1578            })?;
1579            Ok(true)
1580        }
1581        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
1582        Err(error) => Err(format!(
1583            "Failed to create event dedupe marker {}: {error}",
1584            marker.display()
1585        )),
1586    }
1587}
1588
1589fn release_event_fingerprint_claim(layout: &SpoolLayout, fingerprint: &str) {
1590    let _ = fs::remove_file(event_dedupe_marker_path(layout, fingerprint));
1591}
1592
1593fn event_dedupe_marker_path(layout: &SpoolLayout, fingerprint: &str) -> PathBuf {
1594    layout.event_dedupe_dir.join(format!("{fingerprint}.seen"))
1595}
1596
1597fn mutation_event_fingerprint(mutation: &WorktreeMutationEvent) -> String {
1598    let bucket = mutation.occurred_at.timestamp() / EVENT_DEDUPE_WINDOW_SECONDS;
1599    let payload = json!({
1600        "repoOwner": mutation.repo_owner,
1601        "repoName": mutation.repo_name,
1602        "branchName": mutation.branch_name,
1603        "repoRoot": mutation.repo_root,
1604        "headSha": mutation.head_sha,
1605        "eventKind": mutation.event_kind,
1606        "paths": mutation.paths,
1607        "primaryPath": mutation.primary_path,
1608        "oldPath": mutation.old_path,
1609        "newPath": mutation.new_path,
1610        "addedLines": mutation.added_lines,
1611        "removedLines": mutation.removed_lines,
1612        "fileCreated": mutation.file_created,
1613        "fileRemoved": mutation.file_removed,
1614        "folderCreated": mutation.folder_created,
1615        "folderRemoved": mutation.folder_removed,
1616        "renamedOrMoved": mutation.renamed_or_moved,
1617        "timeBucket": bucket,
1618    });
1619    let encoded = serde_json::to_vec(&payload).unwrap_or_default();
1620    let mut hasher = Sha256::new();
1621    hasher.update(encoded);
1622    format!("{:x}", hasher.finalize())
1623}
1624
1625fn load_event_dedupe_fingerprints(path: &Path) -> Result<BTreeSet<String>, String> {
1626    let mut fingerprints = BTreeSet::new();
1627    if !path.exists() {
1628        return Ok(fingerprints);
1629    }
1630
1631    for value in read_jsonl_values(path)? {
1632        if let Some(fingerprint) = value.get("fingerprint").and_then(Value::as_str) {
1633            fingerprints.insert(fingerprint.to_string());
1634        }
1635    }
1636    Ok(fingerprints)
1637}
1638
1639fn event_dedupe_file_contains(path: &Path, fingerprint: &str) -> Result<bool, String> {
1640    if !path.exists() {
1641        return Ok(false);
1642    }
1643    for value in read_jsonl_values(path)? {
1644        if value
1645            .get("fingerprint")
1646            .and_then(Value::as_str)
1647            .map(|value| value == fingerprint)
1648            .unwrap_or(false)
1649        {
1650            return Ok(true);
1651        }
1652    }
1653    Ok(false)
1654}
1655
1656fn classify_event_kind(kind: &EventKind) -> &'static str {
1657    match kind {
1658        EventKind::Create(CreateKind::File) => "file_create",
1659        EventKind::Create(CreateKind::Folder) => "folder_create",
1660        EventKind::Create(_) => "create",
1661        EventKind::Remove(RemoveKind::File) => "file_remove",
1662        EventKind::Remove(RemoveKind::Folder) => "folder_remove",
1663        EventKind::Remove(_) => "remove",
1664        EventKind::Modify(ModifyKind::Name(
1665            RenameMode::From | RenameMode::To | RenameMode::Both,
1666        )) => "rename_or_move",
1667        EventKind::Modify(ModifyKind::Data(_)) => "file_modify",
1668        EventKind::Modify(ModifyKind::Metadata(_)) => "metadata_modify",
1669        EventKind::Modify(_) => "modify",
1670        _ => "other",
1671    }
1672}
1673
1674fn is_rename_or_move(kind: &EventKind) -> bool {
1675    matches!(
1676        kind,
1677        EventKind::Modify(ModifyKind::Name(
1678            RenameMode::From | RenameMode::To | RenameMode::Both
1679        ))
1680    )
1681}
1682
1683fn rename_paths(kind: &EventKind, paths: &[String]) -> (Option<String>, Option<String>) {
1684    if !is_rename_or_move(kind) {
1685        return (None, None);
1686    }
1687
1688    match paths {
1689        [old_path, new_path, ..] => (Some(old_path.clone()), Some(new_path.clone())),
1690        [path] => match kind {
1691            EventKind::Modify(ModifyKind::Name(RenameMode::From)) => (Some(path.clone()), None),
1692            EventKind::Modify(ModifyKind::Name(RenameMode::To)) => (None, Some(path.clone())),
1693            _ => (None, Some(path.clone())),
1694        },
1695        _ => (None, None),
1696    }
1697}
1698
1699fn record_commit_snapshot(
1700    identity: &RepoIdentity,
1701    layout: &SpoolLayout,
1702    previous_head: Option<&str>,
1703) -> Result<Option<String>, String> {
1704    let head = current_head(&identity.root)?;
1705    let Some(head_sha) = head else {
1706        return Ok(None);
1707    };
1708
1709    if previous_head == Some(head_sha.as_str()) {
1710        return Ok(Some(head_sha));
1711    }
1712
1713    let commit = WorktreeCommitEvent {
1714        id: Uuid::new_v4().to_string(),
1715        fingerprint: None,
1716        repo_owner: identity.owner.clone(),
1717        repo_name: identity.name.clone(),
1718        branch_name: identity.branch.clone(),
1719        repo_root: identity.root.display().to_string(),
1720        previous_head_sha: previous_head.map(str::to_string),
1721        head_sha: head_sha.clone(),
1722        subject: git_output(&identity.root, &["log", "-1", "--pretty=%s"]).ok(),
1723        author_name: git_output(&identity.root, &["log", "-1", "--pretty=%an"]).ok(),
1724        author_email: git_output(&identity.root, &["log", "-1", "--pretty=%ae"]).ok(),
1725        committed_at: git_output(&identity.root, &["log", "-1", "--pretty=%cI"]).ok(),
1726        occurred_at: Utc::now(),
1727    };
1728    append_unique_commit_event(layout, &commit)?;
1729    Ok(Some(head_sha))
1730}
1731
1732fn append_unique_commit_event(
1733    layout: &SpoolLayout,
1734    commit: &WorktreeCommitEvent,
1735) -> Result<bool, String> {
1736    let fingerprint = commit_event_fingerprint(commit);
1737    if !claim_commit_fingerprint(layout, &fingerprint)? {
1738        return Ok(false);
1739    }
1740
1741    let mut commit = commit.clone();
1742    commit.fingerprint = Some(fingerprint.clone());
1743    if let Err(error) = append_json_line(&layout.commits_file, &commit) {
1744        release_commit_fingerprint_claim(layout, &fingerprint);
1745        return Err(error);
1746    }
1747    Ok(true)
1748}
1749
1750fn commit_event_fingerprint(commit: &WorktreeCommitEvent) -> String {
1751    let payload = json!({
1752        "repoOwner": commit.repo_owner,
1753        "repoName": commit.repo_name,
1754        "branchName": commit.branch_name,
1755        "repoRoot": commit.repo_root,
1756        "previousHeadSha": commit.previous_head_sha,
1757        "headSha": commit.head_sha,
1758    });
1759    let encoded = serde_json::to_vec(&payload).unwrap_or_default();
1760    let mut hasher = Sha256::new();
1761    hasher.update(encoded);
1762    format!("{:x}", hasher.finalize())
1763}
1764
1765fn claim_commit_fingerprint(layout: &SpoolLayout, fingerprint: &str) -> Result<bool, String> {
1766    fs::create_dir_all(&layout.commit_dedupe_dir).map_err(|error| {
1767        format!(
1768            "Failed to create commit dedupe directory {}: {error}",
1769            layout.commit_dedupe_dir.display()
1770        )
1771    })?;
1772    let marker = commit_dedupe_marker_path(layout, fingerprint);
1773    match OpenOptions::new()
1774        .write(true)
1775        .create_new(true)
1776        .open(&marker)
1777    {
1778        Ok(mut file) => {
1779            writeln!(file, "{}", Utc::now().to_rfc3339()).map_err(|error| {
1780                format!(
1781                    "Failed to write commit dedupe marker {}: {error}",
1782                    marker.display()
1783                )
1784            })?;
1785            Ok(true)
1786        }
1787        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
1788        Err(error) => Err(format!(
1789            "Failed to create commit dedupe marker {}: {error}",
1790            marker.display()
1791        )),
1792    }
1793}
1794
1795fn release_commit_fingerprint_claim(layout: &SpoolLayout, fingerprint: &str) {
1796    let _ = fs::remove_file(commit_dedupe_marker_path(layout, fingerprint));
1797}
1798
1799fn commit_dedupe_marker_path(layout: &SpoolLayout, fingerprint: &str) -> PathBuf {
1800    layout.commit_dedupe_dir.join(format!("{fingerprint}.seen"))
1801}
1802
1803async fn sync_spool_for_identity(
1804    identity: &RepoIdentity,
1805    layout: &SpoolLayout,
1806) -> Result<(), String> {
1807    let candidates = collect_sync_candidates(layout)?;
1808    if candidates.is_empty() {
1809        return Ok(());
1810    }
1811
1812    sync_candidates(identity, candidates, false).await
1813}
1814
1815async fn sync_candidates(
1816    identity: &RepoIdentity,
1817    candidates: Vec<SyncCandidate>,
1818    resync: bool,
1819) -> Result<(), String> {
1820    let mut events = Vec::new();
1821    let mut commits = Vec::new();
1822    for candidate in &candidates {
1823        match &candidate.records {
1824            SyncRecords::Events(records) => events.extend(records.iter().cloned()),
1825            SyncRecords::Commits(records) => commits.extend(records.iter().cloned()),
1826        }
1827    }
1828
1829    let token = resolve_cli_access_token()?;
1830    let device = resolve_device_identity()?;
1831    let client = cli_request_client()?;
1832    let api = ApiConfig::from_env();
1833    let endpoint = api.cli_worktree_mutations_endpoint();
1834    let event_count = events.len();
1835    let commit_count = commits.len();
1836    let hardware_id = device.hardware_id;
1837    let hostname = current_hostname();
1838    let platform = std::env::consts::OS.to_string();
1839    let repo_root = identity.root.display().to_string();
1840    let batches = split_worktree_mutation_upload_batches(events.clone(), commits.clone());
1841    let batch_count = batches.len();
1842
1843    if batch_count > 1 {
1844        println!(
1845            "Uploading {} worktree mutation record(s) in {} batch(es) for {}/{} on {}.",
1846            event_count + commit_count,
1847            batch_count,
1848            identity.owner,
1849            identity.name,
1850            identity.branch
1851        );
1852    }
1853
1854    let mut status_code = 202;
1855    for attempt in 1..=WORKTREE_MUTATION_SYNC_MAX_ATTEMPTS {
1856        let attempt_batches =
1857            split_worktree_mutation_upload_batches(events.clone(), commits.clone());
1858        match upload_worktree_mutation_batches(
1859            &client,
1860            &endpoint,
1861            &token,
1862            &hardware_id,
1863            hostname.as_deref(),
1864            &platform,
1865            identity,
1866            &repo_root,
1867            attempt_batches,
1868        )
1869        .await
1870        {
1871            Ok(code) => {
1872                status_code = code;
1873                break;
1874            }
1875            Err(error)
1876                if error.kind == WorktreeMutationSyncErrorKind::Retryable
1877                    && attempt < WORKTREE_MUTATION_SYNC_MAX_ATTEMPTS =>
1878            {
1879                eprintln!(
1880                    "Worktree mutation sync attempt {attempt}/{} failed for {}/{} on {}: {} Restarting from the first batch in {} second(s).",
1881                    WORKTREE_MUTATION_SYNC_MAX_ATTEMPTS,
1882                    identity.owner,
1883                    identity.name,
1884                    identity.branch,
1885                    error.message,
1886                    WORKTREE_MUTATION_SYNC_RETRY_DELAY_SECONDS
1887                );
1888                tokio::time::sleep(Duration::from_secs(
1889                    WORKTREE_MUTATION_SYNC_RETRY_DELAY_SECONDS,
1890                ))
1891                .await;
1892            }
1893            Err(error) => return Err(error.message),
1894        }
1895    }
1896
1897    let layout = prepare_spool_layout(identity)?;
1898    append_sync_log(SyncLogAppend {
1899        identity,
1900        layout: &layout,
1901        endpoint: &endpoint,
1902        status_code,
1903        event_count,
1904        commit_count,
1905        candidates: &candidates,
1906        resync,
1907    })?;
1908
1909    for candidate in candidates {
1910        if !is_synced_spool_path(&candidate.path) {
1911            mark_synced(&candidate.path)?;
1912        }
1913    }
1914
1915    Ok(())
1916}
1917
1918async fn upload_worktree_mutation_batches(
1919    client: &reqwest::Client,
1920    endpoint: &str,
1921    token: &str,
1922    hardware_id: &str,
1923    hostname: Option<&str>,
1924    platform: &str,
1925    identity: &RepoIdentity,
1926    repo_root: &str,
1927    batches: Vec<WorktreeMutationUploadBatch>,
1928) -> Result<u16, WorktreeMutationSyncError> {
1929    let batch_count = batches.len();
1930    let mut status_code = 202;
1931
1932    for (batch_index, batch) in batches.into_iter().enumerate() {
1933        let batch_event_count = batch.events.len();
1934        let batch_commit_count = batch.commits.len();
1935        let payload = WorktreeMutationIngestPayload {
1936            device: WorktreeMutationDevicePayload {
1937                hardware_id: hardware_id.to_string(),
1938                device_name: hostname.map(str::to_string),
1939                hostname: hostname.map(str::to_string),
1940                platform: platform.to_string(),
1941            },
1942            repository: WorktreeMutationRepositoryPayload {
1943                owner: identity.owner.clone(),
1944                name: identity.name.clone(),
1945                branch_name: identity.branch.clone(),
1946                repo_root: repo_root.to_string(),
1947            },
1948            events: batch.events,
1949            commits: batch.commits,
1950        };
1951
1952        let response = client
1953            .post(endpoint)
1954            .bearer_auth(token)
1955            .json(&payload)
1956            .send()
1957            .await
1958            .map_err(|error| WorktreeMutationSyncError {
1959                kind: WorktreeMutationSyncErrorKind::Retryable,
1960                message: format!(
1961                    "Failed to upload worktree mutation batch {}/{}: {error}",
1962                    batch_index + 1,
1963                    batch_count
1964                ),
1965            })?;
1966
1967        if response.status() == StatusCode::UNAUTHORIZED {
1968            return Err(WorktreeMutationSyncError {
1969                kind: WorktreeMutationSyncErrorKind::NonRetryable,
1970                message: "Your stored CLI session is no longer valid. Run `xbp login` again."
1971                    .to_string(),
1972            });
1973        }
1974
1975        if !response.status().is_success() {
1976            let status = response.status();
1977            let body = response.text().await.unwrap_or_default();
1978            return Err(WorktreeMutationSyncError {
1979                kind: if should_retry_worktree_mutation_sync_status(status) {
1980                    WorktreeMutationSyncErrorKind::Retryable
1981                } else {
1982                    WorktreeMutationSyncErrorKind::NonRetryable
1983                },
1984                message: format!(
1985                    "Worktree mutation upload batch {}/{} failed with {status}: {body}",
1986                    batch_index + 1,
1987                    batch_count
1988                ),
1989            });
1990        }
1991
1992        status_code = response.status().as_u16();
1993        if batch_count > 1 {
1994            println!(
1995                "Uploaded worktree mutation batch {}/{} ({} event(s), {} commit(s)).",
1996                batch_index + 1,
1997                batch_count,
1998                batch_event_count,
1999                batch_commit_count
2000            );
2001        }
2002    }
2003
2004    Ok(status_code)
2005}
2006
2007fn should_retry_worktree_mutation_sync_status(status: StatusCode) -> bool {
2008    status.is_server_error()
2009        || status == StatusCode::REQUEST_TIMEOUT
2010        || status == StatusCode::TOO_MANY_REQUESTS
2011}
2012
2013fn split_worktree_mutation_upload_batches(
2014    events: Vec<WorktreeMutationEvent>,
2015    commits: Vec<WorktreeCommitEvent>,
2016) -> Vec<WorktreeMutationUploadBatch> {
2017    let mut batches = Vec::new();
2018    let mut current = empty_worktree_mutation_upload_batch();
2019
2020    for event in events {
2021        let estimated_json_bytes = serialized_json_len(&event);
2022        if should_start_next_worktree_mutation_upload_batch(&current, estimated_json_bytes) {
2023            batches.push(current);
2024            current = empty_worktree_mutation_upload_batch();
2025        }
2026        current.estimated_json_bytes += estimated_json_bytes;
2027        current.events.push(event);
2028    }
2029
2030    for commit in commits {
2031        let estimated_json_bytes = serialized_json_len(&commit);
2032        if should_start_next_worktree_mutation_upload_batch(&current, estimated_json_bytes) {
2033            batches.push(current);
2034            current = empty_worktree_mutation_upload_batch();
2035        }
2036        current.estimated_json_bytes += estimated_json_bytes;
2037        current.commits.push(commit);
2038    }
2039
2040    if current_record_count(&current) > 0 {
2041        batches.push(current);
2042    }
2043
2044    batches
2045}
2046
2047fn empty_worktree_mutation_upload_batch() -> WorktreeMutationUploadBatch {
2048    WorktreeMutationUploadBatch {
2049        events: Vec::new(),
2050        commits: Vec::new(),
2051        estimated_json_bytes: 0,
2052    }
2053}
2054
2055fn should_start_next_worktree_mutation_upload_batch(
2056    batch: &WorktreeMutationUploadBatch,
2057    next_record_json_bytes: usize,
2058) -> bool {
2059    if current_record_count(batch) == 0 {
2060        return false;
2061    }
2062
2063    current_record_count(batch) >= WORKTREE_MUTATION_SYNC_BATCH_RECORD_LIMIT
2064        || batch.estimated_json_bytes + next_record_json_bytes
2065            > WORKTREE_MUTATION_SYNC_BATCH_JSON_BYTES
2066}
2067
2068fn current_record_count(batch: &WorktreeMutationUploadBatch) -> usize {
2069    batch.events.len() + batch.commits.len()
2070}
2071
2072fn serialized_json_len<T: Serialize>(value: &T) -> usize {
2073    serde_json::to_vec(value)
2074        .map(|encoded| encoded.len())
2075        .unwrap_or(0)
2076}
2077
2078fn collect_sync_candidates(layout: &SpoolLayout) -> Result<Vec<SyncCandidate>, String> {
2079    collect_spool_candidates(layout, false)
2080}
2081
2082fn print_no_sync_candidates_message(
2083    target: &WorktreeWatchTargetOptions,
2084    resync: bool,
2085) -> Result<(), String> {
2086    if resync {
2087        println!("No local worktree mutation JSONL files found.");
2088        return Ok(());
2089    }
2090
2091    let identities = resolve_target_identities(target)?;
2092    let mut synced_files = 0usize;
2093    let mut synced_records = 0usize;
2094    for identity in identities {
2095        let layout = prepare_spool_layout(&identity)?;
2096        let all_candidates = collect_spool_candidates(&layout, true)?;
2097        let unsynced_candidates = collect_sync_candidates(&layout)?;
2098        synced_files += all_candidates
2099            .len()
2100            .saturating_sub(unsynced_candidates.len());
2101        let all_records = all_candidates
2102            .iter()
2103            .map(|candidate| candidate.records.len())
2104            .sum::<usize>();
2105        let unsynced_records = unsynced_candidates
2106            .iter()
2107            .map(|candidate| candidate.records.len())
2108            .sum::<usize>();
2109        synced_records += all_records.saturating_sub(unsynced_records);
2110    }
2111
2112    if synced_files > 0 {
2113        println!(
2114            "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."
2115        );
2116    } else {
2117        println!("No unsynced worktree mutation files found.");
2118    }
2119    Ok(())
2120}
2121
2122fn collect_spool_candidates(
2123    layout: &SpoolLayout,
2124    include_synced: bool,
2125) -> Result<Vec<SyncCandidate>, String> {
2126    let mut candidates = Vec::new();
2127    if !layout.root.exists() {
2128        return Ok(candidates);
2129    }
2130
2131    for entry in fs::read_dir(&layout.root).map_err(|error| {
2132        format!(
2133            "Failed to read spool directory {}: {error}",
2134            layout.root.display()
2135        )
2136    })? {
2137        let path = entry
2138            .map_err(|error| format!("Failed to read spool entry: {error}"))?
2139            .path();
2140        let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
2141            continue;
2142        };
2143        if !file_name.ends_with(".jsonl") || (!include_synced && file_name.contains(".synced.")) {
2144            continue;
2145        }
2146
2147        let kind = if file_name.starts_with("events-") {
2148            SyncFileKind::Events
2149        } else if file_name.starts_with("commits-") {
2150            SyncFileKind::Commits
2151        } else {
2152            continue;
2153        };
2154        let records = sanitize_spool_records(&path, kind, read_spool_records(&path, kind)?)?;
2155        if records.is_empty() {
2156            continue;
2157        }
2158        candidates.push(SyncCandidate { path, records });
2159    }
2160
2161    Ok(candidates)
2162}
2163
2164fn append_sync_log(request: SyncLogAppend<'_>) -> Result<(), String> {
2165    let entry = WorktreeWatchSyncLogEntry {
2166        id: Uuid::new_v4().to_string(),
2167        synced_at: Utc::now(),
2168        endpoint: request.endpoint.to_string(),
2169        repository_owner: request.identity.owner.clone(),
2170        repository_name: request.identity.name.clone(),
2171        branch_name: request.identity.branch.clone(),
2172        repo_root: request.identity.root.display().to_string(),
2173        resync: request.resync,
2174        status_code: request.status_code,
2175        event_count: request.event_count,
2176        commit_count: request.commit_count,
2177        spool_file_count: request.candidates.len(),
2178        spool_files: request
2179            .candidates
2180            .iter()
2181            .map(|candidate| candidate.path.display().to_string())
2182            .collect(),
2183    };
2184    append_json_line(&request.layout.sync_log_file, &entry)
2185}
2186
2187fn sanitize_spool_records(
2188    path: &Path,
2189    kind: SyncFileKind,
2190    records: SyncRecords,
2191) -> Result<SyncRecords, String> {
2192    match (kind, records) {
2193        (SyncFileKind::Events, SyncRecords::Events(records)) => {
2194            let mut sanitized = Vec::with_capacity(records.len());
2195            let mut changed = false;
2196
2197            for record in records {
2198                let original = serde_json::to_value(&record).ok();
2199                let Some(record) = sanitize_spooled_event(record) else {
2200                    changed = true;
2201                    continue;
2202                };
2203                if !changed {
2204                    changed = serde_json::to_value(&record).ok() != original;
2205                }
2206                sanitized.push(record);
2207            }
2208
2209            if changed {
2210                rewrite_jsonl_records(path, &sanitized)?;
2211            }
2212
2213            Ok(SyncRecords::Events(sanitized))
2214        }
2215        (_, records) => Ok(records),
2216    }
2217}
2218
2219fn sanitize_spooled_event(mut event: WorktreeMutationEvent) -> Option<WorktreeMutationEvent> {
2220    event.paths = dedupe_filtered_spool_paths(event.paths);
2221    event.primary_path = sanitize_optional_spool_path(event.primary_path);
2222    event.old_path = sanitize_optional_spool_path(event.old_path);
2223    event.new_path = sanitize_optional_spool_path(event.new_path);
2224
2225    if event.paths.is_empty() {
2226        if let Some(path) = event.primary_path.clone() {
2227            event.paths.push(path);
2228        }
2229        if let Some(path) = event.old_path.clone() {
2230            if !event.paths.iter().any(|existing| existing == &path) {
2231                event.paths.push(path);
2232            }
2233        }
2234        if let Some(path) = event.new_path.clone() {
2235            if !event.paths.iter().any(|existing| existing == &path) {
2236                event.paths.push(path);
2237            }
2238        }
2239    }
2240
2241    if event.primary_path.is_none() {
2242        event.primary_path = event.paths.first().cloned();
2243    }
2244
2245    if event.paths.is_empty() {
2246        return None;
2247    }
2248
2249    Some(event)
2250}
2251
2252fn sanitize_optional_spool_path(path: Option<String>) -> Option<String> {
2253    path.and_then(sanitize_spool_path)
2254}
2255
2256fn sanitize_spool_path(path: String) -> Option<String> {
2257    let trimmed = path.trim();
2258    if trimmed.is_empty() || path_string_has_ignored_component(trimmed) {
2259        return None;
2260    }
2261    Some(trimmed.replace('\\', "/"))
2262}
2263
2264fn dedupe_filtered_spool_paths(paths: Vec<String>) -> Vec<String> {
2265    let mut sanitized = Vec::with_capacity(paths.len());
2266    for path in paths {
2267        let Some(path) = sanitize_spool_path(path) else {
2268            continue;
2269        };
2270        if !sanitized.iter().any(|existing| existing == &path) {
2271            sanitized.push(path);
2272        }
2273    }
2274    sanitized
2275}
2276
2277fn rewrite_jsonl_records<T: Serialize>(path: &Path, records: &[T]) -> Result<(), String> {
2278    if records.is_empty() {
2279        if path.exists() {
2280            fs::remove_file(path).map_err(|error| {
2281                format!(
2282                    "Failed to remove emptied spool file {}: {error}",
2283                    path.display()
2284                )
2285            })?;
2286        }
2287        return Ok(());
2288    }
2289
2290    let temp_path = path.with_extension(format!(
2291        "{}.{}.tmp",
2292        path.extension()
2293            .and_then(|value| value.to_str())
2294            .unwrap_or("jsonl"),
2295        Uuid::new_v4()
2296    ));
2297    {
2298        let mut file = File::create(&temp_path).map_err(|error| {
2299            format!(
2300                "Failed to create temporary spool file {}: {error}",
2301                temp_path.display()
2302            )
2303        })?;
2304        for record in records {
2305            let line = serde_json::to_string(record)
2306                .map_err(|error| format!("Failed to serialize worktree event: {error}"))?;
2307            writeln!(file, "{line}").map_err(|error| {
2308                format!(
2309                    "Failed to write temporary spool file {}: {error}",
2310                    temp_path.display()
2311                )
2312            })?;
2313        }
2314    }
2315
2316    if path.exists() {
2317        fs::remove_file(path).map_err(|error| {
2318            format!(
2319                "Failed to replace rewritten spool file {}: {error}",
2320                path.display()
2321            )
2322        })?;
2323    }
2324
2325    fs::rename(&temp_path, path)
2326        .map_err(|error| format!("Failed to rewrite spool file {}: {error}", path.display()))
2327}
2328
2329fn read_last_sync_log_entry(path: &Path) -> Result<Option<WorktreeWatchSyncLogEntry>, String> {
2330    if !path.exists() {
2331        return Ok(None);
2332    }
2333    let values = read_jsonl_values(path)?;
2334    let Some(value) = values.into_iter().last() else {
2335        return Ok(None);
2336    };
2337    serde_json::from_value(value)
2338        .map(Some)
2339        .map_err(|error| format!("Failed to decode sync log {}: {error}", path.display()))
2340}
2341
2342fn is_synced_spool_path(path: &Path) -> bool {
2343    path.file_name()
2344        .and_then(|value| value.to_str())
2345        .map(|value| value.contains(".synced."))
2346        .unwrap_or(false)
2347}
2348
2349fn mark_synced(path: &Path) -> Result<(), String> {
2350    let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
2351        return Ok(());
2352    };
2353    let synced_name = file_name.replace(
2354        ".jsonl",
2355        &format!(".synced.{}.jsonl", Utc::now().timestamp()),
2356    );
2357    let synced_path = path.with_file_name(synced_name);
2358    fs::rename(path, &synced_path).map_err(|error| {
2359        format!(
2360            "Failed to mark spool file {} as synced: {error}",
2361            path.display()
2362        )
2363    })
2364}
2365
2366fn read_spool_records(path: &Path, kind: SyncFileKind) -> Result<SyncRecords, String> {
2367    match kind {
2368        SyncFileKind::Events => read_jsonl_records::<WorktreeMutationEvent>(path)
2369            .map(SyncRecords::Events)
2370            .map_err(|error| format!("Failed to decode event from {}: {error}", path.display())),
2371        SyncFileKind::Commits => read_jsonl_records::<WorktreeCommitEvent>(path)
2372            .map(SyncRecords::Commits)
2373            .map_err(|error| format!("Failed to decode commit from {}: {error}", path.display())),
2374    }
2375}
2376
2377fn read_jsonl_records<T>(path: &Path) -> Result<Vec<T>, String>
2378where
2379    T: for<'de> Deserialize<'de>,
2380{
2381    let file = File::open(path)
2382        .map_err(|error| format!("Failed to open spool file {}: {error}", path.display()))?;
2383    let reader = BufReader::new(file);
2384    let mut records = Vec::new();
2385    for line in reader.lines() {
2386        let line =
2387            line.map_err(|error| format!("Failed to read spool file {}: {error}", path.display()))?;
2388        if line.trim().is_empty() {
2389            continue;
2390        }
2391        records.push(serde_json::from_str(&line).map_err(|error| {
2392            format!(
2393                "Failed to parse JSONL record in {}: {error}",
2394                path.display()
2395            )
2396        })?);
2397    }
2398    Ok(records)
2399}
2400
2401fn read_jsonl_values(path: &Path) -> Result<Vec<Value>, String> {
2402    read_jsonl_records(path)
2403}
2404
2405fn spawn_detached_worktree_watch(repo: Option<&Path>) -> Result<PathBuf, String> {
2406    let identity = resolve_repo_identity(repo)?;
2407    spawn_detached_worktree_watch_for_identity(&identity)
2408}
2409
2410fn spawn_detached_parent_worktree_watch(parent: &Path) -> Result<PathBuf, String> {
2411    let parent = canonical_parent_path(parent)?;
2412    let layout = prepare_parent_spool_layout(&parent)?;
2413    replace_existing_parent_watcher(&parent, &layout)?;
2414    let executable = std::env::current_exe()
2415        .map_err(|error| format!("Failed to resolve current XBP executable: {error}"))?;
2416    let mut command = Command::new(&executable);
2417    command
2418        .arg("worktree-watch")
2419        .arg("start")
2420        .arg("--parent")
2421        .arg(&parent)
2422        .env(BACKGROUND_CHILD_ENV, "1")
2423        .current_dir(&parent)
2424        .stdin(Stdio::null())
2425        .stdout(Stdio::null())
2426        .stderr(Stdio::null());
2427
2428    #[cfg(windows)]
2429    {
2430        use std::os::windows::process::CommandExt;
2431        command.creation_flags(CREATE_NO_WINDOW);
2432    }
2433
2434    let child = command
2435        .spawn()
2436        .map_err(|error| format!("Failed to spawn background parent worktree watcher: {error}"))?;
2437    write_parent_watcher_state(&parent, &layout, child.id(), &executable)?;
2438    Ok(parent)
2439}
2440
2441fn spawn_detached_worktree_watch_for_identity(identity: &RepoIdentity) -> Result<PathBuf, String> {
2442    let layout = prepare_spool_layout(identity)?;
2443    replace_existing_watcher(identity, &layout)?;
2444    let executable = std::env::current_exe()
2445        .map_err(|error| format!("Failed to resolve current XBP executable: {error}"))?;
2446    let mut command = Command::new(&executable);
2447    command
2448        .arg("worktree-watch")
2449        .arg("start")
2450        .arg("--repo")
2451        .arg(&identity.root)
2452        .env(BACKGROUND_CHILD_ENV, "1")
2453        .current_dir(&identity.root)
2454        .stdin(Stdio::null())
2455        .stdout(Stdio::null())
2456        .stderr(Stdio::null());
2457
2458    #[cfg(windows)]
2459    {
2460        use std::os::windows::process::CommandExt;
2461        command.creation_flags(CREATE_NO_WINDOW);
2462    }
2463
2464    let child: std::process::Child = command
2465        .spawn()
2466        .map_err(|error| format!("Failed to spawn background worktree watcher: {error}"))?;
2467    write_watcher_state(identity, &layout, child.id(), &executable)?;
2468    Ok(identity.root.clone())
2469}
2470
2471fn replace_existing_parent_watcher(
2472    parent: &Path,
2473    layout: &ParentSpoolLayout,
2474) -> Result<(), String> {
2475    let Some(state) = read_parent_watcher_state(&layout.watcher_state_file)? else {
2476        return Ok(());
2477    };
2478
2479    if path_identity_key(parent) != path_identity_key(Path::new(&state.parent_root)) {
2480        return Ok(());
2481    }
2482
2483    if is_matching_parent_watcher_process(&state) {
2484        stop_parent_process(&state, false)?;
2485        println!(
2486            "Replaced existing background parent worktree watcher {} for {}",
2487            state.pid,
2488            parent.display()
2489        );
2490        let _ = fs::remove_file(&layout.watcher_state_file);
2491        return Ok(());
2492    }
2493
2494    if parent_watcher_state_pid_exists_with_same_executable(&state) {
2495        return Err(format!(
2496            "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.",
2497            parent.display(),
2498            state.pid,
2499            parent.display()
2500        ));
2501    }
2502
2503    let _ = fs::remove_file(&layout.watcher_state_file);
2504    Ok(())
2505}
2506
2507fn replace_existing_watcher(identity: &RepoIdentity, layout: &SpoolLayout) -> Result<(), String> {
2508    let Some(state) = read_watcher_state(&layout.watcher_state_file)? else {
2509        return Ok(());
2510    };
2511
2512    if !same_repo_watcher_state(identity, &state) {
2513        return Ok(());
2514    }
2515
2516    if is_matching_watcher_process(&state) {
2517        stop_process(&state, false)?;
2518        println!(
2519            "Replaced existing background worktree watcher {} for {}",
2520            state.pid,
2521            identity.root.display()
2522        );
2523        let _ = fs::remove_file(&layout.watcher_state_file);
2524        return Ok(());
2525    }
2526
2527    if watcher_state_pid_exists_with_same_executable(&state) {
2528        return Err(format!(
2529            "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.",
2530            identity.root.display(),
2531            state.pid,
2532            identity.root.display()
2533        ));
2534    }
2535
2536    let _ = fs::remove_file(&layout.watcher_state_file);
2537    Ok(())
2538}
2539
2540fn stop_existing_watcher(
2541    identity: &RepoIdentity,
2542    layout: &SpoolLayout,
2543    force: bool,
2544) -> Result<StopWatcherOutcome, String> {
2545    let Some(state) = read_watcher_state(&layout.watcher_state_file)? else {
2546        return Ok(StopWatcherOutcome::NoState);
2547    };
2548
2549    if !same_repo_watcher_state(identity, &state) {
2550        return Ok(StopWatcherOutcome::NoState);
2551    }
2552
2553    if is_matching_watcher_process(&state) || force {
2554        let pid = state.pid;
2555        stop_process(&state, force)?;
2556        let _ = fs::remove_file(&layout.watcher_state_file);
2557        return Ok(StopWatcherOutcome::Stopped(pid));
2558    }
2559
2560    if watcher_state_pid_exists_with_same_executable(&state) {
2561        return Err(format!(
2562            "Watcher state for {} points to running XBP process {}, but its command line could not be verified. Re-run with `--force` to stop that PID.",
2563            identity.root.display(),
2564            state.pid
2565        ));
2566    }
2567
2568    let _ = fs::remove_file(&layout.watcher_state_file);
2569    Ok(StopWatcherOutcome::RemovedStaleState)
2570}
2571
2572fn stop_existing_parent_watcher(parent: &Path, force: bool) -> Result<StopWatcherOutcome, String> {
2573    let layout = prepare_parent_spool_layout(parent)?;
2574    let Some(state) = read_parent_watcher_state(&layout.watcher_state_file)? else {
2575        return Ok(StopWatcherOutcome::NoState);
2576    };
2577
2578    if path_identity_key(parent) != path_identity_key(Path::new(&state.parent_root)) {
2579        return Ok(StopWatcherOutcome::NoState);
2580    }
2581
2582    if is_matching_parent_watcher_process(&state) || force {
2583        let pid = state.pid;
2584        stop_parent_process(&state, force)?;
2585        let _ = fs::remove_file(&layout.watcher_state_file);
2586        return Ok(StopWatcherOutcome::Stopped(pid));
2587    }
2588
2589    if parent_watcher_state_pid_exists_with_same_executable(&state) {
2590        return Err(format!(
2591            "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.",
2592            parent.display(),
2593            state.pid
2594        ));
2595    }
2596
2597    let _ = fs::remove_file(&layout.watcher_state_file);
2598    Ok(StopWatcherOutcome::RemovedStaleState)
2599}
2600
2601fn read_watcher_state(path: &Path) -> Result<Option<WorktreeWatcherState>, String> {
2602    if !path.exists() {
2603        return Ok(None);
2604    }
2605    let raw = fs::read_to_string(path)
2606        .map_err(|error| format!("Failed to read watcher state {}: {error}", path.display()))?;
2607    serde_json::from_str(&raw)
2608        .map(Some)
2609        .map_err(|error| format!("Failed to parse watcher state {}: {error}", path.display()))
2610}
2611
2612fn read_parent_watcher_state(path: &Path) -> Result<Option<ParentWorktreeWatcherState>, String> {
2613    if !path.exists() {
2614        return Ok(None);
2615    }
2616    let raw = fs::read_to_string(path).map_err(|error| {
2617        format!(
2618            "Failed to read parent watcher state {}: {error}",
2619            path.display()
2620        )
2621    })?;
2622    serde_json::from_str(&raw).map(Some).map_err(|error| {
2623        format!(
2624            "Failed to parse parent watcher state {}: {error}",
2625            path.display()
2626        )
2627    })
2628}
2629
2630fn write_watcher_state(
2631    identity: &RepoIdentity,
2632    layout: &SpoolLayout,
2633    pid: u32,
2634    executable: &Path,
2635) -> Result<(), String> {
2636    let state = WorktreeWatcherState {
2637        pid,
2638        repo_root: identity.root.display().to_string(),
2639        repository_owner: identity.owner.clone(),
2640        repository_name: identity.name.clone(),
2641        branch_name: identity.branch.clone(),
2642        executable: executable.display().to_string(),
2643        started_at: Utc::now(),
2644    };
2645    let rendered = serde_json::to_string_pretty(&state)
2646        .map_err(|error| format!("Failed to serialize watcher state: {error}"))?;
2647    fs::write(&layout.watcher_state_file, format!("{rendered}\n")).map_err(|error| {
2648        format!(
2649            "Failed to write watcher state {}: {error}",
2650            layout.watcher_state_file.display()
2651        )
2652    })
2653}
2654
2655fn write_parent_watcher_state(
2656    parent: &Path,
2657    layout: &ParentSpoolLayout,
2658    pid: u32,
2659    executable: &Path,
2660) -> Result<(), String> {
2661    let state = ParentWorktreeWatcherState {
2662        pid,
2663        parent_root: parent.display().to_string(),
2664        executable: executable.display().to_string(),
2665        started_at: Utc::now(),
2666    };
2667    let rendered = serde_json::to_string_pretty(&state)
2668        .map_err(|error| format!("Failed to serialize parent watcher state: {error}"))?;
2669    fs::write(&layout.watcher_state_file, format!("{rendered}\n")).map_err(|error| {
2670        format!(
2671            "Failed to write parent watcher state {}: {error}",
2672            layout.watcher_state_file.display()
2673        )
2674    })
2675}
2676
2677fn same_repo_watcher_state(identity: &RepoIdentity, state: &WorktreeWatcherState) -> bool {
2678    path_identity_key(&identity.root) == path_identity_key(Path::new(&state.repo_root))
2679        && identity.owner == state.repository_owner
2680        && identity.name == state.repository_name
2681        && identity.branch == state.branch_name
2682}
2683
2684fn is_matching_watcher_process(state: &WorktreeWatcherState) -> bool {
2685    if state.pid == std::process::id() {
2686        return false;
2687    }
2688    let system = System::new_all();
2689    let Some(process) = system.process(Pid::from_u32(state.pid)) else {
2690        return false;
2691    };
2692    let command_line = process
2693        .cmd()
2694        .iter()
2695        .map(|part| part.to_string_lossy())
2696        .collect::<Vec<_>>()
2697        .join(" ");
2698    command_line.contains("worktree-watch")
2699        && command_line.contains("start")
2700        && command_line.contains(&state.repo_root)
2701        || watcher_state_process_identity_matches(process, state)
2702}
2703
2704fn is_matching_parent_watcher_process(state: &ParentWorktreeWatcherState) -> bool {
2705    if state.pid == std::process::id() {
2706        return false;
2707    }
2708    let system = System::new_all();
2709    let Some(process) = system.process(Pid::from_u32(state.pid)) else {
2710        return false;
2711    };
2712    let command_line = process
2713        .cmd()
2714        .iter()
2715        .map(|part| part.to_string_lossy())
2716        .collect::<Vec<_>>()
2717        .join(" ");
2718    (command_line.contains("worktree-watch")
2719        && command_line.contains("start")
2720        && command_line.contains("--parent")
2721        && command_line.contains(&state.parent_root))
2722        || parent_watcher_state_process_identity_matches(process, state)
2723}
2724
2725fn watcher_state_pid_exists_with_same_executable(state: &WorktreeWatcherState) -> bool {
2726    if state.pid == std::process::id() {
2727        return false;
2728    }
2729    let system = System::new_all();
2730    let Some(process) = system.process(Pid::from_u32(state.pid)) else {
2731        return false;
2732    };
2733    process_executable_matches_state(process, state)
2734}
2735
2736fn parent_watcher_state_pid_exists_with_same_executable(
2737    state: &ParentWorktreeWatcherState,
2738) -> bool {
2739    if state.pid == std::process::id() {
2740        return false;
2741    }
2742    let system = System::new_all();
2743    let Some(process) = system.process(Pid::from_u32(state.pid)) else {
2744        return false;
2745    };
2746    process_executable_matches_path(process, Path::new(&state.executable))
2747}
2748
2749fn watcher_state_process_identity_matches(
2750    process: &sysinfo::Process,
2751    state: &WorktreeWatcherState,
2752) -> bool {
2753    process_executable_matches_state(process, state)
2754        && process_start_time_matches_state(process, state)
2755}
2756
2757fn parent_watcher_state_process_identity_matches(
2758    process: &sysinfo::Process,
2759    state: &ParentWorktreeWatcherState,
2760) -> bool {
2761    process_executable_matches_path(process, Path::new(&state.executable))
2762        && parent_process_start_time_matches_state(process, state)
2763}
2764
2765fn process_executable_matches_state(
2766    process: &sysinfo::Process,
2767    state: &WorktreeWatcherState,
2768) -> bool {
2769    process_executable_matches_path(process, Path::new(&state.executable))
2770}
2771
2772fn process_executable_matches_path(process: &sysinfo::Process, executable: &Path) -> bool {
2773    let expected = path_identity_key(executable);
2774    process
2775        .exe()
2776        .map(|path| path_identity_key(path) == expected)
2777        .unwrap_or(false)
2778}
2779
2780fn process_start_time_matches_state(
2781    process: &sysinfo::Process,
2782    state: &WorktreeWatcherState,
2783) -> bool {
2784    let process_started = process.start_time() as i64;
2785    let state_started = state.started_at.timestamp();
2786    (process_started - state_started).abs() <= 10
2787}
2788
2789fn parent_process_start_time_matches_state(
2790    process: &sysinfo::Process,
2791    state: &ParentWorktreeWatcherState,
2792) -> bool {
2793    let process_started = process.start_time() as i64;
2794    let state_started = state.started_at.timestamp();
2795    (process_started - state_started).abs() <= 10
2796}
2797
2798fn stop_process(state: &WorktreeWatcherState, force: bool) -> Result<(), String> {
2799    let system = System::new_all();
2800    let Some(process) = system.process(Pid::from_u32(state.pid)) else {
2801        return Ok(());
2802    };
2803    if !force && !is_matching_watcher_process(state) {
2804        return Ok(());
2805    }
2806    if process.kill() {
2807        Ok(())
2808    } else {
2809        Err(format!(
2810            "Failed to stop existing watcher process {}",
2811            state.pid
2812        ))
2813    }
2814}
2815
2816fn stop_parent_process(state: &ParentWorktreeWatcherState, force: bool) -> Result<(), String> {
2817    let system = System::new_all();
2818    let Some(process) = system.process(Pid::from_u32(state.pid)) else {
2819        return Ok(());
2820    };
2821    if !force && !is_matching_parent_watcher_process(state) {
2822        return Ok(());
2823    }
2824    if process.kill() {
2825        Ok(())
2826    } else {
2827        Err(format!(
2828            "Failed to stop existing parent watcher process {}",
2829            state.pid
2830        ))
2831    }
2832}
2833
2834fn discover_repo_identities_under(parent: &Path) -> Result<Vec<RepoIdentity>, String> {
2835    let parent = canonical_parent_path(parent)?;
2836
2837    let mut identities = Vec::new();
2838    let mut seen = BTreeSet::new();
2839    discover_repo_identities_in_dir(&parent, &parent, &mut seen, &mut identities)?;
2840    identities.sort_by(|left, right| left.root.cmp(&right.root));
2841    Ok(identities)
2842}
2843
2844fn discover_repo_identities_in_dir(
2845    parent: &Path,
2846    dir: &Path,
2847    seen: &mut BTreeSet<String>,
2848    identities: &mut Vec<RepoIdentity>,
2849) -> Result<(), String> {
2850    if dir != parent && is_skipped_discovery_dir(dir) {
2851        return Ok(());
2852    }
2853
2854    if dir.join(".git").exists() {
2855        if let Ok(identity) = resolve_repo_identity(Some(dir)) {
2856            let key = path_identity_key(&identity.root);
2857            if seen.insert(key) {
2858                identities.push(identity);
2859            }
2860            return Ok(());
2861        }
2862    }
2863
2864    let entries = fs::read_dir(dir)
2865        .map_err(|error| format!("Failed to read folder {}: {error}", dir.display()))?;
2866    for entry in entries {
2867        let entry = entry.map_err(|error| {
2868            format!(
2869                "Failed to read folder entry under {}: {error}",
2870                dir.display()
2871            )
2872        })?;
2873        let file_type = entry
2874            .file_type()
2875            .map_err(|error| format!("Failed to inspect {}: {error}", entry.path().display()))?;
2876        if file_type.is_dir() {
2877            discover_repo_identities_in_dir(parent, &entry.path(), seen, identities)?;
2878        }
2879    }
2880
2881    Ok(())
2882}
2883
2884fn resolve_repo_identity(repo: Option<&Path>) -> Result<RepoIdentity, String> {
2885    let start = match repo {
2886        Some(path) => path.to_path_buf(),
2887        None => std::env::current_dir()
2888            .map_err(|error| format!("Failed to resolve current directory: {error}"))?,
2889    };
2890    let root_raw = git_output(&start, &["rev-parse", "--show-toplevel"])?;
2891    let root = normalize_windows_verbatim_path(
2892        fs::canonicalize(root_raw.trim()).unwrap_or_else(|_| PathBuf::from(root_raw.trim())),
2893    );
2894    let branch = git_output(&root, &["rev-parse", "--abbrev-ref", "HEAD"])
2895        .unwrap_or_else(|_| "unknown".to_string());
2896    let remote = git_output(&root, &["remote", "get-url", DEFAULT_REMOTE]).unwrap_or_default();
2897    let (owner, name) = parse_remote_owner_repo(&remote).unwrap_or_else(|| {
2898        let name = root
2899            .file_name()
2900            .and_then(|value| value.to_str())
2901            .unwrap_or("repository")
2902            .to_string();
2903        ("unknown".to_string(), name)
2904    });
2905    let head_sha = current_head(&root)?;
2906
2907    Ok(RepoIdentity {
2908        owner,
2909        name,
2910        branch: sanitize_path_component(branch.trim()),
2911        root,
2912        head_sha,
2913    })
2914}
2915
2916fn prepare_spool_layout(identity: &RepoIdentity) -> Result<SpoolLayout, String> {
2917    let home = dirs::home_dir().ok_or_else(|| "Failed to resolve home directory.".to_string())?;
2918    let run_id = Uuid::new_v4().to_string();
2919    let root = home
2920        .join(".xbp")
2921        .join("mutations")
2922        .join(sanitize_path_component(&identity.owner))
2923        .join(sanitize_path_component(&identity.name))
2924        .join(sanitize_path_component(&identity.branch));
2925    fs::create_dir_all(&root).map_err(|error| {
2926        format!(
2927            "Failed to create worktree mutation spool {}: {error}",
2928            root.display()
2929        )
2930    })?;
2931    let event_dedupe_dir = root.join("event-dedupe");
2932    fs::create_dir_all(&event_dedupe_dir).map_err(|error| {
2933        format!(
2934            "Failed to create worktree mutation dedupe dir {}: {error}",
2935            event_dedupe_dir.display()
2936        )
2937    })?;
2938    let commit_dedupe_dir = root.join("commit-dedupe");
2939    fs::create_dir_all(&commit_dedupe_dir).map_err(|error| {
2940        format!(
2941            "Failed to create worktree commit dedupe dir {}: {error}",
2942            commit_dedupe_dir.display()
2943        )
2944    })?;
2945    Ok(SpoolLayout {
2946        events_file: root.join(format!("events-{run_id}.jsonl")),
2947        commits_file: root.join(format!("commits-{run_id}.jsonl")),
2948        stats_file: root.join("stats.json"),
2949        watcher_state_file: root.join("watcher-state.json"),
2950        sync_log_file: root.join("sync-log.jsonl"),
2951        event_dedupe_file: root.join("event-dedupe.jsonl"),
2952        event_dedupe_dir,
2953        commit_dedupe_dir,
2954        root,
2955    })
2956}
2957
2958fn prepare_parent_spool_layout(parent: &Path) -> Result<ParentSpoolLayout, String> {
2959    let home = dirs::home_dir().ok_or_else(|| "Failed to resolve home directory.".to_string())?;
2960    let parent = canonical_parent_path(parent)?;
2961    let parent_key = path_identity_key(&parent);
2962    let mut hasher = Sha256::new();
2963    hasher.update(parent_key.as_bytes());
2964    let digest = format!("{:x}", hasher.finalize());
2965    let label = parent
2966        .file_name()
2967        .and_then(|value| value.to_str())
2968        .map(sanitize_path_component)
2969        .filter(|value| !value.is_empty())
2970        .unwrap_or_else(|| "parent".to_string());
2971    let root = home
2972        .join(".xbp")
2973        .join("mutations")
2974        .join("parents")
2975        .join(format!("{}-{}", label, &digest[..16]));
2976    fs::create_dir_all(&root).map_err(|error| {
2977        format!(
2978            "Failed to create parent worktree watcher state dir {}: {error}",
2979            root.display()
2980        )
2981    })?;
2982    Ok(ParentSpoolLayout {
2983        watcher_state_file: root.join("watcher-state.json"),
2984    })
2985}
2986
2987fn canonical_parent_path(parent: &Path) -> Result<PathBuf, String> {
2988    let normalized = normalize_windows_verbatim_path(
2989        fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf()),
2990    );
2991    if !normalized.is_dir() {
2992        return Err(format!(
2993            "Parent folder {} does not exist or is not a directory.",
2994            normalized.display()
2995        ));
2996    }
2997    Ok(normalized)
2998}
2999
3000fn append_json_line<T: Serialize>(path: &Path, value: &T) -> Result<(), String> {
3001    let mut file = OpenOptions::new()
3002        .create(true)
3003        .append(true)
3004        .open(path)
3005        .map_err(|error| format!("Failed to open spool file {}: {error}", path.display()))?;
3006    let line = serde_json::to_string(value)
3007        .map_err(|error| format!("Failed to serialize worktree event: {error}"))?;
3008    writeln!(file, "{line}")
3009        .map_err(|error| format!("Failed to append spool file {}: {error}", path.display()))
3010}
3011
3012fn git_numstat_for_path(repo_root: &Path, relative_path: &str) -> Result<(u64, u64), String> {
3013    let output = Command::new("git")
3014        .args(["diff", "--numstat", "--"])
3015        .arg(relative_path)
3016        .current_dir(repo_root)
3017        .output()
3018        .map_err(|error| format!("Failed to run git diff --numstat: {error}"))?;
3019    if !output.status.success() {
3020        return Ok((0, 0));
3021    }
3022    let stdout = String::from_utf8_lossy(&output.stdout);
3023    let mut added = 0;
3024    let mut removed = 0;
3025    for line in stdout.lines() {
3026        let mut parts = line.split_whitespace();
3027        added += parse_numstat_count(parts.next());
3028        removed += parse_numstat_count(parts.next());
3029    }
3030    Ok((added, removed))
3031}
3032
3033fn file_line_count_for_path(repo_root: &Path, relative_path: &str) -> Result<u64, String> {
3034    let path = repo_root.join(relative_path);
3035    let content = fs::read_to_string(&path)
3036        .map_err(|error| format!("Failed to read file {}: {error}", path.display()))?;
3037    Ok(content.lines().count() as u64)
3038}
3039
3040fn parse_numstat_count(value: Option<&str>) -> u64 {
3041    value.and_then(|raw| raw.parse::<u64>().ok()).unwrap_or(0)
3042}
3043
3044fn current_head(repo_root: &Path) -> Result<Option<String>, String> {
3045    match git_output(repo_root, &["rev-parse", "HEAD"]) {
3046        Ok(value) => Ok(Some(value)),
3047        Err(error)
3048            if error.contains("unknown revision") || error.contains("ambiguous argument") =>
3049        {
3050            Ok(None)
3051        }
3052        Err(error) => Err(error),
3053    }
3054}
3055
3056fn git_output(repo_root: &Path, args: &[&str]) -> Result<String, String> {
3057    let output = Command::new("git")
3058        .args(args)
3059        .current_dir(repo_root)
3060        .output()
3061        .map_err(|error| format!("Failed to run git {}: {error}", args.join(" ")))?;
3062    if !output.status.success() {
3063        return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
3064    }
3065    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
3066}
3067
3068fn parse_remote_owner_repo(remote: &str) -> Option<(String, String)> {
3069    let trimmed = remote.trim().trim_end_matches(".git");
3070    if trimmed.is_empty() {
3071        return None;
3072    }
3073
3074    let path_part = if !trimmed.contains("://") {
3075        if let Some((_, path)) = trimmed.rsplit_once(':') {
3076            path
3077        } else {
3078            trimmed
3079        }
3080    } else {
3081        trimmed
3082            .trim_start_matches("https://")
3083            .trim_start_matches("http://")
3084            .trim_start_matches("ssh://")
3085            .split_once('/')
3086            .map(|(_, path)| path)
3087            .unwrap_or(trimmed)
3088    };
3089    let mut parts = path_part.rsplitn(2, '/');
3090    let name = parts.next()?.trim();
3091    let owner = parts.next()?.trim();
3092    if owner.is_empty() || name.is_empty() {
3093        return None;
3094    }
3095    Some((
3096        sanitize_path_component(owner),
3097        sanitize_path_component(name.trim_end_matches(".git")),
3098    ))
3099}
3100
3101fn sanitize_path_component(value: &str) -> String {
3102    let sanitized: String = value
3103        .chars()
3104        .map(|ch| {
3105            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
3106                ch
3107            } else {
3108                '-'
3109            }
3110        })
3111        .collect();
3112    sanitized
3113        .trim_matches('-')
3114        .chars()
3115        .take(160)
3116        .collect::<String>()
3117}
3118
3119fn normalize_windows_verbatim_path(path: PathBuf) -> PathBuf {
3120    PathBuf::from(strip_windows_verbatim_prefix(&path.to_string_lossy()))
3121}
3122
3123fn strip_windows_verbatim_prefix(input: &str) -> &str {
3124    input.strip_prefix(r"\\?\").unwrap_or(input)
3125}
3126
3127fn path_identity_key(path: &Path) -> String {
3128    let value = path.to_string_lossy().to_string();
3129    if cfg!(windows) {
3130        value.to_ascii_lowercase()
3131    } else {
3132        value
3133    }
3134}
3135
3136fn is_generated_or_vcs_dir_name(name: &str) -> bool {
3137    matches!(
3138        name,
3139        ".git"
3140            | ".hg"
3141            | ".svn"
3142            | ".next"
3143            | ".turbo"
3144            | ".vercel"
3145            | "node_modules"
3146            | "target"
3147            | "dist"
3148            | "build"
3149    )
3150}
3151
3152fn path_string_has_ignored_component(path: &str) -> bool {
3153    path.split(['/', '\\'])
3154        .filter(|component| !component.is_empty())
3155        .any(is_generated_or_vcs_dir_name)
3156}
3157
3158fn is_skipped_discovery_dir(path: &Path) -> bool {
3159    let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
3160        return false;
3161    };
3162    is_generated_or_vcs_dir_name(name)
3163}
3164
3165fn relative_slash_path(root: &Path, path: &Path) -> Option<String> {
3166    let relative = path.strip_prefix(root).ok().unwrap_or(path);
3167    Some(relative.to_string_lossy().replace('\\', "/"))
3168}
3169
3170fn is_ignored_path(root: &Path, path: &Path) -> bool {
3171    let Ok(relative) = path.strip_prefix(root) else {
3172        return false;
3173    };
3174    path_string_has_ignored_component(&relative.to_string_lossy())
3175}
3176
3177fn current_hostname() -> Option<String> {
3178    std::env::var("COMPUTERNAME")
3179        .or_else(|_| std::env::var("HOSTNAME"))
3180        .ok()
3181        .map(|value| value.trim().to_string())
3182        .filter(|value| !value.is_empty())
3183}
3184
3185fn is_background_child() -> bool {
3186    std::env::var(BACKGROUND_CHILD_ENV)
3187        .ok()
3188        .map(|value| value == "1")
3189        .unwrap_or(false)
3190}
3191
3192#[allow(dead_code)]
3193fn content_sha256(path: &Path) -> Option<String> {
3194    let bytes = fs::read(path).ok()?;
3195    let mut hasher = Sha256::new();
3196    hasher.update(bytes);
3197    Some(format!("{:x}", hasher.finalize()))
3198}
3199
3200#[cfg(test)]
3201mod tests {
3202    use super::*;
3203
3204    #[test]
3205    fn parses_https_and_ssh_remote_urls() {
3206        assert_eq!(
3207            parse_remote_owner_repo("https://github.com/xylex-group/xbp.git"),
3208            Some(("xylex-group".to_string(), "xbp".to_string()))
3209        );
3210        assert_eq!(
3211            parse_remote_owner_repo("git@github.com:xylex-group/xbp.git"),
3212            Some(("xylex-group".to_string(), "xbp".to_string()))
3213        );
3214    }
3215
3216    #[test]
3217    fn sanitizes_branch_for_storage_path() {
3218        assert_eq!(
3219            sanitize_path_component("feature/worktree watcher"),
3220            "feature-worktree-watcher"
3221        );
3222    }
3223
3224    #[test]
3225    fn normalizes_windows_verbatim_repo_roots() {
3226        assert_eq!(
3227            normalize_windows_verbatim_path(PathBuf::from(
3228                r"\\?\C:\Users\floris\Documents\GitHub\mollie-api-rust"
3229            )),
3230            PathBuf::from(r"C:\Users\floris\Documents\GitHub\mollie-api-rust")
3231        );
3232    }
3233
3234    #[test]
3235    fn parent_path_matching_uses_repo_boundaries() {
3236        let repo = Path::new(r"C:\Users\floris\Documents\GitHub\xbp");
3237        assert!(path_belongs_to_repo(
3238            Path::new(r"C:\Users\floris\Documents\GitHub\xbp\src\main.rs"),
3239            repo
3240        ));
3241        assert!(!path_belongs_to_repo(
3242            Path::new(r"C:\Users\floris\Documents\GitHub\xbp-other\src\main.rs"),
3243            repo
3244        ));
3245    }
3246
3247    #[test]
3248    fn skips_heavy_discovery_directories() {
3249        assert!(is_skipped_discovery_dir(Path::new("node_modules")));
3250        assert!(is_skipped_discovery_dir(Path::new("target")));
3251        assert!(is_skipped_discovery_dir(Path::new(".next")));
3252        assert!(!is_skipped_discovery_dir(Path::new("mollie-api-rust")));
3253    }
3254
3255    #[test]
3256    fn ignores_nested_generated_mutation_paths() {
3257        let root = Path::new(r"C:\Users\floris\Documents\GitHub\athena");
3258        assert!(is_ignored_path(
3259            root,
3260            Path::new(
3261                r"C:\Users\floris\Documents\GitHub\athena\apps\docs\node_modules\@aws-sdk\client-lambda\dist-types\schemas"
3262            )
3263        ));
3264        assert!(is_ignored_path(
3265            root,
3266            Path::new(r"C:\Users\floris\Documents\GitHub\athena\target\debug\build")
3267        ));
3268        assert!(!is_ignored_path(
3269            root,
3270            Path::new(r"C:\Users\floris\Documents\GitHub\athena\apps\web\src\main.ts")
3271        ));
3272    }
3273
3274    #[test]
3275    fn builds_coding_stats_by_file_and_filetype() {
3276        let identity = RepoIdentity {
3277            owner: "xylex-group".to_string(),
3278            name: "xbp".to_string(),
3279            branch: "main".to_string(),
3280            root: PathBuf::from(r"C:\Users\floris\Documents\GitHub\xbp"),
3281            head_sha: None,
3282        };
3283        let layout = SpoolLayout {
3284            root: PathBuf::from(r"C:\Users\floris\.xbp\mutations\xylex-group\xbp\main"),
3285            events_file: PathBuf::from("events.jsonl"),
3286            commits_file: PathBuf::from("commits.jsonl"),
3287            stats_file: PathBuf::from("stats.json"),
3288            watcher_state_file: PathBuf::from("watcher-state.json"),
3289            sync_log_file: PathBuf::from("sync-log.jsonl"),
3290            event_dedupe_file: PathBuf::from("event-dedupe.jsonl"),
3291            event_dedupe_dir: PathBuf::from("event-dedupe"),
3292            commit_dedupe_dir: PathBuf::from("commit-dedupe"),
3293        };
3294        let candidates = vec![SyncCandidate {
3295            path: PathBuf::from("events.jsonl"),
3296            records: SyncRecords::Events(vec![
3297                stats_test_event_record("2026-07-08T10:00:00Z", "src/main.rs", 4, 1),
3298                stats_test_event_record("2026-07-08T10:05:00Z", "src/main.rs", 2, 0),
3299                stats_test_event_record("2026-07-08T11:00:00Z", "README.md", 1, 3),
3300            ]),
3301        }];
3302
3303        let summary = build_stats_summary(&identity, &layout, &candidates, 15).unwrap();
3304
3305        assert_eq!(summary.total_events, 3);
3306        assert_eq!(summary.estimated_coding_seconds, 60 + 300 + 900);
3307        assert_eq!(summary.added_lines, 7);
3308        assert_eq!(summary.removed_lines, 4);
3309        assert_eq!(summary.by_file[0].path, "README.md");
3310        assert_eq!(summary.by_file[0].estimated_coding_seconds, 900);
3311        assert_eq!(summary.by_filetype[0].name, "md");
3312        assert_eq!(summary.by_filetype[0].estimated_coding_seconds, 900);
3313        assert_eq!(summary.by_filetype[1].name, "rs");
3314        assert_eq!(summary.by_filetype[1].estimated_coding_seconds, 360);
3315    }
3316
3317    #[test]
3318    fn mutation_fingerprint_ignores_random_event_id_but_keeps_time_bucket() {
3319        let first: WorktreeMutationEvent = serde_json::from_value(stats_test_event(
3320            "2026-07-08T10:00:00Z",
3321            "src/main.rs",
3322            4,
3323            1,
3324        ))
3325        .unwrap();
3326        let same_bucket: WorktreeMutationEvent = serde_json::from_value(stats_test_event(
3327            "2026-07-08T10:00:00Z",
3328            "src/main.rs",
3329            4,
3330            1,
3331        ))
3332        .unwrap();
3333        let next_bucket: WorktreeMutationEvent = serde_json::from_value(stats_test_event(
3334            "2026-07-08T10:00:06Z",
3335            "src/main.rs",
3336            4,
3337            1,
3338        ))
3339        .unwrap();
3340
3341        assert_eq!(
3342            mutation_event_fingerprint(&first),
3343            mutation_event_fingerprint(&same_bucket)
3344        );
3345        assert_ne!(
3346            mutation_event_fingerprint(&first),
3347            mutation_event_fingerprint(&next_bucket)
3348        );
3349    }
3350
3351    #[test]
3352    fn append_unique_mutation_event_is_idempotent_across_dedupe_instances() {
3353        let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
3354        fs::create_dir_all(&temp_root).unwrap();
3355        let layout = SpoolLayout {
3356            root: temp_root.clone(),
3357            events_file: temp_root.join("events.jsonl"),
3358            commits_file: temp_root.join("commits.jsonl"),
3359            stats_file: temp_root.join("stats.json"),
3360            watcher_state_file: temp_root.join("watcher-state.json"),
3361            sync_log_file: temp_root.join("sync-log.jsonl"),
3362            event_dedupe_file: temp_root.join("event-dedupe.jsonl"),
3363            event_dedupe_dir: temp_root.join("event-dedupe"),
3364            commit_dedupe_dir: temp_root.join("commit-dedupe"),
3365        };
3366        let mutation: WorktreeMutationEvent = serde_json::from_value(stats_test_event(
3367            "2026-07-08T10:00:00Z",
3368            "src/main.rs",
3369            4,
3370            1,
3371        ))
3372        .unwrap();
3373
3374        let first =
3375            append_unique_mutation_event(&layout, &mutation, &mut RecentEventDedupe::default())
3376                .unwrap();
3377        let second =
3378            append_unique_mutation_event(&layout, &mutation, &mut RecentEventDedupe::default())
3379                .unwrap();
3380
3381        assert!(first);
3382        assert!(!second);
3383        let events = read_jsonl_values(&layout.events_file).unwrap();
3384        assert_eq!(events.len(), 1);
3385        assert_eq!(
3386            events[0].get("fingerprint").and_then(Value::as_str),
3387            Some(mutation_event_fingerprint(&mutation).as_str())
3388        );
3389        let _ = fs::remove_dir_all(temp_root);
3390    }
3391
3392    #[test]
3393    fn reads_spool_records_as_typed_events() {
3394        let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
3395        fs::create_dir_all(&temp_root).unwrap();
3396        let events_file = temp_root.join("events.jsonl");
3397        append_json_line(
3398            &events_file,
3399            &stats_test_event_record("2026-07-08T10:00:00Z", "src/main.rs", 4, 1),
3400        )
3401        .unwrap();
3402
3403        let records = read_spool_records(&events_file, SyncFileKind::Events).unwrap();
3404
3405        match records {
3406            SyncRecords::Events(events) => {
3407                assert_eq!(events.len(), 1);
3408                assert_eq!(events[0].primary_path.as_deref(), Some("src/main.rs"));
3409            }
3410            SyncRecords::Commits(_) => panic!("expected typed event records"),
3411        }
3412        let _ = fs::remove_dir_all(temp_root);
3413    }
3414
3415    #[test]
3416    fn collect_spool_candidates_rewrites_legacy_generated_events() {
3417        let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
3418        fs::create_dir_all(&temp_root).unwrap();
3419        let events_file = temp_root.join("events-legacy.jsonl");
3420        append_json_line(
3421            &events_file,
3422            &stats_test_event_record(
3423                "2026-07-08T10:00:00Z",
3424                "apps/docs/node_modules/@aws-sdk/client-lambda/dist-types/schemas",
3425                0,
3426                0,
3427            ),
3428        )
3429        .unwrap();
3430        append_json_line(
3431            &events_file,
3432            &stats_test_event_record("2026-07-08T10:01:00Z", "apps/web/src/main.ts", 4, 1),
3433        )
3434        .unwrap();
3435        let layout = SpoolLayout {
3436            root: temp_root.clone(),
3437            events_file: temp_root.join("events.jsonl"),
3438            commits_file: temp_root.join("commits.jsonl"),
3439            stats_file: temp_root.join("stats.json"),
3440            watcher_state_file: temp_root.join("watcher-state.json"),
3441            sync_log_file: temp_root.join("sync-log.jsonl"),
3442            event_dedupe_file: temp_root.join("event-dedupe.jsonl"),
3443            event_dedupe_dir: temp_root.join("event-dedupe"),
3444            commit_dedupe_dir: temp_root.join("commit-dedupe"),
3445        };
3446
3447        let candidates = collect_spool_candidates(&layout, false).unwrap();
3448
3449        assert_eq!(candidates.len(), 1);
3450        match &candidates[0].records {
3451            SyncRecords::Events(events) => {
3452                assert_eq!(events.len(), 1);
3453                assert_eq!(
3454                    events[0].primary_path.as_deref(),
3455                    Some("apps/web/src/main.ts")
3456                );
3457            }
3458            SyncRecords::Commits(_) => panic!("expected event spool candidate"),
3459        }
3460        let rewritten = read_jsonl_records::<WorktreeMutationEvent>(&events_file).unwrap();
3461        assert_eq!(rewritten.len(), 1);
3462        assert_eq!(
3463            rewritten[0].primary_path.as_deref(),
3464            Some("apps/web/src/main.ts")
3465        );
3466        let _ = fs::remove_dir_all(temp_root);
3467    }
3468
3469    #[test]
3470    fn collect_spool_candidates_deletes_fully_ignored_event_files() {
3471        let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
3472        fs::create_dir_all(&temp_root).unwrap();
3473        let events_file = temp_root.join("events-legacy.jsonl");
3474        append_json_line(
3475            &events_file,
3476            &stats_test_event_record(
3477                "2026-07-08T10:00:00Z",
3478                "apps/docs/node_modules/@aws-sdk/client-lambda/dist-types/schemas",
3479                0,
3480                0,
3481            ),
3482        )
3483        .unwrap();
3484        let layout = SpoolLayout {
3485            root: temp_root.clone(),
3486            events_file: temp_root.join("events.jsonl"),
3487            commits_file: temp_root.join("commits.jsonl"),
3488            stats_file: temp_root.join("stats.json"),
3489            watcher_state_file: temp_root.join("watcher-state.json"),
3490            sync_log_file: temp_root.join("sync-log.jsonl"),
3491            event_dedupe_file: temp_root.join("event-dedupe.jsonl"),
3492            event_dedupe_dir: temp_root.join("event-dedupe"),
3493            commit_dedupe_dir: temp_root.join("commit-dedupe"),
3494        };
3495
3496        let candidates = collect_spool_candidates(&layout, false).unwrap();
3497
3498        assert!(candidates.is_empty());
3499        assert!(!events_file.exists());
3500        let _ = fs::remove_dir_all(temp_root);
3501    }
3502
3503    #[test]
3504    fn append_unique_commit_event_is_idempotent_across_watcher_instances() {
3505        let temp_root = std::env::temp_dir().join(format!("xbp-watch-test-{}", Uuid::new_v4()));
3506        fs::create_dir_all(&temp_root).unwrap();
3507        let layout = SpoolLayout {
3508            root: temp_root.clone(),
3509            events_file: temp_root.join("events.jsonl"),
3510            commits_file: temp_root.join("commits.jsonl"),
3511            stats_file: temp_root.join("stats.json"),
3512            watcher_state_file: temp_root.join("watcher-state.json"),
3513            sync_log_file: temp_root.join("sync-log.jsonl"),
3514            event_dedupe_file: temp_root.join("event-dedupe.jsonl"),
3515            event_dedupe_dir: temp_root.join("event-dedupe"),
3516            commit_dedupe_dir: temp_root.join("commit-dedupe"),
3517        };
3518        let commit = WorktreeCommitEvent {
3519            id: Uuid::new_v4().to_string(),
3520            fingerprint: None,
3521            repo_owner: "xylex-group".to_string(),
3522            repo_name: "xbp".to_string(),
3523            branch_name: "main".to_string(),
3524            repo_root: r"C:\Users\floris\Documents\GitHub\xbp".to_string(),
3525            previous_head_sha: Some("previous".to_string()),
3526            head_sha: "current".to_string(),
3527            subject: Some("test commit".to_string()),
3528            author_name: Some("Floris".to_string()),
3529            author_email: Some("floris@xylex.group".to_string()),
3530            committed_at: Some("2026-07-08T10:00:00Z".to_string()),
3531            occurred_at: Utc::now(),
3532        };
3533
3534        assert!(append_unique_commit_event(&layout, &commit).unwrap());
3535        assert!(!append_unique_commit_event(&layout, &commit).unwrap());
3536        let commits = read_jsonl_values(&layout.commits_file).unwrap();
3537        assert_eq!(commits.len(), 1);
3538        assert_eq!(
3539            commits[0].get("fingerprint").and_then(Value::as_str),
3540            Some(commit_event_fingerprint(&commit).as_str())
3541        );
3542        let _ = fs::remove_dir_all(temp_root);
3543    }
3544
3545    #[test]
3546    fn splits_worktree_mutation_uploads_into_bounded_batches() {
3547        let events = (0..(WORKTREE_MUTATION_SYNC_BATCH_RECORD_LIMIT + 1))
3548            .map(|index| {
3549                serde_json::from_value::<WorktreeMutationEvent>(stats_test_event(
3550                    "2026-07-08T10:00:00Z",
3551                    &format!("src/file-{index}.rs"),
3552                    1,
3553                    0,
3554                ))
3555                .unwrap()
3556            })
3557            .collect::<Vec<_>>();
3558        let commits = vec![
3559            worktree_commit_test_event("previous-a", "current-a"),
3560            worktree_commit_test_event("previous-b", "current-b"),
3561        ];
3562
3563        let batches = split_worktree_mutation_upload_batches(events, commits);
3564
3565        assert_eq!(batches.len(), 2);
3566        assert_eq!(
3567            batches[0].events.len() + batches[0].commits.len(),
3568            WORKTREE_MUTATION_SYNC_BATCH_RECORD_LIMIT
3569        );
3570        assert_eq!(batches[1].events.len(), 1);
3571        assert_eq!(batches[1].commits.len(), 2);
3572    }
3573
3574    #[test]
3575    fn splits_worktree_mutation_uploads_by_estimated_json_bytes() {
3576        let long_path = format!("src/{}.rs", "x".repeat(8_000));
3577        let events = (0..200)
3578            .map(|index| {
3579                serde_json::from_value::<WorktreeMutationEvent>(stats_test_event(
3580                    "2026-07-08T10:00:00Z",
3581                    &format!("{long_path}-{index}"),
3582                    1,
3583                    0,
3584                ))
3585                .unwrap()
3586            })
3587            .collect::<Vec<_>>();
3588
3589        let batches = split_worktree_mutation_upload_batches(events, Vec::new());
3590
3591        assert!(batches.len() > 1);
3592        for batch in batches {
3593            assert!(
3594                batch.estimated_json_bytes <= WORKTREE_MUTATION_SYNC_BATCH_JSON_BYTES,
3595                "batch estimated JSON bytes exceeded limit: {}",
3596                batch.estimated_json_bytes
3597            );
3598        }
3599    }
3600
3601    #[test]
3602    fn classifies_create_remove_and_rename_events() {
3603        assert_eq!(
3604            classify_event_kind(&EventKind::Create(CreateKind::File)),
3605            "file_create"
3606        );
3607        assert_eq!(
3608            classify_event_kind(&EventKind::Remove(RemoveKind::Folder)),
3609            "folder_remove"
3610        );
3611        assert_eq!(
3612            classify_event_kind(&EventKind::Modify(ModifyKind::Name(RenameMode::Both))),
3613            "rename_or_move"
3614        );
3615    }
3616
3617    #[test]
3618    fn retries_only_retryable_worktree_sync_statuses() {
3619        assert!(should_retry_worktree_mutation_sync_status(
3620            StatusCode::INTERNAL_SERVER_ERROR
3621        ));
3622        assert!(should_retry_worktree_mutation_sync_status(
3623            StatusCode::REQUEST_TIMEOUT
3624        ));
3625        assert!(should_retry_worktree_mutation_sync_status(
3626            StatusCode::TOO_MANY_REQUESTS
3627        ));
3628        assert!(!should_retry_worktree_mutation_sync_status(
3629            StatusCode::BAD_REQUEST
3630        ));
3631        assert!(!should_retry_worktree_mutation_sync_status(
3632            StatusCode::UNAUTHORIZED
3633        ));
3634        assert!(!should_retry_worktree_mutation_sync_status(
3635            StatusCode::PAYLOAD_TOO_LARGE
3636        ));
3637    }
3638
3639    fn stats_test_event(
3640        occurred_at: &str,
3641        path: &str,
3642        added_lines: u64,
3643        removed_lines: u64,
3644    ) -> Value {
3645        json!({
3646            "id": Uuid::new_v4().to_string(),
3647            "repoOwner": "xylex-group",
3648            "repoName": "xbp",
3649            "branchName": "main",
3650            "repoRoot": r"C:\Users\floris\Documents\GitHub\xbp",
3651            "headSha": null,
3652            "eventKind": "file_modify",
3653            "paths": [path],
3654            "primaryPath": path,
3655            "oldPath": null,
3656            "newPath": null,
3657            "addedLines": added_lines,
3658            "removedLines": removed_lines,
3659            "totalLines": added_lines + 42,
3660            "fileCreated": false,
3661            "fileRemoved": false,
3662            "folderCreated": false,
3663            "folderRemoved": false,
3664            "renamedOrMoved": false,
3665            "rawKind": "Modify(Data(Content))",
3666            "occurredAt": occurred_at,
3667        })
3668    }
3669
3670    fn stats_test_event_record(
3671        occurred_at: &str,
3672        path: &str,
3673        added_lines: u64,
3674        removed_lines: u64,
3675    ) -> WorktreeMutationEvent {
3676        serde_json::from_value(stats_test_event(
3677            occurred_at,
3678            path,
3679            added_lines,
3680            removed_lines,
3681        ))
3682        .unwrap()
3683    }
3684
3685    fn worktree_commit_test_event(previous_head_sha: &str, head_sha: &str) -> WorktreeCommitEvent {
3686        WorktreeCommitEvent {
3687            id: Uuid::new_v4().to_string(),
3688            fingerprint: None,
3689            repo_owner: "xylex-group".to_string(),
3690            repo_name: "xbp".to_string(),
3691            branch_name: "main".to_string(),
3692            repo_root: r"C:\Users\floris\Documents\GitHub\xbp".to_string(),
3693            previous_head_sha: Some(previous_head_sha.to_string()),
3694            head_sha: head_sha.to_string(),
3695            subject: Some("test commit".to_string()),
3696            author_name: Some("Floris".to_string()),
3697            author_email: Some("floris@xylex.group".to_string()),
3698            committed_at: Some("2026-07-08T10:00:00Z".to_string()),
3699            occurred_at: Utc::now(),
3700        }
3701    }
3702}