repoforge 0.1.9

Safe repository archive discovery, GitHub slugging, and fast-forward refresh helpers
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
//! Repository archive discovery and safe refresh helpers for `thesa`.
//!
//! Made by Trevor Knott for Knott Dynamics.
//!
//! RepoForge is intentionally small: it knows how to find Git worktrees inside an
//! archive root, derive a stable `owner/repo` slug from a GitHub remote or path
//! layout, and refresh those clones with `git pull --ff-only`.
//! It does not write manifests, own terminal UI, or mutate dirty worktrees.
//!
//! The refresh path refuses dirty worktrees before pulling, so callers do not
//! silently overwrite local archive edits.
//!
//! ```
//! use std::path::Path;
//!
//! assert_eq!(
//!     repoforge::parse_github_remote_slug("https://github.com/octocat/Hello-World.git"),
//!     Some("octocat/Hello-World".to_string())
//! );
//! assert_eq!(
//!     repoforge::fallback_archive_slug(
//!         Path::new("archives"),
//!         Path::new("archives/octocat/Hello-World"),
//!     ),
//!     "octocat/Hello-World"
//! );
//! ```

use std::collections::BTreeSet;
use std::error::Error;
use std::fmt;
use std::fs;
use std::io;
use std::path::{Component, Path, PathBuf};
use std::process::{Command, Output};
use std::thread;

/// Result type used by RepoForge APIs.
pub type Result<T> = std::result::Result<T, RepoForgeError>;

/// Errors returned while discovering or refreshing Git archive worktrees.
#[derive(Debug)]
pub enum RepoForgeError {
    /// The requested archive root exists but is not a directory.
    OutputNotDirectory(PathBuf),
    /// The `git` executable was not found in `PATH`.
    MissingGit,
    /// A worktree contains local changes and was not refreshed.
    DirtyWorktree {
        /// Derived archive slug, usually `owner/repo`.
        slug: String,
        /// Path to the dirty worktree.
        path: PathBuf,
    },
    /// A `git` command failed inside a worktree.
    GitCommandFailed {
        /// Derived archive slug, usually `owner/repo`.
        slug: String,
        /// Path where the command was run.
        path: PathBuf,
        /// Human-readable command context, such as `pull --ff-only`.
        context: String,
        /// Captured stderr/stdout or exit status.
        message: String,
    },
    /// Filesystem or process I/O failure.
    Io(io::Error),
}

impl fmt::Display for RepoForgeError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::OutputNotDirectory(path) => {
                write!(f, "output path '{}' is not a directory", path.display())
            }
            Self::MissingGit => write!(f, "'git' executable not found in PATH"),
            Self::DirtyWorktree { slug, path } => write!(
                f,
                "{slug} has local changes at {}; commit, stash, or clean before refresh",
                path.display()
            ),
            Self::GitCommandFailed {
                slug,
                context,
                message,
                ..
            } => write!(f, "git command failed for '{slug}': {context}: {message}"),
            Self::Io(error) => write!(f, "I/O error: {error}"),
        }
    }
}

impl Error for RepoForgeError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            Self::Io(error) => Some(error),
            _ => None,
        }
    }
}

impl From<io::Error> for RepoForgeError {
    fn from(value: io::Error) -> Self {
        Self::Io(value)
    }
}

/// A discovered Git archive worktree.
///
/// `slug` is the canonical identifier used by callers for display and grouping.
/// GitHub remotes produce `owner/repo`; paths fall back to the archive layout.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitArchiveCandidate {
    /// Absolute or caller-relative path to the Git worktree.
    pub path: PathBuf,
    /// Stable archive slug, usually `owner/repo`.
    pub slug: String,
    /// Configured `remote.origin.url`, when readable.
    pub remote_url: Option<String>,
}

impl GitArchiveCandidate {
    /// Borrow the slug as `(owner, repo)` when the archive slug is `owner/repo`.
    pub fn slug_parts(&self) -> Option<(&str, &str)> {
        split_owner_repo_slug(&self.slug)
    }

    /// Borrow the slug owner when the archive slug is `owner/repo`.
    pub fn owner(&self) -> Option<&str> {
        self.slug_parts().map(|(owner, _)| owner)
    }

    /// Borrow the repository name when the archive slug is `owner/repo`.
    pub fn repo_name(&self) -> Option<&str> {
        self.slug_parts().map(|(_, repo)| repo)
    }

