use super::repo_identity::describe_repos;
use super::AppState;
use axum::{
extract::{Query, State},
http::StatusCode,
response::{IntoResponse, Json},
};
use serde::Deserialize;
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
pub async fn repos_json(state: &AppState) -> Value {
let base = state.repo_path.clone();
let extras = state.extra_repos.clone();
let repos = tokio::task::spawn_blocking(move || describe_repos(scan_repos(&base, &extras)))
.await
.unwrap_or_default();
json!({ "repos": repos })
}
pub(super) async fn list_repos(State(s): State<AppState>) -> impl IntoResponse {
(StatusCode::OK, Json(repos_json(&s).await)).into_response()
}
#[derive(Deserialize)]
pub(super) struct BranchQuery {
#[serde(default)]
repo: String,
}
pub async fn branches_json(state: &AppState, repo: &str) -> Value {
let repo = if repo.trim().is_empty() {
state.repo_path.display().to_string()
} else {
repo.to_string()
};
let (branches, default) = tokio::task::spawn_blocking(move || git_branches(&repo))
.await
.unwrap_or_default();
json!({ "branches": branches, "default": default })
}
pub(super) async fn list_branches(
State(s): State<AppState>,
Query(q): Query<BranchQuery>,
) -> impl IntoResponse {
(StatusCode::OK, Json(branches_json(&s, &q.repo).await)).into_response()
}
#[derive(Deserialize)]
pub(super) struct ClaudeCommandsQuery {
#[serde(default)]
repo: String,
}
pub(super) async fn list_claude_commands(
State(s): State<AppState>,
Query(q): Query<ClaudeCommandsQuery>,
) -> impl IntoResponse {
let repo = if q.repo.trim().is_empty() {
s.repo_path.display().to_string()
} else {
q.repo
};
let commands =
tokio::task::spawn_blocking(move || lopi_skill::discover_claude_commands(Path::new(&repo)))
.await
.unwrap_or_default();
(StatusCode::OK, Json(json!({ "commands": commands }))).into_response()
}
const MAX_REPOS: usize = 500;
fn scan_repos(primary: &Path, extras: &[PathBuf]) -> Vec<String> {
let primary = absolutize(primary);
let mut out: Vec<String> = Vec::new();
if primary.join(".git").exists() {
out.push(primary.display().to_string());
}
if let Some(parent) = primary.parent() {
if let Ok(entries) = std::fs::read_dir(parent) {
for entry in entries.flatten() {
let p: PathBuf = entry.path();
if p.is_dir() && p.join(".git").exists() {
out.push(p.display().to_string());
if out.len() > MAX_REPOS {
break;
}
}
}
}
}
out.extend(
extras
.iter()
.map(|e| absolutize(e))
.filter(|e| e.join(".git").exists())
.map(|e| e.display().to_string()),
);
out.sort();
out.dedup();
if out.len() > MAX_REPOS {
tracing::warn!(
found = out.len(),
limit = MAX_REPOS,
"more git repos than the dropdown lists; the remainder are hidden"
);
out.truncate(MAX_REPOS);
}
out
}
fn absolutize(p: &Path) -> PathBuf {
p.canonicalize().unwrap_or_else(|e| {
tracing::warn!(
path = %p.display(),
error = %e,
"repo path could not be canonicalized; scanning it as-is"
);
p.to_path_buf()
})
}
fn is_generated_branch(name: &str) -> bool {
name.starts_with("lopi/") || name.starts_with("claude/")
}
const MAX_BRANCHES: usize = 100;
fn git_branches(repo: &str) -> (Vec<String>, String) {
let mut branches: Vec<String> = match std::process::Command::new("git")
.args(["-C", repo, "branch", "--format=%(refname:short)"])
.output()
{
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty() && !is_generated_branch(l))
.collect(),
_ => Vec::new(),
};
if branches.len() > MAX_BRANCHES {
tracing::warn!(
repo,
found = branches.len(),
limit = MAX_BRANCHES,
"more branches than the dropdown lists; the remainder are hidden"
);
branches.truncate(MAX_BRANCHES);
}
let default = current_branch(repo)
.filter(|h| branches.contains(h))
.or_else(|| {
branches
.iter()
.find(|b| *b == "main" || *b == "master")
.cloned()
})
.or_else(|| branches.first().cloned())
.unwrap_or_default();
(branches, default)
}
fn current_branch(repo: &str) -> Option<String> {
std::process::Command::new("git")
.args(["-C", repo, "branch", "--show-current"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
static CWD: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn git_repo(root: &Path, name: &str) -> String {
let p = root.join(name);
std::fs::create_dir_all(p.join(".git")).unwrap();
p.canonicalize().unwrap().display().to_string()
}
#[test]
fn relative_primary_discovers_siblings() {
let guard = CWD.lock().unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let a = git_repo(root, "repo-a");
let b = git_repo(root, "repo-b");
std::fs::create_dir_all(root.join("not-a-repo")).unwrap();
let restore = std::env::current_dir().unwrap();
std::env::set_current_dir(root.join("repo-a")).unwrap();
let got = scan_repos(Path::new("."), &[]);
std::env::set_current_dir(restore).unwrap();
drop(guard);
assert_eq!(got, vec![a, b], "siblings discovered, non-repo excluded");
}
#[test]
fn extras_are_included_and_deduped_against_siblings() {
let tmp = tempfile::tempdir().unwrap();
let a = git_repo(tmp.path(), "repo-a");
let b = git_repo(tmp.path(), "repo-b");
let far = tempfile::tempdir().unwrap();
let f = git_repo(far.path(), "far-repo");
let extras = vec![
PathBuf::from(&f),
PathBuf::from(&b), far.path().join("no-git"), ];
let got = scan_repos(&PathBuf::from(&a), &extras);
assert!(
got.contains(&f),
"extra outside the primary's tree is listed"
);
assert_eq!(
got.iter().filter(|r| **r == b).count(),
1,
"an extra that is also a sibling appears once"
);
assert!(
!got.iter().any(|r| r.ends_with("no-git")),
"a non-repo extra is dropped"
);
}
#[test]
fn unresolvable_primary_yields_no_repos() {
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("does-not-exist");
assert!(scan_repos(&missing, &[]).is_empty());
}
fn git(repo: &Path, args: &[&str]) {
let ok = std::process::Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.output()
.unwrap()
.status
.success();
assert!(ok, "git {args:?} failed");
}
fn repo_with_branches(root: &Path, head: &str, branches: &[&str]) -> String {
std::fs::create_dir_all(root).unwrap();
git(root, &["init", "-q", "-b", "base"]);
git(root, &["config", "user.email", "t@t.t"]);
git(root, &["config", "user.name", "t"]);
std::fs::write(root.join("f"), "x").unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "init"]);
for b in branches {
git(root, &["branch", b]);
}
git(root, &["checkout", "-q", head]);
root.display().to_string()
}
#[test]
fn generated_branches_are_hidden() {
let tmp = tempfile::tempdir().unwrap();
let repo = repo_with_branches(
&tmp.path().join("r"),
"main",
&[
"main",
"feat/x",
"lopi/fe125cc0-63b6-43e4-a273-52f1dc84d1e4-attempt-1",
"claude/forge-polish-m3",
],
);
let (branches, default) = git_branches(&repo);
assert_eq!(
branches,
vec!["base", "feat/x", "main"],
"lopi/* and claude/* are dropped"
);
assert_eq!(
default, "main",
"HEAD is reported when it survives the filter"
);
}
#[test]
fn truncates_past_max_branches() {
let tmp = tempfile::tempdir().unwrap();
let extra: Vec<String> = (0..(MAX_BRANCHES + 5)).map(|i| format!("b{i}")).collect();
let extra_refs: Vec<&str> = extra.iter().map(String::as_str).collect();
let repo = repo_with_branches(&tmp.path().join("r"), "base", &extra_refs);
let (branches, _default) = git_branches(&repo);
assert_eq!(
branches.len(),
MAX_BRANCHES,
"must cap at MAX_BRANCHES, not return every branch"
);
}
#[test]
fn default_falls_back_when_head_is_generated() {
let tmp = tempfile::tempdir().unwrap();
let repo = repo_with_branches(
&tmp.path().join("r"),
"lopi/abc-attempt-2",
&["main", "lopi/abc-attempt-2"],
);
let (branches, default) = git_branches(&repo);
assert!(!branches.iter().any(|b| b.starts_with("lopi/")));
assert_eq!(
default, "main",
"a filtered HEAD falls back to main, not itself"
);
assert!(
branches.contains(&default),
"the default is always selectable"
);
}
#[test]
fn branch_names_merely_containing_the_prefix_are_kept() {
let tmp = tempfile::tempdir().unwrap();
let repo = repo_with_branches(
&tmp.path().join("r"),
"main",
&["main", "lopi-ui-refactor", "feat/claude-integration"],
);
let (branches, _) = git_branches(&repo);
assert!(
branches.contains(&"lopi-ui-refactor".to_string()),
"only the `lopi/` path prefix is generated, not `lopi-*`"
);
assert!(
branches.contains(&"feat/claude-integration".to_string()),
"`claude` mid-name is not a `claude/` prefix"
);
}
}