use std::path::Path;
use std::process::Stdio;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use crate::Config;
pub async fn record_activity(repo: &Path, kind: &str) {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let line = format!("{} {}\n", ts, kind);
let activity_file = repo.join(".gitrub_activity");
if let Ok(mut existing) = tokio::fs::read_to_string(&activity_file).await {
existing.push_str(&line);
let lines: Vec<&str> = existing.lines().collect();
let keep = if lines.len() > 100 { &lines[lines.len() - 100..] } else { &lines };
let _ = tokio::fs::write(&activity_file, keep.join("\n") + "\n").await;
} else {
let _ = tokio::fs::write(&activity_file, &line).await;
}
}
pub fn last_activity(repo: &Path) -> Option<(u64, String)> {
let activity_file = repo.join(".gitrub_activity");
let content = std::fs::read_to_string(activity_file).ok()?;
let last_line = content.lines().rev().find(|l| !l.is_empty())?;
let mut parts = last_line.splitn(2, ' ');
let ts: u64 = parts.next()?.parse().ok()?;
let kind = parts.next().unwrap_or("unknown").to_string();
Some((ts, kind))
}
pub fn recent_commits(repo: &Path, count: usize) -> Vec<String> {
let output = std::process::Command::new("git")
.args(["-C"])
.arg(repo)
.args([
"log",
"--all",
"--graph",
"--decorate",
"--format=%h %an │ %s (%ar)",
&format!("-{}", count),
])
.output();
match output {
Ok(out) if out.status.success() => {
String::from_utf8_lossy(&out.stdout)
.lines()
.map(|l| l.to_string())
.collect()
}
_ => vec!["(no commits yet)".into()],
}
}
pub fn contributors(repo: &Path) -> Vec<String> {
let output = std::process::Command::new("git")
.args(["-C"])
.arg(repo)
.args(["shortlog", "-sne", "--all"])
.output();
match output {
Ok(out) if out.status.success() => {
String::from_utf8_lossy(&out.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect()
}
_ => vec!["(no contributors yet)".into()],
}
}
pub fn languages(repo: &Path) -> Vec<String> {
let output = std::process::Command::new("git")
.args(["-C"])
.arg(repo)
.args(["ls-tree", "-r", "--name-only", "HEAD"])
.output();
let files: Vec<String> = match output {
Ok(out) if out.status.success() => {
String::from_utf8_lossy(&out.stdout)
.lines()
.map(|l| l.to_string())
.collect()
}
_ => return vec!["(empty repository)".into()],
};
if files.is_empty() {
return vec!["(empty repository)".into()];
}
let mut ext_lines: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let mut ext_files: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for file in &files {
let ext = match file.rsplit_once('.') {
Some((_, ext)) => ext.to_lowercase(),
None => continue, };
if matches!(
ext.as_str(),
"png" | "jpg" | "jpeg" | "gif" | "ico" | "svg" | "webp"
| "woff" | "woff2" | "ttf" | "eot" | "otf"
| "zip" | "tar" | "gz" | "bz2" | "xz" | "7z"
| "bin" | "exe" | "dll" | "so" | "dylib"
| "pdf" | "doc" | "docx" | "ppt" | "xlsx"
| "mp3" | "mp4" | "wav" | "avi" | "mov"
| "DS_Store" | "lock"
) {
continue;
}
let line_output = std::process::Command::new("git")
.args(["-C"])
.arg(repo)
.args(["show", &format!("HEAD:{}", file)])
.output();
let line_count = match line_output {
Ok(out) if out.status.success() => {
if out.stdout.contains(&0u8) {
continue;
}
out.stdout.iter().filter(|&&b| b == b'\n').count()
}
_ => continue,
};
*ext_lines.entry(ext.clone()).or_default() += line_count;
*ext_files.entry(ext).or_default() += 1;
}
if ext_lines.is_empty() {
return vec!["(no source files detected)".into()];
}
let total_lines: usize = ext_lines.values().sum();
let total_files: usize = ext_files.values().sum();
let mut sorted: Vec<_> = ext_lines.into_iter().collect();
sorted.sort_by(|a, b| b.1.cmp(&a.1));
let mut result = Vec::new();
result.push(format!(
"{:<16} {:>8} {:>6} {:>7}",
"Language", "Lines", "Files", "%"
));
result.push("─".repeat(42));
for (ext, lines) in &sorted {
let files = ext_files.get(ext).copied().unwrap_or(0);
let pct = (*lines as f64 / total_lines as f64) * 100.0;
let lang = ext_to_language(&ext);
result.push(format!(
"{:<16} {:>8} {:>6} {:>6.1}%",
lang, lines, files, pct
));
}
result.push("─".repeat(42));
result.push(format!(
"{:<16} {:>8} {:>6} {:>6.1}%",
"Total", total_lines, total_files, 100.0
));
result
}
fn ext_to_language(ext: &str) -> &str {
match ext {
"rs" => "Rust",
"py" => "Python",
"js" => "JavaScript",
"ts" => "TypeScript",
"tsx" => "TSX",
"jsx" => "JSX",
"go" => "Go",
"java" => "Java",
"kt" => "Kotlin",
"c" => "C",
"h" => "C Header",
"cpp" | "cc" | "cxx" => "C++",
"hpp" => "C++ Header",
"cs" => "C#",
"rb" => "Ruby",
"php" => "PHP",
"swift" => "Swift",
"m" => "Objective-C",
"r" => "R",
"scala" => "Scala",
"dart" => "Dart",
"lua" => "Lua",
"zig" => "Zig",
"nim" => "Nim",
"ex" | "exs" => "Elixir",
"erl" => "Erlang",
"hs" => "Haskell",
"ml" | "mli" => "OCaml",
"clj" => "Clojure",
"sh" | "bash" => "Shell",
"zsh" => "Zsh",
"fish" => "Fish",
"ps1" => "PowerShell",
"bat" | "cmd" => "Batch",
"html" | "htm" => "HTML",
"css" => "CSS",
"scss" => "SCSS",
"sass" => "Sass",
"less" => "Less",
"json" => "JSON",
"yaml" | "yml" => "YAML",
"toml" => "TOML",
"xml" => "XML",
"md" | "markdown" => "Markdown",
"txt" => "Text",
"rst" => "reStructuredText",
"sql" => "SQL",
"graphql" | "gql" => "GraphQL",
"proto" => "Protobuf",
"dockerfile" => "Dockerfile",
"makefile" => "Makefile",
"cmake" => "CMake",
"tf" => "Terraform",
"hcl" => "HCL",
"nix" => "Nix",
"vue" => "Vue",
"svelte" => "Svelte",
"astro" => "Astro",
"sol" => "Solidity",
"v" => "V",
"wasm" => "WebAssembly",
"pl" | "pm" => "Perl",
"vim" => "Vim Script",
"el" => "Emacs Lisp",
"lisp" | "lsp" => "Lisp",
"rkt" => "Racket",
other => other,
}
}
pub fn file_tree(repo: &Path) -> Vec<String> {
let output = std::process::Command::new("git")
.args(["-C"])
.arg(repo)
.args(["ls-tree", "-r", "-l", "HEAD"])
.output();
match output {
Ok(out) if out.status.success() => {
String::from_utf8_lossy(&out.stdout)
.lines()
.map(|line| {
if let Some((meta, path)) = line.split_once('\t') {
let size = meta.split_whitespace()
.last()
.and_then(|s| s.parse::<u64>().ok())
.map(format_file_size)
.unwrap_or_else(|| "-".into());
format!("{:>8} {}", size, path)
} else {
line.to_string()
}
})
.collect()
}
_ => vec!["(empty repository)".into()],
}
}
fn format_file_size(bytes: u64) -> String {
if bytes < 1024 {
format!("{}B", bytes)
} else if bytes < 1024 * 1024 {
format!("{:.1}K", bytes as f64 / 1024.0)
} else if bytes < 1024 * 1024 * 1024 {
format!("{:.1}M", bytes as f64 / (1024.0 * 1024.0))
} else {
format!("{:.1}G", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
}
}
pub fn branches(repo: &Path) -> Vec<String> {
let output = std::process::Command::new("git")
.args(["-C"])
.arg(repo)
.args(["branch", "-a", "--no-color"])
.output();
match output {
Ok(out) if out.status.success() => {
String::from_utf8_lossy(&out.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect()
}
_ => vec![],
}
}
pub async fn init_repo(path: &Path, config: &Config) -> Result<(), String> {
let out = Command::new("git")
.args(["init"])
.arg(path)
.output()
.await
.map_err(|e| e.to_string())?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).into());
}
git_config(path, "receive.denyCurrentBranch", "ignore").await;
git_config(path, "receive.denyDeleteCurrent", "ignore").await;
git_config(path, "http.receivepack", "true").await;
Command::new("git")
.args(["-C"])
.arg(path)
.args(["symbolic-ref", "HEAD", "refs/heads/main"])
.output()
.await
.ok();
git_config(path, "uploadpack.allowFilter", "true").await;
git_config(path, "uploadpack.allowReachableSHA1InWant", "true").await;
install_checkout_hook(path).await;
if let Some(hooks_dir) = &config.hooks_dir {
install_hooks(path, hooks_dir).await;
}
Ok(())
}
async fn install_checkout_hook(repo: &Path) {
let hooks_dir = if repo.join(".git").exists() {
repo.join(".git").join("hooks")
} else {
repo.join("hooks")
};
tokio::fs::create_dir_all(&hooks_dir).await.ok();
let hook_path = hooks_dir.join("post-receive");
if hook_path.exists() {
return;
}
let hook_script = r#"#!/bin/sh
# Built-in gitrub hook: update working tree after push
WORK_TREE="$(cd "$(git rev-parse --git-dir)/.." && pwd)"
export GIT_WORK_TREE="$WORK_TREE"
export GIT_DIR="$WORK_TREE/.git"
while read oldrev newrev refname; do
branch="${refname#refs/heads/}"
if [ -n "$branch" ]; then
git symbolic-ref HEAD "$refname"
git reset --hard
break
fi
done
"#;
if tokio::fs::write(&hook_path, hook_script).await.is_ok() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755))
.await
.ok();
}
}
}
pub fn is_git_repo(path: &Path) -> bool {
if path.join(".git").join("HEAD").exists() {
return true;
}
path.join("HEAD").exists() && path.join("refs").exists()
}
async fn git_config(repo: &Path, key: &str, value: &str) {
Command::new("git")
.args(["-C"])
.arg(repo)
.args(["config", key, value])
.output()
.await
.ok();
}
async fn install_hooks(repo: &Path, hooks_dir: &Path) {
let dest = if repo.join(".git").exists() {
repo.join(".git").join("hooks")
} else {
repo.join("hooks")
};
tokio::fs::create_dir_all(&dest).await.ok();
let Ok(mut entries) = tokio::fs::read_dir(hooks_dir).await else {
return;
};
while let Ok(Some(entry)) = entries.next_entry().await {
let src = entry.path();
if src.is_file() {
let name = entry.file_name();
let dst = dest.join(&name);
if tokio::fs::copy(&src, &dst).await.is_ok() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o755))
.await
.ok();
}
}
}
}
}
pub async fn info_refs(
repo: &Path,
service: &str,
git_protocol: Option<&str>,
) -> Result<(String, Vec<u8>), String> {
let cmd_name = service.strip_prefix("git-").unwrap_or(service);
let mut cmd = Command::new("git");
cmd.arg(cmd_name)
.args(["--stateless-rpc", "--advertise-refs"])
.arg(repo);
if let Some(proto) = git_protocol {
cmd.env("GIT_PROTOCOL", proto);
}
let out = cmd.output().await.map_err(|e| e.to_string())?;
let content_type = format!("application/x-{}-advertisement", service);
let mut body = Vec::new();
if git_protocol.map_or(false, |p| p.contains("version=2")) {
body.extend(&out.stdout);
} else {
let header = format!("# service={}\n", service);
let pkt = format!("{:04x}{}", header.len() + 4, header);
body.extend(pkt.as_bytes());
body.extend(b"0000");
body.extend(&out.stdout);
}
Ok((content_type, body))
}
pub async fn run_pack(
repo: &Path,
service: &str,
input: &[u8],
git_protocol: Option<&str>,
) -> Result<(String, Vec<u8>), String> {
let cmd_name = service.strip_prefix("git-").unwrap_or(service);
let mut cmd = Command::new("git");
cmd.arg(cmd_name)
.args(["--stateless-rpc"])
.arg(repo)
.stdin(Stdio::piped())
.stdout(Stdio::piped());
if let Some(proto) = git_protocol {
cmd.env("GIT_PROTOCOL", proto);
}
let mut child = cmd.spawn().map_err(|e| e.to_string())?;
let mut stdin = child.stdin.take().unwrap();
stdin.write_all(input).await.map_err(|e| e.to_string())?;
drop(stdin);
let out = child.wait_with_output().await.map_err(|e| e.to_string())?;
let content_type = format!("application/x-{}-result", service);
Ok((content_type, out.stdout))
}
pub async fn spawn_git(
repo: &Path,
command: &str,
) -> Result<tokio::process::Child, String> {
let cmd_name = command.strip_prefix("git-").unwrap_or(command);
Command::new("git")
.arg(cmd_name)
.arg(repo)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to spawn git {}: {}", cmd_name, e))
}
pub async fn archive(repo: &Path, tree: &str, format: &str) -> Result<Vec<u8>, String> {
let out = Command::new("git")
.args(["-C"])
.arg(repo)
.args(["archive", "--format", format, tree])
.output()
.await
.map_err(|e| e.to_string())?;
if !out.status.success() {
return Err(format!(
"git archive failed: {}",
String::from_utf8_lossy(&out.stderr)
));
}
Ok(out.stdout)
}
pub async fn spawn_upload_archive(repo: &Path) -> Result<tokio::process::Child, String> {
Command::new("git")
.arg("upload-archive")
.arg(repo)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to spawn git upload-archive: {}", e))
}