    /// Build a canonical GitHub web URL when the slug is `owner/repo`.
    pub fn github_url(&self) -> Option<String> {
        github_web_url_for_slug(&self.slug)
    }

    /// Return true when `filter` matches this archive slug or path.
    ///
    /// Matching is case-insensitive and uses the same semantics as
    /// `discover_git_archives`: an empty filter matches everything.
    pub fn matches_filter(&self, filter: &str) -> bool {
        git_archive_matches_filter(self, Some(filter))
    }

    /// Return true when the archive slug owner matches `owner` case-insensitively.
    pub fn matches_owner(&self, owner: &str) -> bool {
        let owner = owner.trim();
        !owner.is_empty()
            && self
                .owner()
                .is_some_and(|candidate| candidate.eq_ignore_ascii_case(owner))
    }
}

/// Split a normalized `owner/repo` slug into borrowed owner and repo parts.
///
/// Empty components, `.`/`..`, and slugs with more than one slash are rejected.
pub fn split_owner_repo_slug(slug: &str) -> Option<(&str, &str)> {
    let mut parts = slug.trim().split('/');
    let owner = parts.next()?.trim();
    let repo = parts.next()?.trim();
    if parts.next().is_some()
        || owner.is_empty()
        || repo.is_empty()
        || owner == "."
        || owner == ".."
        || repo == "."
        || repo == ".."
    {
        return None;
    }
    Some((owner, repo))
}

/// Build a canonical `https://github.com/owner/repo` URL from an `owner/repo` slug.
pub fn github_web_url_for_slug(slug: &str) -> Option<String> {
    split_owner_repo_slug(slug).map(|(owner, repo)| format!("https://github.com/{owner}/{repo}"))
}

/// Outcome for one successful archive refresh.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitRefreshStatus {
    /// `HEAD` changed after `git pull --ff-only`.
    Updated,
    /// Pull completed and `HEAD` did not change.
    Unchanged,
}

/// Successful refresh result for one worktree.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitRefreshEntry {
    /// Archive slug refreshed.
    pub slug: String,
    /// Worktree path refreshed.
    pub path: PathBuf,
    /// Refresh status.
    pub status: GitRefreshStatus,
}

/// Failed refresh result for one worktree.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitRefreshFailure {
    /// Archive slug that failed.
    pub slug: String,
    /// Worktree path that failed.
    pub path: PathBuf,
    /// Human-readable failure message.
    pub message: String,
}

/// Summary returned by `refresh_git_archives`.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct GitRefreshSummary {
    /// Number of worktrees whose `HEAD` changed.
    pub updated: usize,
    /// Number of worktrees that were already current.
    pub unchanged: usize,
    /// Successful per-worktree outcomes.
    pub entries: Vec<GitRefreshEntry>,
    /// Failed per-worktree outcomes.
    pub failed: Vec<GitRefreshFailure>,
}

impl GitRefreshSummary {
    /// Number of worktrees that completed successfully.
    pub fn processed(&self) -> usize {
        self.updated + self.unchanged
    }

    /// Total attempted worktrees, including failures.
    pub fn total(&self) -> usize {
        self.processed() + self.failed.len()
    }

    /// Whether any worktree failed to refresh.
    pub fn has_failures(&self) -> bool {
        !self.failed.is_empty()
    }

    /// Whether every attempted worktree refreshed successfully.
    pub fn is_success(&self) -> bool {
        self.failed.is_empty()
    }

    /// Failed slugs as a set, useful for caller-side manifest grouping.
    pub fn failed_slugs(&self) -> BTreeSet<String> {
        self.failed
            .iter()
            .map(|failure| failure.slug.clone())
            .collect()
    }
}

/// Discover Git worktrees under an archive root.
///
/// Discovery is recursive, but it stops descending when it finds a worktree.
/// `.git`, `.thesa`, and `target` directories are skipped. If `filter` is set,
/// it is matched case-insensitively against the derived slug and full path.
///
/// Missing roots return an empty list; existing non-directory roots return
/// `RepoForgeError::OutputNotDirectory`.
pub fn discover_git_archives(
    root: &Path,
    filter: Option<&str>,
) -> Result<Vec<GitArchiveCandidate>> {
    if !root.exists() {
        return Ok(Vec::new());
    }
    if !root.is_dir() {
        return Err(RepoForgeError::OutputNotDirectory(root.to_path_buf()));
    }

    let mut archives = Vec::new();
    discover_git_archives_inner(root, root, filter, &mut archives)?;
    archives.sort_by(|a, b| a.slug.cmp(&b.slug).then_with(|| a.path.cmp(&b.path)));
    Ok(archives)
}

