a73x

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> &nbsp; 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>
  &nbsp; 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)
}