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(¤t, 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(¤t, 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(¤t) > 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}