a73x

d413baa0

Add issue claim, unclaim, renew and claims CLI commands

a73x   2026-09-06 08:06

Commit message
Add issue claim, unclaim, renew and claims CLI commands

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);