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::fs::{self, File, OpenOptions};
11use std::io::{BufRead, BufReader, Write};
12use std::path::{Path, PathBuf};
13use std::process::{Command, Stdio};
14use std::sync::mpsc;
15use std::time::{Duration, Instant};
16use uuid::Uuid;
17
18const BACKGROUND_CHILD_ENV: &str = "XBP_WORKTREE_WATCH_BACKGROUND_CHILD";
19const DEFAULT_REMOTE: &str = "origin";
20
21#[cfg(windows)]
22const CREATE_NO_WINDOW: u32 = 0x08000000;
23
24#[derive(Debug, Clone)]
25pub struct WorktreeWatchTargetOptions {
26 pub repo: Option<PathBuf>,
27}
28
29#[derive(Debug, Clone)]
30pub struct WorktreeWatchStartOptions {
31 pub target: WorktreeWatchTargetOptions,
32 pub detach: bool,
33 pub sync_interval_seconds: u64,
34 pub once: bool,
35}
36
37#[derive(Debug, Clone)]
38pub struct WorktreeWatchSyncOptions {
39 pub target: WorktreeWatchTargetOptions,
40 pub dry_run: bool,
41}
42
43#[derive(Debug, Clone)]
44pub struct WorktreeWatchStatusOptions {
45 pub target: WorktreeWatchTargetOptions,
46 pub json: bool,
47}
48
49#[derive(Debug, Clone)]
50struct RepoIdentity {
51 owner: String,
52 name: String,
53 branch: String,
54 root: PathBuf,
55 head_sha: Option<String>,
56}
57
58#[derive(Debug, Clone)]
59struct SpoolLayout {
60 root: PathBuf,
61 events_file: PathBuf,
62 commits_file: PathBuf,
63}
64
65#[derive(Debug, Serialize, Deserialize, Clone)]
66#[serde(rename_all = "camelCase")]
67struct WorktreeMutationEvent {
68 id: String,
69 repo_owner: String,
70 repo_name: String,
71 branch_name: String,
72 repo_root: String,
73 head_sha: Option<String>,
74 event_kind: String,
75 paths: Vec<String>,
76 primary_path: Option<String>,
77 old_path: Option<String>,
78 new_path: Option<String>,
79 added_lines: Option<u64>,
80 removed_lines: Option<u64>,
81 file_created: bool,
82 file_removed: bool,
83 folder_created: bool,
84 folder_removed: bool,
85 renamed_or_moved: bool,
86 raw_kind: String,
87 occurred_at: DateTime<Utc>,
88}
89
90#[derive(Debug, Serialize, Deserialize, Clone)]
91#[serde(rename_all = "camelCase")]
92struct WorktreeCommitEvent {
93 id: String,
94 repo_owner: String,
95 repo_name: String,
96 branch_name: String,
97 repo_root: String,
98 previous_head_sha: Option<String>,
99 head_sha: String,
100 subject: Option<String>,
101 author_name: Option<String>,
102 author_email: Option<String>,
103 committed_at: Option<String>,
104 occurred_at: DateTime<Utc>,
105}
106
107#[derive(Debug, Serialize)]
108#[serde(rename_all = "camelCase")]
109struct WorktreeMutationIngestPayload {
110 device: WorktreeMutationDevicePayload,
111 repository: WorktreeMutationRepositoryPayload,
112 events: Vec<WorktreeMutationEvent>,
113 commits: Vec<WorktreeCommitEvent>,
114}
115
116#[derive(Debug, Serialize)]
117#[serde(rename_all = "camelCase")]
118struct WorktreeMutationDevicePayload {
119 hardware_id: String,
120 device_name: Option<String>,
121 hostname: Option<String>,
122 platform: String,
123}
124
125#[derive(Debug, Serialize)]
126#[serde(rename_all = "camelCase")]
127struct WorktreeMutationRepositoryPayload {
128 owner: String,
129 name: String,
130 branch_name: String,
131 repo_root: String,
132}
133
134#[derive(Debug)]
135struct SyncCandidate {
136 path: PathBuf,
137 kind: SyncFileKind,
138 records: Vec<Value>,
139}
140
141#[derive(Debug, Clone, Copy)]
142enum SyncFileKind {
143 Events,
144 Commits,
145}
146
147pub async fn run_worktree_watch_start(options: WorktreeWatchStartOptions) -> Result<(), String> {
148 if options.detach {
149 return spawn_detached_worktree_watch(options.target.repo.as_deref()).map(|path| {
150 println!("Started background worktree watcher for {}", path.display());
151 });
152 }
153
154 let identity = resolve_repo_identity(options.target.repo.as_deref())?;
155 let layout = prepare_spool_layout(&identity)?;
156 println!(
157 "Watching {} and spooling mutations to {}",
158 identity.root.display(),
159 layout.root.display()
160 );
161
162 if options.once {
163 record_commit_snapshot(&identity, &layout, None)?;
164 return Ok(());
165 }
166
167 watch_foreground(identity, layout, options.sync_interval_seconds).await
168}
169
170pub async fn run_worktree_watch_sync(options: WorktreeWatchSyncOptions) -> Result<(), String> {
171 let identity = resolve_repo_identity(options.target.repo.as_deref())?;
172 let layout = prepare_spool_layout(&identity)?;
173 let candidates = collect_sync_candidates(&layout)?;
174 let total_records: usize = candidates
175 .iter()
176 .map(|candidate| candidate.records.len())
177 .sum();
178
179 if options.dry_run {
180 println!(
181 "Would sync {} record(s) from {} spool file(s) under {}",
182 total_records,
183 candidates.len(),
184 layout.root.display()
185 );
186 return Ok(());
187 }
188
189 if candidates.is_empty() {
190 println!("No unsynced worktree mutation files found.");
191 return Ok(());
192 }
193
194 sync_candidates(&identity, candidates).await?;
195 println!("Synced {} worktree mutation record(s).", total_records);
196 Ok(())
197}
198
199pub async fn run_worktree_watch_status(options: WorktreeWatchStatusOptions) -> Result<(), String> {
200 let identity = resolve_repo_identity(options.target.repo.as_deref())?;
201 let layout = prepare_spool_layout(&identity)?;
202 let candidates = collect_sync_candidates(&layout)?;
203 let record_count: usize = candidates
204 .iter()
205 .map(|candidate| candidate.records.len())
206 .sum();
207 let payload = json!({
208 "repoRoot": identity.root,
209 "repositoryOwner": identity.owner,
210 "repositoryName": identity.name,
211 "branchName": identity.branch,
212 "spoolRoot": layout.root,
213 "unsyncedFiles": candidates.len(),
214 "unsyncedRecords": record_count,
215 });
216
217 if options.json {
218 println!(
219 "{}",
220 serde_json::to_string_pretty(&payload)
221 .map_err(|error| format!("Failed to render status JSON: {error}"))?
222 );
223 } else {
224 println!("repo: {}/{}", identity.owner, identity.name);
225 println!("branch: {}", identity.branch);
226 println!("root: {}", identity.root.display());
227 println!("spool: {}", layout.root.display());
228 println!("unsynced files: {}", candidates.len());
229 println!("unsynced records: {}", record_count);
230 }
231
232 Ok(())
233}
234
235pub fn spawn_detached_worktree_watch_for_current_repo() -> Result<Option<PathBuf>, String> {
236 if is_background_child() {
237 return Ok(None);
238 }
239
240 let identity = match resolve_repo_identity(None) {
241 Ok(identity) => identity,
242 Err(_) => return Ok(None),
243 };
244 spawn_detached_worktree_watch(Some(&identity.root)).map(Some)
245}
246
247fn watch_foreground(
248 identity: RepoIdentity,
249 layout: SpoolLayout,
250 sync_interval_seconds: u64,
251) -> impl std::future::Future<Output = Result<(), String>> {
252 async move {
253 let (tx, rx) = mpsc::channel();
254 let mut watcher = RecommendedWatcher::new(
255 move |result| {
256 let _ = tx.send(result);
257 },
258 Config::default(),
259 )
260 .map_err(|error| format!("Failed to create filesystem watcher: {error}"))?;
261
262 watcher
263 .watch(&identity.root, RecursiveMode::Recursive)
264 .map_err(|error| {
265 format!(
266 "Failed to watch repository root {}: {error}",
267 identity.root.display()
268 )
269 })?;
270
271 let mut last_head = identity.head_sha.clone();
272 let mut last_commit_check = Instant::now();
273 let mut last_sync = Instant::now();
274
275 loop {
276 match rx.recv_timeout(Duration::from_millis(750)) {
277 Ok(Ok(event)) => {
278 if let Some(mutation) = mutation_event_from_notify(&identity, event) {
279 append_json_line(&layout.events_file, &mutation)?;
280 }
281 }
282 Ok(Err(error)) => {
283 eprintln!("worktree watcher error: {error}");
284 }
285 Err(mpsc::RecvTimeoutError::Timeout) => {}
286 Err(mpsc::RecvTimeoutError::Disconnected) => {
287 return Err("Filesystem watcher disconnected.".to_string());
288 }
289 }
290
291 if last_commit_check.elapsed() >= Duration::from_secs(3) {
292 last_head = record_commit_snapshot(&identity, &layout, last_head.as_deref())?;
293 last_commit_check = Instant::now();
294 }
295
296 if sync_interval_seconds > 0
297 && last_sync.elapsed() >= Duration::from_secs(sync_interval_seconds)
298 {
299 if let Err(error) = sync_spool_for_identity(&identity, &layout).await {
300 eprintln!("worktree mutation sync failed: {error}");
301 }
302 last_sync = Instant::now();
303 }
304 }
305 }
306}
307
308fn mutation_event_from_notify(
309 identity: &RepoIdentity,
310 event: Event,
311) -> Option<WorktreeMutationEvent> {
312 if event.kind.is_access() || event.kind.is_other() {
313 return None;
314 }
315
316 let paths: Vec<String> = event
317 .paths
318 .iter()
319 .filter(|path| !is_ignored_path(&identity.root, path))
320 .filter_map(|path| relative_slash_path(&identity.root, path))
321 .collect();
322
323 if paths.is_empty() {
324 return None;
325 }
326
327 let primary_path = paths.first().cloned();
328 let (old_path, new_path) = rename_paths(&event.kind, &paths);
329 let line_counts = primary_path
330 .as_deref()
331 .and_then(|path| git_numstat_for_path(&identity.root, path).ok());
332 let event_kind = classify_event_kind(&event.kind);
333 let is_dir = event
334 .paths
335 .first()
336 .and_then(|path| fs::metadata(path).ok())
337 .map(|metadata| metadata.is_dir())
338 .unwrap_or(false);
339
340 Some(WorktreeMutationEvent {
341 id: Uuid::new_v4().to_string(),
342 repo_owner: identity.owner.clone(),
343 repo_name: identity.name.clone(),
344 branch_name: identity.branch.clone(),
345 repo_root: identity.root.display().to_string(),
346 head_sha: current_head(&identity.root).ok().flatten(),
347 event_kind: event_kind.to_string(),
348 paths,
349 primary_path,
350 old_path,
351 new_path,
352 added_lines: line_counts.map(|counts| counts.0),
353 removed_lines: line_counts.map(|counts| counts.1),
354 file_created: matches!(
355 event.kind,
356 EventKind::Create(CreateKind::File | CreateKind::Any)
357 ) && !is_dir,
358 file_removed: matches!(
359 event.kind,
360 EventKind::Remove(RemoveKind::File | RemoveKind::Any)
361 ) && !is_dir,
362 folder_created: matches!(
363 event.kind,
364 EventKind::Create(CreateKind::Folder | CreateKind::Any)
365 ) && is_dir,
366 folder_removed: matches!(
367 event.kind,
368 EventKind::Remove(RemoveKind::Folder | RemoveKind::Any)
369 ) && is_dir,
370 renamed_or_moved: is_rename_or_move(&event.kind),
371 raw_kind: format!("{:?}", event.kind),
372 occurred_at: Utc::now(),
373 })
374}
375
376fn classify_event_kind(kind: &EventKind) -> &'static str {
377 match kind {
378 EventKind::Create(CreateKind::File) => "file_create",
379 EventKind::Create(CreateKind::Folder) => "folder_create",
380 EventKind::Create(_) => "create",
381 EventKind::Remove(RemoveKind::File) => "file_remove",
382 EventKind::Remove(RemoveKind::Folder) => "folder_remove",
383 EventKind::Remove(_) => "remove",
384 EventKind::Modify(ModifyKind::Name(
385 RenameMode::From | RenameMode::To | RenameMode::Both,
386 )) => "rename_or_move",
387 EventKind::Modify(ModifyKind::Data(_)) => "file_modify",
388 EventKind::Modify(ModifyKind::Metadata(_)) => "metadata_modify",
389 EventKind::Modify(_) => "modify",
390 _ => "other",
391 }
392}
393
394fn is_rename_or_move(kind: &EventKind) -> bool {
395 matches!(
396 kind,
397 EventKind::Modify(ModifyKind::Name(
398 RenameMode::From | RenameMode::To | RenameMode::Both
399 ))
400 )
401}
402
403fn rename_paths(kind: &EventKind, paths: &[String]) -> (Option<String>, Option<String>) {
404 if !is_rename_or_move(kind) {
405 return (None, None);
406 }
407
408 match paths {
409 [old_path, new_path, ..] => (Some(old_path.clone()), Some(new_path.clone())),
410 [path] => match kind {
411 EventKind::Modify(ModifyKind::Name(RenameMode::From)) => (Some(path.clone()), None),
412 EventKind::Modify(ModifyKind::Name(RenameMode::To)) => (None, Some(path.clone())),
413 _ => (None, Some(path.clone())),
414 },
415 _ => (None, None),
416 }
417}
418
419fn record_commit_snapshot(
420 identity: &RepoIdentity,
421 layout: &SpoolLayout,
422 previous_head: Option<&str>,
423) -> Result<Option<String>, String> {
424 let head = current_head(&identity.root)?;
425 let Some(head_sha) = head else {
426 return Ok(None);
427 };
428
429 if previous_head == Some(head_sha.as_str()) {
430 return Ok(Some(head_sha));
431 }
432
433 let commit = WorktreeCommitEvent {
434 id: Uuid::new_v4().to_string(),
435 repo_owner: identity.owner.clone(),
436 repo_name: identity.name.clone(),
437 branch_name: identity.branch.clone(),
438 repo_root: identity.root.display().to_string(),
439 previous_head_sha: previous_head.map(str::to_string),
440 head_sha: head_sha.clone(),
441 subject: git_output(&identity.root, &["log", "-1", "--pretty=%s"]).ok(),
442 author_name: git_output(&identity.root, &["log", "-1", "--pretty=%an"]).ok(),
443 author_email: git_output(&identity.root, &["log", "-1", "--pretty=%ae"]).ok(),
444 committed_at: git_output(&identity.root, &["log", "-1", "--pretty=%cI"]).ok(),
445 occurred_at: Utc::now(),
446 };
447 append_json_line(&layout.commits_file, &commit)?;
448 Ok(Some(head_sha))
449}
450
451async fn sync_spool_for_identity(
452 identity: &RepoIdentity,
453 layout: &SpoolLayout,
454) -> Result<(), String> {
455 let candidates = collect_sync_candidates(layout)?;
456 if candidates.is_empty() {
457 return Ok(());
458 }
459
460 sync_candidates(identity, candidates).await
461}
462
463async fn sync_candidates(
464 identity: &RepoIdentity,
465 candidates: Vec<SyncCandidate>,
466) -> Result<(), String> {
467 let mut events = Vec::new();
468 let mut commits = Vec::new();
469 for candidate in &candidates {
470 match candidate.kind {
471 SyncFileKind::Events => {
472 for record in &candidate.records {
473 events.push(
474 serde_json::from_value::<WorktreeMutationEvent>(record.clone()).map_err(
475 |error| {
476 format!(
477 "Failed to decode event from {}: {error}",
478 candidate.path.display()
479 )
480 },
481 )?,
482 );
483 }
484 }
485 SyncFileKind::Commits => {
486 for record in &candidate.records {
487 commits.push(
488 serde_json::from_value::<WorktreeCommitEvent>(record.clone()).map_err(
489 |error| {
490 format!(
491 "Failed to decode commit from {}: {error}",
492 candidate.path.display()
493 )
494 },
495 )?,
496 );
497 }
498 }
499 }
500 }
501
502 let token = resolve_cli_access_token()?;
503 let device = resolve_device_identity()?;
504 let client = cli_request_client()?;
505 let api = ApiConfig::from_env();
506 let payload = WorktreeMutationIngestPayload {
507 device: WorktreeMutationDevicePayload {
508 hardware_id: device.hardware_id,
509 device_name: current_hostname(),
510 hostname: current_hostname(),
511 platform: std::env::consts::OS.to_string(),
512 },
513 repository: WorktreeMutationRepositoryPayload {
514 owner: identity.owner.clone(),
515 name: identity.name.clone(),
516 branch_name: identity.branch.clone(),
517 repo_root: identity.root.display().to_string(),
518 },
519 events,
520 commits,
521 };
522
523 let response = client
524 .post(api.cli_worktree_mutations_endpoint())
525 .bearer_auth(token)
526 .json(&payload)
527 .send()
528 .await
529 .map_err(|error| format!("Failed to upload worktree mutations: {error}"))?;
530
531 if response.status() == StatusCode::UNAUTHORIZED {
532 return Err(
533 "Your stored CLI session is no longer valid. Run `xbp login` again.".to_string(),
534 );
535 }
536
537 if !response.status().is_success() {
538 let status = response.status();
539 let body = response.text().await.unwrap_or_default();
540 return Err(format!(
541 "Worktree mutation upload failed with {status}: {body}"
542 ));
543 }
544
545 for candidate in candidates {
546 mark_synced(&candidate.path)?;
547 }
548
549 Ok(())
550}
551
552fn collect_sync_candidates(layout: &SpoolLayout) -> Result<Vec<SyncCandidate>, String> {
553 let mut candidates = Vec::new();
554 if !layout.root.exists() {
555 return Ok(candidates);
556 }
557
558 for entry in fs::read_dir(&layout.root).map_err(|error| {
559 format!(
560 "Failed to read spool directory {}: {error}",
561 layout.root.display()
562 )
563 })? {
564 let path = entry
565 .map_err(|error| format!("Failed to read spool entry: {error}"))?
566 .path();
567 let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
568 continue;
569 };
570 if !file_name.ends_with(".jsonl") || file_name.contains(".synced.") {
571 continue;
572 }
573
574 let kind = if file_name.starts_with("events-") {
575 SyncFileKind::Events
576 } else if file_name.starts_with("commits-") {
577 SyncFileKind::Commits
578 } else {
579 continue;
580 };
581 let records = read_jsonl_values(&path)?;
582 if records.is_empty() {
583 continue;
584 }
585 candidates.push(SyncCandidate {
586 path,
587 kind,
588 records,
589 });
590 }
591
592 Ok(candidates)
593}
594
595fn mark_synced(path: &Path) -> Result<(), String> {
596 let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
597 return Ok(());
598 };
599 let synced_name = file_name.replace(
600 ".jsonl",
601 &format!(".synced.{}.jsonl", Utc::now().timestamp()),
602 );
603 let synced_path = path.with_file_name(synced_name);
604 fs::rename(path, &synced_path).map_err(|error| {
605 format!(
606 "Failed to mark spool file {} as synced: {error}",
607 path.display()
608 )
609 })
610}
611
612fn read_jsonl_values(path: &Path) -> Result<Vec<Value>, String> {
613 let file = File::open(path)
614 .map_err(|error| format!("Failed to open spool file {}: {error}", path.display()))?;
615 let reader = BufReader::new(file);
616 let mut values = Vec::new();
617 for line in reader.lines() {
618 let line =
619 line.map_err(|error| format!("Failed to read spool file {}: {error}", path.display()))?;
620 if line.trim().is_empty() {
621 continue;
622 }
623 values.push(serde_json::from_str(&line).map_err(|error| {
624 format!(
625 "Failed to parse JSONL record in {}: {error}",
626 path.display()
627 )
628 })?);
629 }
630 Ok(values)
631}
632
633fn spawn_detached_worktree_watch(repo: Option<&Path>) -> Result<PathBuf, String> {
634 let identity = resolve_repo_identity(repo)?;
635 let executable = std::env::current_exe()
636 .map_err(|error| format!("Failed to resolve current XBP executable: {error}"))?;
637 let mut command = Command::new(executable);
638 command
639 .arg("worktree-watch")
640 .arg("start")
641 .arg("--repo")
642 .arg(&identity.root)
643 .env(BACKGROUND_CHILD_ENV, "1")
644 .current_dir(&identity.root)
645 .stdin(Stdio::null())
646 .stdout(Stdio::null())
647 .stderr(Stdio::null());
648
649 #[cfg(windows)]
650 {
651 use std::os::windows::process::CommandExt;
652 command.creation_flags(CREATE_NO_WINDOW);
653 }
654
655 command
656 .spawn()
657 .map(|_| identity.root)
658 .map_err(|error| format!("Failed to spawn background worktree watcher: {error}"))
659}
660
661fn resolve_repo_identity(repo: Option<&Path>) -> Result<RepoIdentity, String> {
662 let start = match repo {
663 Some(path) => path.to_path_buf(),
664 None => std::env::current_dir()
665 .map_err(|error| format!("Failed to resolve current directory: {error}"))?,
666 };
667 let root_raw = git_output(&start, &["rev-parse", "--show-toplevel"])?;
668 let root = fs::canonicalize(root_raw.trim()).unwrap_or_else(|_| PathBuf::from(root_raw.trim()));
669 let branch = git_output(&root, &["rev-parse", "--abbrev-ref", "HEAD"])
670 .unwrap_or_else(|_| "unknown".to_string());
671 let remote = git_output(&root, &["remote", "get-url", DEFAULT_REMOTE]).unwrap_or_default();
672 let (owner, name) = parse_remote_owner_repo(&remote).unwrap_or_else(|| {
673 let name = root
674 .file_name()
675 .and_then(|value| value.to_str())
676 .unwrap_or("repository")
677 .to_string();
678 ("unknown".to_string(), name)
679 });
680 let head_sha = current_head(&root)?;
681
682 Ok(RepoIdentity {
683 owner,
684 name,
685 branch: sanitize_path_component(branch.trim()),
686 root,
687 head_sha,
688 })
689}
690
691fn prepare_spool_layout(identity: &RepoIdentity) -> Result<SpoolLayout, String> {
692 let home = dirs::home_dir().ok_or_else(|| "Failed to resolve home directory.".to_string())?;
693 let run_id = Uuid::new_v4().to_string();
694 let root = home
695 .join(".xbp")
696 .join("mutations")
697 .join(sanitize_path_component(&identity.owner))
698 .join(sanitize_path_component(&identity.name))
699 .join(sanitize_path_component(&identity.branch));
700 fs::create_dir_all(&root).map_err(|error| {
701 format!(
702 "Failed to create worktree mutation spool {}: {error}",
703 root.display()
704 )
705 })?;
706 Ok(SpoolLayout {
707 events_file: root.join(format!("events-{run_id}.jsonl")),
708 commits_file: root.join(format!("commits-{run_id}.jsonl")),
709 root,
710 })
711}
712
713fn append_json_line<T: Serialize>(path: &Path, value: &T) -> Result<(), String> {
714 let mut file = OpenOptions::new()
715 .create(true)
716 .append(true)
717 .open(path)
718 .map_err(|error| format!("Failed to open spool file {}: {error}", path.display()))?;
719 let line = serde_json::to_string(value)
720 .map_err(|error| format!("Failed to serialize worktree event: {error}"))?;
721 writeln!(file, "{line}")
722 .map_err(|error| format!("Failed to append spool file {}: {error}", path.display()))
723}
724
725fn git_numstat_for_path(repo_root: &Path, relative_path: &str) -> Result<(u64, u64), String> {
726 let output = Command::new("git")
727 .args(["diff", "--numstat", "--"])
728 .arg(relative_path)
729 .current_dir(repo_root)
730 .output()
731 .map_err(|error| format!("Failed to run git diff --numstat: {error}"))?;
732 if !output.status.success() {
733 return Ok((0, 0));
734 }
735 let stdout = String::from_utf8_lossy(&output.stdout);
736 let mut added = 0;
737 let mut removed = 0;
738 for line in stdout.lines() {
739 let mut parts = line.split_whitespace();
740 added += parse_numstat_count(parts.next());
741 removed += parse_numstat_count(parts.next());
742 }
743 Ok((added, removed))
744}
745
746fn parse_numstat_count(value: Option<&str>) -> u64 {
747 value.and_then(|raw| raw.parse::<u64>().ok()).unwrap_or(0)
748}
749
750fn current_head(repo_root: &Path) -> Result<Option<String>, String> {
751 match git_output(repo_root, &["rev-parse", "HEAD"]) {
752 Ok(value) => Ok(Some(value)),
753 Err(error)
754 if error.contains("unknown revision") || error.contains("ambiguous argument") =>
755 {
756 Ok(None)
757 }
758 Err(error) => Err(error),
759 }
760}
761
762fn git_output(repo_root: &Path, args: &[&str]) -> Result<String, String> {
763 let output = Command::new("git")
764 .args(args)
765 .current_dir(repo_root)
766 .output()
767 .map_err(|error| format!("Failed to run git {}: {error}", args.join(" ")))?;
768 if !output.status.success() {
769 return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
770 }
771 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
772}
773
774fn parse_remote_owner_repo(remote: &str) -> Option<(String, String)> {
775 let trimmed = remote.trim().trim_end_matches(".git");
776 if trimmed.is_empty() {
777 return None;
778 }
779
780 let path_part = if !trimmed.contains("://") {
781 if let Some((_, path)) = trimmed.rsplit_once(':') {
782 path
783 } else {
784 trimmed
785 }
786 } else {
787 trimmed
788 .trim_start_matches("https://")
789 .trim_start_matches("http://")
790 .trim_start_matches("ssh://")
791 .split_once('/')
792 .map(|(_, path)| path)
793 .unwrap_or(trimmed)
794 };
795 let mut parts = path_part.rsplitn(2, '/');
796 let name = parts.next()?.trim();
797 let owner = parts.next()?.trim();
798 if owner.is_empty() || name.is_empty() {
799 return None;
800 }
801 Some((
802 sanitize_path_component(owner),
803 sanitize_path_component(name.trim_end_matches(".git")),
804 ))
805}
806
807fn sanitize_path_component(value: &str) -> String {
808 let sanitized: String = value
809 .chars()
810 .map(|ch| {
811 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
812 ch
813 } else {
814 '-'
815 }
816 })
817 .collect();
818 sanitized
819 .trim_matches('-')
820 .chars()
821 .take(160)
822 .collect::<String>()
823}
824
825fn relative_slash_path(root: &Path, path: &Path) -> Option<String> {
826 let relative = path.strip_prefix(root).ok().unwrap_or(path);
827 Some(relative.to_string_lossy().replace('\\', "/"))
828}
829
830fn is_ignored_path(root: &Path, path: &Path) -> bool {
831 let Some(relative) = relative_slash_path(root, path) else {
832 return false;
833 };
834 relative == ".git"
835 || relative.starts_with(".git/")
836 || relative == "target"
837 || relative.starts_with("target/")
838}
839
840fn current_hostname() -> Option<String> {
841 std::env::var("COMPUTERNAME")
842 .or_else(|_| std::env::var("HOSTNAME"))
843 .ok()
844 .map(|value| value.trim().to_string())
845 .filter(|value| !value.is_empty())
846}
847
848fn is_background_child() -> bool {
849 std::env::var(BACKGROUND_CHILD_ENV)
850 .ok()
851 .map(|value| value == "1")
852 .unwrap_or(false)
853}
854
855#[allow(dead_code)]
856fn content_sha256(path: &Path) -> Option<String> {
857 let bytes = fs::read(path).ok()?;
858 let mut hasher = Sha256::new();
859 hasher.update(bytes);
860 Some(format!("{:x}", hasher.finalize()))
861}
862
863#[cfg(test)]
864mod tests {
865 use super::*;
866
867 #[test]
868 fn parses_https_and_ssh_remote_urls() {
869 assert_eq!(
870 parse_remote_owner_repo("https://github.com/xylex-group/xbp.git"),
871 Some(("xylex-group".to_string(), "xbp".to_string()))
872 );
873 assert_eq!(
874 parse_remote_owner_repo("git@github.com:xylex-group/xbp.git"),
875 Some(("xylex-group".to_string(), "xbp".to_string()))
876 );
877 }
878
879 #[test]
880 fn sanitizes_branch_for_storage_path() {
881 assert_eq!(
882 sanitize_path_component("feature/worktree watcher"),
883 "feature-worktree-watcher"
884 );
885 }
886
887 #[test]
888 fn classifies_create_remove_and_rename_events() {
889 assert_eq!(
890 classify_event_kind(&EventKind::Create(CreateKind::File)),
891 "file_create"
892 );
893 assert_eq!(
894 classify_event_kind(&EventKind::Remove(RemoveKind::Folder)),
895 "folder_remove"
896 );
897 assert_eq!(
898 classify_event_kind(&EventKind::Modify(ModifyKind::Name(RenameMode::Both))),
899 "rename_or_move"
900 );
901 }
902}