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.

1,712 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 item(number: u64, title: &str, labels: &[&str]) -> Value {
170 json!({
171 "workspace": "acme", "number": number, "title": title, "state": "open", "labels": labels, "body": "b",
172 "author": { "username": "ana" }, "assignees": [], "updated_at": "2026-10-01T18:04:11.482Z",
173 "repos": [{ "path": "acme/web", "role": "origin" }],
174 })
175}
176
177#[test]
178fn work_list_asks_for_open_work_items_and_prints_json_fields() {
179 let mock = Mock::start(|_| {
180 (
181 200,
182 json!([item(12, "Greeting", &["feature"]), item(11, "Docs", &[])]),
183 )
184 });
185 let out = run(
186 &mock,
187 &["work", "list", "-R", "acme/web", "--json", "number,title"],
188 );
189 assert_eq!(out.code, 0, "{}", out.stderr);
190 assert_eq!(
191 out.stdout,
192 "[{\"number\":12,\"title\":\"Greeting\"},{\"number\":11,\"title\":\"Docs\"}]\n"
193 );
194 let seen = mock.requests();
195 assert_eq!(seen[0].method, "GET");
196 // --repo narrows the workspace's list to the work items linked to it.
197 assert_eq!(seen[0].target, "/workspaces/acme/work?state=open&repo=acme%2Fweb");
198 assert_eq!(seen[0].header("authorization"), Some("Bearer g1t_test"));
199 assert!(
200 seen[0]
201 .header("user-agent")
202 .unwrap()
203 .starts_with("g1t-cli/")
204 );
205}
206
207#[test]
208fn piped_lists_are_tab_separated_without_a_header() {
209 let mock = Mock::start(|_| (200, json!([item(12, "Greeting", &["feature", "p1"])])));
210 let out = run(&mock, &["work", "list", "-R", "acme/web", "--limit", "5"]);
211 assert_eq!(out.code, 0, "{}", out.stderr);
212 let line = out.stdout.lines().next().unwrap();
213 let cells: Vec<&str> = line.split('\t').collect();
214 assert_eq!(&cells[..4], &["#12", "Greeting", "acme/web", "feature, p1"]);
215 assert_eq!(out.stdout.lines().count(), 1);
216}
217
218#[test]
219fn limits_keep_lists_short() {
220 let many: Vec<Value> = (1..=50).map(|n| item(n, "x", &[])).collect();
221 let mock = Mock::start(move |_| (200, Value::Array(many.clone())));
222 let out = run(
223 &mock,
224 &["work", "list", "-R", "acme/web", "--json", "number"],
225 );
226 let list: Vec<Value> = serde_json::from_str(&out.stdout).unwrap();
227 assert_eq!(list.len(), 20, "the default page is small");
228}
229
230#[test]
231fn jq_selects_from_the_json() {
232 let mock = Mock::start(|_| {
233 (
234 200,
235 json!([item(12, "Greeting", &[]), item(11, "Docs", &[])]),
236 )
237 });
238 let out = run(
239 &mock,
240 &["work", "list", "-R", "acme/web", "--jq", ".[].title"],
241 );
242 assert_eq!(out.stdout, "Greeting\nDocs\n");
243}
244
245#[test]
246fn an_unknown_json_field_is_a_usage_error() {
247 let mock = Mock::start(|_| (200, json!([item(12, "Greeting", &[])])));
248 let out = run(
249 &mock,
250 &["work", "list", "-R", "acme/web", "--json", "nope"],
251 );
252 assert_eq!(out.code, 2);
253 assert_eq!(out.stderr.lines().count(), 1, "{}", out.stderr);
254 assert!(out.stderr.contains("Available:"), "{}", out.stderr);
255}
256
257#[test]
258fn work_new_sends_the_title_and_body_with_the_repository_as_origin() {
259 let mock = Mock::start(|r| match (r.method.as_str(), r.path()) {
260 ("POST", "/workspaces/acme/work") => (200, item(13, "Broken link", &["docs"])),
261 _ => (
262 404,
263 json!({ "error": { "code": "not_found", "message": "No." } }),
264 ),
265 });
266 let out = run(
267 &mock,
268 &[
269 "work",
270 "new",
271 "-R",
272 "acme/web",
273 "-t",
274 "Broken link",
275 "-b",
276 "On the home page.",
277 "-l",
278 "docs",
279 "--target",
280 "acme/api",
281 ],
282 );
283 assert_eq!(out.code, 0, "{}", out.stderr);
284 assert_eq!(out.stdout.trim(), "https://g1t.sh/acme/-/work/13");
285 let sent = mock.requests()[0].json();
286 assert_eq!(
287 sent,
288 json!({ "title": "Broken link", "body": "On the home page.", "repo": "acme/web", "targets": ["acme/api"], "labels": ["docs"] })
289 );
290}
291
292#[test]
293fn a_missing_title_without_a_terminal_names_the_flag() {
294 let mock = Mock::start(|_| (200, json!({})));
295 let out = run(&mock, &["work", "new", "-R", "acme/web"]);
296 assert_eq!(out.code, 2);
297 assert!(out.stderr.contains("--title"), "{}", out.stderr);
298 assert!(mock.requests().is_empty());
299}
300
301#[test]
302fn a_refused_token_exits_4_with_one_line() {
303 let mock = Mock::start(|_| {
304 (
305 401,
306 json!({ "error": { "code": "unauthenticated", "message": "The token is not valid." } }),
307 )
308 });
309 let out = run(&mock, &["work", "view", "12", "-R", "acme/web"]);
310 assert_eq!(out.code, 4);
311 assert_eq!(out.stderr.lines().count(), 1, "{}", out.stderr);
312 assert!(
313 out.stderr.starts_with("g1t: The token is not valid."),
314 "{}",
315 out.stderr
316 );
317}
318
319#[test]
320fn a_missing_scope_is_named() {
321 let mock = Mock::start(|_| {
322 (
323 403,
324 json!({ "error": { "code": "forbidden", "message": "Not allowed.", "needed_scope": "work:write" } }),
325 )
326 });
327 let out = run(&mock, &["work", "close", "12", "-R", "acme/web"]);
328 assert_eq!(out.code, 1);
329 assert!(out.stderr.contains("`work:write`"), "{}", out.stderr);
330}
331
332#[test]
333fn usage_mistakes_exit_2_on_one_line() {
334 let mock = Mock::start(|_| (200, json!({})));
335 let out = run(&mock, &["work", "list", "--state", "sideways"]);
336 assert_eq!(out.code, 2);
337 assert_eq!(out.stderr.lines().count(), 1, "{}", out.stderr);
338 let out = run(&mock, &["frobnicate"]);
339 assert_eq!(out.code, 2);
340}
341
342fn pull(check_status: &str) -> Value {
343 json!({ "pull": { "number": 14, "title": "Greet", "status": "open", "head_commit": "9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13", "check_status": check_status, "branch": "greet", "base": "main" }, "comments": [] })
344}
345
346fn checks_mock(runs: Value) -> Mock {
347 Mock::start(move |r| match r.path() {
348 "/repos/acme/web/pulls/14" => (200, pull("pending")),
349 p if p.ends_with("/check-runs") => (200, runs.clone()),
350 p if p.ends_with("/status") => (200, json!({ "state": "success", "statuses": [] })),
351 _ => (404, json!({ "error": { "message": "No such endpoint." } })),
352 })
353}
354
355#[test]
356fn pr_checks_exit_codes_say_how_they_went() {
357 let passing = json!({ "check_runs": [{ "name": "test", "status": "completed", "conclusion": "success" }] });
358 let failing = json!({ "check_runs": [{ "name": "test", "status": "completed", "conclusion": "failure" }] });
359 let pending =
360 json!({ "check_runs": [{ "name": "test", "status": "in_progress", "conclusion": null }] });
361 assert_eq!(
362 run(
363 &checks_mock(passing),
364 &["pr", "checks", "14", "-R", "acme/web"]
365 )
366 .code,
367 0
368 );
369 assert_eq!(
370 run(
371 &checks_mock(failing),
372 &["pr", "checks", "14", "-R", "acme/web"]
373 )
374 .code,
375 1
376 );
377 let mock = checks_mock(pending);
378 let out = run(
379 &mock,
380 &[
381 "pr",
382 "checks",
383 "14",
384 "-R",
385 "acme/web",
386 "--json",
387 "name,bucket",
388 ],
389 );
390 assert_eq!(out.code, 8);
391 assert_eq!(out.stdout, "[{\"bucket\":\"pending\",\"name\":\"test\"}]\n");
392 let refs: Vec<String> = mock.requests().iter().map(|r| r.target.clone()).collect();
393 assert!(
394 refs.iter().any(|t| t.starts_with(
395 "/repos/acme/web/commits/9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13/check-runs"
396 )),
397 "{refs:?}"
398 );
399}
400
401#[test]
402fn pr_checks_watch_fail_fast_stops_at_the_first_failure() {
403 let mixed = json!({ "check_runs": [
404 { "name": "lint", "status": "completed", "conclusion": "failure", "html_url": "https://g1t.sh/x" },
405 { "name": "test", "status": "in_progress", "conclusion": null },
406 ]});
407 let mock = checks_mock(mixed);
408 let started = std::time::Instant::now();
409 let out = run(
410 &mock,
411 &[
412 "pr",
413 "checks",
414 "14",
415 "-R",
416 "acme/web",
417 "--watch",
418 "--fail-fast",
419 "--interval",
420 "1",
421 ],
422 );
423 assert_eq!(out.code, 1, "{}", out.stderr);
424 assert!(started.elapsed() < std::time::Duration::from_secs(10));
425 assert!(out.stdout.contains("lint\tfailure"), "{}", out.stdout);
426}
427
428#[test]
429fn run_logs_failed_prints_only_the_failing_steps_tail() {
430 let mock = Mock::start(|r| match r.path() {
431 "/repos/acme/web/actions/runs/run_1" => (
432 200,
433 json!({ "run": { "id": "run_1", "name": "CI", "number": 3, "status": "completed", "conclusion": "failure" }, "jobs": [
434 { "id": "job_1", "name": "test", "status": "completed", "conclusion": "failure", "started_at": "2026-10-01T18:00:00Z",
435 "steps": [
436 { "number": 1, "name": "checkout", "status": "completed", "conclusion": "success" },
437 { "number": 2, "name": "cargo test", "status": "completed", "conclusion": "failure" }
438 ], "annotations": [] },
439 { "id": "job_2", "name": "lint", "status": "completed", "conclusion": "success", "started_at": "2026-10-01T18:00:00Z", "steps": [] }
440 ]}),
441 ),
442 "/repos/acme/web/actions/jobs/job_1/logs" => (
443 200,
444 json!({ "chunks": [
445 { "seq": 1, "step": 1, "text": "cloned\n" },
446 { "seq": 2, "step": 2, "text": "running 3 tests\nline a\nline b\ntest greet ... FAILED\n" }
447 ], "done": true }),
448 ),
449 _ => (404, json!({ "error": { "message": "No such endpoint." } })),
450 });
451 let out = run(
452 &mock,
453 &[
454 "run", "logs", "run_1", "--failed", "-n", "2", "-R", "acme/web",
455 ],
456 );
457 assert_eq!(out.code, 0, "{}", out.stderr);
458 assert!(out.stdout.contains("test › 2 cargo test"), "{}", out.stdout);
459 assert!(
460 out.stdout.contains("line b\ntest greet ... FAILED"),
461 "{}",
462 out.stdout
463 );
464 assert!(!out.stdout.contains("cloned"), "{}", out.stdout);
465 assert!(!out.stdout.contains("line a"), "{}", out.stdout);
466 // The passing job's log is never fetched.
467 assert!(mock.requests().iter().all(|r| !r.path().contains("job_2")));
468}
469
470#[test]
471fn api_calls_operations_by_name() {
472 let mock = Mock::start(|_| (200, json!([item(12, "Greeting", &[])])));
473 let out = run(
474 &mock,
475 &[
476 "api",
477 "list_repo_work_items",
478 "-f",
479 "state=closed",
480 "-R",
481 "acme/web",
482 "--jq",
483 ".[0].title",
484 ],
485 );
486 assert_eq!(out.code, 0, "{}", out.stderr);
487 assert_eq!(out.stdout, "Greeting\n");
488 assert_eq!(
489 mock.requests()[0].target,
490 "/repos/acme/web/work?state=closed"
491 );
492}
493
494#[test]
495fn api_posts_typed_fields() {
496 let mock = Mock::start(|_| (200, json!({ "number": 13 })));
497 let out = run(
498 &mock,
499 &[
500 "api",
501 "open_work_item",
502 "-R",
503 "acme/web",
504 "-f",
505 "title=Hi",
506 "-f",
507 "labels[]=docs",
508 "-F",
509 "milestone=3",
510 ],
511 );
512 assert_eq!(out.code, 0, "{}", out.stderr);
513 let r = &mock.requests()[0];
514 assert_eq!(
515 (r.method.as_str(), r.path()),
516 ("POST", "/workspaces/acme/work")
517 );
518 assert_eq!(
519 r.json(),
520 json!({ "title": "Hi", "labels": ["docs"], "milestone": 3 })
521 );
522}
523
524#[test]
525fn api_paginates_by_cursor_and_slurps() {
526 let mock = Mock::start(|r| {
527 if r.target.contains("cursor=c2") {
528 (200, json!({ "items": [{ "id": 3 }], "next": null }))
529 } else {
530 (
531 200,
532 json!({ "items": [{ "id": 1 }, { "id": 2 }], "next": "c2" }),
533 )
534 }
535 });
536 let out = run(
537 &mock,
538 &[
539 "api",
540 "notifications",
541 "--paginate",
542 "--slurp",
543 "--jq",
544 "map(.id)",
545 ],
546 );
547 assert_eq!(out.code, 0, "{}", out.stderr);
548 assert_eq!(out.stdout.trim(), "[1,2,3]");
549 assert_eq!(mock.requests().len(), 2);
550}
551
552#[test]
553fn api_errors_print_the_body_and_exit_1() {
554 let mock = Mock::start(|_| {
555 (
556 404,
557 json!({ "error": { "code": "not_found", "message": "No such endpoint." } }),
558 )
559 });
560 let out = run(&mock, &["api", "/nowhere"]);
561 assert_eq!(out.code, 1);
562 assert!(out.stdout.contains("not_found"));
563 assert_eq!(out.stderr.trim(), "g1t: No such endpoint.");
564}
565
566#[test]
567fn signing_in_with_a_token_keeps_it_for_status_token_and_git() {
568 let mock = Mock::start(|r| match r.header("authorization") {
569 Some("Bearer g1t_good") => (
570 200,
571 json!({ "username": "ana", "token": { "scopes": ["repo:read"] } }),
572 ),
573 _ => (
574 401,
575 json!({ "error": { "message": "The token is not valid." } }),
576 ),
577 });
578 let dir = temp_dir("auth");
579 let out = g1t(
580 &mock,
581 &dir,
582 &["auth", "login", "--with-token"],
583 Some("g1t_good\n"),
584 None,
585 );
586 assert_eq!(out.code, 0, "{}", out.stderr);
587 assert!(
588 out.stderr.contains("Signed in to g1t.sh as ana"),
589 "{}",
590 out.stderr
591 );
592 let config: Value =
593 serde_json::from_str(&std::fs::read_to_string(dir.join("config.json")).unwrap()).unwrap();
594 assert_eq!(config["hosts"]["g1t.sh"]["user"], "ana");
595
596 let out = g1t(&mock, &dir, &["auth", "token"], None, None);
597 assert_eq!(out.stdout, "g1t_good\n");
598
599 let out = g1t(
600 &mock,
601 &dir,
602 &["auth", "status", "--json", "host,user,valid,active"],
603 None,
604 None,
605 );
606 assert_eq!(out.code, 0, "{}", out.stderr);
607 assert_eq!(
608 out.stdout.trim(),
609 r#"[{"active":true,"host":"g1t.sh","user":"ana","valid":true}]"#
610 );
611
612 let out = g1t(
613 &mock,
614 &dir,
615 &["auth", "git-credential", "get"],
616 Some("protocol=https\nhost=g1t.sh\n\n"),
617 None,
618 );
619 assert_eq!(
620 out.stdout,
621 "protocol=https\nhost=g1t.sh\nusername=ana\npassword=g1t_good\n"
622 );
623 let out = g1t(
624 &mock,
625 &dir,
626 &["auth", "git-credential", "get"],
627 Some("protocol=https\nhost=example.com\n\n"),
628 None,
629 );
630 assert_eq!(out.stdout, "");
631
632 let out = g1t(&mock, &dir, &["auth", "logout"], None, None);
633 assert_eq!(out.code, 0, "{}", out.stderr);
634 let out = g1t(&mock, &dir, &["auth", "status"], None, None);
635 assert_eq!(out.code, 4);
636}
637
638#[test]
639fn a_bad_token_is_not_kept() {
640 let mock = Mock::start(|_| {
641 (
642 401,
643 json!({ "error": { "message": "The token is not valid." } }),
644 )
645 });
646 let dir = temp_dir("bad");
647 let out = g1t(
648 &mock,
649 &dir,
650 &["auth", "login", "--with-token"],
651 Some("nope"),
652 None,
653 );
654 assert_eq!(out.code, 4);
655 assert!(!dir.join("config.json").exists());
656}
657
658#[test]
659fn secrets_are_read_from_stdin() {
660 let mock = Mock::start(|_| (200, json!({ "name": "NPM_TOKEN", "kind": "secret" })));
661 let dir = temp_dir("secret");
662 let out = g1t(
663 &mock,
664 &dir,
665 &[
666 "secret",
667 "set",
668 "npm_token",
669 "-R",
670 "acme/web",
671 "--env",
672 "production",
673 ],
674 Some("s3cret\n"),
675 Some("g1t_test"),
676 );
677 assert_eq!(out.code, 0, "{}", out.stderr);
678 let r = &mock.requests()[0];
679 assert_eq!(
680 (r.method.as_str(), r.path()),
681 ("PUT", "/repos/acme/web/actions/secrets/NPM_TOKEN")
682 );
683 assert_eq!(
684 r.json(),
685 json!({ "value": "s3cret", "environments": ["production"] })
686 );
687 assert!(!out.stdout.contains("s3cret") && !out.stderr.contains("s3cret"));
688}
689
690#[test]
691fn aliases_expand_before_the_command_runs() {
692 let mock = Mock::start(|_| (200, json!([item(12, "Greeting", &["bug"])])));
693 let dir = temp_dir("alias");
694 let out = g1t(
695 &mock,
696 &dir,
697 &["alias", "set", "bugs", "work list --label bug"],
698 None,
699 None,
700 );
701 assert_eq!(out.code, 0, "{}", out.stderr);
702 let out = g1t(
703 &mock,
704 &dir,
705 &["bugs", "-R", "acme/web", "--json", "number"],
706 None,
707 Some("g1t_test"),
708 );
709 assert_eq!(out.code, 0, "{}", out.stderr);
710 assert_eq!(out.stdout.trim(), r#"[{"number":12}]"#);
711 assert_eq!(
712 mock.requests()[0].target,
713 "/workspaces/acme/work?state=open&label=bug&repo=acme%2Fweb"
714 );
715 let out = g1t(
716 &mock,
717 &dir,
718 &["alias", "set", "pr", "work list"],
719 None,
720 None,
721 );
722 assert_eq!(out.code, 2, "a command's name cannot be an alias");
723}
724
725#[test]
726fn pr_merge_and_review_send_what_was_asked() {
727 let mock = Mock::start(|r| match (r.method.as_str(), r.path()) {
728 ("POST", "/repos/acme/web/pulls/14/merge") => {
729 (200, json!({ "number": 14, "status": "merged" }))
730 }
731 ("POST", "/repos/acme/web/pulls/14/reviews") => {
732 (200, json!({ "id": "cmt_1", "verdict": "approve" }))
733 }
734 _ => (404, json!({ "error": { "message": "No such endpoint." } })),
735 });
736 let out = run(
737 &mock,
738 &[
739 "pr",
740 "review",
741 "14",
742 "--approve",
743 "-b",
744 "Looks right.",
745 "-R",
746 "acme/web",
747 ],
748 );
749 assert_eq!(out.code, 0, "{}", out.stderr);
750 let out = run(
751 &mock,
752 &["pr", "merge", "14", "--keep-item-open", "-R", "acme/web"],
753 );
754 assert_eq!(out.code, 0, "{}", out.stderr);
755 assert_eq!(out.stdout.trim(), "https://g1t.sh/acme/web/pull/14");
756 let seen = mock.requests();
757 assert_eq!(
758 seen[0].json(),
759 json!({ "verdict": "approve", "body": "Looks right." })
760 );
761 assert_eq!(seen[1].json(), json!({ "keep_item_open": true }));
762}
763
764#[test]
765fn pr_merge_says_how_and_auto_merge_turns_on_and_off() {
766 let mock = Mock::start(|r| match (r.method.as_str(), r.path()) {
767 ("POST", "/repos/acme/web/pulls/14/merge") => (
768 200,
769 json!({ "number": 14, "status": "merged", "branch": "greet", "head_branch_deleted": true }),
770 ),
771 ("PUT", "/repos/acme/web/pulls/14/auto-merge") => (
772 200,
773 json!({ "number": 14, "status": "open", "auto_merge": { "method": "rebase", "enabled_by": "ana" } }),
774 ),
775 ("DELETE", "/repos/acme/web/pulls/14/auto-merge") => {
776 (200, json!({ "number": 14, "status": "open", "auto_merge": null }))
777 }
778 _ => (404, json!({ "error": { "message": "No such endpoint." } })),
779 });
780 let out = run(
781 &mock,
782 &[
783 "pr", "merge", "14", "--squash", "-t", "Greet the caller (#14)", "-b", "Names them.",
784 "--match-head-commit", "9f2c4e1", "-d", "-R", "acme/web",
785 ],
786 );
787 assert_eq!(out.code, 0, "{}", out.stderr);
788 assert!(out.stderr.contains("Deleted the branch greet."), "{}", out.stderr);
789 let out = run(&mock, &["pr", "merge", "14", "--ff", "--keep-branch", "-R", "acme/web"]);
790 assert_eq!(out.code, 0, "{}", out.stderr);
791 let out = run(&mock, &["pr", "merge", "14", "--auto", "--rebase", "-R", "acme/web"]);
792 assert_eq!(out.code, 0, "{}", out.stderr);
793 assert!(out.stderr.contains("will merge on its own"), "{}", out.stderr);
794 let out = run(&mock, &["pr", "merge", "14", "--disable-auto", "-R", "acme/web"]);
795 assert_eq!(out.code, 0, "{}", out.stderr);
796 assert!(out.stderr.contains("Turned off auto-merge"), "{}", out.stderr);
797 let seen = mock.requests();
798 assert_eq!(
799 seen[0].json(),
800 json!({
801 "merge_method": "squash",
802 "commit_title": "Greet the caller (#14)",
803 "commit_message": "Names them.",
804 "sha": "9f2c4e1",
805 "delete_branch": true,
806 })
807 );
808 assert_eq!(seen[1].json(), json!({ "merge_method": "fast_forward", "delete_branch": false }));
809 assert_eq!((seen[2].method.as_str(), seen[2].json()), ("PUT", json!({ "merge_method": "rebase" })));
810 assert_eq!(seen[3].method, "DELETE");
811 // One method at a time, and auto-merge takes no branch deletion.
812 for args in [
813 &["pr", "merge", "14", "--squash", "--rebase", "-R", "acme/web"][..],
814 &["pr", "merge", "14", "--auto", "-d", "-R", "acme/web"][..],
815 &["pr", "merge", "14", "--disable-auto", "--squash", "-R", "acme/web"][..],
816 ] {
817 assert_eq!(run(&mock, args).code, 2, "{args:?}");
818 }
819 assert_eq!(mock.requests().len(), 4);
820}
821
822#[test]
823fn pr_update_branch_merges_or_rebases_and_fails_when_it_cannot() {
824 let mock = Mock::start(|r| match (r.method.as_str(), r.path()) {
825 ("PUT", "/repos/acme/web/pulls/14/update-branch") if r.json()["update_method"] == "rebase" => (
826 200,
827 json!({ "outcome": "needs_agent", "reason": "conflicting", "detail": "Both sides changed src/main.rs.", "paths": ["src/main.rs"] }),
828 ),
829 ("PUT", "/repos/acme/web/pulls/14/update-branch") => {
830 (200, json!({ "outcome": "updated", "commit": "5b8d0f2", "previous": "9f2c4e1" }))
831 }
832 _ => (404, json!({ "error": { "message": "No such endpoint." } })),
833 });
834 let out = run(&mock, &["pr", "update-branch", "14", "-R", "acme/web"]);
835 assert_eq!(out.code, 0, "{}", out.stderr);
836 assert!(out.stderr.contains("Updated the branch of acme/web#14"), "{}", out.stderr);
837 assert_eq!(out.stdout.trim(), "https://g1t.sh/acme/web/pull/14");
838 let out = run(&mock, &["pr", "update-branch", "14", "--rebase", "-R", "acme/web"]);
839 assert_eq!(out.code, 1, "{}", out.stderr);
840 assert!(out.stderr.contains("src/main.rs"), "{}", out.stderr);
841 let seen = mock.requests();
842 assert!(seen[0].json().as_object().is_none_or(|body| body.is_empty()), "{}", seen[0].body);
843 assert_eq!(seen[1].json(), json!({ "update_method": "rebase" }));
844}
845
846#[test]
847fn status_gathers_what_needs_you() {
848 let mock = Mock::start(|r| match r.path() {
849 "/user" => (200, json!({ "username": "ana" })),
850 "/search" => (
851 200,
852 json!({ "hits": [{ "repo": "acme/web", "number": 14, "title": "Greet" }] }),
853 ),
854 "/repos/acme/web/pulls/14" => (200, pull("failed")),
855 "/notifications" if r.target.contains("review_requested") => (
856 200,
857 json!({ "items": [{ "repo": "acme/api", "number": 3, "title": "ada asked you to review acme/api#3" }] }),
858 ),
859 "/notifications" => (200, json!({ "items": [] })),
860 _ => (404, json!({ "error": { "message": "No such endpoint." } })),
861 });
862 let out = run(
863 &mock,
864 &["status", "--json", "pulls,review_requests,failing_checks"],
865 );
866 assert_eq!(out.code, 0, "{}", out.stderr);
867 let v: Value = serde_json::from_str(&out.stdout).unwrap();
868 assert_eq!(v["pulls"][0]["number"], 14);
869 assert_eq!(v["review_requests"][0]["number"], 3);
870 assert_eq!(v["failing_checks"][0]["check_status"], "failed");
871 let search = mock
872 .requests()
873 .into_iter()
874 .find(|r| r.path() == "/search")
875 .unwrap();
876 assert!(search.target.contains("author%3Aana"), "{}", search.target);
877}
878
879#[test]
880fn help_and_version_succeed() {
881 let mock = Mock::start(|_| (200, json!({})));
882 let out = run(&mock, &["--version"]);
883 assert_eq!(out.code, 0);
884 assert!(out.stdout.starts_with("g1t "));
885 let out = run(&mock, &["pr", "--help"]);
886 assert_eq!(out.code, 0);
887 assert!(out.stdout.contains("checks"));
888 let out = run(&mock, &["completion", "bash"]);
889 assert_eq!(out.code, 0);
890 assert!(out.stdout.contains("_g1t"));
891}
892
893#[test]
894fn login_approves_a_code_in_the_browser() {
895 let polls = Arc::new(Mutex::new(0));
896 let counter = polls.clone();
897 let mock = Mock::start(move |r| match r.path() {
898 "/device/code" => (
899 200,
900 json!({ "device_code": "dev_1", "user_code": "WDJB-MJHT", "verification_uri": "https://g1t.sh/device",
901 "verification_uri_complete": "https://g1t.sh/device?code=WDJB-MJHT", "expires_in": 60, "interval": 1 }),
902 ),
903 "/device/token" => {
904 let mut n = counter.lock().unwrap();
905 *n += 1;
906 if *n < 2 {
907 (200, json!({ "status": "pending" }))
908 } else {
909 (
910 200,
911 json!({ "status": "approved", "token": "g1t_device", "username": "ana" }),
912 )
913 }
914 }
915 _ => (404, json!({ "error": { "message": "No such endpoint." } })),
916 });
917 let dir = temp_dir("device");
918 let out = g1t(&mock, &dir, &["auth", "login", "--no-browser"], None, None);
919 assert_eq!(out.code, 0, "{}", out.stderr);
920 assert!(out.stderr.contains("WDJB-MJHT"), "{}", out.stderr);
921 assert!(
922 out.stderr.contains("https://g1t.sh/device?code=WDJB-MJHT"),
923 "{}",
924 out.stderr
925 );
926 let seen = mock.requests();
927 assert!(
928 seen[0].json()["client_name"]
929 .as_str()
930 .unwrap()
931 .starts_with("g1t CLI on ")
932 );
933 assert!(
934 seen.iter()
935 .filter(|r| r.path() == "/device/token")
936 .all(|r| r.json() == json!({ "device_code": "dev_1" }))
937 );
938 let out = g1t(&mock, &dir, &["auth", "token"], None, None);
939 assert_eq!(out.stdout, "g1t_device\n");
940}
941
942#[test]
943fn the_repository_comes_from_the_git_remote() {
944 let mock = Mock::start(|_| (200, json!([])));
945 let dir = temp_dir("remote");
946 let git = |args: &[&str]| {
947 let ok = Command::new("git")
948 .args(args)
949 .current_dir(&dir)
950 .output()
951 .unwrap()
952 .status
953 .success();
954 assert!(ok, "git {args:?}");
955 };
956 git(&["init", "--quiet"]);
957 git(&["remote", "add", "mine", "https://g1t.sh/ana/web.git"]);
958 git(&["remote", "add", "origin", "git@g1t.sh:acme/web.git"]);
959 git(&[
960 "remote",
961 "add",
962 "elsewhere",
963 "https://example.com/acme/other.git",
964 ]);
965 let out = g1t(
966 &mock,
967 &dir,
968 &["work", "list", "--json"],
969 None,
970 Some("g1t_test"),
971 );
972 assert_eq!(out.code, 0, "{}", out.stderr);
973 assert_eq!(out.stdout, "[]\n");
974 // The repository's workspace's, not only the repository's.
975 assert_eq!(mock.requests()[0].target, "/workspaces/acme/work?state=open");
976 // Outside any repository, and without --repo, it says what to do.
977 let bare = temp_dir("bare");
978 let out = g1t(&mock, &bare, &["work", "list"], None, Some("g1t_test"));
979 assert_eq!(out.code, 2);
980 assert!(out.stderr.contains("--workspace"), "{}", out.stderr);
981 // A workspace needs no repository.
982 let out = g1t(&mock, &bare, &["work", "list", "--workspace", "acme", "--json"], None, Some("g1t_test"));
983 assert_eq!(out.code, 0, "{}", out.stderr);
984 assert_eq!(mock.requests().last().unwrap().target, "/workspaces/acme/work?state=open");
985}
986
987/// Runs g1t in `dir` with a token and the environment `env` added.
988fn g1t_with(mock: &Mock, dir: &PathBuf, args: &[&str], env: &[(&str, &str)]) -> Run {
989 let mut command = Command::new(env!("CARGO_BIN_EXE_g1t"));
990 command
991 .args(args)
992 .current_dir(dir)
993 .env("G1T_API_URL", &mock.url)
994 .env("G1T_CONFIG_DIR", dir)
995 .env("G1T_KEYRING", "off")
996 .env("G1T_PROMPT_DISABLED", "1")
997 .env("NO_COLOR", "1")
998 .env("GIT_CEILING_DIRECTORIES", std::env::temp_dir())
999 .env("G1T_TOKEN", "g1t_test")
1000 .env_remove("G1T_REPO")
1001 .env_remove("G1T_HOST")
1002 .env_remove("G1T_SESSION")
1003 .stdin(Stdio::null());
1004 for (k, v) in env {
1005 command.env(k, v);
1006 }
1007 let Output { status, stdout, stderr } = command.output().unwrap();
1008 Run {
1009 code: status.code().unwrap_or(-1),
1010 stdout: String::from_utf8_lossy(&stdout).into_owned(),
1011 stderr: String::from_utf8_lossy(&stderr).into_owned(),
1012 }
1013}
1014
1015/// A working copy of acme/web.
1016fn working_copy(name: &str) -> PathBuf {
1017 let dir = temp_dir(name);
1018 for args in [
1019 &["init", "--quiet"][..],
1020 &["remote", "add", "origin", "https://g1t.sh/acme/web.git"][..],
1021 ] {
1022 assert!(Command::new("git").args(args).current_dir(&dir).status().unwrap().success());
1023 }
1024 dir
1025}
1026
1027fn session(id: &str) -> Value {
1028 json!({ "id": id, "state": "active", "harness": "claude_code", "item": 12, "actor": "ana" })
1029}
1030
1031#[test]
1032fn a_session_is_remembered_per_working_copy_and_claims_carry_it() {
1033 let mock = Mock::start(|r| match (r.method.as_str(), r.path()) {
1034 ("POST", "/repos/acme/web/sessions") => (200, session("ses_1")),
1035 ("POST", "/repos/acme/web/sessions/ses_1/entries") => (200, json!({ "recorded": 1 })),
1036 ("POST", "/repos/acme/web/sessions/ses_2/entries") => (200, json!({ "recorded": 1 })),
1037 ("POST", "/workspaces/acme/work/12/claim") => (
1038 200,
1039 json!({ "claim": { "id": "clm_1", "workspace": "acme", "item": 12, "actor": "ana" }, "others": [], "blocked_by": [] }),
1040 ),
1041 ("POST", "/repos/acme/web/sessions/ses_1/end") => (200, session("ses_1")),
1042 _ => (404, json!({ "error": { "message": "No such endpoint." } })),
1043 });
1044 let dir = working_copy("session");
1045 let out = g1t_with(
1046 &mock,
1047 &dir,
1048 &["session", "start", "--harness", "claude_code", "--harness-session-id", "cc-9", "--item", "12", "--json"],
1049 &[],
1050 );
1051 assert_eq!(out.code, 0, "{}", out.stderr);
1052 let started: Value = serde_json::from_str(&out.stdout).unwrap();
1053 assert_eq!(started["id"], "ses_1");
1054 let sent = mock.requests()[0].json();
1055 assert_eq!(sent["harness"], "claude_code");
1056 assert_eq!(sent["harness_session_id"], "cc-9");
1057 assert_eq!(sent["item"], 12);
1058 let kept = std::fs::read_to_string(dir.join(".git/g1t/session")).unwrap();
1059 assert_eq!(kept.trim(), "ses_1");
1060
1061 let out = g1t_with(
1062 &mock,
1063 &dir,
1064 &["session", "log", "--kind", "tool-call", "--tool", "Edit", "--", "--edited src/a.rs"],
1065 &[],
1066 );
1067 assert_eq!(out.code, 0, "{}", out.stderr);
1068 let logged = mock.requests().last().unwrap().clone();
1069 assert_eq!(logged.path(), "/repos/acme/web/sessions/ses_1/entries");
1070 assert_eq!(
1071 logged.json(),
1072 json!({ "entries": [{ "kind": "tool_call", "text": "--edited src/a.rs", "tool": "Edit" }] })
1073 );
1074
1075 // G1T_SESSION wins over the kept one.
1076 let out = g1t_with(&mock, &dir, &["session", "log", "hi"], &[("G1T_SESSION", "ses_2")]);
1077 assert_eq!(out.code, 0, "{}", out.stderr);
1078 assert_eq!(mock.requests().last().unwrap().path(), "/repos/acme/web/sessions/ses_2/entries");
1079
1080 let out = g1t_with(&mock, &dir, &["work", "claim", "12", "--note", "parser", "--ttl", "2h"], &[]);
1081 assert_eq!(out.code, 0, "{}", out.stderr);
1082 assert_eq!(
1083 mock.requests().last().unwrap().json(),
1084 json!({ "repo": "acme/web", "session_id": "ses_1", "note": "parser", "ttl_minutes": 120 })
1085 );
1086 assert!(out.stdout.contains("Claimed #12"), "{}", out.stdout);
1087
1088 let out = g1t_with(&mock, &dir, &["session", "end", "--reason", "done"], &[]);
1089 assert_eq!(out.code, 0, "{}", out.stderr);
1090 assert!(!dir.join(".git/g1t/session").exists(), "an ended session is forgotten");
1091}
1092
1093#[test]
1094fn coordination_without_a_session_or_a_token_fails_at_once() {
1095 let mock = Mock::start(|_| (200, json!({})));
1096 let dir = working_copy("nosession");
1097 let out = g1t_with(&mock, &dir, &["session", "heartbeat"], &[]);
1098 assert_eq!(out.code, 2);
1099 assert!(out.stderr.contains("g1t session start"), "{}", out.stderr);
1100 let out = g1t_with(&mock, &dir, &["work", "claim", "12"], &[("G1T_TOKEN", "")]);
1101 assert_eq!(out.code, 4, "{}", out.stderr);
1102 assert!(mock.requests().is_empty());
1103}
1104
1105#[test]
1106fn an_older_server_says_it_cannot_do_this_yet() {
1107 let mock = Mock::start(|_| {
1108 (404, json!({ "error": { "code": "not_found", "message": "No such endpoint." } }))
1109 });
1110 let out = run(&mock, &["work", "view", "12", "-R", "acme/web"]);
1111 assert_eq!(out.code, 1);
1112 assert!(out.stderr.contains("older than this CLI"), "{}", out.stderr);
1113 assert!(out.stderr.contains("/workspaces/acme/work/12/work"), "{}", out.stderr);
1114}
1115
1116#[test]
1117fn blocking_replaces_the_set_the_work_item_waits_on() {
1118 let mock = Mock::start(|r| match (r.method.as_str(), r.path()) {
1119 ("GET", "/workspaces/acme/work/12") => {
1120 (200, json!({ "item": { "number": 12, "blocked_by": [9] } }))
1121 }
1122 ("PATCH", "/workspaces/acme/work/12") => (200, json!({ "number": 12, "blocked_by": [9, 10] })),
1123 _ => (404, json!({ "error": { "message": "No such endpoint." } })),
1124 });
1125 let out = run(&mock, &["work", "block", "12", "--by", "10", "--by", "9", "-R", "acme/web"]);
1126 assert_eq!(out.code, 0, "{}", out.stderr);
1127 assert_eq!(mock.requests()[1].json(), json!({ "blocked_by": [9, 10] }));
1128}
1129
1130#[test]
1131fn events_follow_from_the_newest_and_resume_from_the_last() {
1132 let calls = Arc::new(Mutex::new(0));
1133 let count = calls.clone();
1134 let event = |id: &str, kind: &str, number: u64| {
1135 json!({ "id": id, "type": kind, "time": "2026-10-12T09:00:00Z", "data": { "number": number, "title": "t" } })
1136 };
1137 let mock = Mock::start(move |r| {
1138 let mut n = count.lock().unwrap();
1139 *n += 1;
1140 match *n {
1141 // Where following starts.
1142 1 => (200, json!([event("evt_5", "push", 0)])),
1143 // The past, newest first.
1144 2 => (200, json!([event("evt_5", "work.claimed", 12), event("evt_4", "work.claimed", 12)])),
1145 3 if r.target.contains("after=evt_5") => (200, json!([event("evt_6", "work.released", 12)])),
1146 4 if r.target.contains("after=evt_6") => (200, json!([event("evt_7", "work.claimed", 12)])),
1147 // A refused token ends the follow.
1148 _ => (401, json!({ "error": { "message": "Token expired." } })),
1149 }
1150 });
1151 let out = run(
1152 &mock,
1153 &[
1154 "work", "events", "--follow", "--item", "12", "--type", "work.claimed", "-t",
1155 "work.released", "--json", "id", "-R", "acme/web",
1156 ],
1157 );
1158 assert_eq!(out.code, 4, "{}", out.stderr);
1159 assert_eq!(
1160 out.stdout,
1161 "{\"id\":\"evt_4\"}\n{\"id\":\"evt_5\"}\n{\"id\":\"evt_6\"}\n{\"id\":\"evt_7\"}\n"
1162 );
1163 let seen = mock.requests();
1164 assert!(!seen[0].target.contains("numbers="), "{}", seen[0].target);
1165 assert!(seen[1].target.contains("numbers=12"), "{}", seen[1].target);
1166 assert!(seen[1].target.contains("types=work.claimed%2Cwork.released"), "{}", seen[1].target);
1167 assert!(seen[2].target.contains("wait=25"), "{}", seen[2].target);
1168}
1169
1170fn cache(id: u64, key: &str, size: u64) -> Value {
1171 json!({
1172 "id": id, "key": key, "ref": "refs/heads/main", "version": "", "size_in_bytes": size,
1173 "created_at": "2026-10-12T10:00:00.000Z", "last_accessed_at": "2026-10-12T11:00:00.000Z",
1174 })
1175}
1176
1177#[test]
1178fn cache_list_pages_through_the_entries_largest_first() {
1179 // 150 entries: a first page of 100, then the 50 the limit still wants.
1180 let mock = Mock::start(|r| {
1181 let ids = if r.target.contains("page=2") { 101..=150 } else { 1..=100 };
1182 (200, json!({ "total_count": 150, "actions_caches": ids.map(|n| cache(n, "cargo-x", 1024)).collect::<Vec<_>>() }))
1183 });
1184 let out = run(
1185 &mock,
1186 &["cache", "list", "-R", "acme/web", "--key", "cargo-", "--sort", "size-in-bytes", "--limit", "120", "--json", "id"],
1187 );
1188 assert_eq!(out.code, 0, "{}", out.stderr);
1189 let list: Vec<Value> = serde_json::from_str(&out.stdout).unwrap();
1190 assert_eq!(list.len(), 120);
1191 let seen = mock.requests();
1192 assert_eq!(seen.len(), 2);
1193 assert_eq!(seen[0].path(), "/repos/acme/web/actions/caches");
1194 for want in ["key=cargo-", "sort=size_in_bytes", "direction=desc", "per_page=100", "page=1"] {
1195 assert!(seen[0].target.contains(want), "{want} in {}", seen[0].target);
1196 }
1197 assert!(seen[1].target.contains("page=2"), "{}", seen[1].target);
1198}
1199
1200#[test]
1201fn cache_usage_says_how_full_the_repository_is() {
1202 let mock = Mock::start(|_| {
1203 (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 }))
1204 });
1205 let out = run(&mock, &["cache", "usage", "-R", "acme/web"]);
1206 assert_eq!(out.code, 0, "{}", out.stderr);
1207 assert_eq!(out.stdout, "acme/web: 12 entries holding 5.0 GiB of 10.0 GiB (50%)\n");
1208 assert_eq!(mock.requests()[0].target, "/repos/acme/web/actions/cache/usage");
1209}
1210
1211#[test]
1212fn cache_delete_takes_an_id_or_a_key() {
1213 let mock = Mock::start(|r| match r.path() {
1214 "/repos/acme/web/actions/caches/9184" => (204, Value::Null),
1215 _ => (200, json!({ "total_count": 2, "actions_caches": [cache(1, "npm-full-Linux-abc", 1), cache(2, "npm-full-Linux-abc", 1)] })),
1216 });
1217 let out = run(&mock, &["cache", "delete", "9184", "-R", "acme/web"]);
1218 assert_eq!(out.code, 0, "{}", out.stderr);
1219 let out = run(&mock, &["cache", "delete", "npm-full-Linux-abc", "--ref", "refs/heads/main", "-R", "acme/web"]);
1220 assert_eq!(out.code, 0, "{}", out.stderr);
1221 assert!(out.stderr.contains("Deleted 2 cache entries with the key npm-full-Linux-abc"), "{}", out.stderr);
1222 let seen = mock.requests();
1223 assert_eq!((seen[0].method.as_str(), seen[0].path()), ("DELETE", "/repos/acme/web/actions/caches/9184"));
1224 assert_eq!(seen[1].method, "DELETE");
1225 assert!(seen[1].target.starts_with("/repos/acme/web/actions/caches?key=npm-full-Linux-abc&ref=refs"), "{}", seen[1].target);
1226}
1227
1228fn workflows_mock(dispatched: Value) -> Mock {
1229 Mock::start(move |r| match (r.method.as_str(), r.path()) {
1230 ("GET", "/repos/acme/web/actions/workflows") => (
1231 200,
1232 json!([
1233 { "id": "wfl_1", "path": ".g1t/workflows/ci.yml", "name": "CI", "events": ["push", "pull_request"],
1234 "state": "active", "error": null, "notes": [], "dispatch": null, "last_run": null },
1235 { "id": "wfl_2", "path": ".g1t/workflows/release.yml", "name": "Release", "events": ["workflow_dispatch"],
1236 "state": "active", "error": null, "notes": [],
1237 "dispatch": { "channel": { "type": "choice", "options": ["beta", "stable"], "default": "beta" } }, "last_run": null },
1238 { "id": "wfl_3", "path": ".g1t/workflows/nightly.yml", "name": "Nightly", "events": ["schedule"],
1239 "state": "disabled", "error": null, "notes": [], "dispatch": null, "last_run": null }
1240 ]),
1241 ),
1242 ("GET", "/repos/acme/web/actions/workflows/wfl_2") => (
1243 200,
1244 json!({ "id": "wfl_2", "path": ".g1t/workflows/release.yml", "name": "Release", "state": "active",
1245 "ref": "v1.4.0", "source": "name: Release\non: workflow_dispatch\n" }),
1246 ),
1247 ("GET", "/repos/acme/web/actions/runs") => (
1248 200,
1249 json!([{ "id": "run_9", "workflow_id": "wfl_2", "name": "Release", "title": "Release run by ana", "number": 4,
1250 "event": "workflow_dispatch", "ref": "refs/heads/main", "status": "queued", "conclusion": null,
1251 "created_at": "2999-01-01T00:00:00Z" }]),
1252 ),
1253 ("POST", "/repos/acme/web/actions/workflows/wfl_2/dispatches") => (200, dispatched.clone()),
1254 ("PUT", p) if p.ends_with("/enable") || p.ends_with("/disable") => (
1255 200,
1256 json!({ "id": "wfl_3", "path": ".g1t/workflows/nightly.yml", "name": "Nightly",
1257 "state": if p.ends_with("/enable") { "active" } else { "disabled" } }),
1258 ),
1259 _ => (404, json!({ "error": { "message": "No such endpoint." } })),
1260 })
1261}
1262
1263fn release_run() -> Value {
1264 json!({ "id": "run_9", "workflow_id": "wfl_2", "name": "Release", "number": 4, "event": "workflow_dispatch",
1265 "ref": "refs/tags/v1.4.0", "status": "queued" })
1266}
1267
1268#[test]
1269fn workflow_list_shows_those_turned_on_unless_all() {
1270 let mock = workflows_mock(json!({}));
1271 let out = run(&mock, &["workflow", "list", "-R", "acme/web"]);
1272 assert_eq!(out.code, 0, "{}", out.stderr);
1273 let names: Vec<&str> = out
1274 .stdout
1275 .lines()
1276 .map(|l| l.split('\t').next().unwrap())
1277 .collect();
1278 assert_eq!(names, ["CI", "Release"]);
1279 assert_eq!(
1280 out.stdout.lines().next().unwrap(),
1281 "CI\tactive\tci.yml\tpush, pull_request\twfl_1"
1282 );
1283 let out = run(
1284 &mock,
1285 &[
1286 "workflow", "list", "--all", "-R", "acme/web", "--jq", ".[].path",
1287 ],
1288 );
1289 assert_eq!(out.code, 0, "{}", out.stderr);
1290 assert_eq!(out.stdout.lines().count(), 3);
1291}
1292
1293#[test]
1294fn workflow_view_takes_a_name_and_shows_its_latest_runs() {
1295 let mock = workflows_mock(json!({}));
1296 let out = run(
1297 &mock,
1298 &[
1299 "workflow",
1300 "view",
1301 "release",
1302 "-R",
1303 "acme/web",
1304 "--json",
1305 "id,runs,url",
1306 ],
1307 );
1308 assert_eq!(out.code, 0, "{}", out.stderr);
1309 let shown: Value = serde_json::from_str(&out.stdout).unwrap();
1310 assert_eq!(shown["id"], "wfl_2");
1311 assert_eq!(shown["runs"][0]["id"], "run_9");
1312 assert_eq!(
1313 shown["url"],
1314 "https://g1t.sh/acme/web/actions?workflow=wfl_2"
1315 );
1316 let runs = mock
1317 .requests()
1318 .into_iter()
1319 .find(|r| r.path() == "/repos/acme/web/actions/runs")
1320 .unwrap();
1321 assert_eq!(
1322 runs.target,
1323 "/repos/acme/web/actions/runs?workflow=wfl_2&per_page=5"
1324 );
1325 // Its file, on a tag: only the file when piped.
1326 let out = run(
1327 &mock,
1328 &[
1329 "workflow",
1330 "view",
1331 "release.yml",
1332 "--yaml",
1333 "--ref",
1334 "v1.4.0",
1335 "-R",
1336 "acme/web",
1337 ],
1338 );
1339 assert_eq!(out.code, 0, "{}", out.stderr);
1340 assert_eq!(out.stdout, "name: Release\non: workflow_dispatch\n");
1341 assert!(
1342 mock.requests()
1343 .iter()
1344 .any(|r| r.target == "/repos/acme/web/actions/workflows/wfl_2?ref=v1.4.0")
1345 );
1346 // --ref is for --yaml.
1347 let out = run(
1348 &mock,
1349 &[
1350 "workflow", "view", "ci.yml", "--ref", "main", "-R", "acme/web",
1351 ],
1352 );
1353 assert_eq!(out.code, 2);
1354}
1355
1356#[test]
1357fn workflow_run_sends_the_ref_and_inputs_and_prints_the_run() {
1358 let mock = workflows_mock(release_run());
1359 let out = run(
1360 &mock,
1361 &[
1362 "workflow",
1363 "run",
1364 "Release",
1365 "--ref",
1366 "v1.4.0",
1367 "-f",
1368 "channel=stable",
1369 "-R",
1370 "acme/web",
1371 ],
1372 );
1373 assert_eq!(out.code, 0, "{}", out.stderr);
1374 assert_eq!(
1375 out.stdout.trim(),
1376 "https://g1t.sh/acme/web/actions/runs/run_9"
1377 );
1378 assert!(
1379 out.stderr
1380 .contains("Started Release (release.yml) #4 on v1.4.0"),
1381 "{}",
1382 out.stderr
1383 );
1384 let sent = mock
1385 .requests()
1386 .into_iter()
1387 .find(|r| r.method == "POST")
1388 .unwrap();
1389 assert_eq!(
1390 sent.json(),
1391 json!({ "ref": "v1.4.0", "inputs": { "channel": "stable" } })
1392 );
1393}
1394
1395#[test]
1396fn workflow_run_json_reads_the_inputs_from_stdin() {
1397 let mock = workflows_mock(release_run());
1398 let dir = temp_dir("dispatch");
1399 let out = g1t(
1400 &mock,
1401 &dir,
1402 &["workflow", "run", "release.yml", "--json", "-R", "acme/web"],
1403 Some("{\"channel\":\"beta\",\"dry_run\":true}"),
1404 Some("g1t_test"),
1405 );
1406 assert_eq!(out.code, 0, "{}", out.stderr);
1407 assert_eq!(
1408 out.stdout.trim(),
1409 "https://g1t.sh/acme/web/actions/runs/run_9"
1410 );
1411 let sent = mock
1412 .requests()
1413 .into_iter()
1414 .find(|r| r.method == "POST")
1415 .unwrap();
1416 assert_eq!(
1417 sent.json(),
1418 json!({ "inputs": { "channel": "beta", "dry_run": true } })
1419 );
1420 // Not an object, or with -f as well, is using it wrongly.
1421 let out = g1t(
1422 &mock,
1423 &dir,
1424 &["workflow", "run", "release.yml", "--json", "-R", "acme/web"],
1425 Some("[1]"),
1426 Some("g1t_test"),
1427 );
1428 assert_eq!(out.code, 2);
1429 let out = g1t(
1430 &mock,
1431 &dir,
1432 &[
1433 "workflow",
1434 "run",
1435 "release.yml",
1436 "--json",
1437 "-f",
1438 "a=b",
1439 "-R",
1440 "acme/web",
1441 ],
1442 Some("{}"),
1443 Some("g1t_test"),
1444 );
1445 assert_eq!(out.code, 2);
1446}
1447
1448#[test]
1449fn workflow_run_looks_for_the_run_when_the_answer_lacks_it() {
1450 let mock = workflows_mock(json!({}));
1451 let out = run(
1452 &mock,
1453 &["workflow", "run", "wfl_2", "-R", "acme/web", "--jq", ".id"],
1454 );
1455 assert_eq!(out.code, 0, "{}", out.stderr);
1456 assert_eq!(out.stdout, "run_9\n");
1457 assert!(mock.requests().iter().any(|r| r.target
1458 == "/repos/acme/web/actions/runs?workflow=wfl_2&event=workflow_dispatch&per_page=1"));
1459}
1460
1461#[test]
1462fn workflow_enable_and_disable_put_its_state() {
1463 let mock = workflows_mock(json!({}));
1464 let out = run(
1465 &mock,
1466 &["workflow", "enable", "nightly.yml", "-R", "acme/web"],
1467 );
1468 assert_eq!(out.code, 0, "{}", out.stderr);
1469 assert!(
1470 out.stderr.contains("Turned Nightly (nightly.yml) on"),
1471 "{}",
1472 out.stderr
1473 );
1474 let out = run(
1475 &mock,
1476 &[
1477 "workflow", "disable", "Nightly", "-R", "acme/web", "--json", "state",
1478 ],
1479 );
1480 assert_eq!(out.stdout, "{\"state\":\"disabled\"}\n");
1481 let puts: Vec<String> = mock
1482 .requests()
1483 .into_iter()
1484 .filter(|r| r.method == "PUT")
1485 .map(|r| r.path().to_owned())
1486 .collect();
1487 assert_eq!(
1488 puts,
1489 [
1490 "/repos/acme/web/actions/workflows/wfl_3/enable",
1491 "/repos/acme/web/actions/workflows/wfl_3/disable"
1492 ]
1493 );
1494}
1495
1496#[test]
1497fn a_workflow_that_is_not_there_or_not_named_fails_without_asking() {
1498 let mock = workflows_mock(json!({}));
1499 let out = run(&mock, &["workflow", "view", "deploy.yml", "-R", "acme/web"]);
1500 assert_eq!(out.code, 1);
1501 assert!(
1502 out.stderr
1503 .contains("There is no workflow deploy.yml in acme/web"),
1504 "{}",
1505 out.stderr
1506 );
1507 let out = run(&mock, &["workflow", "run", "-R", "acme/web"]);
1508 assert_eq!(out.code, 2);
1509 assert!(
1510 out.stderr.contains("The workflow is needed"),
1511 "{}",
1512 out.stderr
1513 );
1514}
1515
1516#[test]
1517fn codex_and_claude_use_the_same_inbox_and_decision_protocol() {
1518 let mock = Mock::start(|r| {
1519 if r.method == "POST" && r.path() == "/repos/acme/web/sessions" {
1520 return (200, session(&format!("ses_{}", r.json()["harness"].as_str().unwrap())));
1521 }
1522 if r.method == "GET" && r.path().contains("/sessions/ses_") {
1523 assert!(r.target.contains("after=4294967295"));
1524 return (200, json!({ "updates": [{ "id": "upd_overlap", "kind": "overlap.detected", "data": { "path": "src/api.rs" }, "at": "2026-10-14T00:00:00Z" }] }));
1525 }
1526 if r.method == "POST" && r.path().ends_with("/entries") {
1527 assert_eq!(r.json()["acknowledge"], json!(["upd_overlap"]));
1528 assert_eq!(r.json()["entries"][0]["kind"], "note");
1529 assert!(r.json()["entries"][0]["text"].as_str().unwrap().contains("I will change tests only"));
1530 return (200, json!({ "recorded": 1, "duplicates": 0 }));
1531 }
1532 (404, json!({}))
1533 });
1534 let dir = working_copy("harness-inbox");
1535 for harness in ["codex", "claude_code"] {
1536 let id = format!("ses_{harness}");
1537 let out = g1t_with(&mock, &dir, &["session", "start", "--harness", harness, "--no-remember", "--json"], &[]);
1538 assert_eq!(out.code, 0, "{}", out.stderr);
1539 assert!(!dir.join(".git/g1t/session").exists());
1540 let out = g1t_with(&mock, &dir, &["session", "updates", "--json"], &[("G1T_SESSION", &id)]);
1541 assert_eq!(out.code, 0, "{}", out.stderr);
1542 let found: Value = serde_json::from_str(&out.stdout).unwrap();
1543 assert_eq!(found["session_id"], id);
1544 assert_eq!(found["updates"][0]["id"], "upd_overlap");
1545 // Explicit identity overrides a conflicting environment, including in a shared checkout.
1546 let out = g1t_with(&mock, &dir, &["session", "acknowledge", "upd_overlap", "--session", &id, "--note", "I will change tests only"], &[("G1T_SESSION", "ses_wrong")]);
1547 assert_eq!(out.code, 0, "{}", out.stderr);
1548 assert_eq!(mock.requests().last().unwrap().path(), format!("/repos/acme/web/sessions/{id}/entries"));
1549 }
1550}
1551
1552#[test]
1553fn an_inbox_missing_from_an_old_server_is_not_reported_as_empty() {
1554 let mock = Mock::start(|_| (200, json!({ "entries": [] })));
1555 let out = run(&mock, &["session", "updates", "ses_a", "-R", "acme/web", "--json"]);
1556 assert_eq!(out.code, 1);
1557 assert!(out.stderr.contains("does not expose session updates"));
1558}
1559
1560#[test]
1561fn any_harness_checking_its_updates_says_which_files_it_changed() {
1562 let mock = Mock::start(|r| {
1563 if r.method == "POST" && r.path().ends_with("/entries") {
1564 return (200, json!({ "recorded": 0, "duplicates": 0, "acknowledged": 0 }));
1565 }
1566 (200, json!({ "updates": [] }))
1567 });
1568 let dir = working_copy("auto-touch");
1569 std::fs::create_dir_all(dir.join("src")).unwrap();
1570 std::fs::write(dir.join("src/api.ts"), "export const x = 1;\n").unwrap();
1571 let out = g1t_with(&mock, &dir, &["session", "updates", "--json"], &[("G1T_SESSION", "ses_a")]);
1572 assert_eq!(out.code, 0, "{}", out.stderr);
1573 let requests = mock.requests();
1574 // Said before the inbox is read, so an overlap it makes is read with it.
1575 assert!(requests[0].path().ends_with("/sessions/ses_a/entries"), "{}", requests[0].path());
1576 assert_eq!(requests[0].json()["touched"], json!([{ "kind": "file", "value": "src/api.ts" }]));
1577 assert!(requests[1].path().ends_with("/sessions/ses_a"));
1578 // Not with G1T_TOUCH=0.
1579 let before = mock.requests().len();
1580 let out = g1t_with(&mock, &dir, &["session", "updates", "--json"], &[("G1T_SESSION", "ses_a"), ("G1T_TOUCH", "0")]);
1581 assert_eq!(out.code, 0, "{}", out.stderr);
1582 assert_eq!(mock.requests().len(), before + 1);
1583}
1584
1585fn repo_json(namespace: &str, name: &str, parent: Option<(&str, &str)>) -> Value {
1586 json!({
1587 "id": format!("rep_{name}"), "namespace": namespace, "name": name,
1588 "is_private": false, "default_branch": "main", "fork_of": null, "archived_at": null,
1589 "parent_id": parent.map(|_| "rep_parent"),
1590 "parent": parent.map(|(owner, name)| json!({ "namespace": owner, "name": name })),
1591 "created_at": "2026-10-14T09:30:12.418Z",
1592 })
1593}
1594
1595/// A clone of ana/web, a fork of acme/web: origin is the fork, upstream
1596/// the repository it came from.
1597fn fork_clone(name: &str) -> PathBuf {
1598 let dir = temp_dir(name);
1599 for args in [
1600 &["init", "--quiet"][..],
1601 &["remote", "add", "origin", "https://g1t.sh/ana/web.git"][..],
1602 &["remote", "add", "upstream", "https://g1t.sh/acme/web.git"][..],
1603 ] {
1604 assert!(Command::new("git").args(args).current_dir(&dir).status().unwrap().success());
1605 }
1606 dir
1607}
1608
1609#[test]
1610fn repo_fork_sends_where_and_what_to_copy() {
1611 let mock = Mock::start(|r| match (r.method.as_str(), r.path()) {
1612 ("POST", "/repos/acme/web/forks") => (200, repo_json("flagon-io", "site", Some(("acme", "web")))),
1613 _ => (404, json!({ "error": { "code": "not_found", "message": "No." } })),
1614 });
1615 let out = run(&mock, &["repo", "fork", "acme/web", "--workspace", "flagon-io", "--name", "site", "--all-branches"]);
1616 assert_eq!(out.code, 0, "{}", out.stderr);
1617 assert!(out.stderr.contains("Forked acme/web to flagon-io/site"), "{}", out.stderr);
1618 assert_eq!(out.stdout.trim(), "https://g1t.sh/flagon-io/site");
1619 assert_eq!(
1620 mock.requests()[0].json(),
1621 json!({ "workspace": "flagon-io", "name": "site", "default_branch_only": false })
1622 );
1623 // By default: the default branch only, into the API's choice of workspace.
1624 let out = run(&mock, &["repo", "fork", "acme/web", "--json"]);
1625 assert_eq!(out.code, 0, "{}", out.stderr);
1626 assert_eq!(mock.requests()[1].json(), json!({ "default_branch_only": true }));
1627}
1628
1629#[test]
1630fn repo_view_says_what_a_fork_came_from_and_forks_lists_them() {
1631 let mock = Mock::start(|r| match (r.method.as_str(), r.path()) {
1632 ("GET", "/repos/ana/web") => (200, repo_json("ana", "web", Some(("acme", "web")))),
1633 ("GET", "/repos/acme/web/forks") => (200, json!([repo_json("ana", "web", Some(("acme", "web")))])),
1634 // A repository has no description: its own project's is shown.
1635 ("GET", "/workspaces/ana/projects") => (
1636 200,
1637 json!([
1638 { "slug": "web", "repository": "ana/web", "primary": true, "description": "A web app." },
1639 { "slug": "docs", "repository": "ana/web", "primary": false, "description": "Not this one." },
1640 ]),
1641 ),
1642 _ => (404, json!({ "error": { "code": "not_found", "message": "No." } })),
1643 });
1644 let out = run(&mock, &["repo", "view", "ana/web"]);
1645 assert_eq!(out.code, 0, "{}", out.stderr);
1646 assert!(out.stdout.contains("Forked from acme/web"), "{}", out.stdout);
1647 assert!(out.stdout.contains("A web app."), "{}", out.stdout);
1648 assert!(!out.stdout.contains("Not this one."), "{}", out.stdout);
1649 let out = run(&mock, &["repo", "forks", "acme/web", "-L", "5"]);
1650 assert_eq!(out.code, 0, "{}", out.stderr);
1651 assert!(out.stdout.contains("ana/web"), "{}", out.stdout);
1652 assert!(out.stdout.contains("A web app."), "{}", out.stdout);
1653 let targets: Vec<String> = mock.requests().iter().map(|r| r.target.clone()).collect();
1654 assert!(targets.contains(&"/repos/acme/web/forks?limit=5".to_owned()), "{targets:?}");
1655}
1656
1657#[test]
1658fn repo_sync_syncs_the_fork_origin_names_and_fails_on_conflicts() {
1659 let mock = Mock::start(|r| match (r.method.as_str(), r.path()) {
1660 ("POST", "/repos/ana/web/merge-upstream") if r.json()["branch"] == "docs" => (
1661 200,
1662 json!({ "status": "conflicted", "branch": "docs", "head": "abc1234def", "behind": 2, "ahead": 1, "conflicts": ["README.md"] }),
1663 ),
1664 ("POST", "/repos/ana/web/merge-upstream") => (
1665 200,
1666 json!({ "status": "fast_forwarded", "branch": "main", "head": "9c4e1f0b7a", "behind": 3, "ahead": 0, "conflicts": [] }),
1667 ),
1668 _ => (404, json!({ "error": { "code": "not_found", "message": "No." } })),
1669 });
1670 // In a clone of the fork, origin is the fork, though upstream is preferred elsewhere.
1671 let dir = fork_clone("sync");
1672 let out = g1t_with(&mock, &dir, &["repo", "sync"], &[]);
1673 assert_eq!(out.code, 0, "{}", out.stderr);
1674 assert!(out.stderr.contains("took in 3 commits"), "{}", out.stderr);
1675 assert_eq!(mock.requests()[0].json(), json!({}));
1676 let out = g1t_with(&mock, &dir, &["repo", "sync", "--branch", "docs"], &[]);
1677 assert_eq!(out.code, 1, "{}", out.stderr);
1678 assert!(out.stderr.contains("README.md"), "{}", out.stderr);
1679 assert!(out.stderr.contains("git pull upstream docs"), "{}", out.stderr);
1680}
1681
1682#[test]
1683fn repo_detach_asks_for_yes_without_a_terminal() {
1684 let mock = Mock::start(|r| match (r.method.as_str(), r.path()) {
1685 ("POST", "/repos/ana/web/detach") => (200, repo_json("ana", "web", None)),
1686 _ => (404, json!({ "error": { "code": "not_found", "message": "No." } })),
1687 });
1688 let out = run(&mock, &["repo", "detach", "ana/web"]);
1689 assert_eq!(out.code, 2, "{}", out.stderr);
1690 assert!(out.stderr.contains("--yes"), "{}", out.stderr);
1691 assert!(mock.requests().is_empty());
1692 let out = run(&mock, &["repo", "detach", "ana/web", "--yes"]);
1693 assert_eq!(out.code, 0, "{}", out.stderr);
1694 assert_eq!(mock.requests()[0].path(), "/repos/ana/web/detach");
1695}
1696
1697#[test]
1698fn pr_create_in_a_fork_clone_proposes_the_forks_branch() {
1699 let mock = Mock::start(|r| match (r.method.as_str(), r.path()) {
1700 ("GET", "/repos/acme/web") => (200, repo_json("acme", "web", None)),
1701 ("POST", "/repos/acme/web/pulls") => (200, json!({ "pull": { "number": 7, "status": "open" } })),
1702 _ => (404, json!({ "error": { "code": "not_found", "message": "No." } })),
1703 });
1704 let dir = fork_clone("fork-pr");
1705 // The fork's own main branch can hold the change.
1706 let out = g1t_with(&mock, &dir, &["pr", "create", "--head", "main", "--title", "Fix the link", "--body", "b", "--no-push"], &[]);
1707 assert_eq!(out.code, 0, "{}", out.stderr);
1708 let sent = mock.requests()[1].json();
1709 assert_eq!(sent["head_repo"], "ana/web");
1710 assert_eq!(sent["branch"], "main");
1711 assert!(out.stderr.contains("from ana/web main"), "{}", out.stderr);
1712}