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>,
}
#[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)
}
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 path = if let Some(path) = cleaned.strip_prefix("git@github.com:") {
path
} else if let Some(path) = cleaned.strip_prefix("ssh://git@github.com/") {
path
} else if let Some(path) = cleaned.strip_prefix("https://github.com/") {
path
} else if let Some(path) = cleaned.strip_prefix("http://github.com/") {
path
} else if let Some((_, path)) = cleaned.split_once("github.com/") {
path
} else if let Some((_, path)) = cleaned.split_once("github.com:") {
path
} else {
return None;
};
github_path_to_slug(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("https://example.com/owner/repo"),
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");
}
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!("gitforge-{name}-{nonce}"))
}
}