The TUI read its leader from `[tui].leader`; the web used `DEFAULT_LEADER`, a module constant. Rebinding one left the other on `ctrl-a`, so the two escape hatches hanging off the leader (`leader ctrl-k`, `leader leader`) moved on one client and not the other. `GET /api/info` now carries `leader`. The web binds that when it arrives and rebuilds the leader-owning `CommandSet`, so the `?` overlay and the command bar render the configured chord; an absent or empty field — an older daemon, or an unreachable `/api/info` — leaves it on `ctrl-a`. Chose the existing `[tui].leader` served as-is over a new shared key: one knob, no precedence rules between two spellings, and no migration for operators who already set it. The name is a wart — it is the leader for every client now, not the TUI's — and `docs/COMMAND-BAR.md` and `docs/INSTALL.md` say so.
143 lines
4.0 KiB
Rust
143 lines
4.0 KiB
Rust
//! 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()),
|
|
maestro_config::DEFAULT_LEADER,
|
|
));
|
|
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 `<tmp>/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<maestro_protocol::BranchInfo> {
|
|
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::<Vec<_>>(),
|
|
["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());
|
|
}
|