d413baa0
Add issue claim, unclaim, renew and claims CLI commands
a73x 2026-09-06 08:06
Commit message
src/cli.rs
| Old | New | ||
|---|---|---|---|
| @@ -390,7 +390,11 @@ impl IssueCmd { | |||
| 390 | | IssueCmd::Unassign { json, .. } | 390 | | IssueCmd::Unassign { json, .. } |
| 391 | | IssueCmd::Close { json, .. } | 391 | | IssueCmd::Close { json, .. } |
| 392 | | IssueCmd::Delete { json, .. } | 392 | | IssueCmd::Delete { json, .. } |
| 393 | | IssueCmd::Reopen { json, .. } => *json, | 393 | | IssueCmd::Reopen { json, .. } |
| 394 | | IssueCmd::Claim { json, .. } | ||
| 395 | | IssueCmd::Unclaim { json, .. } | ||
| 396 | | IssueCmd::Renew { json, .. } | ||
| 397 | | IssueCmd::Claims { json, .. } => *json, | ||
| 394 | } | 398 | } |
| 395 | } | 399 | } |
| 396 | } | 400 | } |
| @@ -510,6 +514,59 @@ pub enum IssueCmd { | |||
| 510 | #[arg(long)] | 514 | #[arg(long)] |
| 511 | json: bool, | 515 | json: bool, |
| 512 | }, | 516 | }, |
| 517 | /// Claim an issue on the server, so nobody else works on it | ||
| 518 | /// | ||
| 519 | /// Without --ttl the claim is open-ended, i.e. an assignment. With one it | ||
| 520 | /// expires unless renewed, which is what an agent in a short-lived VM | ||
| 521 | /// wants: if it dies, the work returns to the pool on its own. Exits 4 if | ||
| 522 | /// somebody else holds the claim. | ||
| 523 | Claim { | ||
| 524 | /// Issue ID (prefix match) | ||
| 525 | id: String, | ||
| 526 | /// Seconds until the claim lapses unless renewed | ||
| 527 | #[arg(long)] | ||
| 528 | ttl: Option<u64>, | ||
| 529 | /// Remote to claim on | ||
| 530 | #[arg(long, default_value = "origin")] | ||
| 531 | remote: String, | ||
| 532 | /// Output as JSON | ||
| 533 | #[arg(long)] | ||
| 534 | json: bool, | ||
| 535 | }, | ||
| 536 | /// Give up a claim on an issue | ||
| 537 | Unclaim { | ||
| 538 | /// Issue ID (prefix match) | ||
| 539 | id: String, | ||
| 540 | /// Remote to release on | ||
| 541 | #[arg(long, default_value = "origin")] | ||
| 542 | remote: String, | ||
| 543 | /// Output as JSON | ||
| 544 | #[arg(long)] | ||
| 545 | json: bool, | ||
| 546 | }, | ||
| 547 | /// Extend a claim you hold before it lapses | ||
| 548 | Renew { | ||
| 549 | /// Issue ID (prefix match) | ||
| 550 | id: String, | ||
| 551 | /// Seconds from now until the claim lapses | ||
| 552 | #[arg(long)] | ||
| 553 | ttl: u64, | ||
| 554 | /// Remote to renew on | ||
| 555 | #[arg(long, default_value = "origin")] | ||
| 556 | remote: String, | ||
| 557 | /// Output as JSON | ||
| 558 | #[arg(long)] | ||
| 559 | json: bool, | ||
| 560 | }, | ||
| 561 | /// List the claims currently held on this repository | ||
| 562 | Claims { | ||
| 563 | /// Remote to ask | ||
| 564 | #[arg(long, default_value = "origin")] | ||
| 565 | remote: String, | ||
| 566 | /// Output as JSON | ||
| 567 | #[arg(long)] | ||
| 568 | json: bool, | ||
| 569 | }, | ||
| 513 | /// Comment on an issue | 570 | /// Comment on an issue |
| 514 | /// | 571 | /// |
| 515 | /// With no --body and no --body-file, opens $EDITOR. | 572 | /// With no --body and no --body-file, opens $EDITOR. |
src/error.rs
| Old | New | ||
|---|---|---|---|
| @@ -35,6 +35,16 @@ pub enum Error { | |||
| 35 | #[error("sync partially failed: {succeeded} of {total} refs pushed")] | 35 | #[error("sync partially failed: {succeeded} of {total} refs pushed")] |
| 36 | PartialSync { succeeded: usize, total: usize }, | 36 | PartialSync { succeeded: usize, total: usize }, |
| 37 | 37 | ||
| 38 | /// A lease is held by someone else, or is not the caller's to renew. | ||
| 39 | /// | ||
| 40 | /// Its own variant, and its own exit code (4), because losing a race is an | ||
| 41 | /// *answer* and not a failure: an agent that races for work needs to tell | ||
| 42 | /// "someone else got it" apart from "the command was wrong" without | ||
| 43 | /// parsing prose. See the exit-code contract in | ||
| 44 | /// docs/superpowers/plans/2026-09-05-issue-leases.md. | ||
| 45 | #[error("{0}")] | ||
| 46 | LeaseConflict(String), | ||
| 47 | |||
| 38 | #[error("sync failed for {failed} of {total} configured remote(s)")] | 48 | #[error("sync failed for {failed} of {total} configured remote(s)")] |
| 39 | MultiRemoteSync { failed: usize, total: usize }, | 49 | MultiRemoteSync { failed: usize, total: usize }, |
| 40 | 50 | ||
src/lease.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,209 @@ | |||
| 1 | //! Client-side work leases: claim an issue, keep the claim alive, give it up. | ||
| 2 | //! | ||
| 3 | //! Leases live on the server, not in the repository — they are the one | ||
| 4 | //! collaboration primitive git cannot express, needing an atomic decision | ||
| 5 | //! point and a TTL. So unlike `issue open` or `issue comment`, these commands | ||
| 6 | //! do not write refs and never sync: each is one `collab-lease` exec verb | ||
| 7 | //! over SSH, authenticated by the same key that clones the repository. | ||
| 8 | |||
| 9 | use std::process::Stdio; | ||
| 10 | |||
| 11 | use git2::Repository; | ||
| 12 | |||
| 13 | use crate::error::Error; | ||
| 14 | use crate::remote_ssh::{run_remote_expecting, ssh_remote, SshRemote}; | ||
| 15 | |||
| 16 | /// The exit code the server uses for "held by someone else" / "not yours to | ||
| 17 | /// renew". Distinct from 1 so a racing agent can branch on it. | ||
| 18 | const CONFLICT_CODE: i32 = 4; | ||
| 19 | |||
| 20 | /// Reject anything that is not a plain hex id or prefix before it reaches a | ||
| 21 | /// single-quoted remote command string. The server validates too; this turns | ||
| 22 | /// a typo into a local error rather than a round trip, and keeps a quote from | ||
| 23 | /// ever reaching the quoting we cannot escape. | ||
| 24 | fn validate_id(id: &str) -> Result<(), Error> { | ||
| 25 | if id.is_empty() || !id.chars().all(|c| c.is_ascii_hexdigit()) { | ||
| 26 | return Err(Error::Cmd(format!( | ||
| 27 | "'{}' is not an issue id — expected hex characters", | ||
| 28 | id | ||
| 29 | ))); | ||
| 30 | } | ||
| 31 | Ok(()) | ||
| 32 | } | ||
| 33 | |||
| 34 | fn run( | ||
| 35 | repo: &Repository, | ||
| 36 | remote: &SshRemote, | ||
| 37 | remote_cmd: &str, | ||
| 38 | ) -> Result<(serde_json::Value, bool), Error> { | ||
| 39 | let output = run_remote_expecting(repo, remote, remote_cmd, Stdio::null(), &[CONFLICT_CODE])?; | ||
| 40 | let text = String::from_utf8_lossy(&output.stdout); | ||
| 41 | let value: serde_json::Value = serde_json::from_str(text.trim()).map_err(|e| { | ||
| 42 | Error::Cmd(format!( | ||
| 43 | "could not read the server's reply ({e}): {}", | ||
| 44 | text.trim() | ||
| 45 | )) | ||
| 46 | })?; | ||
| 47 | let conflict = output.status.code() == Some(CONFLICT_CODE); | ||
| 48 | Ok((value, conflict)) | ||
| 49 | } | ||
| 50 | |||
| 51 | /// Print the server's reply verbatim under `--json`, so a caller reads exactly | ||
| 52 | /// what the server said rather than a re-rendering of it. | ||
| 53 | fn emit(value: &serde_json::Value, json: bool, prose: impl FnOnce() -> String) { | ||
| 54 | if json { | ||
| 55 | println!("{}", value); | ||
| 56 | } else { | ||
| 57 | println!("{}", prose()); | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | fn expiry_note(value: &serde_json::Value) -> String { | ||
| 62 | match value["expires_at"].as_str() { | ||
| 63 | Some(at) => format!(" (expires {})", at), | ||
| 64 | None => String::new(), | ||
| 65 | } | ||
| 66 | } | ||
| 67 | |||
| 68 | /// A conflict, rendered for a human and carried as the exit-4 error. | ||
| 69 | fn conflict(value: &serde_json::Value, id: &str, json: bool) -> Error { | ||
| 70 | if json { | ||
| 71 | println!("{}", value); | ||
| 72 | } | ||
| 73 | let holder = value["holder"].as_str().unwrap_or("someone else"); | ||
| 74 | Error::LeaseConflict(format!( | ||
| 75 | "issue {} is claimed by {}{}", | ||
| 76 | id, | ||
| 77 | holder, | ||
| 78 | expiry_note(value) | ||
| 79 | )) | ||
| 80 | } | ||
| 81 | |||
| 82 | pub fn claim( | ||
| 83 | repo: &Repository, | ||
| 84 | remote_name: &str, | ||
| 85 | id: &str, | ||
| 86 | ttl_secs: Option<u64>, | ||
| 87 | json: bool, | ||
| 88 | ) -> Result<(), Error> { | ||
| 89 | validate_id(id)?; | ||
| 90 | let remote = ssh_remote(repo, remote_name)?; | ||
| 91 | let ttl = ttl_secs | ||
| 92 | .map(|t| format!(" --ttl {}", t)) | ||
| 93 | .unwrap_or_default(); | ||
| 94 | let cmd = format!("collab-lease acquire '{}' '{}'{}", remote.path, id, ttl); | ||
| 95 | let (value, is_conflict) = run(repo, &remote, &cmd)?; | ||
| 96 | if is_conflict { | ||
| 97 | return Err(conflict(&value, id, json)); | ||
| 98 | } | ||
| 99 | emit(&value, json, || { | ||
| 100 | format!( | ||
| 101 | "Claimed issue {}{}", | ||
| 102 | value["issue"].as_str().unwrap_or(id), | ||
| 103 | expiry_note(&value) | ||
| 104 | ) | ||
| 105 | }); | ||
| 106 | Ok(()) | ||
| 107 | } | ||
| 108 | |||
| 109 | pub fn renew( | ||
| 110 | repo: &Repository, | ||
| 111 | remote_name: &str, | ||
| 112 | id: &str, | ||
| 113 | ttl_secs: u64, | ||
| 114 | json: bool, | ||
| 115 | ) -> Result<(), Error> { | ||
| 116 | validate_id(id)?; | ||
| 117 | let remote = ssh_remote(repo, remote_name)?; | ||
| 118 | let cmd = format!( | ||
| 119 | "collab-lease renew '{}' '{}' --ttl {}", | ||
| 120 | remote.path, id, ttl_secs | ||
| 121 | ); | ||
| 122 | let (value, is_conflict) = run(repo, &remote, &cmd)?; | ||
| 123 | if is_conflict { | ||
| 124 | // Not a rival's claim necessarily — an expired lease reads the same | ||
| 125 | // way, and the fix is the same: claim it again. | ||
| 126 | if json { | ||
| 127 | println!("{}", value); | ||
| 128 | } | ||
| 129 | return Err(Error::LeaseConflict(match value["holder"].as_str() { | ||
| 130 | Some(holder) => format!("issue {} is claimed by {}", id, holder), | ||
| 131 | None => format!("you do not hold a lease on issue {} — claim it first", id), | ||
| 132 | })); | ||
| 133 | } | ||
| 134 | emit(&value, json, || { | ||
| 135 | format!("Renewed the claim on {}{}", id, expiry_note(&value)) | ||
| 136 | }); | ||
| 137 | Ok(()) | ||
| 138 | } | ||
| 139 | |||
| 140 | pub fn unclaim(repo: &Repository, remote_name: &str, id: &str, json: bool) -> Result<(), Error> { | ||
| 141 | validate_id(id)?; | ||
| 142 | let remote = ssh_remote(repo, remote_name)?; | ||
| 143 | let cmd = format!("collab-lease release '{}' '{}'", remote.path, id); | ||
| 144 | let (value, is_conflict) = run(repo, &remote, &cmd)?; | ||
| 145 | if is_conflict { | ||
| 146 | return Err(conflict(&value, id, json)); | ||
| 147 | } | ||
| 148 | emit(&value, json, || format!("Released the claim on {}", id)); | ||
| 149 | Ok(()) | ||
| 150 | } | ||
| 151 | |||
| 152 | pub fn claims(repo: &Repository, remote_name: &str, json: bool) -> Result<(), Error> { | ||
| 153 | let remote = ssh_remote(repo, remote_name)?; | ||
| 154 | let cmd = format!("collab-lease list '{}'", remote.path); | ||
| 155 | let (value, _) = run(repo, &remote, &cmd)?; | ||
| 156 | if json { | ||
| 157 | println!("{}", value); | ||
| 158 | return Ok(()); | ||
| 159 | } | ||
| 160 | let rows = value["leases"].as_array().cloned().unwrap_or_default(); | ||
| 161 | if rows.is_empty() { | ||
| 162 | println!("No claims."); | ||
| 163 | return Ok(()); | ||
| 164 | } | ||
| 165 | for row in &rows { | ||
| 166 | println!( | ||
| 167 | "{} {}{}", | ||
| 168 | row["issue"].as_str().unwrap_or("?"), | ||
| 169 | row["holder"].as_str().unwrap_or("?"), | ||
| 170 | match row["expires_at"].as_str() { | ||
| 171 | Some(at) => format!(" expires {}", at), | ||
| 172 | None => " assigned".to_string(), | ||
| 173 | } | ||
| 174 | ); | ||
| 175 | } | ||
| 176 | Ok(()) | ||
| 177 | } | ||
| 178 | |||
| 179 | #[cfg(test)] | ||
| 180 | mod tests { | ||
| 181 | use super::*; | ||
| 182 | |||
| 183 | #[test] | ||
| 184 | fn validate_id_accepts_hex_and_prefixes() { | ||
| 185 | assert!(validate_id("a1b2c3d4").is_ok()); | ||
| 186 | assert!(validate_id("a1b2").is_ok()); | ||
| 187 | assert!(validate_id("ABCDEF01").is_ok()); | ||
| 188 | } | ||
| 189 | |||
| 190 | #[test] | ||
| 191 | fn validate_id_rejects_anything_that_could_break_quoting() { | ||
| 192 | // A quote would escape the single-quoted remote argument. | ||
| 193 | assert!(validate_id("a1'; rm -rf /").is_err()); | ||
| 194 | assert!(validate_id("a1 b2").is_err()); | ||
| 195 | assert!(validate_id("--ttl").is_err()); | ||
| 196 | assert!(validate_id("").is_err()); | ||
| 197 | assert!(validate_id("zzzz").is_err()); | ||
| 198 | } | ||
| 199 | |||
| 200 | #[test] | ||
| 201 | fn expiry_note_reads_open_ended_leases_as_no_note() { | ||
| 202 | assert_eq!(expiry_note(&serde_json::json!({})), ""); | ||
| 203 | assert_eq!(expiry_note(&serde_json::json!({ "expires_at": null })), ""); | ||
| 204 | assert_eq!( | ||
| 205 | expiry_note(&serde_json::json!({ "expires_at": "2026-09-05T12:00:00Z" })), | ||
| 206 | " (expires 2026-09-05T12:00:00Z)" | ||
| 207 | ); | ||
| 208 | } | ||
| 209 | } | ||
src/lib.rs
| Old | New | ||
|---|---|---|---|
| @@ -11,6 +11,7 @@ pub mod event; | |||
| 11 | pub mod hooks; | 11 | pub mod hooks; |
| 12 | pub mod identity; | 12 | pub mod identity; |
| 13 | pub mod issue; | 13 | pub mod issue; |
| 14 | pub mod lease; | ||
| 14 | pub mod log; | 15 | pub mod log; |
| 15 | pub mod merge_scan; | 16 | pub mod merge_scan; |
| 16 | pub mod output; | 17 | pub mod output; |
| @@ -371,6 +372,22 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 371 | } | 372 | } |
| 372 | Ok(()) | 373 | Ok(()) |
| 373 | } | 374 | } |
| 375 | // The lease commands talk to the server and touch no refs, so | ||
| 376 | // they are not `is_write` and never trigger an auto-sync. | ||
| 377 | IssueCmd::Claim { | ||
| 378 | id, | ||
| 379 | ttl, | ||
| 380 | remote, | ||
| 381 | json, | ||
| 382 | } => lease::claim(repo, &remote, &id, ttl, json), | ||
| 383 | IssueCmd::Unclaim { id, remote, json } => lease::unclaim(repo, &remote, &id, json), | ||
| 384 | IssueCmd::Renew { | ||
| 385 | id, | ||
| 386 | ttl, | ||
| 387 | remote, | ||
| 388 | json, | ||
| 389 | } => lease::renew(repo, &remote, &id, ttl, json), | ||
| 390 | IssueCmd::Claims { remote, json } => lease::claims(repo, &remote, json), | ||
| 374 | IssueCmd::Label { id, label, json } => { | 391 | IssueCmd::Label { id, label, json } => { |
| 375 | let r = issue::label(repo, &id, &label)?; | 392 | let r = issue::label(repo, &id, &label)?; |
| 376 | report( | 393 | report( |
src/main.rs
| Old | New | ||
|---|---|---|---|
| @@ -31,6 +31,13 @@ fn main() { | |||
| 31 | // Summary already printed by sync; just exit with code 1 | 31 | // Summary already printed by sync; just exit with code 1 |
| 32 | std::process::exit(1); | 32 | std::process::exit(1); |
| 33 | } | 33 | } |
| 34 | // A lost lease race is an answer, not a failure: exit 4 so a | ||
| 35 | // script can branch on it. The message (and, under --json, the | ||
| 36 | // server's own reply) is already on the right stream. | ||
| 37 | git_collab::error::Error::LeaseConflict(message) => { | ||
| 38 | eprintln!("{}", message); | ||
| 39 | std::process::exit(4); | ||
| 40 | } | ||
| 34 | _ => fail(&e.to_string(), json), | 41 | _ => fail(&e.to_string(), json), |
| 35 | } | 42 | } |
| 36 | } | 43 | } |
src/server/leases.rs
| Old | New | ||
|---|---|---|---|
| @@ -74,9 +74,14 @@ pub enum Acquire { | |||
| 74 | 74 | ||
| 75 | #[derive(Debug, PartialEq)] | 75 | #[derive(Debug, PartialEq)] |
| 76 | pub enum Renew { | 76 | pub enum Renew { |
| 77 | Renewed { token: i64, expires_at: Option<i64> }, | 77 | Renewed { |
| 78 | token: i64, | ||
| 79 | expires_at: Option<i64>, | ||
| 80 | }, | ||
| 78 | /// No live lease held by caller (expired, released, or someone else's). | 81 | /// No live lease held by caller (expired, released, or someone else's). |
| 79 | NotHolder { holder: Option<String> }, | 82 | NotHolder { |
| 83 | holder: Option<String>, | ||
| 84 | }, | ||
| 80 | } | 85 | } |
| 81 | 86 | ||
| 82 | #[derive(Debug, PartialEq)] | 87 | #[derive(Debug, PartialEq)] |
| @@ -224,11 +229,9 @@ pub fn release( | |||
| 224 | let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; | 229 | let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; |
| 225 | let existing = get_row(&tx, repo, issue_id)?; | 230 | let existing = get_row(&tx, repo, issue_id)?; |
| 226 | let outcome = match existing { | 231 | let outcome = match existing { |
| 227 | Some(row) if row.held(now) && row.holder.as_deref() != Some(holder) => { | 232 | Some(row) if row.held(now) && row.holder.as_deref() != Some(holder) => Release::NotHolder { |
| 228 | Release::NotHolder { | 233 | holder: row.holder.unwrap_or_default(), |
| 229 | holder: row.holder.unwrap_or_default(), | 234 | }, |
| 230 | } | ||
| 231 | } | ||
| 232 | Some(_) => { | 235 | Some(_) => { |
| 233 | // Free the row, keep the token. Deleting would reset the tenure | 236 | // Free the row, keep the token. Deleting would reset the tenure |
| 234 | // counter — see the module docs. | 237 | // counter — see the module docs. |
src/server/ssh/session.rs
| Old | New | ||
|---|---|---|---|
| @@ -459,25 +459,22 @@ impl SshHandler { | |||
| 459 | return reply_and_close(session, channel, NOT_FOUND, 1); | 459 | return reply_and_close(session, channel, NOT_FOUND, 1); |
| 460 | } | 460 | } |
| 461 | }; | 461 | }; |
| 462 | let (ref_name, full_id) = | 462 | let (ref_name, full_id) = match git_collab::state::resolve_issue_ref(&repo, issue) { |
| 463 | match git_collab::state::resolve_issue_ref(&repo, issue) { | 463 | Ok(pair) => pair, |
| 464 | Ok(pair) => pair, | 464 | Err(_) => { |
| 465 | Err(_) => { | 465 | return reply_and_close(session, channel, "error: issue not found\n", 1); |
| 466 | return reply_and_close( | 466 | } |
| 467 | session, | 467 | }; |
| 468 | channel, | ||
| 469 | "error: issue not found\n", | ||
| 470 | 1, | ||
| 471 | ); | ||
| 472 | } | ||
| 473 | }; | ||
| 474 | match git_collab::state::IssueState::from_ref(&repo, &ref_name, &full_id) { | 468 | match git_collab::state::IssueState::from_ref(&repo, &ref_name, &full_id) { |
| 475 | Ok(is) if is.status == git_collab::state::IssueStatus::Open => full_id, | 469 | Ok(is) if is.status == git_collab::state::IssueStatus::Open => full_id, |
| 476 | Ok(_) => { | 470 | Ok(_) => { |
| 477 | return reply_and_close(session, channel, "error: issue is closed\n", 1); | 471 | return reply_and_close(session, channel, "error: issue is closed\n", 1); |
| 478 | } | 472 | } |
| 479 | Err(e) => { | 473 | Err(e) => { |
| 480 | error!("Failed to read issue {} in {:?}: {}", full_id, resolved_path, e); | 474 | error!( |
| 475 | "Failed to read issue {} in {:?}: {}", | ||
| 476 | full_id, resolved_path, e | ||
| 477 | ); | ||
| 481 | return reply_and_close(session, channel, "error: issue not found\n", 1); | 478 | return reply_and_close(session, channel, "error: issue not found\n", 1); |
| 482 | } | 479 | } |
| 483 | } | 480 | } |
| @@ -1794,7 +1791,9 @@ mod tests { | |||
| 1794 | })) | 1791 | })) |
| 1795 | ); | 1792 | ); |
| 1796 | assert_eq!( | 1793 | assert_eq!( |
| 1797 | parse_exec_command("collab-lease list 'r.git'").unwrap().repo(), | 1794 | parse_exec_command("collab-lease list 'r.git'") |
| 1795 | .unwrap() | ||
| 1796 | .repo(), | ||
| 1798 | "r.git" | 1797 | "r.git" |
| 1799 | ); | 1798 | ); |
| 1800 | } | 1799 | } |
tests/common/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -1611,10 +1611,13 @@ impl ServerHarness { | |||
| 1611 | /// The ssh client options needed to reach this test server, as a single | 1611 | /// The ssh client options needed to reach this test server, as a single |
| 1612 | /// command string usable both directly and as GIT_COLLAB_SSH_COMMAND. | 1612 | /// command string usable both directly and as GIT_COLLAB_SSH_COMMAND. |
| 1613 | pub fn ssh_command_string(&self) -> String { | 1613 | pub fn ssh_command_string(&self) -> String { |
| 1614 | format!( | 1614 | self.ssh_command_string_for(&self.ssh_client_key()) |
| 1615 | "ssh -i {} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=5", | 1615 | } |
| 1616 | self.ssh_client_key().display() | 1616 | |
| 1617 | ) | 1617 | /// Like `ssh_command_string`, but authenticating as a specific key — for |
| 1618 | /// tests that need a second principal (a lease conflict needs a rival). | ||
| 1619 | pub fn ssh_command_string_for(&self, key: &Path) -> String { | ||
| 1620 | ssh_command_for(key) | ||
| 1618 | } | 1621 | } |
| 1619 | 1622 | ||
| 1620 | /// Run a remote command over SSH with the given stdin bytes. | 1623 | /// Run a remote command over SSH with the given stdin bytes. |
tests/lease_cli_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,209 @@ | |||
| 1 | mod common; | ||
| 2 | |||
| 3 | use std::path::Path; | ||
| 4 | use std::process::Output; | ||
| 5 | |||
| 6 | use common::ServerHarness; | ||
| 7 | |||
| 8 | /// Run `git-collab issue …` in the harness work repo against the harness SSH | ||
| 9 | /// server, as the default client key. | ||
| 10 | fn issue_cmd(harness: &ServerHarness, args: &[&str]) -> Output { | ||
| 11 | issue_cmd_as(harness, &harness.ssh_client_key(), args) | ||
| 12 | } | ||
| 13 | |||
| 14 | /// Same, as a specific key — a lease conflict needs two principals. | ||
| 15 | fn issue_cmd_as(harness: &ServerHarness, key: &Path, args: &[&str]) -> Output { | ||
| 16 | let mut cmd = harness.work_repo().cli_command(); | ||
| 17 | cmd.env( | ||
| 18 | "GIT_COLLAB_SSH_COMMAND", | ||
| 19 | harness.ssh_command_string_for(key), | ||
| 20 | ); | ||
| 21 | cmd.args(["issue"]).args(args); | ||
| 22 | cmd.output().expect("failed to run git-collab issue") | ||
| 23 | } | ||
| 24 | |||
| 25 | fn out(output: &Output) -> String { | ||
| 26 | String::from_utf8_lossy(&output.stdout).to_string() | ||
| 27 | } | ||
| 28 | |||
| 29 | fn err(output: &Output) -> String { | ||
| 30 | String::from_utf8_lossy(&output.stderr).to_string() | ||
| 31 | } | ||
| 32 | |||
| 33 | /// A harness whose work repo has an `srv` SSH remote and one open issue, | ||
| 34 | /// already pushed. Returns the issue id. | ||
| 35 | fn setup(name: &str) -> (ServerHarness, String) { | ||
| 36 | let harness = ServerHarness::new(name); | ||
| 37 | harness.push_head(); | ||
| 38 | let url = harness.repo_ssh_url(); | ||
| 39 | harness.work_repo().git(&["remote", "add", "srv", &url]); | ||
| 40 | let (_ref_name, id) = common::open_issue( | ||
| 41 | &harness.work_repo_git2(), | ||
| 42 | &common::alice(), | ||
| 43 | "parser chokes on empty input", | ||
| 44 | ); | ||
| 45 | harness.push_collab_refs(); | ||
| 46 | (harness, id) | ||
| 47 | } | ||
| 48 | |||
| 49 | #[test] | ||
| 50 | fn claim_then_claims_lists_it() { | ||
| 51 | let (harness, id) = setup("cli-lease-claim"); | ||
| 52 | |||
| 53 | let claim = issue_cmd(&harness, &["claim", &id, "--remote", "srv"]); | ||
| 54 | assert!( | ||
| 55 | claim.status.success(), | ||
| 56 | "claim failed: {}{}", | ||
| 57 | out(&claim), | ||
| 58 | err(&claim) | ||
| 59 | ); | ||
| 60 | assert!(out(&claim).contains("Claimed issue"), "{}", out(&claim)); | ||
| 61 | |||
| 62 | let claims = issue_cmd(&harness, &["claims", "--remote", "srv"]); | ||
| 63 | assert!(claims.status.success()); | ||
| 64 | assert!( | ||
| 65 | out(&claims).contains(&id[..8]), | ||
| 66 | "claims should list the issue: {}", | ||
| 67 | out(&claims) | ||
| 68 | ); | ||
| 69 | // No --ttl, so the claim is an assignment rather than a lease. | ||
| 70 | assert!(out(&claims).contains("assigned"), "{}", out(&claims)); | ||
| 71 | } | ||
| 72 | |||
| 73 | #[test] | ||
| 74 | fn claims_with_no_claims_says_so() { | ||
| 75 | let (harness, _id) = setup("cli-lease-empty"); | ||
| 76 | |||
| 77 | let claims = issue_cmd(&harness, &["claims", "--remote", "srv"]); | ||
| 78 | assert!(claims.status.success()); | ||
| 79 | assert!(out(&claims).contains("No claims."), "{}", out(&claims)); | ||
| 80 | } | ||
| 81 | |||
| 82 | #[test] | ||
| 83 | fn claim_conflict_exits_4_and_names_the_holder() { | ||
| 84 | let (harness, id) = setup("cli-lease-conflict"); | ||
| 85 | let first = harness.ssh_client_key(); | ||
| 86 | let second = harness.second_authorized_key(); | ||
| 87 | |||
| 88 | let mine = issue_cmd_as( | ||
| 89 | &harness, | ||
| 90 | &first, | ||
| 91 | &["claim", &id, "--ttl", "300", "--remote", "srv"], | ||
| 92 | ); | ||
| 93 | assert!(mine.status.success(), "{}{}", out(&mine), err(&mine)); | ||
| 94 | |||
| 95 | let theirs = issue_cmd_as( | ||
| 96 | &harness, | ||
| 97 | &second, | ||
| 98 | &["claim", &id, "--ttl", "300", "--remote", "srv"], | ||
| 99 | ); | ||
| 100 | // 4, not 1: losing a race is an answer a script can branch on. | ||
| 101 | assert_eq!( | ||
| 102 | theirs.status.code(), | ||
| 103 | Some(4), | ||
| 104 | "stdout {} stderr {}", | ||
| 105 | out(&theirs), | ||
| 106 | err(&theirs) | ||
| 107 | ); | ||
| 108 | assert!( | ||
| 109 | err(&theirs).contains("is claimed by"), | ||
| 110 | "stderr should name the holder: {}", | ||
| 111 | err(&theirs) | ||
| 112 | ); | ||
| 113 | } | ||
| 114 | |||
| 115 | #[test] | ||
| 116 | fn unclaim_frees_the_issue() { | ||
| 117 | let (harness, id) = setup("cli-lease-unclaim"); | ||
| 118 | let second = harness.second_authorized_key(); | ||
| 119 | |||
| 120 | issue_cmd(&harness, &["claim", &id, "--ttl", "300", "--remote", "srv"]); | ||
| 121 | let unclaim = issue_cmd(&harness, &["unclaim", &id, "--remote", "srv"]); | ||
| 122 | assert!( | ||
| 123 | unclaim.status.success(), | ||
| 124 | "{}{}", | ||
| 125 | out(&unclaim), | ||
| 126 | err(&unclaim) | ||
| 127 | ); | ||
| 128 | |||
| 129 | let theirs = issue_cmd_as( | ||
| 130 | &harness, | ||
| 131 | &second, | ||
| 132 | &["claim", &id, "--ttl", "300", "--remote", "srv"], | ||
| 133 | ); | ||
| 134 | assert!( | ||
| 135 | theirs.status.success(), | ||
| 136 | "a released issue must be claimable: {}{}", | ||
| 137 | out(&theirs), | ||
| 138 | err(&theirs) | ||
| 139 | ); | ||
| 140 | } | ||
| 141 | |||
| 142 | #[test] | ||
| 143 | fn renew_updates_the_expiry() { | ||
| 144 | let (harness, id) = setup("cli-lease-renew"); | ||
| 145 | |||
| 146 | issue_cmd(&harness, &["claim", &id, "--ttl", "60", "--remote", "srv"]); | ||
| 147 | let renew = issue_cmd(&harness, &["renew", &id, "--ttl", "600", "--remote", "srv"]); | ||
| 148 | assert!(renew.status.success(), "{}{}", out(&renew), err(&renew)); | ||
| 149 | assert!(out(&renew).contains("Renewed"), "{}", out(&renew)); | ||
| 150 | } | ||
| 151 | |||
| 152 | #[test] | ||
| 153 | fn renew_without_a_claim_exits_4() { | ||
| 154 | let (harness, id) = setup("cli-lease-renew-none"); | ||
| 155 | |||
| 156 | let renew = issue_cmd(&harness, &["renew", &id, "--ttl", "600", "--remote", "srv"]); | ||
| 157 | assert_eq!( | ||
| 158 | renew.status.code(), | ||
| 159 | Some(4), | ||
| 160 | "{}{}", | ||
| 161 | out(&renew), | ||
| 162 | err(&renew) | ||
| 163 | ); | ||
| 164 | assert!( | ||
| 165 | err(&renew).contains("claim it first"), | ||
| 166 | "stderr should say how to fix it: {}", | ||
| 167 | err(&renew) | ||
| 168 | ); | ||
| 169 | } | ||
| 170 | |||
| 171 | #[test] | ||
| 172 | fn claim_json_is_the_servers_own_reply() { | ||
| 173 | let (harness, id) = setup("cli-lease-json"); | ||
| 174 | |||
| 175 | let claim = issue_cmd(&harness, &["claim", &id, "--remote", "srv", "--json"]); | ||
| 176 | assert!(claim.status.success(), "{}{}", out(&claim), err(&claim)); | ||
| 177 | let json: serde_json::Value = serde_json::from_str(out(&claim).trim()).unwrap(); | ||
| 178 | assert_eq!(json["status"], "acquired"); | ||
| 179 | assert_eq!(json["issue"], id); | ||
| 180 | assert_eq!(json["token"], 1); | ||
| 181 | } | ||
| 182 | |||
| 183 | #[test] | ||
| 184 | fn claim_rejects_a_non_hex_id_locally() { | ||
| 185 | let (harness, _id) = setup("cli-lease-badid"); | ||
| 186 | |||
| 187 | // Client-side: a quote must never reach the single-quoted remote command. | ||
| 188 | let claim = issue_cmd(&harness, &["claim", "nope'; true", "--remote", "srv"]); | ||
| 189 | assert!(!claim.status.success()); | ||
| 190 | assert!( | ||
| 191 | err(&claim).contains("not an issue id"), | ||
| 192 | "stderr: {}", | ||
| 193 | err(&claim) | ||
| 194 | ); | ||
| 195 | } | ||
| 196 | |||
| 197 | #[test] | ||
| 198 | fn claim_against_a_non_ssh_remote_fails_clearly() { | ||
| 199 | let (harness, id) = setup("cli-lease-localremote"); | ||
| 200 | |||
| 201 | // `origin` is the local bare path the harness set up, not an SSH remote. | ||
| 202 | let claim = issue_cmd(&harness, &["claim", &id, "--remote", "origin"]); | ||
| 203 | assert!(!claim.status.success()); | ||
| 204 | assert!( | ||
| 205 | err(&claim).contains("not an SSH remote"), | ||
| 206 | "stderr: {}", | ||
| 207 | err(&claim) | ||
| 208 | ); | ||
| 209 | } | ||
tests/lease_server_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -42,7 +42,12 @@ fn acquire_open_issue_succeeds() { | |||
| 42 | "collab-lease acquire 'lease-acquire.git' '{}'", | 42 | "collab-lease acquire 'lease-acquire.git' '{}'", |
| 43 | id | 43 | id |
| 44 | )); | 44 | )); |
| 45 | assert_eq!(code(&out), 0, "stderr: {}", String::from_utf8_lossy(&out.stderr)); | 45 | assert_eq!( |
| 46 | code(&out), | ||
| 47 | 0, | ||
| 48 | "stderr: {}", | ||
| 49 | String::from_utf8_lossy(&out.stderr) | ||
| 50 | ); | ||
| 46 | let json = stdout_json(&out); | 51 | let json = stdout_json(&out); |
| 47 | assert_eq!(json["status"], "acquired"); | 52 | assert_eq!(json["status"], "acquired"); |
| 48 | assert_eq!(json["issue"], id); | 53 | assert_eq!(json["issue"], id); |
| @@ -90,7 +95,10 @@ fn acquire_conflict_reports_holder_with_exit_4() { | |||
| 90 | 95 | ||
| 91 | let mine = harness.ssh_exec_as( | 96 | let mine = harness.ssh_exec_as( |
| 92 | &first, | 97 | &first, |
| 93 | &format!("collab-lease acquire 'lease-conflict.git' '{}' --ttl 300", id), | 98 | &format!( |
| 99 | "collab-lease acquire 'lease-conflict.git' '{}' --ttl 300", | ||
| 100 | id | ||
| 101 | ), | ||
| 94 | b"", | 102 | b"", |
| 95 | ); | 103 | ); |
| 96 | assert_eq!(code(&mine), 0); | 104 | assert_eq!(code(&mine), 0); |
| @@ -98,7 +106,10 @@ fn acquire_conflict_reports_holder_with_exit_4() { | |||
| 98 | 106 | ||
| 99 | let theirs = harness.ssh_exec_as( | 107 | let theirs = harness.ssh_exec_as( |
| 100 | &second, | 108 | &second, |
| 101 | &format!("collab-lease acquire 'lease-conflict.git' '{}' --ttl 300", id), | 109 | &format!( |
| 110 | "collab-lease acquire 'lease-conflict.git' '{}' --ttl 300", | ||
| 111 | id | ||
| 112 | ), | ||
| 102 | b"", | 113 | b"", |
| 103 | ); | 114 | ); |
| 104 | assert_eq!( | 115 | assert_eq!( |
| @@ -162,7 +173,10 @@ fn release_frees_the_issue_and_bumps_the_next_tenure() { | |||
| 162 | 173 | ||
| 163 | harness.ssh_exec_as( | 174 | harness.ssh_exec_as( |
| 164 | &first, | 175 | &first, |
| 165 | &format!("collab-lease acquire 'lease-release.git' '{}' --ttl 300", id), | 176 | &format!( |
| 177 | "collab-lease acquire 'lease-release.git' '{}' --ttl 300", | ||
| 178 | id | ||
| 179 | ), | ||
| 166 | b"", | 180 | b"", |
| 167 | ); | 181 | ); |
| 168 | 182 | ||
| @@ -184,7 +198,10 @@ fn release_frees_the_issue_and_bumps_the_next_tenure() { | |||
| 184 | 198 | ||
| 185 | let next = harness.ssh_exec_as( | 199 | let next = harness.ssh_exec_as( |
| 186 | &second, | 200 | &second, |
| 187 | &format!("collab-lease acquire 'lease-release.git' '{}' --ttl 300", id), | 201 | &format!( |
| 202 | "collab-lease acquire 'lease-release.git' '{}' --ttl 300", | ||
| 203 | id | ||
| 204 | ), | ||
| 188 | b"", | 205 | b"", |
| 189 | ); | 206 | ); |
| 190 | assert_eq!(code(&next), 0); | 207 | assert_eq!(code(&next), 0); |
| @@ -260,19 +277,14 @@ fn acquire_closed_issue_is_refused() { | |||
| 260 | fn unknown_repo_and_unauthorized_repo_answer_identically() { | 277 | fn unknown_repo_and_unauthorized_repo_answer_identically() { |
| 261 | let (harness, id) = harness_with_issue("lease-policy"); | 278 | let (harness, id) = harness_with_issue("lease-policy"); |
| 262 | 279 | ||
| 263 | let unknown = harness.ssh_exec(&format!( | 280 | let unknown = harness.ssh_exec(&format!("collab-lease acquire 'no-such-repo.git' '{}'", id)); |
| 264 | "collab-lease acquire 'no-such-repo.git' '{}'", | ||
| 265 | id | ||
| 266 | )); | ||
| 267 | 281 | ||
| 268 | // Readable but not writable: acquiring is a write. | 282 | // Readable but not writable: acquiring is a write. |
| 269 | harness.write_repo_server_policy( | 283 | harness.write_repo_server_policy( |
| 270 | "visibility = \"public\"\n[access]\nread = [\"*\"]\nwrite = []\n", | 284 | "visibility = \"public\"\n[access]\nread = [\"*\"]\nwrite = []\n", |
| 271 | ); | 285 | ); |
| 272 | let unauthorized = harness.ssh_exec(&format!( | 286 | let unauthorized = |
| 273 | "collab-lease acquire 'lease-policy.git' '{}'", | 287 | harness.ssh_exec(&format!("collab-lease acquire 'lease-policy.git' '{}'", id)); |
| 274 | id | ||
| 275 | )); | ||
| 276 | 288 | ||
| 277 | assert_eq!(code(&unknown), 1); | 289 | assert_eq!(code(&unknown), 1); |
| 278 | assert_eq!(code(&unauthorized), 1); | 290 | assert_eq!(code(&unauthorized), 1); |