168 lines
5.0 KiB
Rust
168 lines
5.0 KiB
Rust
//! `GET /api/branches` — local branches of one repository (#55).
|
|
//!
|
|
//! Spawning into a worktree on an *existing* branch needs to know which
|
|
//! branches the repository has and which are taken: git refuses to check the
|
|
//! same branch out in two worktrees at once. Each [`BranchInfo`] answers for
|
|
//! one local branch — its name, whether it is the main checkout's HEAD, and
|
|
//! which linked worktree (if any) holds it.
|
|
//!
|
|
//! Read-only, like [`crate::gitroot`]: two git probes (`for-each-ref`,
|
|
//! `worktree list --porcelain`) with no write path anywhere. A path outside
|
|
//! any repository yields an empty list rather than an error, matching how
|
|
//! `/api/gitroot` reports "not a repository".
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
use axum::extract::{Query, State};
|
|
use axum::response::{IntoResponse, Response};
|
|
use axum::Json;
|
|
use maestro_protocol::BranchInfo;
|
|
use serde::Deserialize;
|
|
|
|
use crate::local_api::LocalApiState;
|
|
|
|
/// Query string for `GET /api/branches`. Same `path` convention as
|
|
/// `/api/dirs` and `/api/gitroot`: resolved against the daemon's home dir.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct BranchesQuery {
|
|
#[serde(default)]
|
|
pub path: String,
|
|
}
|
|
|
|
pub async fn handler(State(s): State<LocalApiState>, Query(q): Query<BranchesQuery>) -> Response {
|
|
let dir = crate::dirlist::resolve(&s.home_dir, &q.path);
|
|
Json(list(&dir)).into_response()
|
|
}
|
|
|
|
/// List the enclosing repository's local branches. Empty when `dir` is not
|
|
/// inside a repository.
|
|
pub fn list(dir: &Path) -> Vec<BranchInfo> {
|
|
let Some(root) = crate::gitroot::find_root(dir) else {
|
|
return Vec::new();
|
|
};
|
|
let worktrees = worktree_branches(&root);
|
|
// The first porcelain record is always the main checkout; its branch is
|
|
// the HEAD answer. Linked worktrees are the occupancy map.
|
|
let head_branch = worktrees.first().and_then(|(branch, _)| branch.clone());
|
|
let occupied: HashMap<&str, &Path> = worktrees
|
|
.iter()
|
|
.skip(1)
|
|
.filter_map(|(branch, path)| branch.as_deref().map(|b| (b, path.as_path())))
|
|
.collect();
|
|
|
|
branch_names(&root)
|
|
.into_iter()
|
|
.map(|name| BranchInfo {
|
|
is_head: head_branch.as_deref() == Some(name.as_str()),
|
|
worktree: occupied.get(name.as_str()).copied().map(Path::to_path_buf),
|
|
name,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn run_git(root: &Path, args: &[&str]) -> Option<String> {
|
|
let out = Command::new("git")
|
|
.arg("-C")
|
|
.arg(root)
|
|
.args(args)
|
|
.output()
|
|
.ok()?;
|
|
if !out.status.success() {
|
|
return None;
|
|
}
|
|
Some(String::from_utf8_lossy(&out.stdout).into_owned())
|
|
}
|
|
|
|
fn branch_names(root: &Path) -> Vec<String> {
|
|
run_git(
|
|
root,
|
|
&["for-each-ref", "refs/heads", "--format=%(refname:short)"],
|
|
)
|
|
.map(|out| {
|
|
out.lines()
|
|
.filter(|l| !l.is_empty())
|
|
.map(str::to_owned)
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn worktree_branches(root: &Path) -> Vec<(Option<String>, PathBuf)> {
|
|
let out = match run_git(root, &["worktree", "list", "--porcelain"]) {
|
|
Some(out) => out,
|
|
None => return Vec::new(),
|
|
};
|
|
parse_worktrees(&out)
|
|
}
|
|
|
|
/// Parse `git worktree list --porcelain` into `(branch, toplevel)` records,
|
|
/// branch-less for detached/bare checkouts. Records are separated by blank
|
|
/// lines; each `worktree` line starts a new one.
|
|
fn parse_worktrees(out: &str) -> Vec<(Option<String>, PathBuf)> {
|
|
let mut result: Vec<(Option<String>, PathBuf)> = Vec::new();
|
|
let mut pending: Option<PathBuf> = None;
|
|
let mut branch: Option<String> = None;
|
|
|
|
for line in out.lines() {
|
|
if let Some(path) = line.strip_prefix("worktree ") {
|
|
if let Some(prev) = pending.take() {
|
|
result.push((branch.take(), prev));
|
|
}
|
|
pending = Some(PathBuf::from(path));
|
|
} else if let Some(b) = line.strip_prefix("branch ") {
|
|
branch = Some(b.trim_start_matches("refs/heads/").to_owned());
|
|
}
|
|
}
|
|
if let Some(path) = pending {
|
|
result.push((branch.take(), path));
|
|
}
|
|
result
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parses_main_and_linked_worktrees() {
|
|
let out = "\
|
|
worktree /repos/maestro
|
|
HEAD 0123abcd
|
|
branch refs/heads/main
|
|
|
|
worktree /repos/wt-feature
|
|
HEAD 4567abcd
|
|
branch refs/heads/spawn-v2
|
|
|
|
";
|
|
assert_eq!(
|
|
parse_worktrees(out),
|
|
vec![
|
|
(Some("main".into()), PathBuf::from("/repos/maestro")),
|
|
(Some("spawn-v2".into()), PathBuf::from("/repos/wt-feature")),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn detached_head_yields_no_branch() {
|
|
let out = "\
|
|
worktree /repos/detached
|
|
HEAD 0123abcd
|
|
detached
|
|
|
|
";
|
|
assert_eq!(
|
|
parse_worktrees(out),
|
|
vec![(None, PathBuf::from("/repos/detached"))]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_output_yields_no_records() {
|
|
assert!(parse_worktrees("").is_empty());
|
|
}
|
|
}
|