/// Discover Git worktrees for one GitHub owner/user/org under an archive root.
///
/// The owner is matched against the derived `owner/repo` slug, case-insensitively.
/// This supports both GitHub remote-derived slugs and path-derived layouts such
/// as `archives/<owner>/<repo>`.
pub fn discover_git_archives_for_owner(
    root: &Path,
    owner: &str,
    filter: Option<&str>,
) -> Result<Vec<GitArchiveCandidate>> {
    let owner = owner.trim();
    if owner.is_empty() {
        return Ok(Vec::new());
    }

    let mut archives = discover_git_archives(root, filter)?;
    archives.retain(|archive| archive.matches_owner(owner));
    Ok(archives)
}

fn discover_git_archives_inner(
    root: &Path,
    current: &Path,
    filter: Option<&str>,
    archives: &mut Vec<GitArchiveCandidate>,
) -> Result<()> {
    if is_git_worktree(current) {
        let remote_url = git_remote_origin_url(current);
        let slug = git_archive_slug(root, current, remote_url.as_deref());
        let candidate = GitArchiveCandidate {
            path: current.to_path_buf(),
            slug,
            remote_url,
        };
        if git_archive_matches_filter(&candidate, filter) {
            archives.push(candidate);
        }
        return Ok(());
    }

    let mut entries = fs::read_dir(current)?.collect::<io::Result<Vec<_>>>()?;
    entries.sort_by_key(|entry| entry.path());
    for entry in entries {
        let path = entry.path();
        let file_type = entry.file_type()?;
        if !file_type.is_dir() || should_skip_archive_scan_dir(&path) {
            continue;
        }
        discover_git_archives_inner(root, &path, filter, archives)?;
    }

    Ok(())
}

/// Return true when `path` looks like a Git worktree.
///
/// This checks for a `.git` entry, which supports both regular clone
/// directories and worktrees whose `.git` is a file.
pub fn is_git_worktree(path: &Path) -> bool {
    path.join(".git").exists()
}

fn should_skip_archive_scan_dir(path: &Path) -> bool {
    let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
        return false;
    };
    matches!(name, ".git" | ".thesa" | "target")
}

/// Read `remote.origin.url` from a Git worktree.
pub fn git_remote_origin_url(repo_path: &Path) -> Option<String> {
    let output = Command::new("git")
        .current_dir(repo_path)
        .args(["config", "--get", "remote.origin.url"])
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let remote = String::from_utf8_lossy(&output.stdout).trim().to_string();
    (!remote.is_empty()).then_some(remote)
}

/// Derive a stable archive slug from a remote URL or archive path layout.
///
/// GitHub remotes win. If the remote is absent or not parseable as GitHub, the
/// slug falls back to `fallback_archive_slug`.
pub fn git_archive_slug(root: &Path, repo_path: &Path, remote_url: Option<&str>) -> String {
    remote_url
        .and_then(parse_github_remote_slug)
        .unwrap_or_else(|| fallback_archive_slug(root, repo_path))
}

/// Parse a GitHub remote URL into `owner/repo`.
///
/// Supports HTTPS, SSH, optional host ports, and common
/// `git@github.com:owner/repo.git` forms.
pub fn parse_github_remote_slug(remote_url: &str) -> Option<String> {
    let cleaned = remote_url.trim();
    let lower = cleaned.to_ascii_lowercase();
    let path = strip_known_github_remote_prefix(cleaned, &lower)?;
    github_path_to_slug(path)
}

