Skip to main content

ctx_tui/
status.rs

1//! Status column providers: user commands plus the built-ins.
2
3use 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
36/// First line of a command's output, run in the checkout; None if it yields nothing.
37///
38/// Failures (non-zero exit, timeout, missing executable) also yield None: the
39/// contract is "produce a status or stay quiet", so a broken or inapplicable
40/// provider must not break listings.
41fn 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    // Drain stdout on a thread so a chatty provider can't fill the pipe and
52    // deadlock against the timed wait. The result comes over a channel: the
53    // timeout must bound the read as well as the wait, because a spawned
54    // grandchild can hold the pipe open long after the provider exits.
55    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
92/// The `command` provider: a user-configured shell command.
93pub fn command_status(ctx: &Context, command: &str) -> Option<String> {
94    run_shell(command, ctx)
95}
96
97/// The `agent` built-in: the checkout's agent-status file.
98///
99/// Agent harness hooks write a word (e.g. working/blocked/idle) to
100/// `.git/agent-status`, rewriting it only when the state changes, so the
101/// file's mtime is the state's start; active states show their age from it.
102/// A file untouched for an hour is stale — the agent likely died without
103/// its hooks firing — and reads as no status.
104pub 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
127/// Seconds only under the first minute: a table full of ticking
128/// second-counters reads as nervous.
129fn 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
140/// The (owner, repo) of a git remote URL, tolerating ssh/https/scp forms.
141pub 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
152/// A GraphQL query via gh for the checkout's branch, lowercased.
153///
154/// No `gh`, a non-GitHub remote, or an empty jq result all read as no status.
155fn 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
183/// The `github` built-in: the branch's latest PR collapsed into one cell.
184///
185/// Shows the most urgent fact about the PR: merged / closed / conflicts /
186/// failing / draft / pending / ready. No PR reads as no status.
187pub fn github_status(ctx: &Context) -> Option<String> {
188    let raw = github_query(ctx, GITHUB_QUERY, GITHUB_JQ)?;
189    github_state(&raw)
190}
191
192/// Collapse '<state> <draft> <mergeable> <ci>' into the most urgent fact.
193fn 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
214// Compact display forms per built-in; colour still keys on the status word.
215// Nerd-font glyphs by default, plain Unicode with nerd_font = false.
216fn 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}"),    // nf-oct-git_merge
223            "closed" => Some("\u{f05e}"),    // nf-fa-ban
224            "conflicts" => Some("\u{f071}"), // nf-fa-warning
225            "failing" => Some("\u{f00d}"),   // nf-fa-times
226            "draft" => Some("\u{f040}"),     // nf-fa-pencil
227            "pending" => Some("\u{f017}"),   // nf-fa-clock_o
228            "ready" => Some("\u{f00c}"),     // nf-fa-check
229            _ => 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
245/// A cell's display form: its leading word mapped through the built-in's icons.
246///
247/// Any detail after the word (e.g. the elapsed time in "working 1m30s")
248/// is kept as is.
249pub 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
266/// A cell's colour: the shared vocabulary's style for its leading word.
267pub 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
275// Colours for well-known status words, keyed by value so that command
276// providers speaking the same vocabulary get them too. GitHub's conventions
277// for PR and check states; attention-based colours for agent states (red
278// needs you now, yellow wants new instructions, green is progressing).
279pub 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
297/// Seconds between runs of a column's provider; 0 means every ask.
298pub fn refresh_interval(column: &StatusColumn) -> f64 {
299    if let Some(interval) = column.interval {
300        return interval;
301    }
302    // Default refresh interval per built-in: how often a caller should re-run
303    // the provider. Keeps the GitHub built-in well inside API rate limits when
304    // the caller polls every couple of seconds. 0 means every ask.
305    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    // parse_status guarantees command xor builtin.
316    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// Scoped provider threads carry the calling test's env stubs along.
324#[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
331/// Compact git state: `*` for uncommitted changes, `↑n` for unpushed commits.
332pub fn git_state(ctx: &Context) -> String {
333    // Both probes can idle up to the timeout; overlap them.
334    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
364/// The STATUS column's value plus one display cell per configured column.
365pub fn status_cells(cfg: &Config, ctx: &Context) -> Vec<String> {
366    // Each provider can take up to the full timeout; running them together
367    // keeps a listing's latency at the slowest provider, not the sum.
368    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        // column_status panics on a builtin the allowlist admits but the
436        // dispatch below doesn't know; probe each against a real checkout.
437        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        // A backgrounded process inherits the provider's stdout; the read
549        // must give up at the timeout instead of waiting for its exit.
550        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        // The hooks rewrite the file only on change, so mtime is the state's start.
601        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}