use crate::error::Result;
use crate::json_api::{self, JsonDoctorReport, JsonPath, JsonWorktree};
use crate::{config::Config, doctor, worktree};
use serde::Deserialize;
use serde_json::{json, Value};
use std::path::Path;
pub const PARSE_ERROR: i64 = -32700;
pub const INVALID_REQUEST: i64 = -32600;
pub const METHOD_NOT_FOUND: i64 = -32601;
pub const INVALID_PARAMS: i64 = -32602;
pub const INTERNAL_ERROR: i64 = -32603;
#[derive(Debug, Clone, Deserialize)]
pub struct RpcRequest {
#[serde(default)]
pub jsonrpc: String,
pub method: String,
#[serde(default)]
pub params: Value,
#[serde(default)]
pub id: Value,
}
pub fn success(id: &Value, result: Value) -> Value {
json!({ "jsonrpc": "2.0", "result": result, "id": id })
}
pub fn error(id: &Value, code: i64, message: &str) -> Value {
json!({ "jsonrpc": "2.0", "error": { "code": code, "message": message }, "id": id })
}
fn open_repo(workdir: &Path) -> Result<git2::Repository> {
worktree::discover_repo(Some(workdir))
}
fn run_list(workdir: &Path) -> Result<Vec<JsonWorktree>> {
let repo = open_repo(workdir)?;
json_api::worktrees(&repo)
}
fn run_path(workdir: &Path, pattern: &str) -> Result<JsonPath> {
let repo = open_repo(workdir)?;
let found = worktree::find_fuzzy(&repo, pattern)?;
Ok(JsonPath::from(&found))
}
fn run_doctor(workdir: &Path) -> Result<JsonDoctorReport> {
let repo = open_repo(workdir)?;
let repo_workdir = repo
.workdir()
.ok_or(crate::error::GwmError::NotInGitRepo)?
.to_path_buf();
let config = Config::load_for_repo(&repo_workdir).unwrap_or_default();
let global = crate::config::global_config_path();
let ctx = doctor::DoctorCtx {
repo_workdir: &repo_workdir,
repo: &repo,
config: &config,
global_config_path: global.as_deref(),
};
Ok(JsonDoctorReport::from(&doctor::run(&ctx)?))
}
pub fn dispatch(workdir: &Path, req: &RpcRequest) -> Value {
let id = &req.id;
match req.method.as_str() {
"list" => match run_list(workdir) {
Ok(list) => match serde_json::to_value(list) {
Ok(v) => success(id, v),
Err(e) => error(id, INTERNAL_ERROR, &e.to_string()),
},
Err(e) => error(id, INTERNAL_ERROR, &e.to_string()),
},
"doctor" => match run_doctor(workdir) {
Ok(report) => match serde_json::to_value(report) {
Ok(v) => success(id, v),
Err(e) => error(id, INTERNAL_ERROR, &e.to_string()),
},
Err(e) => error(id, INTERNAL_ERROR, &e.to_string()),
},
"path" => match req.params.get("pattern").and_then(|v| v.as_str()) {
None => error(id, INVALID_PARAMS, "method 'path' requires a string 'pattern' param"),
Some(pattern) => match run_path(workdir, pattern) {
Ok(p) => match serde_json::to_value(p) {
Ok(v) => success(id, v),
Err(e) => error(id, INTERNAL_ERROR, &e.to_string()),
},
Err(e) => error(id, INTERNAL_ERROR, &e.to_string()),
},
},
"subscribe" => error(
id,
INVALID_PARAMS,
"method 'subscribe' is only valid over a streaming socket connection",
),
other => error(id, METHOD_NOT_FOUND, &format!("unknown method '{other}'")),
}
}
pub fn handle_line(workdir: &Path, line: &str) -> Option<String> {
let value: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(e) => return Some(error(&Value::Null, PARSE_ERROR, &format!("parse error: {e}")).to_string()),
};
let req: RpcRequest = match serde_json::from_value(value.clone()) {
Ok(r) => r,
Err(e) => return Some(error(&Value::Null, INVALID_REQUEST, &format!("invalid request: {e}")).to_string()),
};
value.get("id")?;
Some(dispatch(workdir, &req).to_string())
}
pub fn worktrees_changed_notification(worktrees: &[JsonWorktree]) -> Value {
json!({
"jsonrpc": "2.0",
"method": "worktrees.changed",
"params": {
"schema_version": crate::contract::SCHEMA_VERSION,
"worktrees": worktrees,
},
})
}
pub fn worktrees_differ(old: &[JsonWorktree], new: &[JsonWorktree]) -> bool {
if old.len() != new.len() {
return true;
}
old.iter().zip(new).any(|(a, b)| {
a.name != b.name
|| a.id != b.id
|| a.path != b.path
|| a.branch != b.branch
|| a.head != b.head
|| a.is_main != b.is_main
|| a.is_locked != b.is_locked
|| a.is_prunable != b.is_prunable
|| a.status != b.status
|| a.issue != b.issue
|| a.pr != b.pr
})
}
pub const LIST_REQUEST: &str = r#"{"jsonrpc":"2.0","method":"list","id":1}"#;
pub const SUBSCRIBE_REQUEST: &str = r#"{"jsonrpc":"2.0","method":"subscribe","id":1}"#;
pub fn parse_list_result(line: &str) -> Result<Vec<JsonWorktree>> {
let v: Value = serde_json::from_str(line)
.map_err(|e| crate::error::GwmError::Other(format!("daemon: malformed list response: {e}")))?;
if let Some(err) = v.get("error") {
let msg = err.get("message").and_then(Value::as_str).unwrap_or("unknown error");
return Err(crate::error::GwmError::Other(format!("daemon list error: {msg}")));
}
let result = v
.get("result")
.ok_or_else(|| crate::error::GwmError::Other("daemon list response missing 'result'".into()))?;
serde_json::from_value(result.clone())
.map_err(|e| crate::error::GwmError::Other(format!("daemon: cannot decode worktree list: {e}")))
}
pub fn parse_worktrees_changed(line: &str) -> Result<Vec<JsonWorktree>> {
let v: Value = serde_json::from_str(line)
.map_err(|e| crate::error::GwmError::Other(format!("daemon: malformed notification: {e}")))?;
let arr = v
.get("params")
.and_then(|p| p.get("worktrees"))
.ok_or_else(|| crate::error::GwmError::Other("daemon notification missing 'params.worktrees'".into()))?;
serde_json::from_value(arr.clone())
.map_err(|e| crate::error::GwmError::Other(format!("daemon: cannot decode notification worktrees: {e}")))
}
#[cfg(all(unix, feature = "daemon"))]
pub mod client {
use super::*;
use crate::error::GwmError;
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::time::Duration;
const CLIENT_TIMEOUT: Duration = Duration::from_secs(5);
fn connect(socket: &Path, timeout: Option<Duration>) -> Result<UnixStream> {
let stream = UnixStream::connect(socket)
.map_err(|e| GwmError::Other(format!("daemon: cannot connect to {}: {e}", socket.display())))?;
stream
.set_read_timeout(timeout)
.map_err(|e| GwmError::Other(format!("daemon: {e}")))?;
stream
.set_write_timeout(timeout)
.map_err(|e| GwmError::Other(format!("daemon: {e}")))?;
Ok(stream)
}
pub fn list_once(socket: &Path) -> Result<Vec<JsonWorktree>> {
list_once_with_timeout(socket, Some(CLIENT_TIMEOUT))
}
#[doc(hidden)]
pub fn list_once_with_timeout(socket: &Path, timeout: Option<Duration>) -> Result<Vec<JsonWorktree>> {
let stream = connect(socket, timeout)?;
let mut writer = stream
.try_clone()
.map_err(|e| GwmError::Other(format!("daemon: {e}")))?;
writeln!(writer, "{LIST_REQUEST}").map_err(|e| GwmError::Other(format!("daemon: {e}")))?;
writer.flush().map_err(|e| GwmError::Other(format!("daemon: {e}")))?;
let mut reader = BufReader::new(stream);
let mut line = String::new();
reader
.read_line(&mut line)
.map_err(|e| GwmError::Other(format!("daemon: {e}")))?;
parse_list_result(line.trim())
}
pub fn subscribe(socket: &Path, mut on_snapshot: impl FnMut(&[JsonWorktree]) -> bool) -> Result<()> {
let stream = connect(socket, Some(CLIENT_TIMEOUT))?;
let mut writer = stream
.try_clone()
.map_err(|e| GwmError::Other(format!("daemon: {e}")))?;
writeln!(writer, "{SUBSCRIBE_REQUEST}").map_err(|e| GwmError::Other(format!("daemon: {e}")))?;
writer.flush().map_err(|e| GwmError::Other(format!("daemon: {e}")))?;
let mut reader = BufReader::new(stream);
let mut delivered_any = false;
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line) {
Ok(0) => break, Ok(_) => {}
Err(_) => break, }
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let worktrees = parse_worktrees_changed(trimmed)?;
if !delivered_any {
let _ = reader.get_ref().set_read_timeout(None);
}
delivered_any = true;
if !on_snapshot(&worktrees) {
break;
}
}
if !delivered_any {
return Err(GwmError::Other(
"daemon: stream closed before the first snapshot".to_string(),
));
}
Ok(())
}
}
#[cfg(all(unix, feature = "daemon"))]
mod server {
use super::*;
use crate::error::GwmError;
use std::io::{BufRead, BufReader, ErrorKind, Read, Write};
use std::os::unix::fs::FileTypeExt;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
const ACCEPT_TICK: Duration = Duration::from_millis(50);
pub struct ServeOptions {
pub socket: PathBuf,
pub repo_workdir: PathBuf,
pub poll_interval: Duration,
}
pub fn socket_path() -> PathBuf {
let base = std::env::var_os("XDG_RUNTIME_DIR")
.filter(|s| !s.is_empty())
.or_else(|| std::env::var_os("TMPDIR").filter(|s| !s.is_empty()))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/tmp"));
base.join("gwm.sock")
}
fn clear_stale_socket(path: &Path) -> Result<()> {
let meta = match std::fs::symlink_metadata(path) {
Ok(m) => m,
Err(_) => return Ok(()), };
if !meta.file_type().is_socket() {
return Err(GwmError::Other(format!(
"daemon: refusing to use {}: exists and is not a unix socket",
path.display()
)));
}
if UnixStream::connect(path).is_ok() {
return Err(GwmError::Other(format!(
"daemon: socket {} is already in use by a live daemon",
path.display()
)));
}
let _ = std::fs::remove_file(path);
Ok(())
}
pub fn serve(opts: &ServeOptions, shutdown: Arc<AtomicBool>) -> Result<()> {
clear_stale_socket(&opts.socket)?;
let listener = UnixListener::bind(&opts.socket)
.map_err(|e| GwmError::Other(format!("daemon: failed to bind {}: {e}", opts.socket.display())))?;
listener
.set_nonblocking(true)
.map_err(|e| GwmError::Other(format!("daemon: set_nonblocking failed: {e}")))?;
eprintln!("gwm daemon listening on {}", opts.socket.display());
loop {
if shutdown.load(Ordering::Relaxed) {
break;
}
match listener.accept() {
Ok((stream, _addr)) => {
if let Err(e) = stream.set_nonblocking(false) {
eprintln!("daemon: failed to set connection blocking: {e}");
continue;
}
let workdir = opts.repo_workdir.clone();
let poll = opts.poll_interval;
let shutdown = Arc::clone(&shutdown);
std::thread::spawn(move || {
handle_connection(stream, &workdir, poll, &shutdown);
});
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(ACCEPT_TICK);
}
Err(e) => {
eprintln!("daemon: accept error: {e}");
std::thread::sleep(ACCEPT_TICK);
}
}
}
let _ = std::fs::remove_file(&opts.socket);
Ok(())
}
fn handle_connection(stream: UnixStream, workdir: &Path, poll: Duration, shutdown: &AtomicBool) {
let read_half = match stream.try_clone() {
Ok(s) => s,
Err(_) => return,
};
let mut writer = stream;
let reader = BufReader::new(read_half);
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
if line.trim().is_empty() {
continue;
}
let is_subscribe = serde_json::from_str::<RpcRequest>(&line)
.map(|r| r.method == "subscribe")
.unwrap_or(false);
if is_subscribe {
stream_subscription(&mut writer, workdir, poll, shutdown);
return;
}
if let Some(response) = handle_line(workdir, &line) {
if writeln!(writer, "{response}").is_err() || writer.flush().is_err() {
break;
}
}
}
}
fn stream_subscription(stream: &mut UnixStream, workdir: &Path, poll: Duration, shutdown: &AtomicBool) {
if stream.set_read_timeout(Some(poll)).is_err() {
return;
}
let mut last = run_list(workdir).unwrap_or_default();
if send_notification(stream, &last).is_err() {
return;
}
let mut buf = [0u8; 64];
loop {
if shutdown.load(Ordering::Relaxed) {
return;
}
match stream.read(&mut buf) {
Ok(0) => return,
Ok(_) => {} Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => {}
Err(_) => return,
}
let now = run_list(workdir).unwrap_or_default();
if worktrees_differ(&last, &now) {
if send_notification(stream, &now).is_err() {
return;
}
last = now;
}
}
}
fn send_notification(writer: &mut UnixStream, worktrees: &[JsonWorktree]) -> std::io::Result<()> {
let note = worktrees_changed_notification(worktrees);
writeln!(writer, "{note}")?;
writer.flush()
}
}
#[cfg(all(unix, feature = "daemon"))]
pub use server::{serve, socket_path, ServeOptions};