Menu
AkurAI-Build
publicLatest change 8bf253731c2408b3673572946d9d5e21d900e6f1 - Self-heal interrupted mirror clones in RepoTree::sync_mirror by Ólafur Búi Ólafsson
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, ensure};
use serde::Serialize;
use crate::config::validate_ref;
/// Maximum age (seconds) of a cached mirror before a fetch is forced.
const MIRROR_TTL: u64 = 30;
/// Bound the number of tree entries we resolve per-file commits for.
const MAX_ENTRIES: usize = 500;
#[derive(Clone)]
pub struct RepoTree {
root: PathBuf,
}
#[derive(Serialize)]
pub struct TreeEntry {
pub name: String,
pub kind: String,
pub size: u64,
pub commit_message: Option<String>,
pub commit_at: Option<i64>,
}
#[derive(Serialize)]
pub struct LatestCommit {
pub sha: String,
pub message: String,
pub author: String,
pub committed_at: i64,
}
#[derive(Serialize)]
pub struct RepoCounts {
pub commits: u64,
pub branches: u64,
pub tags: u64,
}
#[derive(Serialize)]
pub struct TreeResponse {
pub reference: String,
pub path: String,
pub entries: Vec<TreeEntry>,
pub latest_commit: Option<LatestCommit>,
pub recent_commits: Vec<LatestCommit>,
pub counts: RepoCounts,
pub readme: Option<String>,
pub readme_name: Option<String>,
pub contributors: Vec<Contributor>,
pub languages: Vec<Language>,
pub description: Option<String>,
}
#[derive(Serialize)]
pub struct BlobResponse {
pub reference: String,
pub path: String,
pub content: String,
pub latest_commit: Option<LatestCommit>,
}
#[derive(Serialize)]
pub struct Contributor {
pub name: String,
pub email: String,
pub commit_count: u64,
}
#[derive(Serialize)]
pub struct Language {
pub name: String,
pub color: String,
pub percentage: f64,
}
impl RepoTree {
pub fn new(data_root: &Path) -> Result<Self> {
let root = data_root.join("mirrors");
std::fs::create_dir_all(&root)?;
Ok(Self { root })
}
fn mirror_path(&self, repository: &str) -> PathBuf {
self.root.join(format!("{repository}.git"))
}
/// Ensure a bare mirror exists and is reasonably fresh.
fn sync_mirror(&self, repository: &str, url: &str) -> Result<PathBuf> {
let path = self.mirror_path(repository);
if path.join("HEAD").is_file() {
let current_origin = git_output(&path, ["remote", "get-url", "origin"])
.ok()
.map(|value| value.trim().to_owned());
let origin_changed = current_origin.as_deref() != Some(url);
if origin_changed {
let configured = if current_origin.is_some() {
git(&path, ["remote", "set-url", "origin", url])?
} else {
git(&path, ["remote", "add", "origin", url])?
};
ensure!(configured, "update mirror origin failed for {repository}");
}
let fresh = !origin_changed
&& std::fs::metadata(path.join("FETCH_HEAD"))
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.elapsed().ok())
.is_some_and(|age| age.as_secs() < MIRROR_TTL);
if !fresh {
let status = git(&path, ["fetch", "--prune", "origin", "+refs/*:refs/*"])?;
ensure!(status, "git fetch failed for {repository}");
}
return Ok(path);
}
// A directory without HEAD is a remnant of an interrupted clone; it
// would make `git clone --bare` fail forever. Remove it and start over.
if path.is_dir() {
std::fs::remove_dir_all(&path)?;
}
std::fs::create_dir_all(&path)?;
let parent = path
.parent()
.context("mirror path has no parent")?
.to_path_buf();
let name = path
.file_name()
.context("mirror path has no file name")?
.to_string_lossy()
.to_string();
let status = git_output(
&parent,
["clone", "--bare", "--filter=blob:none", url, &name],
)
.is_ok();
ensure!(status, "git clone --bare failed for {repository}");
Ok(path)
}
/// Resolve the default branch of the mirror.
fn default_ref(&self, path: &Path) -> Result<String> {
if let Ok(head) = git_output(path, ["symbolic-ref", "--short", "HEAD"])
&& !head.is_empty()
{
return Ok(head);
}
Ok("HEAD".to_string())
}
pub fn tree(
&self,
repository: &str,
url: &str,
reference: Option<&str>,
sub_path: &str,
) -> Result<TreeResponse> {
if let Some(reference) = reference {
validate_ref(reference)?;
}
validate_code_path(sub_path)?;
let path = self.sync_mirror(repository, url)?;
let reference = match reference {
Some(value) => value.to_string(),
None => self.default_ref(&path)?,
};
let spec = if sub_path.is_empty() {
reference.clone()
} else {
format!("{reference}:{sub_path}")
};
let raw = git_output(&path, ["ls-tree", "--long", "-z", &spec])
.with_context(|| format!("ls-tree failed for {repository}@{reference}"))?;
let mut entries = parse_tree(&raw);
entries.sort_by(|a, b| {
(a.kind != "tree")
.cmp(&(b.kind != "tree"))
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
// Per-file latest commit (bounded).
for entry in entries.iter_mut().take(MAX_ENTRIES) {
let entry_path = if sub_path.is_empty() {
entry.name.clone()
} else {
format!("{sub_path}/{}", entry.name)
};
if let Ok(line) = git_output(
&path,
[
"log",
"-1",
"--format=%s%x00%ct",
&reference,
"--",
&entry_path,
],
) && let Some((message, ts)) = line.split_once('\0')
{
entry.commit_message = Some(message.trim().to_string());
entry.commit_at = ts.trim().parse::<i64>().ok();
}
}
let latest_commit = git_output(
&path,
["log", "-1", "--format=%H%x00%s%x00%an%x00%ct", &reference],
)
.ok()
.and_then(|line| parse_commit(&line));
let counts = RepoCounts {
commits: git_output(&path, ["rev-list", "--count", &reference])
.ok()
.and_then(|v| v.trim().parse().ok())
.unwrap_or(0),
branches: count_lines(&git_output(&path, ["branch", "--list"]).unwrap_or_default()),
tags: count_lines(&git_output(&path, ["tag", "--list"]).unwrap_or_default()),
};
let recent_commits = git_output(
&path,
[
"log",
"-8",
"--format=%H%x00%s%x00%an%x00%ct%x1e",
&reference,
],
)
.unwrap_or_default()
.split('\x1e')
.filter_map(parse_commit)
.collect();
// Contributors: top 12 by commit count
let contributors = git_output(&path, ["shortlog", "-sne", &reference])
.ok()
.map(|raw| {
raw.lines()
.filter_map(|line| {
let (count, identity) = line.trim().split_once('\t')?;
let (name, email) = identity.rsplit_once(" <")?;
Some(Contributor {
name: name.to_string(),
email: email.trim_end_matches('>').to_string(),
commit_count: count.trim().parse().unwrap_or(0),
})
})
.take(12)
.collect()
})
.unwrap_or_default();
// Languages: from top-level tree entries by extension
let languages = {
let mut ext_counts: std::collections::HashMap<String, u64> =
std::collections::HashMap::new();
let mut total = 0u64;
for entry in &entries {
if entry.kind == "tree" {
continue;
}
let ext = std::path::Path::new(&entry.name)
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_else(|| entry.name.to_lowercase());
*ext_counts.entry(ext).or_default() += entry.size.max(1);
total += entry.size.max(1);
}
if total == 0 {
total = 1;
}
let mut langs: Vec<Language> = ext_counts
.into_iter()
.map(|(name, size)| {
let percentage = (size as f64 / total as f64) * 100.0;
let color = language_color(&name);
Language {
name,
color,
percentage,
}
})
.collect();
langs.sort_by(|a, b| {
b.percentage
.partial_cmp(&a.percentage)
.unwrap_or(std::cmp::Ordering::Equal)
});
langs
};
let (readme, readme_name) = read_readme(&path, &reference, sub_path);
// Description: from the mirror's git description file
let description = std::fs::read_to_string(path.join("description"))
.ok()
.map(|d| d.trim().to_string())
.filter(|d| !d.is_empty() && d != "Unnamed repository; edit this file 'description' to name the repository.");
Ok(TreeResponse {
reference,
path: sub_path.to_string(),
entries,
latest_commit,
recent_commits,
counts,
readme,
readme_name,
contributors,
languages,
description,
})
}
pub fn blob(
&self,
repository: &str,
url: &str,
reference: &str,
blob_path: &str,
) -> Result<BlobResponse> {
crate::config::validate_ref(reference)?;
validate_code_path(blob_path)?;
let mirror = self.sync_mirror(repository, url)?;
let spec = format!("{reference}:{blob_path}");
let content = git_output(&mirror, ["show", &spec])?;
ensure!(
content.len() <= 2 * 1024 * 1024,
"blob is too large to render"
);
let latest_commit = git_output(
&mirror,
[
"log",
"-1",
"--format=%H%x00%s%x00%an%x00%ct",
reference,
"--",
blob_path,
],
)
.ok()
.and_then(|line| parse_commit(&line));
Ok(BlobResponse {
reference: reference.to_owned(),
path: blob_path.to_owned(),
content,
latest_commit,
})
}
}
fn validate_code_path(path: &str) -> Result<()> {
if path.is_empty() {
return Ok(());
}
ensure!(
path.split('/')
.all(|segment| !segment.is_empty() && segment != "." && segment != ".."),
"invalid code path"
);
Ok(())
}
fn parse_tree(raw: &str) -> Vec<TreeEntry> {
// Each record: "<mode> <type> <sha> <size>\t<name>\0"
raw.split('\0')
.filter(|record| !record.is_empty())
.filter_map(|record| {
let (meta, name) = record.split_once('\t')?;
let mut fields = meta.split_whitespace();
let _mode = fields.next()?;
let kind = fields.next()?;
let _sha = fields.next()?;
let size = fields.next().unwrap_or("0");
Some(TreeEntry {
name: name.to_string(),
kind: kind.to_string(),
size: size.parse().unwrap_or(0),
commit_message: None,
commit_at: None,
})
})
.collect()
}
fn parse_commit(line: &str) -> Option<LatestCommit> {
let mut fields = line.split('\0');
let sha = fields.next()?.trim().to_string();
let message = fields.next()?.trim().to_string();
let author = fields.next()?.trim().to_string();
let committed_at = fields.next()?.trim().parse().ok()?;
if sha.is_empty() {
return None;
}
Some(LatestCommit {
sha,
message,
author,
committed_at,
})
}
fn read_readme(path: &Path, reference: &str, sub_path: &str) -> (Option<String>, Option<String>) {
for candidate in ["README.md", "README.markdown", "README", "README.txt"] {
let spec = if sub_path.is_empty() {
format!("{reference}:{candidate}")
} else {
format!("{reference}:{sub_path}/{candidate}")
};
if let Ok(content) = git_output(path, ["show", &spec])
&& !content.is_empty()
{
return (Some(content), Some(candidate.to_string()));
}
}
(None, None)
}
fn language_color(ext: &str) -> String {
match ext {
"rs" => "#dea584",
"ts" | "tsx" => "#3178c6",
"js" | "jsx" | "mjs" | "cjs" => "#f1e05a",
"py" => "#3572A5",
"go" => "#00ADD8",
"rb" => "#701516",
"c" => "#555555",
"cpp" | "cxx" | "cc" | "hpp" | "h" | "hxx" => "#f34b7d",
"java" => "#b07219",
"html" | "htm" => "#e34c26",
"css" | "scss" | "sass" | "less" => "#563d7c",
"json" => "#292929",
"yaml" | "yml" => "#cb171e",
"toml" => "#9c4221",
"md" | "mdx" | "markdown" => "#083fa1",
"sql" => "#e38c00",
"sh" | "bash" | "zsh" | "fish" => "#89e051",
"dockerfile" | "Dockerfile" => "#384d54",
"svg" => "#ff9900",
"png" | "jpg" | "jpeg" | "gif" | "ico" | "webp" | "bmp" => "#cccccc",
"zip" | "tar" | "gz" | "bz2" | "xz" | "7z" => "#cccccc",
"lock" => "#7a7a7a",
"txt" | "text" => "#6c6c6c",
_ => "#6c6c6c",
}
.to_string()
}
fn count_lines(value: &str) -> u64 {
value.lines().filter(|l| !l.trim().is_empty()).count() as u64
}
fn git<const N: usize>(cwd: &Path, args: [&str; N]) -> Result<bool> {
let status = crate::git_process::command(cwd).args(args).status()?;
Ok(status.success())
}
fn git_output<const N: usize>(cwd: &Path, args: [&str; N]) -> Result<String> {
crate::git_process::output(cwd, args)
}
#[cfg(test)]
mod tests {
use super::*;
use std::{
fs,
path::{Path, PathBuf},
};
use tempfile::tempdir;
fn fixture_repository(root: &Path, name: &str, file: &str) -> Result<PathBuf> {
let repository = root.join(name);
fs::create_dir(&repository)?;
assert!(git(&repository, ["init", "-b", "main"])?);
assert!(git(&repository, ["config", "user.name", "AkurAI Test"])?);
assert!(git(
&repository,
["config", "user.email", "test@example.invalid"]
)?);
fs::write(repository.join(file), "fixture\n")?;
assert!(git(&repository, ["add", "."])?);
assert!(git(&repository, ["commit", "-m", "fixture"])?);
Ok(repository)
}
#[test]
fn parses_ls_tree_records() {
let raw = "100644 blob abc123 42\tREADME.md\x0040000 tree def456 -\tsrc\x00";
let entries = parse_tree(raw);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].name, "README.md");
assert_eq!(entries[0].kind, "blob");
assert_eq!(entries[0].size, 42);
assert_eq!(entries[1].name, "src");
assert_eq!(entries[1].kind, "tree");
}
#[test]
fn parses_empty_output() {
let entries = parse_tree("");
assert!(entries.is_empty(), "empty output -> no entries");
let entries = parse_tree("\x00");
assert!(entries.is_empty(), "bare null -> no entries");
}
#[test]
fn skips_malformed_records() {
let raw = "bad-record\x00100644 blob abc123 42\tok.txt\x00no-tab-here\x00";
let entries = parse_tree(raw);
// Only the middle record has a \t separator; first and last should be skipped.
assert_eq!(entries.len(), 1, "only one well-formed record");
assert_eq!(entries[0].name, "ok.txt");
assert_eq!(entries[0].kind, "blob");
}
#[test]
fn handles_unicode_filenames() {
let raw = b"100644 blob abc123 42\t\xF0\x9F\x93\xA6.md\x00";
let raw_str = String::from_utf8_lossy(raw);
let entries = parse_tree(&raw_str);
assert_eq!(entries.len(), 1);
assert!(
entries[0].name.contains('\u{1F4E6}'),
"unicode filename should be preserved"
);
}
#[test]
fn handles_large_sizes() {
let raw = "100644 blob abc123 4294967296\tbig.bin\x00";
let entries = parse_tree(raw);
assert_eq!(entries.len(), 1);
assert_eq!(
entries[0].size, 4294967296,
"large size should parse correctly"
);
}
#[test]
fn size_defaults_to_zero_for_submodules() {
// Submodules show '-' for size in ls-tree --long.
let raw = "160000 commit abc123 -\tsubmod\x00";
let entries = parse_tree(raw);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].kind, "commit");
assert_eq!(entries[0].size, 0, "submodule size '-' should default to 0");
}
#[test]
fn parses_commit_line() {
let commit =
parse_commit("abc\0Initial commit\0Alice\x001700000000").expect("valid commit fixture");
assert_eq!(commit.sha, "abc");
assert_eq!(commit.message, "Initial commit");
assert_eq!(commit.author, "Alice");
assert_eq!(commit.committed_at, 1700000000);
}
#[test]
fn rejects_empty_commit_sha() {
assert!(parse_commit("\0msg\0author\x00123").is_none());
}
#[test]
fn rejects_commit_with_missing_fields() {
// Not enough null-delimited fields.
assert!(parse_commit("sha\0msg").is_none());
assert!(parse_commit("sha\x00msg\x00author").is_none());
}
#[test]
fn rejects_commit_with_bad_timestamp() {
assert!(
parse_commit("sha\0msg\0author\0not-a-number").is_none(),
"non-numeric timestamp should fail"
);
}
#[test]
fn parses_recent_commit_records() {
let commits: Vec<_> =
"first\x00Add sidebar data\x00Alice\x001\x1esecond\x00Refine layout\x00Bob\x002\x1e"
.split('\x1e')
.filter_map(parse_commit)
.collect();
assert_eq!(commits.len(), 2);
assert_eq!(commits[0].message, "Add sidebar data");
assert_eq!(commits[1].author, "Bob");
}
#[test]
fn tree_reconciles_cached_mirror_origin() -> Result<()> {
let work = tempdir()?;
let first = fixture_repository(work.path(), "first", "first.txt")?;
let second = fixture_repository(work.path(), "second", "second.txt")?;
let tree = RepoTree::new(work.path())?;
let first_view = tree.tree("app", first.to_str().expect("UTF-8 path"), None, "")?;
assert!(
first_view
.entries
.iter()
.any(|entry| entry.name == "first.txt")
);
let second_view = tree.tree("app", second.to_str().expect("UTF-8 path"), None, "")?;
assert!(
second_view
.entries
.iter()
.any(|entry| entry.name == "second.txt")
);
assert!(
!second_view
.entries
.iter()
.any(|entry| entry.name == "first.txt")
);
Ok(())
}
#[test]
fn tree_recovers_from_interrupted_clone_remnant() -> Result<()> {
let work = tempdir()?;
let source = fixture_repository(work.path(), "source", "fixture.txt")?;
let tree = RepoTree::new(work.path())?;
// Simulate an interrupted clone: mirror directory exists without HEAD.
let remnant = work.path().join("mirrors").join("app.git");
fs::create_dir_all(&remnant)?;
fs::write(remnant.join("description"), "junk\n")?;
let view = tree.tree("app", source.to_str().expect("UTF-8 path"), None, "")?;
assert!(view.entries.iter().any(|entry| entry.name == "fixture.txt"));
Ok(())
}
#[test]
fn blob_reads_file_at_ref_and_rejects_traversal() -> Result<()> {
let work = tempdir()?;
let repository = fixture_repository(work.path(), "source", "fixture.txt")?;
let tree = RepoTree::new(work.path())?;
let source = tree.blob(
"app",
repository.to_str().expect("UTF-8 path"),
"main",
"fixture.txt",
)?;
assert_eq!(source.content, "fixture");
assert_eq!(source.path, "fixture.txt");
assert!(source.latest_commit.is_some());
assert!(
tree.blob(
"app",
repository.to_str().expect("UTF-8 path"),
"main",
"../fixture.txt",
)
.is_err()
);
Ok(())
}
}