fn strip_known_github_remote_prefix<'a>(remote_url: &'a str, lower: &str) -> Option<&'a str> {
    for prefix in [
        "git@github.com:",
        "ssh://git@github.com/",
        "git+ssh://git@github.com/",
        "https://github.com/",
        "http://github.com/",
        "https://www.github.com/",
        "http://www.github.com/",
    ] {
        if lower.starts_with(prefix) {
            return Some(&remote_url[prefix.len()..]);
        }
    }

    for prefix in [
        "ssh://git@github.com:",
        "git+ssh://git@github.com:",
        "https://github.com:",
        "http://github.com:",
        "https://www.github.com:",
        "http://www.github.com:",
    ] {
        if lower.starts_with(prefix) {
            return Some(strip_optional_leading_port(&remote_url[prefix.len()..]));
        }
    }

    lower
        .find("github.com/")
        .map(|index| &remote_url[index + "github.com/".len()..])
        .or_else(|| {
            lower.find("github.com:").map(|index| {
                strip_optional_leading_port(&remote_url[index + "github.com:".len()..])
            })
        })
}

fn strip_optional_leading_port(path: &str) -> &str {
    let Some((port, rest)) = path.split_once('/') else {
        return path;
    };
    if !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) {
        rest
    } else {
        path
    }
}

fn github_path_to_slug(path: &str) -> Option<String> {
    let path = path
        .split(['?', '#'])
        .next()
        .unwrap_or(path)
        .trim()
        .trim_matches('/');
    let mut parts = path.split('/').filter(|part| !part.is_empty());
    let owner = parts.next()?.trim();
    let repo = parts.next()?.trim().trim_end_matches(".git");
    if owner.is_empty()
        || repo.is_empty()
        || owner == "."
        || owner == ".."
        || repo == "."
        || repo == ".."
    {
        return None;
    }
    Some(format!("{owner}/{repo}"))
}

/// Derive a slug from archive path layout when no GitHub remote is available.
///
/// For `archives/octocat/Hello-World`, this returns `octocat/Hello-World`.
pub fn fallback_archive_slug(root: &Path, repo_path: &Path) -> String {
    let relative = repo_path.strip_prefix(root).unwrap_or(repo_path);
    let parts = relative
        .components()
        .filter_map(|component| match component {
            Component::Normal(value) => value.to_str().map(ToString::to_string),
            _ => None,
        })
        .collect::<Vec<_>>();

    match parts.as_slice() {
        [] => repo_path
            .file_name()
            .and_then(|value| value.to_str())
            .unwrap_or("archive")
            .to_string(),
        [single] => single.clone(),
        [owner, rest @ ..] => format!("{owner}/{}", rest.last().unwrap_or(owner)),
    }
}

fn git_archive_matches_filter(candidate: &GitArchiveCandidate, filter: Option<&str>) -> bool {
    let Some(filter) = filter.map(str::trim).filter(|value| !value.is_empty()) else {
        return true;
    };
    let filter = filter.to_ascii_lowercase();
    candidate.slug.to_ascii_lowercase().contains(&filter)
        || candidate
            .path
            .display()
            .to_string()
            .to_ascii_lowercase()
            .contains(&filter)
}

/// Refresh multiple Git archives with bounded concurrency.
///
/// Each worktree is checked with `git status --porcelain` before pulling. Dirty
/// worktrees are reported as failures and are not pulled.
///
/// ```no_run
/// # fn main() -> repoforge::Result<()> {
/// let archives = repoforge::discover_git_archives(std::path::Path::new("./archives"), None)?;
/// let summary = repoforge::refresh_git_archives(&archives, 4);
/// if summary.has_failures() {
///     eprintln!("{} archives failed", summary.failed.len());
/// }
/// # Ok(())
/// # }
/// ```
pub fn refresh_git_archives(
    archives: &[GitArchiveCandidate],
    concurrency: usize,
) -> GitRefreshSummary {
    let mut summary = GitRefreshSummary::default();
    let batch_size = concurrency.max(1);

    for batch in archives.chunks(batch_size) {
        let handles = batch
            .iter()
            .cloned()
            .map(|archive| {
                let thread_archive = archive.clone();
                (
                    archive,
                    thread::spawn(move || refresh_one_git_archive(&thread_archive)),
                )
            })
            .collect::<Vec<_>>();

        for (archive, handle) in handles {
            match handle.join() {
                Ok(Ok(status)) => {
                    match status {
                        GitRefreshStatus::Updated => summary.updated += 1,
                        GitRefreshStatus::Unchanged => summary.unchanged += 1,
                    }
                    summary.entries.push(GitRefreshEntry {
                        slug: archive.slug,
                        path: archive.path,
                        status,
                    });
                }
                Ok(Err(err)) => summary.failed.push(GitRefreshFailure {
                    slug: archive.slug,
                    path: archive.path,
                    message: err.to_string(),
                }),
                Err(_) => summary.failed.push(GitRefreshFailure {
                    slug: archive.slug,
                    path: archive.path,
                    message: "worker thread panicked while refreshing".to_string(),
                }),
            }
        }
    }

    summary
}

