Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
1,423 linesCodeBlameRaw
1//! The g1t binary against a mock API: what it asks for, what it prints, and
2//! the exit codes it gives.
3
4use serde_json::{Value, json};
5use std::io::{BufRead, BufReader, Read, Write};
6use std::net::TcpListener;
7use std::path::PathBuf;
8use std::process::{Command, Output, Stdio};
9use std::sync::{Arc, Mutex};
10
11#[derive(Clone, Debug)]
12struct Request {
13 method: String,
14 /// The path and query, as sent.
15 target: String,
16 headers: Vec<(String, String)>,
17 body: String,
18}
19
20impl Request {
21 fn path(&self) -> &str {
22 self.target.split('?').next().unwrap_or("")
23 }
24 fn json(&self) -> Value {
25 serde_json::from_str(&self.body).unwrap_or(Value::Null)
26 }
27 fn header(&self, name: &str) -> Option<&str> {
28 self.headers
29 .iter()
30 .find(|(k, _)| k.eq_ignore_ascii_case(name))
31 .map(|(_, v)| v.as_str())
32 }
33}
34
35type Handler = dyn Fn(&Request) -> (u16, Value) + Send + Sync;
36
37struct Mock {
38 url: String,
39 seen: Arc<Mutex<Vec<Request>>>,
40}
41
42impl Mock {
43 fn start(handler: impl Fn(&Request) -> (u16, Value) + Send + Sync + 'static) -> Mock {
44 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
45 let url = format!("http://{}", listener.local_addr().unwrap());
46 let seen = Arc::new(Mutex::new(Vec::new()));
47 let handler: Arc<Handler> = Arc::new(handler);
48 let log = seen.clone();
49 std::thread::spawn(move || {
50 for stream in listener.incoming() {
51 let Ok(mut stream) = stream else { continue };
52 let mut reader = BufReader::new(stream.try_clone().unwrap());
53 let mut line = String::new();
54 if reader.read_line(&mut line).unwrap_or(0) == 0 {
55 continue;
56 }
57 let mut parts = line.split_whitespace();
58 let method = parts.next().unwrap_or("").to_owned();
59 let target = parts.next().unwrap_or("").to_owned();
60 let mut headers = Vec::new();
61 loop {
62 let mut h = String::new();
63 reader.read_line(&mut h).unwrap();
64 let h = h.trim_end().to_owned();
65 if h.is_empty() {
66 break;
67 }
68 if let Some((k, v)) = h.split_once(':') {
69 headers.push((k.trim().to_owned(), v.trim().to_owned()));
70 }
71 }
72 let length: usize = headers
73 .iter()
74 .find(|(k, _)| k.eq_ignore_ascii_case("content-length"))
75 .and_then(|(_, v)| v.parse().ok())
76 .unwrap_or(0);
77 let mut body = vec![0; length];
78 reader.read_exact(&mut body).unwrap();
79 let request = Request {
80 method,
81 target,
82 headers,
83 body: String::from_utf8_lossy(&body).into_owned(),
84 };
85 let (status, reply) = handler(&request);
86 log.lock().unwrap().push(request);
87 let text = serde_json::to_string(&reply).unwrap();
88 let _ = write!(
89 stream,
90 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{text}",
91 text.len()
92 );
93 }
94 });
95 Mock { url, seen }
96 }
97
98 fn requests(&self) -> Vec<Request> {
99 self.seen.lock().unwrap().clone()
100 }
101}
102
103fn temp_dir(name: &str) -> PathBuf {
104 let nanos = std::time::SystemTime::now()
105 .duration_since(std::time::UNIX_EPOCH)
106 .unwrap()
107 .as_nanos();
108 let dir = std::env::temp_dir().join(format!("g1t-test-{name}-{}-{nanos}", std::process::id()));
109 std::fs::create_dir_all(&dir).unwrap();
110 dir
111}
112
113struct Run {
114 code: i32,
115 stdout: String,
116 stderr: String,
117}
118
119fn g1t(
120 mock: &Mock,
121 config: &PathBuf,
122 args: &[&str],
123 stdin: Option<&str>,
124 token: Option<&str>,
125) -> Run {
126 let mut command = Command::new(env!("CARGO_BIN_EXE_g1t"));
127 command
128 .args(args)
129 .current_dir(config)
130 .env("G1T_API_URL", &mock.url)
131 .env("G1T_CONFIG_DIR", config)
132 .env("G1T_KEYRING", "off")
133 .env("G1T_PROMPT_DISABLED", "1")
134 .env("NO_COLOR", "1")
135 .env("GIT_CEILING_DIRECTORIES", std::env::temp_dir())
136 .env_remove("G1T_REPO")
137 .env_remove("G1T_HOST")
138 .env_remove("G1T_TOKEN")
139 .stdin(Stdio::piped())
140 .stdout(Stdio::piped())
141 .stderr(Stdio::piped());
142 if let Some(t) = token {
143 command.env("G1T_TOKEN", t);
144 }
145 let mut child = command.spawn().unwrap();
146 {
147 let mut input = child.stdin.take().unwrap();
148 if let Some(text) = stdin {
149 input.write_all(text.as_bytes()).unwrap();
150 }
151 }
152 let Output {
153 status,
154 stdout,
155 stderr,
156 } = child.wait_with_output().unwrap();
157 Run {
158 code: status.code().unwrap_or(-1),
159 stdout: String::from_utf8_lossy(&stdout).into_owned(),
160 stderr: String::from_utf8_lossy(&stderr).into_owned(),
161 }
162}
163
164fn run(mock: &Mock, args: &[&str]) -> Run {
165 let dir = temp_dir("run");
166 g1t(mock, &dir, args, None, Some("g1t_test"))
167}
168
169fn issue(number: u64, title: &str, labels: &[&str]) -> Value {
170 json!({
171 "number": number, "title": title, "state": "open", "labels": labels, "body": "b",
172 "author": { "username": "ana" }, "assignees": [], "updated_at": "2026-10-01T18:04:11.482Z",
173 })
174}
175
176#[test]
177fn issue_list_asks_for_open_issues_and_prints_json_fields() {
178 let mock = Mock::start(|_| {
179 (
180 200,
181 json!([issue(12, "Greeting", &["feature"]), issue(11, "Docs", &[])]),
182 )
183 });
184 let out = run(
185 &mock,
186 &["issue", "list", "-R", "acme/web", "--json", "number,title"],
187 );
188 assert_eq!(out.code, 0, "{}", out.stderr);
189 assert_eq!(
190 out.stdout,
191 "[{\"number\":12,\"title\":\"Greeting\"},{\"number\":11,\"title\":\"Docs\"}]\n"
192 );
193 let seen = mock.requests();
194 assert_eq!(seen[0].method, "GET");
195 assert_eq!(seen[0].target, "/repos/acme/web/issues?state=open");
196 assert_eq!(seen[0].header("authorization"), Some("Bearer g1t_test"));
197 assert!(
198 seen[0]
199 .header("user-agent")
200 .unwrap()
201 .starts_with("g1t-cli/")
202 );
203}
204
205#[test]
206fn piped_lists_are_tab_separated_without_a_header() {
207 let mock = Mock::start(|_| (200, json!([issue(12, "Greeting", &["feature", "p1"])])));
208 let out = run(&mock, &["issue", "list", "-R", "acme/web", "--limit", "5"]);
209 assert_eq!(out.code, 0, "{}", out.stderr);
210 let line = out.stdout.lines().next().unwrap();
211 let cells: Vec<&str> = line.split('\t').collect();
212 assert_eq!(&cells[..3], &["#12", "Greeting", "feature, p1"]);
213 assert_eq!(out.stdout.lines().count(), 1);
214}
215
216#[test]
217fn limits_keep_lists_short() {
218 let many: Vec<Value> = (1..=50).map(|n| issue(n, "x", &[])).collect();
219 let mock = Mock::start(move |_| (200, Value::Array(many.clone())));
220 let out = run(
221 &mock,
222 &["issue", "list", "-R", "acme/web", "--json", "number"],
223 );
224 let list: Vec<Value> = serde_json::from_str(&out.stdout).unwrap();
225 assert_eq!(list.len(), 20, "the default page is small");
226}
227
228#[test]
229fn jq_selects_from_the_json() {
230 let mock = Mock::start(|_| {
231 (
232 200,
233 json!([issue(12, "Greeting", &[]), issue(11, "Docs", &[])]),
234 )
235 });
236 let out = run(
237 &mock,
238 &["issue", "list", "-R", "acme/web", "--jq", ".[].title"],
239 );
240 assert_eq!(out.stdout, "Greeting\nDocs\n");
241}
242
243#[test]
244fn an_unknown_json_field_is_a_usage_error() {
245 let mock = Mock::start(|_| (200, json!([issue(12, "Greeting", &[])])));
246 let out = run(
247 &mock,
248 &["issue", "list", "-R", "acme/web", "--json", "nope"],
249 );
250 assert_eq!(out.code, 2);
251 assert_eq!(out.stderr.lines().count(), 1, "{}", out.stderr);
252 assert!(out.stderr.contains("Available:"), "{}", out.stderr);
253}
254
255#[test]
256fn issue_create_sends_the_title_and_body() {
257 let mock = Mock::start(|r| match (r.method.as_str(), r.path()) {
258 ("POST", "/repos/acme/web/issues") => (200, issue(13, "Broken link", &["docs"])),
259 _ => (
260 404,
261 json!({ "error": { "code": "not_found", "message": "No." } }),
262 ),
263 });
264 let out = run(
265 &mock,
266 &[
267 "issue",
268 "create",
269 "-R",
270 "acme/web",
271 "-t",
272 "Broken link",
273 "-b",
274 "On the home page.",
275 "-l",
276 "docs",
277 ],
278 );
279 assert_eq!(out.code, 0, "{}", out.stderr);
280 assert_eq!(out.stdout.trim(), "https://g1t.sh/acme/web/issues/13");
281 let sent = mock.requests()[0].json();
282 assert_eq!(
283 sent,
284 json!({ "title": "Broken link", "body": "On the home page.", "labels": ["docs"] })
285 );
286}
287
288#[test]
289fn a_missing_title_without_a_terminal_names_the_flag() {
290 let mock = Mock::start(|_| (200, json!({})));
291 let out = run(&mock, &["issue", "create", "-R", "acme/web"]);
292 assert_eq!(out.code, 2);
293 assert!(out.stderr.contains("--title"), "{}", out.stderr);
294 assert!(mock.requests().is_empty());
295}
296
297#[test]
298fn a_refused_token_exits_4_with_one_line() {
299 let mock = Mock::start(|_| {
300 (
301 401,
302 json!({ "error": { "code": "unauthenticated", "message": "The token is not valid." } }),
303 )
304 });
305 let out = run(&mock, &["issue", "view", "12", "-R", "acme/web"]);
306 assert_eq!(out.code, 4);
307 assert_eq!(out.stderr.lines().count(), 1, "{}", out.stderr);
308 assert!(
309 out.stderr.starts_with("g1t: The token is not valid."),
310 "{}",
311 out.stderr
312 );
313}
314
315#[test]
316fn a_missing_scope_is_named() {
317 let mock = Mock::start(|_| {
318 (
319 403,
320 json!({ "error": { "code": "forbidden", "message": "Not allowed.", "needed_scope": "issues:write" } }),
321 )
322 });
323 let out = run(&mock, &["issue", "close", "12", "-R", "acme/web"]);
324 assert_eq!(out.code, 1);
325 assert!(out.stderr.contains("`issues:write`"), "{}", out.stderr);
326}
327
328#[test]
329fn usage_mistakes_exit_2_on_one_line() {
330 let mock = Mock::start(|_| (200, json!({})));
331 let out = run(&mock, &["issue", "list", "--state", "sideways"]);
332 assert_eq!(out.code, 2);
333 assert_eq!(out.stderr.lines().count(), 1, "{}", out.stderr);
334 let out = run(&mock, &["frobnicate"]);
335 assert_eq!(out.code, 2);
336}
337
338fn pull(check_status: &str) -> Value {
339 json!({ "pull": { "number": 14, "title": "Greet", "status": "open", "head_commit": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13", "check_status": check_status, "branch": "greet", "base": "main" }, "comments": [] })
340}
341
342fn checks_mock(runs: Value) -> Mock {
343 Mock::start(move |r| match r.path() {
344 "/repos/acme/web/pulls/14" => (200, pull("pending")),
345 p if p.ends_with("/check-runs") => (200, runs.clone()),
346 p if p.ends_with("/status") => (200, json!({ "state": "success", "statuses": [] })),
347 _ => (404, json!({ "error": { "message": "No such endpoint." } })),
348 })
349}
350
351#[test]
352fn pr_checks_exit_codes_say_how_they_went() {
353 let passing = json!({ "check_runs": [{ "name": "test", "status": "completed", "conclusion": "success" }] });
354 let failing = json!({ "check_runs": [{ "name": "test", "status": "completed", "conclusion": "failure" }] });
355 let pending =
356 json!({ "check_runs": [{ "name": "test", "status": "in_progress", "conclusion": null }] });
357 assert_eq!(
358 run(
359 &checks_mock(passing),
360 &["pr", "checks", "14", "-R", "acme/web"]
361 )
362 .code,
363 0
364 );
365 assert_eq!(
366 run(
367 &checks_mock(failing),
368 &["pr", "checks", "14", "-R", "acme/web"]
369 )
370 .code,
371 1
372 );
373 let mock = checks_mock(pending);
374 let out = run(
375 &mock,
376 &[
377 "pr",
378 "checks",
379 "14",
380 "-R",
381 "acme/web",
382 "--json",
383 "name,bucket",
384 ],
385 );
386 assert_eq!(out.code, 8);
387 assert_eq!(out.stdout, "[{\"bucket\":\"pending\",\"name\":\"test\"}]\n");
388 let refs: Vec<String> = mock.requests().iter().map(|r| r.target.clone()).collect();
389 assert!(
390 refs.iter().any(|t| t.starts_with(
391 "/repos/acme/web/commits/9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13/check-runs"
392 )),
393 "{refs:?}"
394 );
395}
396
397#[test]
398fn pr_checks_watch_fail_fast_stops_at_the_first_failure() {
399 let mixed = json!({ "check_runs": [
400 { "name": "lint", "status": "completed", "conclusion": "failure", "html_url": "https://g1t.sh/x" },
401 { "name": "test", "status": "in_progress", "conclusion": null },
402 ]});
403 let mock = checks_mock(mixed);
404 let started = std::time::Instant::now();
405 let out = run(
406 &mock,
407 &[
408 "pr",
409 "checks",
410 "14",
411 "-R",
412 "acme/web",
413 "--watch",
414 "--fail-fast",
415 "--interval",
416 "1",
417 ],
418 );
419 assert_eq!(out.code, 1, "{}", out.stderr);
420 assert!(started.elapsed() < std::time::Duration::from_secs(10));
421 assert!(out.stdout.contains("lint\tfailure"), "{}", out.stdout);
422}
423
424#[test]
425fn run_logs_failed_prints_only_the_failing_steps_tail() {
426 let mock = Mock::start(|r| match r.path() {
427 "/repos/acme/web/actions/runs/run_1" => (
428 200,
429 json!({ "run": { "id": "run_1", "name": "CI", "number": 3, "status": "completed", "conclusion": "failure" }, "jobs": [
430 { "id": "job_1", "name": "test", "status": "completed", "conclusion": "failure", "started_at": "2026-10-01T18:00:00Z",
431 "steps": [
432 { "number": 1, "name": "checkout", "status": "completed", "conclusion": "success" },
433 { "number": 2, "name": "cargo test", "status": "completed", "conclusion": "failure" }
434 ], "annotations": [] },
435 { "id": "job_2", "name": "lint", "status": "completed", "conclusion": "success", "started_at": "2026-10-01T18:00:00Z", "steps": [] }
436 ]}),
437 ),
438 "/repos/acme/web/actions/jobs/job_1/logs" => (
439 200,
440 json!({ "chunks": [
441 { "seq": 1, "step": 1, "text": "cloned\n" },
442 { "seq": 2, "step": 2, "text": "running 3 tests\nline a\nline b\ntest greet ... FAILED\n" }
443 ], "done": true }),
444 ),
445 _ => (404, json!({ "error": { "message": "No such endpoint." } })),
446 });
447 let out = run(
448 &mock,
449 &[
450 "run", "logs", "run_1", "--failed", "-n", "2", "-R", "acme/web",
451 ],
452 );
453 assert_eq!(out.code, 0, "{}", out.stderr);
454 assert!(out.stdout.contains("test › 2 cargo test"), "{}", out.stdout);
455 assert!(
456 out.stdout.contains("line b\ntest greet ... FAILED"),
457 "{}",
458 out.stdout
459 );
460 assert!(!out.stdout.contains("cloned"), "{}", out.stdout);
461 assert!(!out.stdout.contains("line a"), "{}", out.stdout);
462 // The passing job's log is never fetched.
463 assert!(mock.requests().iter().all(|r| !r.path().contains("job_2")));
464}
465
466#[test]
467fn api_calls_operations_by_name() {
468 let mock = Mock::start(|_| (200, json!([issue(12, "Greeting", &[])])));
469 let out = run(
470 &mock,
471 &[
472 "api",
473 "list_issues",
474 "-f",
475 "state=closed",
476 "-R",
477 "acme/web",
478 "--jq",
479 ".[0].title",
480 ],
481 );
482 assert_eq!(out.code, 0, "{}", out.stderr);
483 assert_eq!(out.stdout, "Greeting\n");
484 assert_eq!(
485 mock.requests()[0].target,
486 "/repos/acme/web/issues?state=closed"
487 );
488}
489
490#[test]
491fn api_posts_typed_fields() {
492 let mock = Mock::start(|_| (200, json!({ "number": 13 })));
493 let out = run(
494 &mock,
495 &[
496 "api",
497 "create_issue",
498 "-R",
499 "acme/web",
500 "-f",
501 "title=Hi",
502 "-f",
503 "labels[]=docs",
504 "-F",
505 "milestone=3",
506 ],
507 );
508 assert_eq!(out.code, 0, "{}", out.stderr);
509 let r = &mock.requests()[0];
510 assert_eq!(
511 (r.method.as_str(), r.path()),
512 ("POST", "/repos/acme/web/issues")
513 );
514 assert_eq!(
515 r.json(),
516 json!({ "title": "Hi", "labels": ["docs"], "milestone": 3 })
517 );
518}
519
520#[test]
521fn api_paginates_by_cursor_and_slurps() {
522 let mock = Mock::start(|r| {
523 if r.target.contains("cursor=c2") {
524 (200, json!({ "items": [{ "id": 3 }], "next": null }))
525 } else {
526 (
527 200,
528 json!({ "items": [{ "id": 1 }, { "id": 2 }], "next": "c2" }),
529 )
530 }
531 });
532 let out = run(
533 &mock,
534 &[
535 "api",
536 "notifications",
537 "--paginate",
538 "--slurp",
539 "--jq",
540 "map(.id)",
541 ],
542 );
543 assert_eq!(out.code, 0, "{}", out.stderr);
544 assert_eq!(out.stdout.trim(), "[1,2,3]");
545 assert_eq!(mock.requests().len(), 2);
546}
547
548#[test]
549fn api_errors_print_the_body_and_exit_1() {
550 let mock = Mock::start(|_| {
551 (
552 404,
553 json!({ "error": { "code": "not_found", "message": "No such endpoint." } }),
554 )
555 });
556 let out = run(&mock, &["api", "/nowhere"]);
557 assert_eq!(out.code, 1);
558 assert!(out.stdout.contains("not_found"));
559 assert_eq!(out.stderr.trim(), "g1t: No such endpoint.");
560}
561
562#[test]
563fn signing_in_with_a_token_keeps_it_for_status_token_and_git() {
564 let mock = Mock::start(|r| match r.header("authorization") {
565 Some("Bearer g1t_good") => (
566 200,
567 json!({ "username": "ana", "token": { "scopes": ["repo:read"] } }),
568 ),
569 _ => (
570 401,
571 json!({ "error": { "message": "The token is not valid." } }),
572 ),
573 });
574 let dir = temp_dir("auth");
575 let out = g1t(
576 &mock,
577 &dir,
578 &["auth", "login", "--with-token"],
579 Some("g1t_good\n"),
580 None,
581 );
582 assert_eq!(out.code, 0, "{}", out.stderr);
583 assert!(
584 out.stderr.contains("Signed in to g1t.sh as ana"),
585 "{}",
586 out.stderr
587 );
588 let config: Value =
589 serde_json::from_str(&std::fs::read_to_string(dir.join("config.json")).unwrap()).unwrap();
590 assert_eq!(config["hosts"]["g1t.sh"]["user"], "ana");
591
592 let out = g1t(&mock, &dir, &["auth", "token"], None, None);
593 assert_eq!(out.stdout, "g1t_good\n");
594
595 let out = g1t(
596 &mock,
597 &dir,
598 &["auth", "status", "--json", "host,user,valid,active"],
599 None,
600 None,
601 );
602 assert_eq!(out.code, 0, "{}", out.stderr);
603 assert_eq!(
604 out.stdout.trim(),
605 r#"[{"active":true,"host":"g1t.sh","user":"ana","valid":true}]"#
606 );
607
608 let out = g1t(
609 &mock,
610 &dir,
611 &["auth", "git-credential", "get"],
612 Some("protocol=https\nhost=g1t.sh\n\n"),
613 None,
614 );
615 assert_eq!(
616 out.stdout,
617 "protocol=https\nhost=g1t.sh\nusername=ana\npassword=g1t_good\n"
618 );
619 let out = g1t(
620 &mock,
621 &dir,
622 &["auth", "git-credential", "get"],
623 Some("protocol=https\nhost=example.com\n\n"),
624 None,
625 );
626 assert_eq!(out.stdout, "");
627
628 let out = g1t(&mock, &dir, &["auth", "logout"], None, None);
629 assert_eq!(out.code, 0, "{}", out.stderr);
630 let out = g1t(&mock, &dir, &["auth", "status"], None, None);
631 assert_eq!(out.code, 4);
632}
633
634#[test]
635fn a_bad_token_is_not_kept() {
636 let mock = Mock::start(|_| {
637 (
638 401,
639 json!({ "error": { "message": "The token is not valid." } }),
640 )
641 });
642 let dir = temp_dir("bad");
643 let out = g1t(
644 &mock,
645 &dir,
646 &["auth", "login", "--with-token"],
647 Some("nope"),
648 None,
649 );
650 assert_eq!(out.code, 4);
651 assert!(!dir.join("config.json").exists());
652}
653
654#[test]
655fn secrets_are_read_from_stdin() {
656 let mock = Mock::start(|_| (200, json!({ "name": "NPM_TOKEN", "kind": "secret" })));
657 let dir = temp_dir("secret");
658 let out = g1t(
659 &mock,
660 &dir,
661 &[
662 "secret",
663 "set",
664 "npm_token",
665 "-R",
666 "acme/web",
667 "--env",
668 "production",
669 ],
670 Some("s3cret\n"),
671 Some("g1t_test"),
672 );
673 assert_eq!(out.code, 0, "{}", out.stderr);
674 let r = &mock.requests()[0];
675 assert_eq!(
676 (r.method.as_str(), r.path()),
677 ("PUT", "/repos/acme/web/actions/secrets/NPM_TOKEN")
678 );
679 assert_eq!(
680 r.json(),
681 json!({ "value": "s3cret", "environments": ["production"] })
682 );
683 assert!(!out.stdout.contains("s3cret") && !out.stderr.contains("s3cret"));
684}
685
686#[test]
687fn aliases_expand_before_the_command_runs() {
688 let mock = Mock::start(|_| (200, json!([issue(12, "Greeting", &["bug"])])));
689 let dir = temp_dir("alias");
690 let out = g1t(
691 &mock,
692 &dir,
693 &["alias", "set", "bugs", "issue list --label bug"],
694 None,
695 None,
696 );
697 assert_eq!(out.code, 0, "{}", out.stderr);
698 let out = g1t(
699 &mock,
700 &dir,
701 &["bugs", "-R", "acme/web", "--json", "number"],
702 None,
703 Some("g1t_test"),
704 );
705 assert_eq!(out.code, 0, "{}", out.stderr);
706 assert_eq!(out.stdout.trim(), r#"[{"number":12}]"#);
707 assert_eq!(
708 mock.requests()[0].target,
709 "/repos/acme/web/issues?state=open&label=bug"
710 );
711 let out = g1t(
712 &mock,
713 &dir,
714 &["alias", "set", "pr", "issue list"],
715 None,
716 None,
717 );
718 assert_eq!(out.code, 2, "a command's name cannot be an alias");
719}
720
721#[test]
722fn pr_merge_and_review_send_what_was_asked() {
723 let mock = Mock::start(|r| match (r.method.as_str(), r.path()) {
724 ("POST", "/repos/acme/web/pulls/14/merge") => {
725 (200, json!({ "number": 14, "status": "merged" }))
726 }
727 ("POST", "/repos/acme/web/pulls/14/reviews") => {
728 (200, json!({ "id": "cmt_1", "verdict": "approve" }))
729 }
730 _ => (404, json!({ "error": { "message": "No such endpoint." } })),
731 });
732 let out = run(
733 &mock,
734 &[
735 "pr",
736 "review",
737 "14",
738 "--approve",
739 "-b",
740 "Looks right.",
741 "-R",
742 "acme/web",
743 ],
744 );
745 assert_eq!(out.code, 0, "{}", out.stderr);
746 let out = run(
747 &mock,
748 &["pr", "merge", "14", "--keep-issue-open", "-R", "acme/web"],
749 );
750 assert_eq!(out.code, 0, "{}", out.stderr);
751 assert_eq!(out.stdout.trim(), "https://g1t.sh/acme/web/pull/14");
752 let seen = mock.requests();
753 assert_eq!(
754 seen[0].json(),
755 json!({ "verdict": "approve", "body": "Looks right." })
756 );
757 assert_eq!(seen[1].json(), json!({ "keep_issue_open": true }));
758}
759
760#[test]
761fn status_gathers_what_needs_you() {
762 let mock = Mock::start(|r| match r.path() {
763 "/user" => (200, json!({ "username": "ana" })),
764 "/search" => (
765 200,
766 json!({ "hits": [{ "repo": "acme/web", "number": 14, "title": "Greet" }] }),
767 ),
768 "/repos/acme/web/pulls/14" => (200, pull("failed")),
769 "/notifications" if r.target.contains("review_requested") => (
770 200,
771 json!({ "items": [{ "repo": "acme/api", "number": 3, "title": "ada asked you to review acme/api#3" }] }),
772 ),
773 "/notifications" => (200, json!({ "items": [] })),
774 _ => (404, json!({ "error": { "message": "No such endpoint." } })),
775 });
776 let out = run(
777 &mock,
778 &["status", "--json", "pulls,review_requests,failing_checks"],
779 );
780 assert_eq!(out.code, 0, "{}", out.stderr);
781 let v: Value = serde_json::from_str(&out.stdout).unwrap();
782 assert_eq!(v["pulls"][0]["number"], 14);
783 assert_eq!(v["review_requests"][0]["number"], 3);
784 assert_eq!(v["failing_checks"][0]["check_status"], "failed");
785 let search = mock
786 .requests()
787 .into_iter()
788 .find(|r| r.path() == "/search")
789 .unwrap();
790 assert!(search.target.contains("author%3Aana"), "{}", search.target);
791}
792
793#[test]
794fn help_and_version_succeed() {
795 let mock = Mock::start(|_| (200, json!({})));
796 let out = run(&mock, &["--version"]);
797 assert_eq!(out.code, 0);
798 assert!(out.stdout.starts_with("g1t "));
799 let out = run(&mock, &["pr", "--help"]);
800 assert_eq!(out.code, 0);
801 assert!(out.stdout.contains("checks"));
802 let out = run(&mock, &["completion", "bash"]);
803 assert_eq!(out.code, 0);
804 assert!(out.stdout.contains("_g1t"));
805}
806
807#[test]
808fn login_approves_a_code_in_the_browser() {
809 let polls = Arc::new(Mutex::new(0));
810 let counter = polls.clone();
811 let mock = Mock::start(move |r| match r.path() {
812 "/device/code" => (
813 200,
814 json!({ "device_code": "dev_1", "user_code": "WDJB-MJHT", "verification_uri": "https://g1t.sh/device",
815 "verification_uri_complete": "https://g1t.sh/device?code=WDJB-MJHT", "expires_in": 60, "interval": 1 }),
816 ),
817 "/device/token" => {
818 let mut n = counter.lock().unwrap();
819 *n += 1;
820 if *n < 2 {
821 (200, json!({ "status": "pending" }))
822 } else {
823 (
824 200,
825 json!({ "status": "approved", "token": "g1t_device", "username": "ana" }),
826 )
827 }
828 }
829 _ => (404, json!({ "error": { "message": "No such endpoint." } })),
830 });
831 let dir = temp_dir("device");
832 let out = g1t(&mock, &dir, &["auth", "login", "--no-browser"], None, None);
833 assert_eq!(out.code, 0, "{}", out.stderr);
834 assert!(out.stderr.contains("WDJB-MJHT"), "{}", out.stderr);
835 assert!(
836 out.stderr.contains("https://g1t.sh/device?code=WDJB-MJHT"),
837 "{}",
838 out.stderr
839 );
840 let seen = mock.requests();
841 assert!(
842 seen[0].json()["client_name"]
843 .as_str()
844 .unwrap()
845 .starts_with("g1t CLI on ")
846 );
847 assert!(
848 seen.iter()
849 .filter(|r| r.path() == "/device/token")
850 .all(|r| r.json() == json!({ "device_code": "dev_1" }))
851 );
852 let out = g1t(&mock, &dir, &["auth", "token"], None, None);
853 assert_eq!(out.stdout, "g1t_device\n");
854}
855
856#[test]
857fn the_repository_comes_from_the_git_remote() {
858 let mock = Mock::start(|_| (200, json!([])));
859 let dir = temp_dir("remote");
860 let git = |args: &[&str]| {
861 let ok = Command::new("git")
862 .args(args)
863 .current_dir(&dir)
864 .output()
865 .unwrap()
866 .status
867 .success();
868 assert!(ok, "git {args:?}");
869 };
870 git(&["init", "--quiet"]);
871 git(&["remote", "add", "mine", "https://g1t.sh/ana/web.git"]);
872 git(&["remote", "add", "origin", "git@g1t.sh:acme/web.git"]);
873 git(&[
874 "remote",
875 "add",
876 "elsewhere",
877 "https://example.com/acme/other.git",
878 ]);
879 let out = g1t(
880 &mock,
881 &dir,
882 &["issue", "list", "--json"],
883 None,
884 Some("g1t_test"),
885 );
886 assert_eq!(out.code, 0, "{}", out.stderr);
887 assert_eq!(out.stdout, "[]\n");
888 assert_eq!(mock.requests()[0].path(), "/repos/acme/web/issues");
889 // Outside any repository, and without --repo, it says what to do.
890 let bare = temp_dir("bare");
891 let out = g1t(&mock, &bare, &["issue", "list"], None, Some("g1t_test"));
892 assert_eq!(out.code, 2);
893 assert!(out.stderr.contains("--repo"), "{}", out.stderr);
894}
895
896/// Runs g1t in `dir` with a token and the environment `env` added.
897fn g1t_with(mock: &Mock, dir: &PathBuf, args: &[&str], env: &[(&str, &str)]) -> Run {
898 let mut command = Command::new(env!("CARGO_BIN_EXE_g1t"));
899 command
900 .args(args)
901 .current_dir(dir)
902 .env("G1T_API_URL", &mock.url)
903 .env("G1T_CONFIG_DIR", dir)
904 .env("G1T_KEYRING", "off")
905 .env("G1T_PROMPT_DISABLED", "1")
906 .env("NO_COLOR", "1")
907 .env("GIT_CEILING_DIRECTORIES", std::env::temp_dir())
908 .env("G1T_TOKEN", "g1t_test")
909 .env_remove("G1T_REPO")
910 .env_remove("G1T_HOST")
911 .env_remove("G1T_SESSION")
912 .stdin(Stdio::null());
913 for (k, v) in env {
914 command.env(k, v);
915 }
916 let Output { status, stdout, stderr } = command.output().unwrap();
917 Run {
918 code: status.code().unwrap_or(-1),
919 stdout: String::from_utf8_lossy(&stdout).into_owned(),
920 stderr: String::from_utf8_lossy(&stderr).into_owned(),
921 }
922}
923
924/// A working copy of acme/web.
925fn working_copy(name: &str) -> PathBuf {
926 let dir = temp_dir(name);
927 for args in [
928 &["init", "--quiet"][..],
929 &["remote", "add", "origin", "https://g1t.sh/acme/web.git"][..],
930 ] {
931 assert!(Command::new("git").args(args).current_dir(&dir).status().unwrap().success());
932 }
933 dir
934}
935
936fn session(id: &str) -> Value {
937 json!({ "id": id, "state": "active", "harness": "claude_code", "issue": 12, "actor": "ana" })
938}
939
940#[test]
941fn a_session_is_remembered_per_working_copy_and_claims_carry_it() {
942 let mock = Mock::start(|r| match (r.method.as_str(), r.path()) {
943 ("POST", "/repos/acme/web/sessions") => (200, session("ses_1")),
944 ("POST", "/repos/acme/web/sessions/ses_1/entries") => (200, json!({ "recorded": 1 })),
945 ("POST", "/repos/acme/web/sessions/ses_2/entries") => (200, json!({ "recorded": 1 })),
946 ("POST", "/repos/acme/web/issues/12/claim") => (
947 200,
948 json!({ "claim": { "id": "clm_1", "issue": 12, "actor": "ana" }, "others": [], "blocked_by": [] }),
949 ),
950 ("POST", "/repos/acme/web/sessions/ses_1/end") => (200, session("ses_1")),
951 _ => (404, json!({ "error": { "message": "No such endpoint." } })),
952 });
953 let dir = working_copy("session");
954 let out = g1t_with(
955 &mock,
956 &dir,
957 &["session", "start", "--harness", "claude_code", "--harness-session-id", "cc-9", "--issue", "12", "--json"],
958 &[],
959 );
960 assert_eq!(out.code, 0, "{}", out.stderr);
961 let started: Value = serde_json::from_str(&out.stdout).unwrap();
962 assert_eq!(started["id"], "ses_1");
963 let sent = mock.requests()[0].json();
964 assert_eq!(sent["harness"], "claude_code");
965 assert_eq!(sent["harness_session_id"], "cc-9");
966 assert_eq!(sent["issue"], 12);
967 let kept = std::fs::read_to_string(dir.join(".git/g1t/session")).unwrap();
968 assert_eq!(kept.trim(), "ses_1");
969
970 let out = g1t_with(
971 &mock,
972 &dir,
973 &["session", "log", "--kind", "tool-call", "--tool", "Edit", "--", "--edited src/a.rs"],
974 &[],
975 );
976 assert_eq!(out.code, 0, "{}", out.stderr);
977 let logged = mock.requests().last().unwrap().clone();
978 assert_eq!(logged.path(), "/repos/acme/web/sessions/ses_1/entries");
979 assert_eq!(
980 logged.json(),
981 json!({ "entries": [{ "kind": "tool_call", "text": "--edited src/a.rs", "tool": "Edit" }] })
982 );
983
984 // G1T_SESSION wins over the kept one.
985 let out = g1t_with(&mock, &dir, &["session", "log", "hi"], &[("G1T_SESSION", "ses_2")]);
986 assert_eq!(out.code, 0, "{}", out.stderr);
987 assert_eq!(mock.requests().last().unwrap().path(), "/repos/acme/web/sessions/ses_2/entries");
988
989 let out = g1t_with(&mock, &dir, &["work", "claim", "12", "--note", "parser", "--ttl", "2h"], &[]);
990 assert_eq!(out.code, 0, "{}", out.stderr);
991 assert_eq!(
992 mock.requests().last().unwrap().json(),
993 json!({ "session_id": "ses_1", "note": "parser", "ttl_minutes": 120 })
994 );
995 assert!(out.stdout.contains("Claimed #12"), "{}", out.stdout);
996
997 let out = g1t_with(&mock, &dir, &["session", "end", "--reason", "done"], &[]);
998 assert_eq!(out.code, 0, "{}", out.stderr);
999 assert!(!dir.join(".git/g1t/session").exists(), "an ended session is forgotten");
1000}
1001
1002#[test]
1003fn coordination_without_a_session_or_a_token_fails_at_once() {
1004 let mock = Mock::start(|_| (200, json!({})));
1005 let dir = working_copy("nosession");
1006 let out = g1t_with(&mock, &dir, &["session", "heartbeat"], &[]);
1007 assert_eq!(out.code, 2);
1008 assert!(out.stderr.contains("g1t session start"), "{}", out.stderr);
1009 let out = g1t_with(&mock, &dir, &["work", "claim", "12"], &[("G1T_TOKEN", "")]);
1010 assert_eq!(out.code, 4, "{}", out.stderr);
1011 assert!(mock.requests().is_empty());
1012}
1013
1014#[test]
1015fn an_older_server_says_it_cannot_do_this_yet() {
1016 let mock = Mock::start(|_| {
1017 (404, json!({ "error": { "code": "not_found", "message": "No such endpoint." } }))
1018 });
1019 let out = run(&mock, &["work", "context", "12", "-R", "acme/web"]);
1020 assert_eq!(out.code, 1);
1021 assert!(out.stderr.contains("older than this CLI"), "{}", out.stderr);
1022 assert!(out.stderr.contains("/repos/acme/web/issues/12/work"), "{}", out.stderr);
1023}
1024
1025#[test]
1026fn blocking_replaces_the_set_the_issue_waits_on() {
1027 let mock = Mock::start(|r| match (r.method.as_str(), r.path()) {
1028 ("GET", "/repos/acme/web/issues/12") => {
1029 (200, json!({ "issue": { "number": 12, "blocked_by": [9] } }))
1030 }
1031 ("PATCH", "/repos/acme/web/issues/12") => (200, json!({ "number": 12, "blocked_by": [9, 10] })),
1032 _ => (404, json!({ "error": { "message": "No such endpoint." } })),
1033 });
1034 let out = run(&mock, &["work", "block", "12", "--by", "10", "--by", "9", "-R", "acme/web"]);
1035 assert_eq!(out.code, 0, "{}", out.stderr);
1036 assert_eq!(mock.requests()[1].json(), json!({ "blocked_by": [9, 10] }));
1037}
1038
1039#[test]
1040fn events_follow_from_the_newest_and_resume_from_the_last() {
1041 let calls = Arc::new(Mutex::new(0));
1042 let count = calls.clone();
1043 let event = |id: &str, kind: &str, number: u64| {
1044 json!({ "id": id, "type": kind, "time": "2026-10-12T09:00:00Z", "data": { "number": number, "title": "t" } })
1045 };
1046 let mock = Mock::start(move |r| {
1047 let mut n = count.lock().unwrap();
1048 *n += 1;
1049 match *n {
1050 // Where following starts.
1051 1 => (200, json!([event("evt_5", "push", 0)])),
1052 // The past, newest first.
1053 2 => (200, json!([event("evt_5", "work.claimed", 12), event("evt_4", "work.claimed", 12)])),
1054 3 if r.target.contains("after=evt_5") => (200, json!([event("evt_6", "work.released", 12)])),
1055 4 if r.target.contains("after=evt_6") => (200, json!([event("evt_7", "work.claimed", 12)])),
1056 // A refused token ends the follow.
1057 _ => (401, json!({ "error": { "message": "Token expired." } })),
1058 }
1059 });
1060 let out = run(
1061 &mock,
1062 &[
1063 "work", "events", "--follow", "--issue", "12", "--type", "work.claimed", "-t",
1064 "work.released", "--json", "id", "-R", "acme/web",
1065 ],
1066 );
1067 assert_eq!(out.code, 4, "{}", out.stderr);
1068 assert_eq!(
1069 out.stdout,
1070 "{\"id\":\"evt_4\"}\n{\"id\":\"evt_5\"}\n{\"id\":\"evt_6\"}\n{\"id\":\"evt_7\"}\n"
1071 );
1072 let seen = mock.requests();
1073 assert!(!seen[0].target.contains("numbers="), "{}", seen[0].target);
1074 assert!(seen[1].target.contains("numbers=12"), "{}", seen[1].target);
1075 assert!(seen[1].target.contains("types=work.claimed%2Cwork.released"), "{}", seen[1].target);
1076 assert!(seen[2].target.contains("wait=25"), "{}", seen[2].target);
1077}
1078
1079fn cache(id: u64, key: &str, size: u64) -> Value {
1080 json!({
1081 "id": id, "key": key, "ref": "refs/heads/main", "version": "", "size_in_bytes": size,
1082 "created_at": "2026-10-12T10:00:00.000Z", "last_accessed_at": "2026-10-12T11:00:00.000Z",
1083 })
1084}
1085
1086#[test]
1087fn cache_list_pages_through_the_entries_largest_first() {
1088 // 150 entries: a first page of 100, then the 50 the limit still wants.
1089 let mock = Mock::start(|r| {
1090 let ids = if r.target.contains("page=2") { 101..=150 } else { 1..=100 };
1091 (200, json!({ "total_count": 150, "actions_caches": ids.map(|n| cache(n, "cargo-x", 1024)).collect::<Vec<_>>() }))
1092 });
1093 let out = run(
1094 &mock,
1095 &["cache", "list", "-R", "acme/web", "--key", "cargo-", "--sort", "size-in-bytes", "--limit", "120", "--json", "id"],
1096 );
1097 assert_eq!(out.code, 0, "{}", out.stderr);
1098 let list: Vec<Value> = serde_json::from_str(&out.stdout).unwrap();
1099 assert_eq!(list.len(), 120);
1100 let seen = mock.requests();
1101 assert_eq!(seen.len(), 2);
1102 assert_eq!(seen[0].path(), "/repos/acme/web/actions/caches");
1103 for want in ["key=cargo-", "sort=size_in_bytes", "direction=desc", "per_page=100", "page=1"] {
1104 assert!(seen[0].target.contains(want), "{want} in {}", seen[0].target);
1105 }
1106 assert!(seen[1].target.contains("page=2"), "{}", seen[1].target);
1107}
1108
1109#[test]
1110fn cache_usage_says_how_full_the_repository_is() {
1111 let mock = Mock::start(|_| {
1112 (200, json!({ "full_name": "acme/web", "active_caches_size_in_bytes": 5_368_709_120u64, "active_caches_count": 12, "quota_bytes": 10_737_418_240u64 }))
1113 });
1114 let out = run(&mock, &["cache", "usage", "-R", "acme/web"]);
1115 assert_eq!(out.code, 0, "{}", out.stderr);
1116 assert_eq!(out.stdout, "acme/web: 12 entries holding 5.0 GiB of 10.0 GiB (50%)\n");
1117 assert_eq!(mock.requests()[0].target, "/repos/acme/web/actions/cache/usage");
1118}
1119
1120#[test]
1121fn cache_delete_takes_an_id_or_a_key() {
1122 let mock = Mock::start(|r| match r.path() {
1123 "/repos/acme/web/actions/caches/9184" => (204, Value::Null),
1124 _ => (200, json!({ "total_count": 2, "actions_caches": [cache(1, "npm-full-Linux-abc", 1), cache(2, "npm-full-Linux-abc", 1)] })),
1125 });
1126 let out = run(&mock, &["cache", "delete", "9184", "-R", "acme/web"]);
1127 assert_eq!(out.code, 0, "{}", out.stderr);
1128 let out = run(&mock, &["cache", "delete", "npm-full-Linux-abc", "--ref", "refs/heads/main", "-R", "acme/web"]);
1129 assert_eq!(out.code, 0, "{}", out.stderr);
1130 assert!(out.stderr.contains("Deleted 2 cache entries with the key npm-full-Linux-abc"), "{}", out.stderr);
1131 let seen = mock.requests();
1132 assert_eq!((seen[0].method.as_str(), seen[0].path()), ("DELETE", "/repos/acme/web/actions/caches/9184"));
1133 assert_eq!(seen[1].method, "DELETE");
1134 assert!(seen[1].target.starts_with("/repos/acme/web/actions/caches?key=npm-full-Linux-abc&ref=refs"), "{}", seen[1].target);
1135}
1136
1137fn workflows_mock(dispatched: Value) -> Mock {
1138 Mock::start(move |r| match (r.method.as_str(), r.path()) {
1139 ("GET", "/repos/acme/web/actions/workflows") => (
1140 200,
1141 json!([
1142 { "id": "wfl_1", "path": ".g1t/workflows/ci.yml", "name": "CI", "events": ["push", "pull_request"],
1143 "state": "active", "error": null, "notes": [], "dispatch": null, "last_run": null },
1144 { "id": "wfl_2", "path": ".g1t/workflows/release.yml", "name": "Release", "events": ["workflow_dispatch"],
1145 "state": "active", "error": null, "notes": [],
1146 "dispatch": { "channel": { "type": "choice", "options": ["beta", "stable"], "default": "beta" } }, "last_run": null },
1147 { "id": "wfl_3", "path": ".g1t/workflows/nightly.yml", "name": "Nightly", "events": ["schedule"],
1148 "state": "disabled", "error": null, "notes": [], "dispatch": null, "last_run": null }
1149 ]),
1150 ),
1151 ("GET", "/repos/acme/web/actions/workflows/wfl_2") => (
1152 200,
1153 json!({ "id": "wfl_2", "path": ".g1t/workflows/release.yml", "name": "Release", "state": "active",
1154 "ref": "v1.4.0", "source": "name: Release\non: workflow_dispatch\n" }),
1155 ),
1156 ("GET", "/repos/acme/web/actions/runs") => (
1157 200,
1158 json!([{ "id": "run_9", "workflow_id": "wfl_2", "name": "Release", "title": "Release run by ana", "number": 4,
1159 "event": "workflow_dispatch", "ref": "refs/heads/main", "status": "queued", "conclusion": null,
1160 "created_at": "2999-01-01T00:00:00Z" }]),
1161 ),
1162 ("POST", "/repos/acme/web/actions/workflows/wfl_2/dispatches") => (200, dispatched.clone()),
1163 ("PUT", p) if p.ends_with("/enable") || p.ends_with("/disable") => (
1164 200,
1165 json!({ "id": "wfl_3", "path": ".g1t/workflows/nightly.yml", "name": "Nightly",
1166 "state": if p.ends_with("/enable") { "active" } else { "disabled" } }),
1167 ),
1168 _ => (404, json!({ "error": { "message": "No such endpoint." } })),
1169 })
1170}
1171
1172fn release_run() -> Value {
1173 json!({ "id": "run_9", "workflow_id": "wfl_2", "name": "Release", "number": 4, "event": "workflow_dispatch",
1174 "ref": "refs/tags/v1.4.0", "status": "queued" })
1175}
1176
1177#[test]
1178fn workflow_list_shows_those_turned_on_unless_all() {
1179 let mock = workflows_mock(json!({}));
1180 let out = run(&mock, &["workflow", "list", "-R", "acme/web"]);
1181 assert_eq!(out.code, 0, "{}", out.stderr);
1182 let names: Vec<&str> = out
1183 .stdout
1184 .lines()
1185 .map(|l| l.split('\t').next().unwrap())
1186 .collect();
1187 assert_eq!(names, ["CI", "Release"]);
1188 assert_eq!(
1189 out.stdout.lines().next().unwrap(),
1190 "CI\tactive\tci.yml\tpush, pull_request\twfl_1"
1191 );
1192 let out = run(
1193 &mock,
1194 &[
1195 "workflow", "list", "--all", "-R", "acme/web", "--jq", ".[].path",
1196 ],
1197 );
1198 assert_eq!(out.code, 0, "{}", out.stderr);
1199 assert_eq!(out.stdout.lines().count(), 3);
1200}
1201
1202#[test]
1203fn workflow_view_takes_a_name_and_shows_its_latest_runs() {
1204 let mock = workflows_mock(json!({}));
1205 let out = run(
1206 &mock,
1207 &[
1208 "workflow",
1209 "view",
1210 "release",
1211 "-R",
1212 "acme/web",
1213 "--json",
1214 "id,runs,url",
1215 ],
1216 );
1217 assert_eq!(out.code, 0, "{}", out.stderr);
1218 let shown: Value = serde_json::from_str(&out.stdout).unwrap();
1219 assert_eq!(shown["id"], "wfl_2");
1220 assert_eq!(shown["runs"][0]["id"], "run_9");
1221 assert_eq!(
1222 shown["url"],
1223 "https://g1t.sh/acme/web/actions?workflow=wfl_2"
1224 );
1225 let runs = mock
1226 .requests()
1227 .into_iter()
1228 .find(|r| r.path() == "/repos/acme/web/actions/runs")
1229 .unwrap();
1230 assert_eq!(
1231 runs.target,
1232 "/repos/acme/web/actions/runs?workflow=wfl_2&per_page=5"
1233 );
1234 // Its file, on a tag: only the file when piped.
1235 let out = run(
1236 &mock,
1237 &[
1238 "workflow",
1239 "view",
1240 "release.yml",
1241 "--yaml",
1242 "--ref",
1243 "v1.4.0",
1244 "-R",
1245 "acme/web",
1246 ],
1247 );
1248 assert_eq!(out.code, 0, "{}", out.stderr);
1249 assert_eq!(out.stdout, "name: Release\non: workflow_dispatch\n");
1250 assert!(
1251 mock.requests()
1252 .iter()
1253 .any(|r| r.target == "/repos/acme/web/actions/workflows/wfl_2?ref=v1.4.0")
1254 );
1255 // --ref is for --yaml.
1256 let out = run(
1257 &mock,
1258 &[
1259 "workflow", "view", "ci.yml", "--ref", "main", "-R", "acme/web",
1260 ],
1261 );
1262 assert_eq!(out.code, 2);
1263}
1264
1265#[test]
1266fn workflow_run_sends_the_ref_and_inputs_and_prints_the_run() {
1267 let mock = workflows_mock(release_run());
1268 let out = run(
1269 &mock,
1270 &[
1271 "workflow",
1272 "run",
1273 "Release",
1274 "--ref",
1275 "v1.4.0",
1276 "-f",
1277 "channel=stable",
1278 "-R",
1279 "acme/web",
1280 ],
1281 );
1282 assert_eq!(out.code, 0, "{}", out.stderr);
1283 assert_eq!(
1284 out.stdout.trim(),
1285 "https://g1t.sh/acme/web/actions/runs/run_9"
1286 );
1287 assert!(
1288 out.stderr
1289 .contains("Started Release (release.yml) #4 on v1.4.0"),
1290 "{}",
1291 out.stderr
1292 );
1293 let sent = mock
1294 .requests()
1295 .into_iter()
1296 .find(|r| r.method == "POST")
1297 .unwrap();
1298 assert_eq!(
1299 sent.json(),
1300 json!({ "ref": "v1.4.0", "inputs": { "channel": "stable" } })
1301 );
1302}
1303
1304#[test]
1305fn workflow_run_json_reads_the_inputs_from_stdin() {
1306 let mock = workflows_mock(release_run());
1307 let dir = temp_dir("dispatch");
1308 let out = g1t(
1309 &mock,
1310 &dir,
1311 &["workflow", "run", "release.yml", "--json", "-R", "acme/web"],
1312 Some("{\"channel\":\"beta\",\"dry_run\":true}"),
1313 Some("g1t_test"),
1314 );
1315 assert_eq!(out.code, 0, "{}", out.stderr);
1316 assert_eq!(
1317 out.stdout.trim(),
1318 "https://g1t.sh/acme/web/actions/runs/run_9"
1319 );
1320 let sent = mock
1321 .requests()
1322 .into_iter()
1323 .find(|r| r.method == "POST")
1324 .unwrap();
1325 assert_eq!(
1326 sent.json(),
1327 json!({ "inputs": { "channel": "beta", "dry_run": true } })
1328 );
1329 // Not an object, or with -f as well, is using it wrongly.
1330 let out = g1t(
1331 &mock,
1332 &dir,
1333 &["workflow", "run", "release.yml", "--json", "-R", "acme/web"],
1334 Some("[1]"),
1335 Some("g1t_test"),
1336 );
1337 assert_eq!(out.code, 2);
1338 let out = g1t(
1339 &mock,
1340 &dir,
1341 &[
1342 "workflow",
1343 "run",
1344 "release.yml",
1345 "--json",
1346 "-f",
1347 "a=b",
1348 "-R",
1349 "acme/web",
1350 ],
1351 Some("{}"),
1352 Some("g1t_test"),
1353 );
1354 assert_eq!(out.code, 2);
1355}
1356
1357#[test]
1358fn workflow_run_looks_for_the_run_when_the_answer_lacks_it() {
1359 let mock = workflows_mock(json!({}));
1360 let out = run(
1361 &mock,
1362 &["workflow", "run", "wfl_2", "-R", "acme/web", "--jq", ".id"],
1363 );
1364 assert_eq!(out.code, 0, "{}", out.stderr);
1365 assert_eq!(out.stdout, "run_9\n");
1366 assert!(mock.requests().iter().any(|r| r.target
1367 == "/repos/acme/web/actions/runs?workflow=wfl_2&event=workflow_dispatch&per_page=1"));
1368}
1369
1370#[test]
1371fn workflow_enable_and_disable_put_its_state() {
1372 let mock = workflows_mock(json!({}));
1373 let out = run(
1374 &mock,
1375 &["workflow", "enable", "nightly.yml", "-R", "acme/web"],
1376 );
1377 assert_eq!(out.code, 0, "{}", out.stderr);
1378 assert!(
1379 out.stderr.contains("Turned Nightly (nightly.yml) on"),
1380 "{}",
1381 out.stderr
1382 );
1383 let out = run(
1384 &mock,
1385 &[
1386 "workflow", "disable", "Nightly", "-R", "acme/web", "--json", "state",
1387 ],
1388 );
1389 assert_eq!(out.stdout, "{\"state\":\"disabled\"}\n");
1390 let puts: Vec<String> = mock
1391 .requests()
1392 .into_iter()
1393 .filter(|r| r.method == "PUT")
1394 .map(|r| r.path().to_owned())
1395 .collect();
1396 assert_eq!(
1397 puts,
1398 [
1399 "/repos/acme/web/actions/workflows/wfl_3/enable",
1400 "/repos/acme/web/actions/workflows/wfl_3/disable"
1401 ]
1402 );
1403}
1404
1405#[test]
1406fn a_workflow_that_is_not_there_or_not_named_fails_without_asking() {
1407 let mock = workflows_mock(json!({}));
1408 let out = run(&mock, &["workflow", "view", "deploy.yml", "-R", "acme/web"]);
1409 assert_eq!(out.code, 1);
1410 assert!(
1411 out.stderr
1412 .contains("There is no workflow deploy.yml in acme/web"),
1413 "{}",
1414 out.stderr
1415 );
1416 let out = run(&mock, &["workflow", "run", "-R", "acme/web"]);
1417 assert_eq!(out.code, 2);
1418 assert!(
1419 out.stderr.contains("The workflow is needed"),
1420 "{}",
1421 out.stderr
1422 );
1423}