1use std::io::Read;
4use std::process::{Command, Stdio};
5use std::time::{Duration, SystemTime};
6
7use crate::config::{Config, StatusColumn};
8use crate::contexts::Context;
9use crate::errors::{Result, msg};
10use crate::git::new_command;
11
12const TIMEOUT: Duration = Duration::from_secs(2);
13
14const AGENT_STALE_SECONDS: f64 = 3600.0;
15
16const GITHUB_QUERY: &str = "
17query($owner: String!, $repo: String!, $branch: String!) {
18 repository(owner: $owner, name: $repo) {
19 pullRequests(headRefName: $branch, first: 1, orderBy: {field: CREATED_AT, direction: DESC}) {
20 nodes {
21 state
22 isDraft
23 mergeable
24 commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }
25 }
26 }
27 }
28}
29";
30const GITHUB_JQ: &str = ".data.repository.pullRequests.nodes[0]\
31 | if . == null then empty else\
32 [.state, (.isDraft | tostring), .mergeable,\
33 (.commits.nodes[0].commit.statusCheckRollup.state // \"NONE\")]\
34 | join(\" \") end";
35
36fn run(mut cmd: Command, ctx: &Context) -> Option<String> {
42 use wait_timeout::ChildExt;
43
44 cmd.current_dir(&ctx.path)
45 .env("CTX_REPO", &ctx.repo)
46 .env("CTX_NAME", &ctx.name)
47 .stdout(Stdio::piped())
48 .stderr(Stdio::null());
49 let deadline = std::time::Instant::now() + TIMEOUT;
50 let mut child = cmd.spawn().ok()?;
51 let mut stdout = child.stdout.take()?;
56 let (tx, rx) = std::sync::mpsc::channel();
57 std::thread::spawn(move || {
58 let mut buf = Vec::new();
59 let _ = stdout.read_to_end(&mut buf);
60 let _ = tx.send(buf);
61 });
62 let status = match child.wait_timeout(TIMEOUT).ok()? {
63 Some(status) => status,
64 None => {
65 let _ = child.kill();
66 let _ = child.wait();
67 return None;
68 }
69 };
70 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
71 let buf = rx.recv_timeout(remaining).ok()?;
72 if !status.success() {
73 return None;
74 }
75 let text = String::from_utf8_lossy(&buf);
76 let first = text.trim().lines().next()?.trim().to_string();
77 Some(first)
78}
79
80fn run_argv(argv: &[&str], ctx: &Context) -> Option<String> {
81 let mut cmd = new_command(argv[0]);
82 cmd.args(&argv[1..]);
83 run(cmd, ctx)
84}
85
86fn run_shell(command: &str, ctx: &Context) -> Option<String> {
87 let mut cmd = new_command("sh");
88 cmd.args(["-c", command]);
89 run(cmd, ctx)
90}
91
92pub fn command_status(ctx: &Context, command: &str) -> Option<String> {
94 run_shell(command, ctx)
95}
96
97pub fn agent_status(ctx: &Context) -> Option<String> {
105 let path = ctx.path.join(".git").join("agent-status");
106 let mtime = std::fs::metadata(&path)
107 .and_then(|meta| meta.modified())
108 .ok()?;
109 let age = SystemTime::now()
110 .duration_since(mtime)
111 .unwrap_or(Duration::ZERO)
112 .as_secs_f64();
113 if age > AGENT_STALE_SECONDS {
114 return None;
115 }
116 let text = std::fs::read_to_string(&path).ok()?;
117 let word = text.trim().lines().next()?.trim().to_string();
118 if word.is_empty() {
119 return None;
120 }
121 if word == "working" || word == "monitoring" {
122 return Some(format!("{word} {}", elapsed(age)));
123 }
124 Some(word)
125}
126
127fn elapsed(seconds: f64) -> String {
130 let whole = seconds as u64;
131 if whole < 60 {
132 format!("{whole}s")
133 } else if whole < 3600 {
134 format!("{}m", whole / 60)
135 } else {
136 format!("{}h{}m", whole / 3600, whole % 3600 / 60)
137 }
138}
139
140pub fn github_repo(url: &str) -> Result<(String, String)> {
142 let trimmed = url.strip_suffix(".git").unwrap_or(url).replace(':', "/");
143 let mut parts = trimmed.rsplitn(3, '/');
144 let repo = parts.next().unwrap_or_default();
145 let owner = parts.next().unwrap_or_default();
146 if parts.next().is_none() || owner.is_empty() || repo.is_empty() {
147 return msg(format!("cannot parse owner/repo from remote URL '{url}'"));
148 }
149 Ok((owner.to_string(), repo.to_string()))
150}
151
152fn github_query(ctx: &Context, query: &str, jq: &str) -> Option<String> {
156 let origin = run_argv(&["git", "remote", "get-url", "origin"], ctx)?;
157 let branch = run_argv(&["git", "branch", "--show-current"], ctx)?;
158 let (owner, repo) = github_repo(&origin).ok()?;
159 let state = run_argv(
160 &[
161 "gh",
162 "api",
163 "graphql",
164 "-F",
165 &format!("owner={owner}"),
166 "-F",
167 &format!("repo={repo}"),
168 "-F",
169 &format!("branch={branch}"),
170 "-f",
171 &format!("query={query}"),
172 "--jq",
173 jq,
174 ],
175 ctx,
176 )?;
177 if state.is_empty() {
178 return None;
179 }
180 Some(state.to_lowercase())
181}
182
183pub fn github_status(ctx: &Context) -> Option<String> {
188 let raw = github_query(ctx, GITHUB_QUERY, GITHUB_JQ)?;
189 github_state(&raw)
190}
191
192fn github_state(raw: &str) -> Option<String> {
194 let parts: Vec<&str> = raw.split_whitespace().collect();
195 let [state, draft, mergeable, ci] = parts.as_slice() else {
196 return None;
197 };
198 let fact = if *state == "merged" || *state == "closed" {
199 state
200 } else if *mergeable == "conflicting" {
201 "conflicts"
202 } else if *ci == "failure" || *ci == "error" {
203 "failing"
204 } else if *draft == "true" {
205 "draft"
206 } else if *ci == "pending" || *ci == "expected" {
207 "pending"
208 } else {
209 "ready"
210 };
211 Some(fact.to_string())
212}
213
214fn builtin_icon(builtin: &str, word: &str, nerd_font: bool) -> Option<&'static str> {
217 if builtin != "github" {
218 return None;
219 }
220 if nerd_font {
221 match word {
222 "merged" => Some("\u{f419}"), "closed" => Some("\u{f05e}"), "conflicts" => Some("\u{f071}"), "failing" => Some("\u{f00d}"), "draft" => Some("\u{f040}"), "pending" => Some("\u{f017}"), "ready" => Some("\u{f00c}"), _ => None,
230 }
231 } else {
232 match word {
233 "merged" => Some("◆"),
234 "closed" => Some("⊘"),
235 "conflicts" => Some("⚠"),
236 "failing" => Some("✖"),
237 "draft" => Some("✎"),
238 "pending" => Some("◌"),
239 "ready" => Some("✔"),
240 _ => None,
241 }
242 }
243}
244
245pub fn cell_icon(column: &StatusColumn, cell: &str, nerd_font: bool) -> String {
250 let (word, rest) = match cell.split_once(' ') {
251 Some((word, rest)) => (word, rest),
252 None => (cell, ""),
253 };
254 let display = column
255 .builtin
256 .as_deref()
257 .and_then(|builtin| builtin_icon(builtin, word, nerd_font))
258 .unwrap_or(word);
259 if rest.is_empty() {
260 display.to_string()
261 } else {
262 format!("{display} {rest}")
263 }
264}
265
266pub fn cell_style(cell: &str) -> Option<&'static str> {
268 let word = cell.split(' ').next().unwrap_or(cell);
269 STATUS_STYLES
270 .iter()
271 .find(|(known, _)| *known == word)
272 .map(|(_, style)| *style)
273}
274
275pub const STATUS_STYLES: &[(&str, &str)] = &[
280 ("working", "bold bright_green"),
281 ("monitoring", "bold bright_cyan"),
282 ("open", "bold bright_green"),
283 ("success", "bold bright_green"),
284 ("idle", "bold bright_yellow"),
285 ("pending", "bold bright_yellow"),
286 ("blocked", "bold bright_red"),
287 ("closed", "bold bright_red"),
288 ("failure", "bold bright_red"),
289 ("error", "bold bright_red"),
290 ("failing", "bold bright_red"),
291 ("conflicts", "bold bright_yellow"),
292 ("ready", "bold bright_green"),
293 ("merged", "bold bright_magenta"),
294 ("draft", "bright_black"),
295];
296
297pub fn refresh_interval(column: &StatusColumn) -> f64 {
299 if let Some(interval) = column.interval {
300 return interval;
301 }
302 match column.builtin.as_deref() {
306 Some("github") => 30.0,
307 _ => 0.0,
308 }
309}
310
311pub fn column_status(ctx: &Context, column: &StatusColumn) -> Option<String> {
312 if let Some(command) = &column.command {
313 return command_status(ctx, command);
314 }
315 match column.builtin.as_deref().expect("column has a builtin") {
317 "agent" => agent_status(ctx),
318 "github" => github_status(ctx),
319 other => unreachable!("unknown status builtin '{other}' survived config parsing"),
320 }
321}
322
323#[cfg(test)]
325use crate::testutil::propagate_env as carry_env;
326#[cfg(not(test))]
327fn carry_env<R, F: FnOnce() -> R + Send>(f: F) -> F {
328 f
329}
330
331pub fn git_state(ctx: &Context) -> String {
333 let (dirty, unpushed) = std::thread::scope(|scope| {
335 let dirty = scope.spawn(carry_env(|| {
336 run_argv(&["git", "status", "--porcelain"], ctx)
337 }));
338 let unpushed = run_argv(
339 &[
340 "git",
341 "rev-list",
342 "--count",
343 "--branches",
344 "--not",
345 "--remotes",
346 ],
347 ctx,
348 );
349 (dirty.join().unwrap_or(None), unpushed)
350 });
351 let mut parts = Vec::new();
352 if dirty.is_some_and(|out| !out.is_empty()) {
353 parts.push("*".to_string());
354 }
355 if let Some(count) = unpushed
356 && !count.is_empty()
357 && count != "0"
358 {
359 parts.push(format!("↑{count}"));
360 }
361 parts.join(" ")
362}
363
364pub fn status_cells(cfg: &Config, ctx: &Context) -> Vec<String> {
366 let (state, cells) = std::thread::scope(|scope| {
369 let columns: Vec<_> = cfg
370 .status
371 .iter()
372 .map(|column| scope.spawn(carry_env(move || column_status(ctx, column))))
373 .collect();
374 let state = git_state(ctx);
375 let cells: Vec<Option<String>> = columns
376 .into_iter()
377 .map(|handle| handle.join().unwrap_or(None))
378 .collect();
379 (state, cells)
380 });
381 let mut out = vec![state];
382 for (column, cell) in cfg.status.iter().zip(cells) {
383 out.push(match cell {
384 Some(cell) if !cell.is_empty() => cell_icon(column, &cell, cfg.nerd_font),
385 _ => String::new(),
386 });
387 }
388 out
389}
390
391#[cfg(test)]
392mod tests {
393 use std::path::PathBuf;
394
395 use super::*;
396 use crate::config;
397 use crate::repos::add_repo;
398 use crate::testutil::{TestEnv, commit_file, test_env};
399
400 fn context() -> (TestEnv, Context) {
401 let env = test_env();
402 let origin = env.origin();
403 add_repo(&env.cfg, &origin.to_string_lossy(), None).unwrap();
404 let ctx = crate::contexts::create_context(&env.cfg, "origin", "feat", None).unwrap();
405 (env, ctx)
406 }
407
408 fn column(
409 name: &str,
410 command: Option<&str>,
411 builtin: Option<&str>,
412 interval: Option<f64>,
413 ) -> StatusColumn {
414 StatusColumn {
415 name: name.to_string(),
416 command: command.map(str::to_string),
417 builtin: builtin.map(str::to_string),
418 interval,
419 }
420 }
421
422 fn set_mtime_secs_ago(path: &PathBuf, ago: u64) {
423 let when = SystemTime::now() - Duration::from_secs(ago);
424 let file = std::fs::OpenOptions::new().write(true).open(path).unwrap();
425 file.set_times(
426 std::fs::FileTimes::new()
427 .set_accessed(when)
428 .set_modified(when),
429 )
430 .unwrap();
431 }
432
433 #[test]
434 fn every_allowlisted_builtin_dispatches() {
435 let (_env, ctx) = context();
438
439 for builtin in config::BUILTIN_STATUS {
440 column_status(&ctx, &column(builtin, None, Some(builtin), None));
441 }
442 }
443
444 #[test]
445 fn github_state_collapses_to_the_most_urgent_fact() {
446 for (raw, state) in [
447 ("merged false mergeable none", Some("merged")),
448 ("closed false conflicting failure", Some("closed")),
449 ("open false conflicting success", Some("conflicts")),
450 ("open true mergeable failure", Some("failing")),
451 ("open false mergeable error", Some("failing")),
452 ("open true mergeable success", Some("draft")),
453 ("open false mergeable pending", Some("pending")),
454 ("open false unknown success", Some("ready")),
455 ("open false mergeable none", Some("ready")),
456 ("garbage", None),
457 ] {
458 assert_eq!(github_state(raw).as_deref(), state, "raw: {raw}");
459 }
460 }
461
462 #[test]
463 fn github_status_combines_the_query_fields() {
464 let (env, ctx) = context();
465 let _gh = env.fake_cli("gh", "echo 'OPEN false MERGEABLE FAILURE'");
466
467 assert_eq!(github_status(&ctx).as_deref(), Some("failing"));
468 }
469
470 #[test]
471 fn github_status_without_a_pr_is_empty() {
472 let (env, ctx) = context();
473 let _gh = env.fake_cli("gh", "exit 0");
474
475 assert_eq!(github_status(&ctx), None);
476 }
477
478 #[test]
479 fn github_cells_render_as_nerd_font_icons() {
480 let column = column("pr", None, Some("github"), None);
481
482 assert_eq!(cell_icon(&column, "merged", true), "\u{f419}");
483 assert_eq!(cell_icon(&column, "ready", true), "\u{f00c}");
484 }
485
486 #[test]
487 fn nerd_font_off_falls_back_to_plain_unicode() {
488 let column = column("pr", None, Some("github"), None);
489
490 assert_eq!(cell_icon(&column, "merged", false), "◆");
491 assert_eq!(cell_icon(&column, "ready", false), "✔");
492 }
493
494 #[test]
495 fn command_cells_show_their_word() {
496 let column = column("claude", Some("echo working"), None, None);
497
498 assert_eq!(cell_icon(&column, "working", true), "working");
499 }
500
501 #[test]
502 fn cell_icons_keep_the_detail_after_the_word() {
503 let column = column("claude", None, Some("agent"), None);
504
505 assert_eq!(cell_icon(&column, "working 12m", true), "working 12m");
506 }
507
508 #[test]
509 fn cell_style_keys_on_the_leading_word() {
510 assert_eq!(cell_style("working 12m"), Some("bold bright_green"));
511 assert_eq!(cell_style("idle"), Some("bold bright_yellow"));
512 assert_eq!(cell_style("anything-else"), None);
513 }
514
515 #[test]
516 fn command_status_returns_the_first_output_line() {
517 let (_env, ctx) = context();
518
519 assert_eq!(
520 command_status(&ctx, "printf 'working\\nextra'").as_deref(),
521 Some("working")
522 );
523 }
524
525 #[test]
526 fn command_status_runs_in_the_checkout() {
527 let (_env, ctx) = context();
528 std::fs::write(ctx.path.join(".git").join("agent-status"), "blocked\n").unwrap();
529
530 assert_eq!(
531 command_status(&ctx, "cat .git/agent-status").as_deref(),
532 Some("blocked")
533 );
534 }
535
536 #[test]
537 fn command_status_exposes_the_context_in_env() {
538 let (_env, ctx) = context();
539
540 assert_eq!(
541 command_status(&ctx, "echo \"$CTX_REPO/$CTX_NAME\"").as_deref(),
542 Some("origin/feat")
543 );
544 }
545
546 #[test]
547 fn command_status_is_bounded_even_when_a_grandchild_holds_the_pipe() {
548 let (_env, ctx) = context();
551
552 let start = std::time::Instant::now();
553 let cell = command_status(&ctx, "sleep 5 & echo working");
554
555 assert_eq!(cell, None);
556 assert!(
557 start.elapsed() < Duration::from_secs(4),
558 "the provider read outlived the timeout"
559 );
560 }
561
562 #[test]
563 fn status_cells_run_a_context_s_providers_concurrently() {
564 let (env, ctx) = context();
565 let mut cfg = env.cfg.clone();
566 cfg.status = vec![
567 column("a", Some("sleep 0.6; echo a"), None, None),
568 column("b", Some("sleep 0.6; echo b"), None, None),
569 ];
570
571 let start = std::time::Instant::now();
572 let cells = status_cells(&cfg, &ctx);
573
574 assert_eq!(cells, vec!["", "a", "b"]);
575 assert!(
576 start.elapsed() < Duration::from_millis(1100),
577 "providers ran sequentially: {:?}",
578 start.elapsed()
579 );
580 }
581
582 #[test]
583 fn command_status_swallows_failures_and_silence() {
584 let (_env, ctx) = context();
585
586 assert_eq!(command_status(&ctx, "cat .git/agent-status"), None);
587 assert_eq!(command_status(&ctx, "true"), None);
588 }
589
590 #[test]
591 fn agent_status_reads_the_status_file() {
592 let (_env, ctx) = context();
593 std::fs::write(ctx.path.join(".git").join("agent-status"), "blocked\n").unwrap();
594
595 assert_eq!(agent_status(&ctx).as_deref(), Some("blocked"));
596 }
597
598 #[test]
599 fn agent_status_shows_how_long_active_states_have_run() {
600 let (_env, ctx) = context();
602 let path = ctx.path.join(".git").join("agent-status");
603 std::fs::write(&path, "working\n").unwrap();
604 set_mtime_secs_ago(&path, 300);
605
606 assert_eq!(agent_status(&ctx).as_deref(), Some("working 5m"));
607
608 std::fs::write(&path, "monitoring\n").unwrap();
609 set_mtime_secs_ago(&path, 300);
610 assert_eq!(agent_status(&ctx).as_deref(), Some("monitoring 5m"));
611 }
612
613 #[test]
614 fn elapsed_formats_by_magnitude() {
615 assert_eq!(elapsed(42.0), "42s");
616 assert_eq!(elapsed(99.0), "1m");
617 assert_eq!(elapsed(300.0), "5m");
618 assert_eq!(elapsed(3900.0), "1h5m");
619 }
620
621 #[test]
622 fn agent_status_without_a_file_is_empty() {
623 let (_env, ctx) = context();
624
625 assert_eq!(agent_status(&ctx), None);
626 }
627
628 #[test]
629 fn agent_status_ignores_stale_files() {
630 let (_env, ctx) = context();
631 let path = ctx.path.join(".git").join("agent-status");
632 std::fs::write(&path, "working\n").unwrap();
633 set_mtime_secs_ago(&path, 4000);
634
635 assert_eq!(agent_status(&ctx), None);
636 }
637
638 #[test]
639 fn github_repo_parses_remote_url_forms() {
640 for url in [
641 "git@github.com:jane/tool.git",
642 "https://github.com/jane/tool.git",
643 "https://github.com/jane/tool",
644 "ssh://git@github.com/jane/tool.git",
645 ] {
646 assert_eq!(
647 github_repo(url).unwrap(),
648 ("jane".to_string(), "tool".to_string())
649 );
650 }
651 }
652
653 #[test]
654 fn github_repo_rejects_unparseable_urls() {
655 let err = github_repo("nonsense").expect_err("must reject");
656
657 assert!(err.to_string().contains("cannot parse"));
658 }
659
660 #[test]
661 fn github_builtin_defaults_to_a_coarse_interval() {
662 assert_eq!(
663 refresh_interval(&column("pr", None, Some("github"), None)),
664 30.0
665 );
666 }
667
668 #[test]
669 fn other_columns_default_to_every_ask() {
670 assert_eq!(
671 refresh_interval(&column("a", None, Some("agent"), None)),
672 0.0
673 );
674 assert_eq!(
675 refresh_interval(&column("c", Some("echo hi"), None, None)),
676 0.0
677 );
678 }
679
680 #[test]
681 fn a_user_interval_overrides_the_default() {
682 assert_eq!(
683 refresh_interval(&column("pr", None, Some("github"), Some(5.0))),
684 5.0
685 );
686 assert_eq!(
687 refresh_interval(&column("c", Some("echo hi"), None, Some(60.0))),
688 60.0
689 );
690 }
691
692 #[test]
693 fn github_swallows_gh_failures() {
694 let (env, ctx) = context();
695 let _gh = env.fake_cli("gh", "exit 1");
696
697 assert_eq!(github_status(&ctx), None);
698 }
699
700 #[test]
701 fn column_status_dispatches_on_the_column_kind() {
702 let (env, ctx) = context();
703 let _gh = env.fake_cli("gh", "echo 'OPEN false MERGEABLE FAILURE'");
704 std::fs::write(ctx.path.join(".git").join("agent-status"), "idle\n").unwrap();
705
706 assert_eq!(
707 column_status(&ctx, &column("c", Some("echo hi"), None, None)).as_deref(),
708 Some("hi")
709 );
710 assert_eq!(
711 column_status(&ctx, &column("a", None, Some("agent"), None)).as_deref(),
712 Some("idle")
713 );
714 assert_eq!(
715 column_status(&ctx, &column("g", None, Some("github"), None)).as_deref(),
716 Some("failing")
717 );
718 }
719
720 #[test]
721 fn git_state_is_empty_for_a_clean_checkout() {
722 let (_env, ctx) = context();
723
724 assert_eq!(git_state(&ctx), "");
725 }
726
727 #[test]
728 fn git_state_marks_dirty_and_unpushed_work() {
729 let (_env, ctx) = context();
730 commit_file(&ctx.path, "work.txt", "x\n");
731 std::fs::write(ctx.path.join("scratch.txt"), "x\n").unwrap();
732
733 assert_eq!(git_state(&ctx), "* ↑1");
734 }
735
736 #[test]
737 fn status_cells_hold_git_state_and_column_output() {
738 let (env, ctx) = context();
739 let mut cfg = env.cfg.clone();
740 cfg.status = vec![
741 column("claude", None, Some("agent"), None),
742 column("ci", Some("false"), None, None),
743 ];
744 std::fs::write(ctx.path.join(".git").join("agent-status"), "working\n").unwrap();
745 std::fs::write(ctx.path.join("scratch.txt"), "x\n").unwrap();
746
747 assert_eq!(status_cells(&cfg, &ctx), vec!["*", "working 0s", ""]);
748 }
749
750 #[test]
751 fn status_cells_without_columns_report_git_state() {
752 let (env, ctx) = context();
753
754 assert_eq!(status_cells(&env.cfg, &ctx), vec![""]);
755 }
756}