/// Refresh one Git archive with `git pull --ff-only`.
///
/// Returns `Updated` when `HEAD` changes and `Unchanged` otherwise.
pub fn refresh_one_git_archive(archive: &GitArchiveCandidate) -> Result<GitRefreshStatus> {
    ensure_clean_git_worktree(archive)?;
    let before = git_head(archive)?;
    let output = run_git_capture(&archive.path, &["pull", "--ff-only"])?;
    if !output.status.success() {
        return Err(git_command_failed(archive, "pull --ff-only", &output));
    }
    let after = git_head(archive)?;

    if before.is_some() && after.is_some() && before != after {
        Ok(GitRefreshStatus::Updated)
    } else {
        Ok(GitRefreshStatus::Unchanged)
    }
}

/// Fail if a worktree has local changes according to `git status --porcelain`.
pub fn ensure_clean_git_worktree(archive: &GitArchiveCandidate) -> Result<()> {
    let output = run_git_capture(&archive.path, &["status", "--porcelain"])?;
    if !output.status.success() {
        return Err(git_command_failed(archive, "status --porcelain", &output));
    }
    let status = String::from_utf8_lossy(&output.stdout);
    if !status.trim().is_empty() {
        return Err(RepoForgeError::DirtyWorktree {
            slug: archive.slug.clone(),
            path: archive.path.clone(),
        });
    }
    Ok(())
}

/// Read the current `HEAD` commit hash for a worktree.
///
/// Returns `Ok(None)` for repositories without a readable `HEAD`.
pub fn git_head(archive: &GitArchiveCandidate) -> Result<Option<String>> {
    let output = run_git_capture(&archive.path, &["rev-parse", "HEAD"])?;
    if !output.status.success() {
        return Ok(None);
    }
    let head = String::from_utf8_lossy(&output.stdout).trim().to_string();
    Ok((!head.is_empty()).then_some(head))
}

/// Run `git` in `repo_path` and capture stdout/stderr.
///
/// This low-level helper is public for callers that want RepoForge's consistent
/// missing-git error handling.
pub fn run_git_capture(repo_path: &Path, args: &[&str]) -> Result<Output> {
    Command::new("git")
        .current_dir(repo_path)
        .args(args)
        .output()
        .map_err(|error| {
            if error.kind() == io::ErrorKind::NotFound {
                RepoForgeError::MissingGit
            } else {
                RepoForgeError::Io(error)
            }
        })
}

