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;
pub type Result<T> = std::result::Result<T, RepoForgeError>;
#[derive(Debug)]
pub enum RepoForgeError {
OutputNotDirectory(PathBuf),
MissingGit,
DirtyWorktree {
slug: String,
path: PathBuf,
},
GitCommandFailed {
slug: String,
path: PathBuf,
context: String,
message: String,
},
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)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitArchiveCandidate {
pub path: PathBuf,
pub slug: String,
pub remote_url: Option<String>,
}
impl GitArchiveCandidate {
pub fn slug_parts(&self) -> Option<(&str, &str)> {
split_owner_repo_slug(&self.slug)
}
pub fn owner(&self) -> Option<&str> {
self.slug_parts().map(|(owner, _)| owner)
}
pub fn repo_name(&self) -> Option<&str> {
self.slug_parts().map(|(_, repo)| repo)
}
pub fn github_url(&self) -> Option<String> {
github_web_url_for_slug(&self.slug)
}
pub fn matches_filter(&self, filter: &str) -> bool {
git_archive_matches_filter(self, Some(filter))
}
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))
}
}
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))
}
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}"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitRefreshStatus {
Updated,
Unchanged,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitRefreshEntry {
pub slug: String,
pub path: PathBuf,
pub status: GitRefreshStatus,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitRefreshFailure {
pub slug: String,
pub path: PathBuf,
pub message: String,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct GitRefreshSummary {
pub updated: usize,
pub unchanged: usize,
pub entries: Vec<GitRefreshEntry>,
pub failed: Vec<GitRefreshFailure>,
}
impl GitRefreshSummary {
pub fn processed(&self) -> usize {
self.updated + self.unchanged
}
pub fn total(&self) -> usize {
self.processed() + self.failed.len()
}
pub fn has_failures(&self) -> bool {
!self.failed.is_empty()
}
pub fn is_success(&self) -> bool {
self.failed.is_empty()
}
pub fn failed_slugs(&self) -> BTreeSet<String> {
self.failed
.iter()
.map(|failure| failure.slug.clone())
.collect()
}
}
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)
}
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(())
}
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")
}
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)
}
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))
}
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}"))
}
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)
}
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
}
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)
}
}
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(())
}
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))
}
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}"))
}
}