Skip to main content

xbp_cli/commands/
worktree_watch.rs

1use crate::commands::cli_session::{cli_request_client, resolve_cli_access_token};
2use crate::config::{resolve_device_identity, ApiConfig};
3use chrono::{DateTime, Utc};
4use notify::event::{CreateKind, ModifyKind, RemoveKind, RenameMode};
5use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
6use reqwest::StatusCode;
7use serde::{Deserialize, Serialize};
8use serde_json::{json, Value};
9use sha2::{Digest, Sha256};
10use std::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}