646cf295
Refine server workflows
a73x 2026-04-03 17:23
diff --git a/Makefile b/Makefile index 6e4fa19..3154d61 100644 --- a/Makefile +++ b/Makefile @@ -57,6 +57,19 @@ install-man: man clean-man: rm -rf man/ # --- Docker --- REGISTRY := registry.a73x.sh IMAGE := $(REGISTRY)/git-collab-server TAG ?= 0.0.1 .PHONY: docker docker-push docker: docker build -t $(IMAGE):$(TAG) . docker-push: docker docker push $(IMAGE):$(TAG) # --- Dev helpers --- .PHONY: run serve dev dashboard diff --git a/src/server/http/mod.rs b/src/server/http/mod.rs index 1c6c189..0897f0c 100644 --- a/src/server/http/mod.rs +++ b/src/server/http/mod.rs @@ -19,6 +19,7 @@ pub fn router(state: AppState) -> Router { .route("/", axum::routing::get(repo_list::handler)) .route("/{repo_name}", axum::routing::get(repo::overview)) .route("/{repo_name}/commits", axum::routing::get(repo::commits)) .route("/{repo_name}/commits/{ref_name}", axum::routing::get(repo::commits_ref)) .route("/{repo_name}/tree", axum::routing::get(repo::tree_root)) .route("/{repo_name}/tree/{*rest}", axum::routing::get(repo::tree)) .route("/{repo_name}/blob/{*rest}", axum::routing::get(repo::blob)) diff --git a/src/server/http/repo/commits.rs b/src/server/http/repo/commits.rs index 5b6a68b..df0a4f4 100644 --- a/src/server/http/repo/commits.rs +++ b/src/server/http/repo/commits.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use axum::extract::{Path, State}; use axum::response::{IntoResponse, Response}; use super::{AppState, OverviewCommit, collab_counts, open_repo, recent_commits}; use super::{AppState, OverviewCommit, collab_counts, head_branch_name, list_branches, open_repo}; #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "commits.html")] @@ -13,9 +13,46 @@ pub struct CommitsTemplate { pub active_section: String, pub open_patches: usize, pub open_issues: usize, pub ref_name: String, pub branches: Vec<String>, pub commits: Vec<OverviewCommit>, } fn commits_for_ref(repo: &git2::Repository, ref_name: &str, limit: usize) -> Vec<OverviewCommit> { let obj = match repo.revparse_single(ref_name) { Ok(o) => o, Err(_) => return Vec::new(), }; let commit = match obj.peel_to_commit() { Ok(c) => c, Err(_) => return Vec::new(), }; let mut revwalk = match repo.revwalk() { Ok(rw) => rw, Err(_) => return Vec::new(), }; if revwalk.push(commit.id()).is_err() { return Vec::new(); } revwalk .take(limit) .filter_map(|oid| { let oid = oid.ok()?; let commit = repo.find_commit(oid).ok()?; let id = oid.to_string(); let short_id = id[..8.min(id.len())].to_string(); let summary = commit.summary().unwrap_or("").to_string(); let author = commit.author().name().unwrap_or("").to_string(); let secs = commit.time().seconds(); let date = chrono::TimeZone::timestamp_opt(&chrono::Utc, secs, 0) .single() .map(|dt| dt.format("%Y-%m-%d").to_string()) .unwrap_or_default(); Some(OverviewCommit { id, short_id, summary, author, date }) }) .collect() } pub async fn commits( Path(repo_name): Path<String>, State(state): State<Arc<AppState>>, @@ -25,7 +62,35 @@ pub async fn commits( Err(resp) => return resp, }; let commits = recent_commits(&repo, 200); let ref_name = head_branch_name(&repo); let branches = list_branches(&repo); let commits = commits_for_ref(&repo, &ref_name, 200); let (open_patches, open_issues) = collab_counts(&repo); CommitsTemplate { site_title: state.site_title.clone(), repo_name, active_section: "commits".to_string(), open_patches, open_issues, ref_name, branches, commits, } .into_response() } pub async fn commits_ref( Path((repo_name, ref_name)): Path<(String, String)>, State(state): State<Arc<AppState>>, ) -> Response { let (_entry, repo) = match open_repo(&state, &repo_name) { Ok(r) => r, Err(resp) => return resp, }; let branches = list_branches(&repo); let commits = commits_for_ref(&repo, &ref_name, 200); let (open_patches, open_issues) = collab_counts(&repo); CommitsTemplate { @@ -34,6 +99,8 @@ pub async fn commits( active_section: "commits".to_string(), open_patches, open_issues, ref_name, branches, commits, } .into_response() diff --git a/src/server/http/repo/mod.rs b/src/server/http/repo/mod.rs index b0d0b4b..650ee0c 100644 --- a/src/server/http/repo/mod.rs +++ b/src/server/http/repo/mod.rs @@ -6,7 +6,7 @@ mod patches; mod issues; pub use overview::overview; pub use commits::commits; pub use commits::{commits, commits_ref}; pub use tree::{tree_root, tree, blob}; pub use diff::diff; pub use patches::{patches, patch_detail}; @@ -82,8 +82,16 @@ fn recent_commits(repo: &git2::Repository, limit: usize) -> Vec<OverviewCommit> Err(_) => return Vec::new(), }; // Try HEAD first; if unborn, fall back to the first local branch if revwalk.push_head().is_err() { return Vec::new(); let branch = list_branches(repo).into_iter().next(); let pushed = branch.and_then(|name| { let obj = repo.revparse_single(&name).ok()?; revwalk.push(obj.id()).ok() }); if pushed.is_none() { return Vec::new(); } } revwalk @@ -113,6 +121,34 @@ pub struct CommentView { pub timestamp: String, } /// List local branch names, sorted alphabetically. fn list_branches(repo: &git2::Repository) -> Vec<String> { let mut names: Vec<String> = repo .branches(Some(git2::BranchType::Local)) .into_iter() .flatten() .filter_map(|b| { let (branch, _) = b.ok()?; branch.name().ok()?.map(|n| n.to_string()) }) .collect(); names.sort(); names } /// Resolve HEAD to its short branch name (e.g. "main"). /// Falls back to the first existing branch if HEAD points to an unborn branch, /// which happens when a bare repo is init'd with master but only main is pushed. fn head_branch_name(repo: &git2::Repository) -> String { if let Ok(head) = repo.head() { if let Some(name) = head.shorthand() { return name.to_string(); } } // HEAD is unborn or detached — pick the first local branch list_branches(repo).into_iter().next().unwrap_or_else(|| "HEAD".to_string()) } /// Open a repo by name, returning the entry and git2::Repository or an error Response. #[allow(clippy::result_large_err)] fn open_repo(state: &AppState, repo_name: &str) -> Result<(crate::repos::RepoEntry, git2::Repository), Response> { diff --git a/src/server/http/repo/tree.rs b/src/server/http/repo/tree.rs index a2554f4..6e22437 100644 --- a/src/server/http/repo/tree.rs +++ b/src/server/http/repo/tree.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use axum::extract::{Path, State}; use axum::response::{IntoResponse, Response}; use super::{AppState, collab_counts, internal_error, not_found, open_repo}; use super::{AppState, collab_counts, head_branch_name, internal_error, list_branches, not_found, open_repo}; #[derive(Debug)] pub struct TreeEntry { @@ -21,6 +21,7 @@ pub struct TreeTemplate { pub open_patches: usize, pub open_issues: usize, pub ref_name: String, pub branches: Vec<String>, pub path_display: String, pub show_parent: bool, pub parent_path: String, @@ -36,6 +37,7 @@ pub struct BlobTemplate { pub open_patches: usize, pub open_issues: usize, pub ref_name: String, pub branches: Vec<String>, pub file_path: String, pub content: String, pub is_binary: bool, @@ -62,6 +64,8 @@ fn build_tree_response( path: String, ) -> Response { let (open_patches, open_issues) = collab_counts(repo); let branches = list_branches(repo); let ref_name = if ref_name == "HEAD" { head_branch_name(repo) } else { ref_name }; let obj = match repo.revparse_single(&ref_name) { Ok(o) => o, @@ -131,6 +135,7 @@ fn build_tree_response( open_patches, open_issues, ref_name, branches, path_display: path, show_parent, parent_path, @@ -174,7 +179,9 @@ pub async fn blob( }; let (open_patches, open_issues) = collab_counts(&repo); let branches = list_branches(&repo); let (ref_name, file_path) = split_ref_path(&rest); let ref_name = if ref_name == "HEAD" { head_branch_name(&repo) } else { ref_name }; let obj = match repo.revparse_single(&ref_name) { Ok(o) => o, @@ -229,6 +236,7 @@ pub async fn blob( open_patches, open_issues, ref_name, branches, file_path, content, is_binary, diff --git a/src/server/http/templates/blob.html b/src/server/http/templates/blob.html index b1ea4bb..c8b3ed7 100644 --- a/src/server/http/templates/blob.html +++ b/src/server/http/templates/blob.html @@ -4,7 +4,13 @@ {% block content %} <h2>{{ file_path }}</h2> <p style="color: #666;">Ref: <span class="mono">{{ ref_name }}</span> Size: {{ size_display }}</p> <p style="color: #666;">Ref: <select class="mono" onchange="location.href='/{{ repo_name }}/blob/' + this.value + '/{{ file_path }}'"> {% for b in branches %} <option value="{{ b }}"{% if *b == ref_name %} selected{% endif %}>{{ b }}</option> {% endfor %} </select> Size: {{ size_display }}</p> {% if is_binary %} <p style="color: #666; font-style: italic;">Binary file</p> {% else %} diff --git a/src/server/http/templates/commits.html b/src/server/http/templates/commits.html index 5a525b6..c7f96ed 100644 --- a/src/server/http/templates/commits.html +++ b/src/server/http/templates/commits.html @@ -3,7 +3,13 @@ {% block title %}Commits — {{ repo_name }} — {{ site_title }}{% endblock %} {% block content %} <h2>Commits</h2> <h2>Commits: <select onchange="location.href='/{{ repo_name }}/commits/' + this.value"> {% for b in branches %} <option value="{{ b }}"{% if *b == ref_name %} selected{% endif %}>{{ b }}</option> {% endfor %} </select> </h2> {% if commits.is_empty() %} <p style="color: #666;">No commits yet.</p> {% else %} diff --git a/src/server/http/templates/tree.html b/src/server/http/templates/tree.html index 9474f41..2c5d440 100644 --- a/src/server/http/templates/tree.html +++ b/src/server/http/templates/tree.html @@ -3,7 +3,14 @@ {% block title %}Tree — {{ repo_name }} — {{ site_title }}{% endblock %} {% block content %} <h2>Tree: {{ ref_name }}{% if !path_display.is_empty() %} / {{ path_display }}{% endif %}</h2> <h2> <select onchange="location.href='/{{ repo_name }}/tree/' + this.value + '{% if !path_display.is_empty() %}/{{ path_display }}{% endif %}'"> {% for b in branches %} <option value="{{ b }}"{% if *b == ref_name %} selected{% endif %}>{{ b }}</option> {% endfor %} </select> {% if !path_display.is_empty() %} / {{ path_display }}{% endif %} </h2> <table> <thead> <tr> diff --git a/src/server/ssh/session.rs b/src/server/ssh/session.rs index 8cf95c1..1f776b6 100644 --- a/src/server/ssh/session.rs +++ b/src/server/ssh/session.rs @@ -94,6 +94,24 @@ pub fn resolve_repo_path(repos_dir: &Path, requested: &str) -> Option<PathBuf> { Some(full) } fn ensure_repo_exists_for_command(git_cmd: &str, repo_path: &Path) -> Result<bool, git2::Error> { if repo_path.exists() { return Ok(false); } if git_cmd != "git-receive-pack" { return Ok(false); } if let Some(parent) = repo_path.parent() { std::fs::create_dir_all(parent) .map_err(|e| git2::Error::from_str(&format!("failed to create repo parent dir: {e}")))?; } git2::Repository::init_bare(repo_path)?; Ok(true) } #[async_trait] impl Handler for SshHandler { type Error = russh::Error; @@ -183,11 +201,25 @@ impl Handler for SshHandler { }; if !resolved_path.exists() { warn!("Rejected exec request: repo path does not exist: {:?}", resolved_path); session.exit_status_request(channel, 1); session.eof(channel); session.close(channel); return Ok(()); match ensure_repo_exists_for_command(&git_cmd, &resolved_path) { Ok(true) => { info!("Created bare repo for receive-pack: {:?}", resolved_path); } Ok(false) => { warn!("Rejected exec request: repo path does not exist: {:?}", resolved_path); session.exit_status_request(channel, 1); session.eof(channel); session.close(channel); return Ok(()); } Err(e) => { error!("Failed to create repo {:?}: {}", resolved_path, e); session.exit_status_request(channel, 1); session.eof(channel); session.close(channel); return Ok(()); } } } // Create a channel for forwarding client stdin data to the git subprocess @@ -279,6 +311,7 @@ async fn run_git_command( #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; #[test] fn parse_upload_pack() { @@ -339,4 +372,28 @@ mod tests { let result = resolve_repo_path(repos_dir, "myrepo.git"); assert_eq!(result, Some(PathBuf::from("/srv/git/myrepo.git"))); } #[test] fn receive_pack_creates_missing_bare_repo() { let tmp = TempDir::new().unwrap(); let repo_path = tmp.path().join("org").join("new-repo.git"); let created = ensure_repo_exists_for_command("git-receive-pack", &repo_path).unwrap(); assert!(created); assert!(repo_path.exists()); let repo = git2::Repository::open_bare(&repo_path).unwrap(); assert!(repo.is_bare()); } #[test] fn upload_pack_does_not_create_missing_repo() { let tmp = TempDir::new().unwrap(); let repo_path = tmp.path().join("org").join("missing.git"); let created = ensure_repo_exists_for_command("git-upload-pack", &repo_path).unwrap(); assert!(!created); assert!(!repo_path.exists()); } } diff --git a/src/state.rs b/src/state.rs index 5b5cfb2..a57edd5 100644 --- a/src/state.rs +++ b/src/state.rs @@ -593,16 +593,23 @@ fn resolve_ref( singular: &str, prefix: &str, ) -> Result<(String, String), crate::error::Error> { let mut matches: Vec<_> = collab_refs(repo, kind)? let active: Vec<_> = collab_refs(repo, kind)? .into_iter() .filter(|(_, id)| id.starts_with(prefix)) .collect(); // Also search archive namespace let archive_matches: Vec<_> = collab_archive_refs(repo, kind)? let archived: Vec<_> = collab_archive_refs(repo, kind)? .into_iter() .filter(|(_, id)| id.starts_with(prefix)) .collect(); matches.extend(archive_matches); // Deduplicate: if the same ID appears in both active and archive, prefer archive let mut seen = std::collections::HashSet::new(); let mut matches = Vec::new(); for entry in archived.into_iter().chain(active.into_iter()) { if seen.insert(entry.1.clone()) { matches.push(entry); } } match matches.len() { 0 => Err( @@ -619,49 +626,78 @@ fn resolve_ref( } } /// List all issue refs and return their materialized state. /// List active issue refs, excluding any that also have an archived ref. pub fn list_issues(repo: &Repository) -> Result<Vec<IssueState>, crate::error::Error> { let archived_ids: std::collections::HashSet<String> = collab_archive_refs(repo, "issues")? .into_iter() .map(|(_, id)| id) .collect(); let items = collab_refs(repo, "issues")? .into_iter() .filter(|(_, id)| !archived_ids.contains(id)) .filter_map(|(ref_name, id)| IssueState::from_ref(repo, &ref_name, &id).ok()) .collect(); Ok(items) } /// List all patch refs and return their materialized state. /// List active patch refs, excluding any that also have an archived ref. pub fn list_patches(repo: &Repository) -> Result<Vec<PatchState>, crate::error::Error> { let archived_ids: std::collections::HashSet<String> = collab_archive_refs(repo, "patches")? .into_iter() .map(|(_, id)| id) .collect(); let items = collab_refs(repo, "patches")? .into_iter() .filter(|(_, id)| !archived_ids.contains(id)) .filter_map(|(ref_name, id)| PatchState::from_ref(repo, &ref_name, &id).ok()) .collect(); Ok(items) } /// List all issue refs (active + archived) and return their materialized state. /// Deduplicates by ID, preferring the archived version (which has the final state). pub fn list_issues_with_archived(repo: &Repository) -> Result<Vec<IssueState>, crate::error::Error> { let mut items: Vec<_> = collab_refs(repo, "issues")? .into_iter() .filter_map(|(ref_name, id)| IssueState::from_ref(repo, &ref_name, &id).ok()) .collect(); let archived: Vec<_> = collab_archive_refs(repo, "issues")? .into_iter() .filter_map(|(ref_name, id)| IssueState::from_ref(repo, &ref_name, &id).ok()) .collect(); items.extend(archived); let mut seen = std::collections::HashSet::new(); let mut items = Vec::new(); // Archived first so they take priority for (ref_name, id) in collab_archive_refs(repo, "issues")? { if seen.insert(id.clone()) { if let Ok(state) = IssueState::from_ref(repo, &ref_name, &id) { items.push(state); } } } for (ref_name, id) in collab_refs(repo, "issues")? { if seen.insert(id.clone()) { if let Ok(state) = IssueState::from_ref(repo, &ref_name, &id) { items.push(state); } } } Ok(items) } /// List all patch refs (active + archived) and return their materialized state. /// Deduplicates by ID, preferring the archived version (which has the final state). pub fn list_patches_with_archived(repo: &Repository) -> Result<Vec<PatchState>, crate::error::Error> { let mut items: Vec<_> = collab_refs(repo, "patches")? .into_iter() .filter_map(|(ref_name, id)| PatchState::from_ref(repo, &ref_name, &id).ok()) .collect(); let archived: Vec<_> = collab_archive_refs(repo, "patches")? .into_iter() .filter_map(|(ref_name, id)| PatchState::from_ref(repo, &ref_name, &id).ok()) .collect(); items.extend(archived); let mut seen = std::collections::HashSet::new(); let mut items = Vec::new(); for (ref_name, id) in collab_archive_refs(repo, "patches")? { if seen.insert(id.clone()) { if let Ok(state) = PatchState::from_ref(repo, &ref_name, &id) { items.push(state); } } } for (ref_name, id) in collab_refs(repo, "patches")? { if seen.insert(id.clone()) { if let Ok(state) = PatchState::from_ref(repo, &ref_name, &id) { items.push(state); } } } Ok(items) }