src/lease.rs
Ref: Size: 7.6 KiB History
//! Client-side work leases: claim an issue, keep the claim alive, give it up.
//!
//! Leases live on the server, not in the repository — they are the one
//! collaboration primitive git cannot express, needing an atomic decision
//! point and a TTL. So unlike `issue open` or `issue comment`, these commands
//! do not write refs and never sync: each is one `collab-lease` exec verb
//! over SSH, authenticated by the same key that clones the repository.
use std::process::Stdio;
use git2::Repository;
use crate::error::Error;
use crate::remote_ssh::{run_remote_expecting, ssh_remote, SshRemote};
/// The exit code the server uses for "held by someone else" / "not yours to
/// renew". Distinct from 1 so a racing agent can branch on it.
const CONFLICT_CODE: i32 = 4;
/// Reject anything that is not a plain hex id or prefix before it reaches a
/// single-quoted remote command string. The server validates too; this turns
/// a typo into a local error rather than a round trip, and keeps a quote from
/// ever reaching the quoting we cannot escape.
fn validate_id(id: &str) -> Result<(), Error> {
if id.is_empty() || !id.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(Error::Cmd(format!(
"'{}' is not an issue id — expected hex characters",
id
)));
}
Ok(())
}
fn run(
repo: &Repository,
remote: &SshRemote,
remote_cmd: &str,
) -> Result<(serde_json::Value, bool), Error> {
let output = run_remote_expecting(repo, remote, remote_cmd, Stdio::null(), &[CONFLICT_CODE])?;
let text = String::from_utf8_lossy(&output.stdout);
let value: serde_json::Value = serde_json::from_str(text.trim()).map_err(|e| {
Error::Cmd(format!(
"could not read the server's reply ({e}): {}",
text.trim()
))
})?;
let conflict = output.status.code() == Some(CONFLICT_CODE);
Ok((value, conflict))
}
/// Print the server's reply verbatim under `--json`, so a caller reads exactly
/// what the server said rather than a re-rendering of it.
fn emit(value: &serde_json::Value, json: bool, prose: impl FnOnce() -> String) {
if json {
println!("{}", value);
} else {
println!("{}", prose());
}
}
/// The id to show a human: abbreviated against this repository's issues, as
/// every other command does. The server always answers with the full id —
/// that is what `--json` passes through, and what a script should hold — but
/// prose that printed 40 characters here would be the only place in the CLI
/// that did.
fn short(repo: &Repository, value: &serde_json::Value, fallback: &str) -> String {
let full = value["issue"].as_str().unwrap_or(fallback);
crate::abbrev::for_issues(repo).of(full).to_string()
}
fn expiry_note(value: &serde_json::Value) -> String {
match value["expires_at"].as_str() {
Some(at) => format!(" (expires {})", at),
None => String::new(),
}
}
/// A conflict, rendered for a human and carried as the exit-4 error.
fn conflict(value: &serde_json::Value, id: &str, json: bool) -> Error {
if json {
println!("{}", value);
}
let holder = value["holder"].as_str().unwrap_or("someone else");
Error::LeaseConflict(format!(
"issue {} is claimed by {}{}",
id,
holder,
expiry_note(value)
))
}
pub fn claim(
repo: &Repository,
remote_name: &str,
id: &str,
ttl_secs: Option<u64>,
json: bool,
) -> Result<(), Error> {
validate_id(id)?;
let remote = ssh_remote(repo, remote_name)?;
let ttl = ttl_secs
.map(|t| format!(" --ttl {}", t))
.unwrap_or_default();
let cmd = format!("collab-lease acquire '{}' '{}'{}", remote.path, id, ttl);
let (value, is_conflict) = run(repo, &remote, &cmd)?;
if is_conflict {
return Err(conflict(&value, id, json));
}
emit(&value, json, || {
format!(
"Claimed issue {}{}",
short(repo, &value, id),
expiry_note(&value)
)
});
Ok(())
}
pub fn renew(
repo: &Repository,
remote_name: &str,
id: &str,
ttl_secs: u64,
json: bool,
) -> Result<(), Error> {
validate_id(id)?;
let remote = ssh_remote(repo, remote_name)?;
let cmd = format!(
"collab-lease renew '{}' '{}' --ttl {}",
remote.path, id, ttl_secs
);
let (value, is_conflict) = run(repo, &remote, &cmd)?;
if is_conflict {
// Not a rival's claim necessarily — an expired lease reads the same
// way, and the fix is the same: claim it again.
if json {
println!("{}", value);
}
return Err(Error::LeaseConflict(match value["holder"].as_str() {
Some(holder) => format!("issue {} is claimed by {}", id, holder),
None => format!("you do not hold a lease on issue {} — claim it first", id),
}));
}
emit(&value, json, || {
format!("Renewed the claim on {}{}", id, expiry_note(&value))
});
Ok(())
}
pub fn unclaim(repo: &Repository, remote_name: &str, id: &str, json: bool) -> Result<(), Error> {
validate_id(id)?;
let remote = ssh_remote(repo, remote_name)?;
let cmd = format!("collab-lease release '{}' '{}'", remote.path, id);
let (value, is_conflict) = run(repo, &remote, &cmd)?;
if is_conflict {
return Err(conflict(&value, id, json));
}
emit(&value, json, || {
// "released" and "not-held" are both exit 0, and saying "released"
// for either is what hid a bug where a claim made by prefix could
// not be released by that prefix at all (2026-09-06).
if value["status"] == "not-held" {
format!("No claim on {} to release", short(repo, &value, id))
} else {
format!("Released the claim on {}", short(repo, &value, id))
}
});
Ok(())
}
pub fn claims(repo: &Repository, remote_name: &str, json: bool) -> Result<(), Error> {
let remote = ssh_remote(repo, remote_name)?;
let cmd = format!("collab-lease list '{}'", remote.path);
let (value, _) = run(repo, &remote, &cmd)?;
if json {
println!("{}", value);
return Ok(());
}
let rows = value["leases"].as_array().cloned().unwrap_or_default();
if rows.is_empty() {
println!("No claims.");
return Ok(());
}
let abbrev = crate::abbrev::for_issues(repo);
for row in &rows {
println!(
"{} {}{}",
abbrev.of(row["issue"].as_str().unwrap_or("?")),
row["holder"].as_str().unwrap_or("?"),
match row["expires_at"].as_str() {
Some(at) => format!(" expires {}", at),
None => " assigned".to_string(),
}
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_id_accepts_hex_and_prefixes() {
assert!(validate_id("a1b2c3d4").is_ok());
assert!(validate_id("a1b2").is_ok());
assert!(validate_id("ABCDEF01").is_ok());
}
#[test]
fn validate_id_rejects_anything_that_could_break_quoting() {
// A quote would escape the single-quoted remote argument.
assert!(validate_id("a1'; rm -rf /").is_err());
assert!(validate_id("a1 b2").is_err());
assert!(validate_id("--ttl").is_err());
assert!(validate_id("").is_err());
assert!(validate_id("zzzz").is_err());
}
#[test]
fn expiry_note_reads_open_ended_leases_as_no_note() {
assert_eq!(expiry_note(&serde_json::json!({})), "");
assert_eq!(expiry_note(&serde_json::json!({ "expires_at": null })), "");
assert_eq!(
expiry_note(&serde_json::json!({ "expires_at": "2026-09-05T12:00:00Z" })),
" (expires 2026-09-05T12:00:00Z)"
);
}
}