fn git_command_failed(
    archive: &GitArchiveCandidate,
    context: &str,
    output: &Output,
) -> RepoForgeError {
    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
    let message = if !stderr.is_empty() {
        stderr
    } else if !stdout.is_empty() {
        stdout
    } else {
        output.status.code().map_or_else(
            || "signal/unknown".to_string(),
            |code| format!("exit code {code}"),
        )
    };

    RepoForgeError::GitCommandFailed {
        slug: archive.slug.clone(),
        path: archive.path.clone(),
        context: context.to_string(),
        message,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::{SystemTime, UNIX_EPOCH};

    #[test]
    fn parses_github_remote_slugs() {
        assert_eq!(
            parse_github_remote_slug("https://github.com/octocat/Hello-World.git"),
            Some("octocat/Hello-World".to_string())
        );
        assert_eq!(
            parse_github_remote_slug("git@github.com:Tknott95/GitArchiver.git"),
            Some("Tknott95/GitArchiver".to_string())
        );
        assert_eq!(
            parse_github_remote_slug("ssh://git@github.com/owner/repo"),
            Some("owner/repo".to_string())
        );
        assert_eq!(
            parse_github_remote_slug("git+ssh://git@github.com/owner/repo.git"),
            Some("owner/repo".to_string())
        );
        assert_eq!(
            parse_github_remote_slug("ssh://git@github.com:22/owner/repo.git"),
            Some("owner/repo".to_string())
        );
        assert_eq!(
            parse_github_remote_slug("https://github.com:443/owner/repo.git"),
            Some("owner/repo".to_string())
        );
        assert_eq!(
            parse_github_remote_slug("https://www.github.com/owner/repo.git"),
            Some("owner/repo".to_string())
        );
        assert_eq!(
            parse_github_remote_slug("https://example.com/owner/repo"),
            None
        );
    }

    #[test]
    fn splits_owner_repo_slugs() {
        assert_eq!(
            split_owner_repo_slug(" octocat/Hello-World "),
            Some(("octocat", "Hello-World"))
        );
        assert_eq!(split_owner_repo_slug("octocat"), None);
        assert_eq!(split_owner_repo_slug("octocat/Hello/World"), None);
        assert_eq!(split_owner_repo_slug("../repo"), None);
        assert_eq!(split_owner_repo_slug("owner/."), None);
        assert_eq!(
            github_web_url_for_slug("octocat/Hello-World"),
            Some("https://github.com/octocat/Hello-World".to_string())
        );
        assert_eq!(github_web_url_for_slug("octocat"), None);
    }

    #[test]
    fn fallback_slug_uses_archive_path_layout() {
        let root = Path::new("archives");
        assert_eq!(
            fallback_archive_slug(root, Path::new("archives/octocat/Hello-World")),
            "octocat/Hello-World"
        );
        assert_eq!(
            fallback_archive_slug(root, Path::new("archives/linux/linux")),
            "linux/linux"
        );
    }

    #[test]
    fn discovers_git_archives_from_archive_tree() {
        let root = test_temp_dir("discover_git_archives");
        let repo = root.join("octocat").join("Hello-World");
        let skipped = root.join("sites").join("docs").join(".thesa");
        fs::create_dir_all(repo.join(".git")).expect("create fake git worktree marker");
        fs::create_dir_all(skipped).expect("create skipped metadata dir");

        let archives = discover_git_archives(&root, None).expect("discover archives");

        assert_eq!(archives.len(), 1);
        assert_eq!(archives[0].path, repo);
        assert_eq!(archives[0].slug, "octocat/Hello-World");

        let filtered = discover_git_archives(&root, Some("hello")).expect("filter archives");
        assert_eq!(filtered.len(), 1);
        let none = discover_git_archives(&root, Some("nomatch")).expect("filter archives");
        assert!(none.is_empty());

        fs::remove_dir_all(root).expect("cleanup temp dir");
    }

    #[test]
    fn discovers_git_archives_for_owner_from_archive_tree() {
        let root = test_temp_dir("discover_owner_git_archives");
        let octo_repo = root.join("octocat").join("Hello-World");
        let rust_repo = root.join("rust-lang").join("rust");
        fs::create_dir_all(octo_repo.join(".git")).expect("create octocat worktree marker");
        fs::create_dir_all(rust_repo.join(".git")).expect("create rust worktree marker");

        let archives = discover_git_archives_for_owner(&root, "OCTOCAT", None)
            .expect("discover owner archives");

        assert_eq!(archives.len(), 1);
        assert_eq!(archives[0].slug, "octocat/Hello-World");
        assert_eq!(archives[0].slug_parts(), Some(("octocat", "Hello-World")));
        assert_eq!(archives[0].owner(), Some("octocat"));
        assert_eq!(archives[0].repo_name(), Some("Hello-World"));
        assert_eq!(
            archives[0].github_url(),
            Some("https://github.com/octocat/Hello-World".to_string())
        );
        assert!(archives[0].matches_filter("hello"));
        assert!(archives[0].matches_filter("OCTOCAT"));
        assert!(archives[0].matches_filter(""));
        assert!(!archives[0].matches_filter("rust"));
        assert!(archives[0].matches_owner("octocat"));

        let filtered = discover_git_archives_for_owner(&root, "octocat", Some("hello"))
            .expect("filter owner archives");
        assert_eq!(filtered.len(), 1);
        let none = discover_git_archives_for_owner(&root, "octocat", Some("rust"))
            .expect("filter owner archives");
        assert!(none.is_empty());

        fs::remove_dir_all(root).expect("cleanup temp dir");
    }

    fn test_temp_dir(name: &str) -> PathBuf {
        let nonce = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("system clock")
            .as_nanos();
        std::env::temp_dir().join(format!("repoforge-{name}-{nonce}"))
    }
}