diff --git a/crates/maestro-daemon/src/branches.rs b/crates/maestro-daemon/src/branches.rs new file mode 100644 index 0000000..6f08f9d --- /dev/null +++ b/crates/maestro-daemon/src/branches.rs @@ -0,0 +1,167 @@ +//! `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, Query(q): Query) -> 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 { + 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 { + 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 { + 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, 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, PathBuf)> { + let mut result: Vec<(Option, PathBuf)> = Vec::new(); + let mut pending: Option = None; + let mut branch: Option = 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()); + } +} diff --git a/crates/maestro-daemon/src/lib.rs b/crates/maestro-daemon/src/lib.rs index 993536c..68e110c 100644 --- a/crates/maestro-daemon/src/lib.rs +++ b/crates/maestro-daemon/src/lib.rs @@ -9,6 +9,7 @@ pub mod activity; pub mod attach; +pub mod branches; pub mod cli; pub mod config; pub mod dirlist; diff --git a/crates/maestro-daemon/src/local_api.rs b/crates/maestro-daemon/src/local_api.rs index 515324b..082451e 100644 --- a/crates/maestro-daemon/src/local_api.rs +++ b/crates/maestro-daemon/src/local_api.rs @@ -48,6 +48,7 @@ pub fn router(state: LocalApiState) -> Router { .route(routes::RECENT_DIRS, get(recent_dirs)) .route(routes::PROJECTS, get(projects)) .route(routes::GITROOT, get(git_root)) + .route(routes::BRANCHES, get(crate::branches::handler)) .route(routes::INFO, get(daemon_info)) .route(routes::EVENTS, any(events_ws)) .route(routes::patterns::ATTACH, any(attach_ws)) diff --git a/crates/maestro-daemon/tests/branches.rs b/crates/maestro-daemon/tests/branches.rs new file mode 100644 index 0000000..1884e38 --- /dev/null +++ b/crates/maestro-daemon/tests/branches.rs @@ -0,0 +1,141 @@ +//! Integration tests for `GET /api/branches` (#55). + +mod common; + +use std::net::{Ipv4Addr, SocketAddr}; +use std::path::Path; +use std::process::Command; +use std::sync::Arc; + +use common::DaemonFixture; +use maestro_daemon::local_api; +use tokio::net::TcpListener; +use tokio::sync::oneshot; + +struct Harness { + addr: SocketAddr, + _shutdown: oneshot::Sender<()>, + _fx: DaemonFixture, +} + +async fn spawn() -> Harness { + let fx = DaemonFixture::new(); + let presets = maestro_config::SpawnSection::default().resolve().unwrap(); + let router = local_api::router(local_api::state_for( + fx.manager.clone(), + "naps-laptop", + "/home/test", + presets, + Arc::new(maestro_daemon::projects::ProjectScanner::disabled()), + )); + let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) + .await + .unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, rx) = oneshot::channel::<()>(); + tokio::spawn(async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { + let _ = rx.await; + }) + .await; + }); + Harness { + addr, + _shutdown: tx, + _fx: fx, + } +} + +fn git(repo: &Path, args: &[&str]) { + let out = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .output() + .unwrap(); + assert!( + out.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +/// A real repo on `main` with a second branch `feature`, and a linked +/// worktree at `/wt-feature` holding `feature`. +fn repo_with_occupied_branch() -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path(); + git(repo, &["init", "-q", "-b", "main"]); + git( + repo, + &[ + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-qm", + "init", + "--allow-empty", + ], + ); + git(repo, &["branch", "feature"]); + let wt = tmp.path().join("wt-feature"); + git(repo, &["worktree", "add", &wt.to_string_lossy(), "feature"]); + tmp +} + +async fn get_branches(addr: SocketAddr, path: &Path) -> Vec { + reqwest::get(format!( + "http://{addr}/api/branches?path={}", + urlencoding::encode(&path.to_string_lossy()) + )) + .await + .unwrap() + .json() + .await + .unwrap() +} + +/// #55: every local branch is listed; the main checkout's HEAD reports +/// `is_head`, and a branch held by a linked worktree reports that worktree's +/// path so the client can explain why the entry is disabled. +#[tokio::test] +async fn branches_lists_local_branches_and_worktree_occupancy() { + let tmp = repo_with_occupied_branch(); + let h = spawn().await; + + let mut branches = get_branches(h.addr, tmp.path()).await; + branches.sort_by(|a, b| a.name.cmp(&b.name)); + + assert_eq!( + branches.iter().map(|b| b.name.as_str()).collect::>(), + ["feature", "main"] + ); + + let main = branches.iter().find(|b| b.name == "main").unwrap(); + assert!(main.is_head, "main is the main checkout's HEAD"); + assert_eq!(main.worktree, None, "HEAD occupancy rides `is_head`"); + + let feature = branches.iter().find(|b| b.name == "feature").unwrap(); + assert!(!feature.is_head); + assert_eq!( + feature.worktree.as_deref(), + Some(tmp.path().join("wt-feature").as_path()), + "the linked worktree holding the branch must be reported" + ); +} + +/// #55: a path outside any repository is not an error — an empty list, +/// matching how `/api/gitroot` reports "not a repository". +#[tokio::test] +async fn branches_returns_empty_for_a_path_outside_a_repository() { + let h = spawn().await; + let tmp = tempfile::tempdir().unwrap(); + let plain = tmp.path().join("plain"); + std::fs::create_dir_all(&plain).unwrap(); + + let branches = get_branches(h.addr, &plain).await; + assert!(branches.is_empty()); +} diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index b06e3b7..e63b34e 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -212,6 +212,21 @@ pub struct Project { pub last_command: Option, } +/// One local branch of a repository for `GET /api/branches` (#55). A client +/// disables the entry when `is_head` is set or `worktree` is present — git +/// refuses to check the same branch out in two worktrees at once. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BranchInfo { + /// Short branch name (`main`, `feature/foo`). + pub name: String, + /// Whether this branch is the main checkout's current HEAD. The main + /// checkout's occupancy reports here rather than through `worktree`. + pub is_head: bool, + /// Toplevel path of the linked worktree holding this branch, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worktree: Option, +} + /// Structured failure used everywhere we don't want to leak a string-only error. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ErrorInfo { diff --git a/crates/maestro-protocol/src/routes.rs b/crates/maestro-protocol/src/routes.rs index 777d086..c89aa97 100644 --- a/crates/maestro-protocol/src/routes.rs +++ b/crates/maestro-protocol/src/routes.rs @@ -21,10 +21,12 @@ pub const RECENT_DIRS: &str = "/api/recent-dirs"; pub const PROJECTS: &str = "/api/projects"; /// `GET` the enclosing repository of a path. Takes a `path` query param. pub const GITROOT: &str = "/api/gitroot"; +/// `GET` one repository's local branches. Takes a `path` query param. +pub const BRANCHES: &str = "/api/branches"; /// `WS` catalog snapshot + session deltas. pub const EVENTS: &str = "/api/events"; -/// Name of the query parameter [`DIRS`] and [`GITROOT`] both take. +/// Name of the query parameter [`DIRS`], [`GITROOT`], and [`BRANCHES`] take. pub const PATH_QUERY: &str = "path"; /// `DELETE /api/sessions/:id`. @@ -174,7 +176,16 @@ mod tests { #[test] fn every_route_sits_under_the_api_prefix() { - for path in [SESSIONS, INFO, DIRS, RECENT_DIRS, PROJECTS, GITROOT, EVENTS] { + for path in [ + SESSIONS, + INFO, + DIRS, + RECENT_DIRS, + PROJECTS, + GITROOT, + BRANCHES, + EVENTS, + ] { assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}"); } assert!(attach(id()).starts_with(API_PREFIX));