use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use axum::{
extract::{Path as UrlPath, State},
http::StatusCode,
response::sse::{Event, KeepAlive, Sse},
routing::{delete, get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use crate::machine::MachineState;
type Refusal = (StatusCode, Json<serde_json::Value>);
fn refuse(status: StatusCode, error: impl std::fmt::Display) -> Refusal {
(status, Json(serde_json::json!({ "error": error.to_string() })))
}
pub fn routes() -> Router<Arc<MachineState>> {
Router::new()
.route("/api/v1/projects", get(list))
.route("/api/v1/projects/events", get(events))
.route("/api/v1/projects/:project", get(project))
.route("/api/v1/prj/slots/:name/resume", post(resume_slot))
.route("/api/v1/prj/slots/:name/stop", post(stop_slot))
.route("/api/v1/prj/start", post(start))
.route("/api/v1/prj/worktrees", post(add_worktree))
.route("/api/v1/prj/worktrees/:id", delete(remove_worktree))
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Projects {
pub prj_root: Option<String>,
pub projects: Vec<Project>,
pub standalone: Vec<Worktree>,
pub slots_error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Project {
pub name: String,
pub path: String,
pub worktrees: Vec<Worktree>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Worktree {
pub id: String,
pub project: Option<String>,
pub name: String,
pub path: String,
pub branch: Option<String>,
pub head: Option<String>,
pub dirty: u64,
pub upstream: Option<String>,
pub ahead: Option<u64>,
pub behind: Option<u64>,
pub last_commit_at: Option<i64>,
pub open: bool,
pub error: Option<String>,
pub slots: Vec<Slot>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Slot {
pub key: String,
pub name: String,
pub status: String,
#[serde(default)]
pub detail: Option<String>,
#[serde(default)]
pub session_id: Option<String>,
pub activity_at: Option<i64>,
}
#[derive(Debug, Deserialize)]
struct SlotRow {
project: String,
worktree: String,
#[serde(flatten)]
slot: Slot,
}
pub struct Listing {
prj: Option<wsb_prj::Prj>,
current: tokio::sync::RwLock<Option<Arc<Result<Projects, String>>>>,
reading: tokio::sync::Mutex<()>,
wanted: tokio::sync::Notify,
pub changed: tokio::sync::broadcast::Sender<()>,
}
const RELIST_EVERY: std::time::Duration = std::time::Duration::from_secs(30);
impl Listing {
pub fn new(prj: Option<wsb_prj::Prj>) -> Self {
Self {
prj,
current: tokio::sync::RwLock::new(None),
reading: tokio::sync::Mutex::new(()),
wanted: tokio::sync::Notify::new(),
changed: tokio::sync::broadcast::channel(16).0,
}
}
pub fn want(&self) {
self.wanted.notify_one();
}
async fn get(&self, machine: &MachineState) -> Arc<Result<Projects, String>> {
if let Some(current) = self.current.read().await.clone() {
return current;
}
self.read(machine).await
}
async fn read(&self, machine: &MachineState) -> Arc<Result<Projects, String>> {
let _one = self.reading.lock().await;
let open = open_ids(machine).await;
let reading = match machine.store.worktrees() {
Ok(recorded) => {
let prj = self.prj.clone();
tokio::task::spawn_blocking(move || read_projects(prj.as_ref(), &open, &recorded))
.await
.unwrap_or_else(|e| Err(format!("reading the projects stopped: {e}")))
}
Err(error) => Err(error),
};
let reading = Arc::new(reading);
let mut current = self.current.write().await;
let differs = current.as_deref() != Some(&*reading);
*current = Some(reading.clone());
drop(current);
if differs {
let _ = self.changed.send(());
}
reading
}
}
pub fn spawn_listing(machine: Arc<MachineState>) {
tokio::spawn(async move {
loop {
machine.listing.read(&machine).await;
tokio::select! {
_ = machine.listing.wanted.notified() => {}
_ = tokio::time::sleep(RELIST_EVERY) => {}
}
}
});
}
async fn list(State(machine): State<Arc<MachineState>>) -> Result<Json<Projects>, Refusal> {
match &*machine.listing.get(&machine).await {
Ok(projects) => Ok(Json(projects.clone())),
Err(error) => Err(refuse(StatusCode::INTERNAL_SERVER_ERROR, error)),
}
}
async fn open_ids(machine: &MachineState) -> std::collections::BTreeSet<String> {
machine
.all()
.await
.into_iter()
.map(|runtime| runtime.registered.id.to_string())
.collect()
}
fn read_projects(
prj: Option<&wsb_prj::Prj>,
open: &std::collections::BTreeSet<String>,
recorded: &[wsb::daemon::RegisteredWorktree],
) -> Result<Projects, String> {
let (mut slots, slots_error) = match prj {
Some(_) => match read_slots() {
Ok(slots) => (slots, None),
Err(error) => (BTreeMap::new(), Some(error)),
},
None => (BTreeMap::new(), None),
};
let mut projects = Vec::new();
if let Some(prj) = prj {
for project in prj.projects().map_err(|e| e.to_string())? {
let mut worktrees = Vec::new();
for worktree in project.worktrees().map_err(|e| e.to_string())? {
let id = format!("{}:{}", project.name(), worktree.name);
let here = slots
.remove(&(project.name().to_string(), worktree.name.clone()))
.unwrap_or_default();
worktrees.push(describe(
id.clone(),
Some(project.name().to_string()),
worktree.name,
&worktree.path,
open.contains(&id),
here,
));
}
projects.push(Project {
name: project.name().to_string(),
path: project.dir().display().to_string(),
worktrees: in_parallel(worktrees),
});
}
}
let standalone = recorded
.iter()
.filter(|worktree| worktree.project.is_none() && worktree.available)
.map(|worktree| {
describe(
worktree.id.to_string(),
None,
worktree.name.clone(),
&worktree.super_root,
open.contains(worktree.id.as_str()),
Vec::new(),
)
})
.collect();
Ok(Projects {
prj_root: prj.map(|prj| prj.root().display().to_string()),
projects,
standalone: in_parallel(standalone),
slots_error,
})
}
struct Pending {
worktree: Worktree,
path: PathBuf,
}
fn describe(
id: String,
project: Option<String>,
name: String,
path: &Path,
open: bool,
slots: Vec<Slot>,
) -> Pending {
Pending {
worktree: Worktree {
id,
project,
name,
path: path.display().to_string(),
branch: None,
head: None,
dirty: 0,
upstream: None,
ahead: None,
behind: None,
last_commit_at: None,
open,
error: None,
slots,
},
path: path.to_path_buf(),
}
}
fn in_parallel(pending: Vec<Pending>) -> Vec<Worktree> {
std::thread::scope(|scope| {
let handles: Vec<_> = pending
.into_iter()
.map(|Pending { mut worktree, path }| {
scope.spawn(move || {
match git_state(&path) {
Ok(state) => {
worktree.branch = state.branch;
worktree.head = state.head;
worktree.dirty = state.dirty;
worktree.upstream = state.upstream;
worktree.ahead = state.ahead;
worktree.behind = state.behind;
worktree.last_commit_at = state.last_commit_at;
}
Err(error) => worktree.error = Some(error),
}
worktree
})
})
.collect();
handles
.into_iter()
.map(|handle| handle.join().expect("reading a worktree's git state panicked"))
.collect()
})
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct GitState {
pub branch: Option<String>,
pub head: Option<String>,
pub dirty: u64,
pub upstream: Option<String>,
pub ahead: Option<u64>,
pub behind: Option<u64>,
pub last_commit_at: Option<i64>,
}
fn git_state(root: &Path) -> Result<GitState, String> {
let output = wsb::git_backend::git_command()
.arg("-C")
.arg(root)
.args(["--no-optional-locks", "status", "--porcelain=v2", "--branch"])
.output()
.map_err(|e| format!("cannot run git in {}: {e}", root.display()))?;
if !output.status.success() {
return Err(format!(
"git status in {} failed: {}",
root.display(),
String::from_utf8_lossy(&output.stderr).trim()
));
}
let mut state = parse_status(&String::from_utf8_lossy(&output.stdout))?;
if state.head.is_some() {
let output = wsb::git_backend::git_command()
.arg("-C")
.arg(root)
.args(["log", "-1", "--format=%ct"])
.output()
.map_err(|e| format!("cannot run git in {}: {e}", root.display()))?;
let text = String::from_utf8_lossy(&output.stdout);
state.last_commit_at = Some(text.trim().parse().map_err(|_| {
format!("git log in {} gave no commit time: {:?}", root.display(), text.trim())
})?);
}
Ok(state)
}
pub fn parse_status(text: &str) -> Result<GitState, String> {
let mut state = GitState::default();
for line in text.lines() {
if let Some(header) = line.strip_prefix("# ") {
let (key, value) = header.split_once(' ').unwrap_or((header, ""));
match key {
"branch.oid" if value != "(initial)" => state.head = Some(value.to_string()),
"branch.head" if value != "(detached)" => state.branch = Some(value.to_string()),
"branch.upstream" => state.upstream = Some(value.to_string()),
"branch.ab" => {
let (ahead, behind) = value
.split_once(' ')
.ok_or_else(|| format!("git status gave a malformed branch.ab: {value:?}"))?;
let count = |raw: &str, sign: char| {
raw.strip_prefix(sign)
.and_then(|n| n.parse::<u64>().ok())
.ok_or_else(|| format!("git status gave a malformed branch.ab: {value:?}"))
};
state.ahead = Some(count(ahead, '+')?);
state.behind = Some(count(behind, '-')?);
}
_ => {}
}
} else if !line.is_empty() {
state.dirty += 1;
}
}
Ok(state)
}
fn read_slots() -> Result<BTreeMap<(String, String), Vec<Slot>>, String> {
let output = std::process::Command::new(prj_program()?)
.args(["ls", "--json"])
.output()
.map_err(|e| format!("cannot run prj ls --json: {e}"))?;
if !output.status.success() {
return Err(format!(
"prj ls --json failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
parse_slots(&String::from_utf8_lossy(&output.stdout))
}
pub fn parse_slots(text: &str) -> Result<BTreeMap<(String, String), Vec<Slot>>, String> {
let rows: Vec<SlotRow> =
serde_json::from_str(text).map_err(|e| format!("prj ls --json is not a list of slots: {e}"))?;
let mut slots: BTreeMap<(String, String), Vec<Slot>> = BTreeMap::new();
for row in rows {
slots.entry((row.project, row.worktree)).or_default().push(row.slot);
}
Ok(slots)
}
fn prj_program() -> Result<PathBuf, String> {
wsb::report::which("prj").map(PathBuf::from).ok_or_else(|| {
"prj is not on PATH; the toolset installs it (`dx install` in the toolset repository)"
.to_string()
})
}
async fn project(
State(machine): State<Arc<MachineState>>,
UrlPath(name): UrlPath<String>,
) -> Result<Json<serde_json::Value>, Refusal> {
let listing = match &*machine.listing.get(&machine).await {
Ok(projects) => projects.clone(),
Err(error) => return Err(refuse(StatusCode::INTERNAL_SERVER_ERROR, error)),
};
let wanted = name.clone();
let found = tokio::task::spawn_blocking(move || -> Result<(Project, Vec<String>, serde_json::Value, Option<String>), Refusal> {
let project = listing
.projects
.into_iter()
.find(|project| project.name == wanted)
.ok_or_else(|| refuse(StatusCode::NOT_FOUND, format!("there is no project {wanted}")))?;
let prj = wsb_prj::Prj::from_env().map_err(|e| refuse(StatusCode::INTERNAL_SERVER_ERROR, e))?;
let located = prj.project(&wanted).map_err(|e| refuse(StatusCode::NOT_FOUND, e))?;
let repositories = wsb_github::checkout::repositories(&located.main_worktree())
.map_err(|e| refuse(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?
.into_iter()
.map(|repository| repository.name)
.collect();
let boards = serde_json::to_value(
wsb_github::boards::read(&located)
.map_err(|e| refuse(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?
.boards,
)
.map_err(|e| refuse(StatusCode::INTERNAL_SERVER_ERROR, e))?;
Ok((project, repositories, boards, listing.slots_error))
})
.await
.map_err(|e| refuse(StatusCode::INTERNAL_SERVER_ERROR, e))??;
let (project, repositories, boards, slots_error) = found;
Ok(Json(serde_json::json!({
"project": project,
"repositories": repositories,
"boards": boards,
"slots_error": slots_error,
})))
}
async fn events(
State(machine): State<Arc<MachineState>>,
) -> Sse<impl futures::Stream<Item = Result<Event, std::convert::Infallible>>> {
let mut changed = machine.listing.changed.subscribe();
let stream = async_stream::stream! {
yield Ok(Event::default().event("connected").data("{}"));
loop {
match changed.recv().await {
Ok(()) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
yield Ok(Event::default().event("projects_changed").data("{}"));
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
};
Sse::new(stream).keep_alive(KeepAlive::default())
}
async fn run_prj(
machine: &MachineState,
arguments: Vec<String>,
) -> Result<Json<serde_json::Value>, Refusal> {
let program = prj_program().map_err(|e| refuse(StatusCode::SERVICE_UNAVAILABLE, e))?;
let shown = format!("prj {}", arguments.join(" "));
let output = tokio::task::spawn_blocking(move || {
std::process::Command::new(program).args(&arguments).output()
})
.await
.map_err(|e| refuse(StatusCode::INTERNAL_SERVER_ERROR, e))?
.map_err(|e| refuse(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot run {shown}: {e}")))?;
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
machine.note_projects_changed();
if output.status.success() {
Ok(Json(serde_json::json!({ "command": shown, "output": stdout, "errors": stderr })))
} else {
Err(refuse(
StatusCode::UNPROCESSABLE_ENTITY,
format!("{shown} failed: {}", if stderr.is_empty() { stdout } else { stderr }),
))
}
}
fn slot_name(name: &str) -> Result<String, Refusal> {
if wsb_prj::is_valid_name(name) {
Ok(name.to_string())
} else {
Err(refuse(StatusCode::BAD_REQUEST, format!("{name:?} is not a slot name")))
}
}
async fn resume_slot(
State(machine): State<Arc<MachineState>>,
UrlPath(name): UrlPath<String>,
) -> Result<Json<serde_json::Value>, Refusal> {
run_prj(&machine, vec!["resume".into(), slot_name(&name)?, "--new-window".into()]).await
}
async fn stop_slot(
State(machine): State<Arc<MachineState>>,
UrlPath(name): UrlPath<String>,
) -> Result<Json<serde_json::Value>, Refusal> {
run_prj(&machine, vec!["stop".into(), slot_name(&name)?]).await
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StartRequest {
pub target: String,
}
fn target(raw: &str) -> Result<String, Refusal> {
let (project, worktree) = match raw.split_once('/') {
Some((project, worktree)) => (project, Some(worktree)),
None => (raw, None),
};
let valid = wsb_prj::is_valid_name(project) && worktree.map_or(true, wsb_prj::is_valid_name);
if valid {
Ok(raw.to_string())
} else {
Err(refuse(StatusCode::BAD_REQUEST, format!("{raw:?} is not <project> or <project>/<worktree>")))
}
}
async fn start(
State(machine): State<Arc<MachineState>>,
Json(body): Json<StartRequest>,
) -> Result<Json<serde_json::Value>, Refusal> {
run_prj(&machine, vec!["start".into(), target(&body.target)?, "--new-window".into()]).await
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AddWorktreeRequest {
pub project: String,
pub name: String,
pub branch: Option<String>,
pub from: Option<String>,
}
fn git_ref(raw: &str) -> Result<String, Refusal> {
let refused = raw.is_empty()
|| raw.starts_with('-')
|| raw.chars().any(|c| c.is_whitespace() || c.is_control());
if refused {
Err(refuse(StatusCode::BAD_REQUEST, format!("{raw:?} is not a branch or commit")))
} else {
Ok(raw.to_string())
}
}
async fn add_worktree(
State(machine): State<Arc<MachineState>>,
Json(body): Json<AddWorktreeRequest>,
) -> Result<Json<serde_json::Value>, Refusal> {
let mut arguments = vec![
"wt".to_string(),
"add".to_string(),
target(&format!("{}/{}", body.project, body.name))?,
];
if let Some(branch) = &body.branch {
arguments.extend(["--branch".to_string(), git_ref(branch)?]);
}
if let Some(from) = &body.from {
arguments.extend(["--from".to_string(), git_ref(from)?]);
}
run_prj(&machine, arguments).await
}
async fn remove_worktree(
State(machine): State<Arc<MachineState>>,
UrlPath(id): UrlPath<String>,
) -> Result<Json<serde_json::Value>, Refusal> {
let id = wsb::daemon::WorktreeId::parse(&id).map_err(|e| refuse(StatusCode::BAD_REQUEST, e))?;
let (project, worktree) = id
.in_project()
.ok_or_else(|| refuse(StatusCode::BAD_REQUEST, format!("{id} is not a worktree of a project")))?;
if worktree == wsb_prj::MAIN {
return Err(refuse(StatusCode::BAD_REQUEST, "a project's main worktree is not removed"));
}
run_prj(&machine, vec!["wt".into(), "rm".into(), format!("{project}/{worktree}")]).await
}
pub fn spawn_watcher(machine: Arc<MachineState>) {
use notify_debouncer_mini::{new_debouncer, notify::RecursiveMode, DebouncedEvent};
let prj = match wsb_prj::Prj::from_env() {
Ok(prj) => prj,
Err(wsb_prj::PrjError::NotSet) => return,
Err(error) => {
tracing::warn!(%error, "projects are not followed");
return;
}
};
let host = machine.machine_dir.clone();
let runtime = tokio::runtime::Handle::current();
std::thread::spawn(move || {
let notify = machine.clone();
let watched_host = host.clone();
let mut debouncer = match new_debouncer(
std::time::Duration::from_millis(400),
move |events: Result<Vec<DebouncedEvent>, notify::Error>| match events {
Ok(events) => {
let relevant = events.iter().any(|event| {
match event.path.strip_prefix(&watched_host) {
Ok(rest) => {
rest.starts_with("state.json") || rest.starts_with("run")
}
Err(_) => true,
}
});
if relevant {
notify.note_projects_changed();
}
}
Err(error) => tracing::warn!(%error, "the projects watcher reported an error"),
},
) {
Ok(debouncer) => debouncer,
Err(error) => {
tracing::warn!(%error, "projects are not followed");
return;
}
};
let mut watched: std::collections::BTreeSet<PathBuf> = Default::default();
let mut changed = machine.listing.changed.subscribe();
loop {
let mut wanted = vec![prj.root().to_path_buf(), host.clone(), host.join("run")];
match prj.projects() {
Ok(projects) => wanted.extend(projects.iter().map(|p| p.dir().to_path_buf())),
Err(error) => tracing::warn!(%error, "cannot list the projects to follow"),
}
for dir in wanted {
if dir.is_dir() && !watched.contains(&dir) {
match debouncer.watcher().watch(&dir, RecursiveMode::NonRecursive) {
Ok(()) => {
watched.insert(dir);
}
Err(error) => {
tracing::warn!(%error, dir = %dir.display(), "cannot follow")
}
}
}
}
watched.retain(|dir| dir.is_dir());
if runtime.block_on(changed.recv()).is_err_and(|e| {
matches!(e, tokio::sync::broadcast::error::RecvError::Closed)
}) {
return;
}
}
});
}
#[cfg(test)]
#[path = "prj_tests.rs"]
mod tests;