diff --git a/fleet/cli.py b/fleet/cli.py index 2c32d99..832a9c1 100644 --- a/fleet/cli.py +++ b/fleet/cli.py @@ -1,9 +1,10 @@ """The ``fleet`` command-line interface. Subcommands: - - ``fleet status [--root DIR] [--filter active|stalled|dead|all]`` + - ``fleet status [--root DIR] [--filter active|stalled|dead|stranded|paused|all]`` Scan the root, assess each project, and print a markdown portfolio - table (optionally filtered by health). + table (optionally filtered by health). The table always shows a + ``Git`` column summarizing each project's git-side work in flight. - ``fleet snapshot [--root DIR] [--snapshot FILE]`` Scan the root, assess each project, and save the portfolio as a JSON snapshot (the baseline that :func:`diff` compares against). @@ -19,6 +20,12 @@ rows because ``removed`` rows have no resulting health, and filtering would hide part of the change set. Use ``status --filter`` for a health view of the current state. +- ``status --filter`` accepts the v1 classes (``active`` / ``stalled`` / + ``dead``) and the two v2-only classes (``stranded`` / ``paused``). The v1 + classes match the ``health`` column (v1 classification); ``stranded`` and + ``paused`` are selected with the v2 classifier + (:func:`fleet.health.classify_health_v2`) over the project's git state. + Full v2 integration of the ``health`` column is a later cycle. - Machine-readable (``--json``) output is intentionally not provided yet: no consumer needs it, and the data model (``ProjectHealth`` / ``DiffRow``) would serialize trivially if a concrete consumer appears. @@ -33,8 +40,10 @@ from fleet import __version__, discover, report from fleet import health as health_mod from fleet import snapshot as snapshot_mod +from fleet.gittest import EMPTY_STATE, GitState, read_gitstate +from fleet.health import classify_health_v2 -_VALID_FILTERS = ("active", "stalled", "dead", "all") +_VALID_FILTERS = ("active", "stalled", "dead", "stranded", "paused", "all") def _build_parser() -> argparse.ArgumentParser: @@ -104,12 +113,35 @@ def _assess_all(root: str) -> list[health_mod.ProjectHealth]: return [health_mod.assess(p.name, p.ai_dir) for p in projects] +def _git_states(root: str) -> dict[str, GitState]: + """Read the git work-in-flight state for every project under *root*. + + Returns a mapping of project name -> :class:`~fleet.gittest.GitState`. + Projects that are not git repos (or have no ``main``) map to the empty + state (a clean ``-`` in the ``Git`` column). + """ + return {p.name: read_gitstate(p.path) for p in discover.discover(root)} + + def _cmd_status(args: argparse.Namespace) -> int: """Run the ``status`` subcommand; return a process exit code.""" healths = _assess_all(args.root) + git_states = _git_states(args.root) if args.filter != "all": - healths = [h for h in healths if h.health == args.filter] - print(report.render_portfolio(healths)) + if args.filter in ("stranded", "paused"): + healths = [ + h + for h in healths + if classify_health_v2( + h.days_since_activity, + h.last_outcome, + git_states.get(h.name, EMPTY_STATE), + ) + == args.filter + ] + else: + healths = [h for h in healths if h.health == args.filter] + print(report.render_portfolio(healths, git_states)) return 0 diff --git a/fleet/report.py b/fleet/report.py index d05981e..d88b69f 100644 --- a/fleet/report.py +++ b/fleet/report.py @@ -3,10 +3,17 @@ :func:`render_portfolio` turns a list of :class:`~fleet.health.ProjectHealth` into a one-page markdown table sorted by last-activity descending (projects with no activity sort last). + +By default the table has six columns (Project, Last Cycle, Last Outcome, Days +Since Activity, Open Issues, Health). Passing a ``git_states`` mapping adds a +seventh ``Git`` column at the end summarizing each project's git-side work in +flight (an unmerged ``build*`` branch and/or unpushed commits); a clean +project renders ``-``. """ from __future__ import annotations +from fleet.gittest import EMPTY_STATE, GitState from fleet.health import ProjectHealth # Health labels in display order (most to least healthy). @@ -28,6 +35,25 @@ def _fmt_outcome(outcome: str | None) -> str: return "-" if outcome is None else outcome +def _fmt_git(gs: GitState) -> str: + """Format a git work-in-flight summary for the ``Git`` column. + + A clean state (no unmerged ``build*`` branch and no unpushed commits) + renders ``-``. Otherwise the parts are joined with ``,``: + + - ``unmerged:+`` — the unmerged ``build*`` branch names. + - ``unpushed:`` — the count of unpushed commits on ``main``. + """ + if not gs.unmerged_build_branches and gs.unpushed_commits == 0: + return "-" + parts: list[str] = [] + if gs.unmerged_build_branches: + parts.append("unmerged:" + "+".join(gs.unmerged_build_branches)) + if gs.unpushed_commits > 0: + parts.append("unpushed:" + str(gs.unpushed_commits)) + return ",".join(parts) + + def _sort_key(h: ProjectHealth) -> tuple[int, float, int, str]: """Sort key: last-activity descending, then health, then name. @@ -43,13 +69,23 @@ def _sort_key(h: ProjectHealth) -> tuple[int, float, int, str]: return (time_key, -ts, _HEALTH_ORDER.get(h.health, 3), h.name) -def render_portfolio(healths: list[ProjectHealth]) -> str: +def render_portfolio( + healths: list[ProjectHealth], + git_states: dict[str, GitState] | None = None, +) -> str: """Render a markdown portfolio status table. Parameters ---------- healths: The per-project health rows to render. + git_states: + Optional mapping of project name -> :class:`~fleet.gittest.GitState`. + When provided, a seventh ``Git`` column is appended at the end of each + row summarizing that project's git-side work in flight (``-`` when + clean, ``unmerged:+`` / ``unpushed:`` otherwise). When + ``None`` (the default) the output is the six-column table, byte- + identical to the pre-git-column form. Returns ------- @@ -59,18 +95,28 @@ def render_portfolio(healths: list[ProjectHealth]) -> str: with a single "no projects" row. """ rows = sorted(healths, key=_sort_key) + with_git = git_states is not None + + header = "| Project | Last Cycle | Last Outcome | Days Since Activity | Open Issues | Health |" + separator = "|---|---|---|---|---|---|" + if with_git: + header += " Git |" + separator += "---|" + lines = [header, separator] - lines = [ - "| Project | Last Cycle | Last Outcome | Days Since Activity | Open Issues | Health |", - "|---|---|---|---|---|---|", - ] if not rows: - lines.append("| (no projects discovered) | - | - | - | - | - |") + no_projects = "| (no projects discovered) | - | - | - | - | - |" + if with_git: + no_projects += " - |" + lines.append(no_projects) return "\n".join(lines) for h in rows: - lines.append( + row = ( f"| {h.name} | {_fmt_cycle(h.last_cycle)} | {_fmt_outcome(h.last_outcome)} " f"| {_fmt_days(h.days_since_activity)} | {h.open_issues} | {h.health} |" ) + if git_states is not None: + row += f" {_fmt_git(git_states.get(h.name, EMPTY_STATE))} |" + lines.append(row) return "\n".join(lines) diff --git a/tests/test_cli.py b/tests/test_cli.py index faff8b3..d78e38c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -19,6 +19,7 @@ import pytest from fleet import __version__, cli, health, report, snapshot +from fleet.gittest import EMPTY_STATE, GitState, read_gitstate from tests._fixtures import make_project NOW = datetime(2025, 6, 1, 12, 0, 0, tzinfo=timezone.utc) @@ -58,6 +59,17 @@ def _assess_root(root: Path) -> list[health.ProjectHealth]: return [health.assess(p.name, p.ai_dir, now=NOW) for p in projects] +def _git_states(root: Path) -> dict[str, GitState]: + """Read the git state for every discovered project under *root*. + + Mirrors the CLI's ``_git_states``. The tmp projects are not git repos, + so every value is the empty state (a clean ``-`` in the Git column). + """ + from fleet import discover + + return {p.name: read_gitstate(p.path) for p in discover.discover(root)} + + def _run_status(root: Path, *extra: str, capsys) -> str: """Run `fleet status --root [extra...]` and return captured stdout.""" argv = ["status", "--root", str(root), *extra] @@ -75,14 +87,16 @@ def test_cli_status_matches_render_portfolio(tmp_path: Path, capsys) -> None: assessed = _assess_root(tmp_path) out = _run_status(tmp_path, capsys=capsys) - assert out == report.render_portfolio(assessed) + "\n" + assert out == report.render_portfolio(assessed, _git_states(tmp_path)) + "\n" def test_cli_status_filter_active(tmp_path: Path, capsys) -> None: """`--filter active` yields only the active project's row.""" _build_root(tmp_path) assessed = _assess_root(tmp_path) - expected = report.render_portfolio([h for h in assessed if h.health == "active"]) + expected = report.render_portfolio( + [h for h in assessed if h.health == "active"], _git_states(tmp_path) + ) out = _run_status(tmp_path, "--filter", "active", capsys=capsys) assert out == expected + "\n" @@ -95,7 +109,9 @@ def test_cli_status_filter_stalled(tmp_path: Path, capsys) -> None: """`--filter stalled` yields only the stalled project's row.""" _build_root(tmp_path) assessed = _assess_root(tmp_path) - expected = report.render_portfolio([h for h in assessed if h.health == "stalled"]) + expected = report.render_portfolio( + [h for h in assessed if h.health == "stalled"], _git_states(tmp_path) + ) out = _run_status(tmp_path, "--filter", "stalled", capsys=capsys) assert out == expected + "\n" @@ -108,7 +124,9 @@ def test_cli_status_filter_dead(tmp_path: Path, capsys) -> None: """`--filter dead` yields only the dead project's row.""" _build_root(tmp_path) assessed = _assess_root(tmp_path) - expected = report.render_portfolio([h for h in assessed if h.health == "dead"]) + expected = report.render_portfolio( + [h for h in assessed if h.health == "dead"], _git_states(tmp_path) + ) out = _run_status(tmp_path, "--filter", "dead", capsys=capsys) assert out == expected + "\n" @@ -123,11 +141,68 @@ def test_cli_status_filter_all(tmp_path: Path, capsys) -> None: assessed = _assess_root(tmp_path) out = _run_status(tmp_path, "--filter", "all", capsys=capsys) - assert out == report.render_portfolio(assessed) + "\n" + assert out == report.render_portfolio(assessed, _git_states(tmp_path)) + "\n" for name in ("alpha", "beta", "gamma"): assert name in out +def test_cli_status_filter_stranded(tmp_path: Path, capsys) -> None: + """`--filter stranded` selects exactly the projects whose v2 class is stranded. + + A project is stranded when git work is in flight (an unmerged build* + branch or unpushed commits), regardless of recency. Here only alpha has + an unmerged build branch, so only alpha's row is printed. + """ + _build_root(tmp_path) + assessed = _assess_root(tmp_path) + alpha = next(h for h in assessed if h.name == "alpha") + git_states = { + "alpha": GitState(("build42/x",), 0), + "beta": EMPTY_STATE, + "gamma": EMPTY_STATE, + } + + def _fake_read_gitstate(path): + return git_states[Path(path).name] + + with mock.patch.object(health, "count_open_issues", side_effect=_issues), mock.patch.object( + health, "datetime", _FakeDatetime + ), mock.patch.object(cli, "read_gitstate", side_effect=_fake_read_gitstate): + rc = cli.main(["status", "--root", str(tmp_path), "--filter", "stranded"]) + assert rc == 0 + out = capsys.readouterr().out + assert out == report.render_portfolio([alpha], git_states) + "\n" + assert "alpha" in out + assert "beta" not in out + assert "gamma" not in out + + +def test_cli_status_shows_git_column(tmp_path: Path, capsys) -> None: + """`status` (default filter) always shows the Git column. + + With one project given an unmerged build branch, the header gains a + `Git` column and that project's row shows `unmerged:build42/x`. + """ + _build_root(tmp_path) + git_states = { + "alpha": GitState(("build42/x",), 0), + "beta": EMPTY_STATE, + "gamma": EMPTY_STATE, + } + + def _fake_read_gitstate(path): + return git_states[Path(path).name] + + with mock.patch.object(health, "count_open_issues", side_effect=_issues), mock.patch.object( + health, "datetime", _FakeDatetime + ), mock.patch.object(cli, "read_gitstate", side_effect=_fake_read_gitstate): + rc = cli.main(["status", "--root", str(tmp_path)]) + assert rc == 0 + out = capsys.readouterr().out + assert " Git |" in out + assert "unmerged:build42/x" in out + + # --------------------------------------------------------------------------- # CLI `diff` subcommand # --------------------------------------------------------------------------- diff --git a/tests/test_report.py b/tests/test_report.py index 7cd7753..70da954 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -9,6 +9,7 @@ from datetime import datetime, timedelta, timezone +from fleet.gittest import EMPTY_STATE, GitState from fleet.health import ProjectHealth from fleet.report import render_portfolio @@ -132,3 +133,77 @@ def test_render_portfolio_last_activity_descending() -> None: ) md = render_portfolio([old_active, recent_dead]) assert _names(md) == ["recent_dead", "old_active"] + + +# --------------------------------------------------------------------------- +# Opt-in Git column (git_states param) +# --------------------------------------------------------------------------- + + +def test_render_portfolio_no_git_states_is_six_column() -> None: + """render_portfolio(healths) with no git_states is the 6-column form.""" + healths = [_ph("alpha", "active", last_activity=NOW, days=1)] + md = render_portfolio(healths) + lines = md.splitlines() + assert lines[0] == ( + "| Project | Last Cycle | Last Outcome | Days Since Activity | Open Issues | Health |" + ) + assert lines[1] == "|---|---|---|---|---|---|" + assert lines[2] == "| alpha | - | - | 1 | 0 | active |" + assert len(lines) == 3 + + +def test_render_portfolio_git_column_header_and_clean() -> None: + """With git_states, the header gains a Git column; a clean project is '-'.""" + healths = [_ph("alpha", "active", last_activity=NOW, days=1)] + md = render_portfolio(healths, {"alpha": EMPTY_STATE}) + lines = md.splitlines() + assert lines[0] == ( + "| Project | Last Cycle | Last Outcome | Days Since Activity | Open Issues | Health | Git |" + ) + assert lines[1] == "|---|---|---|---|---|---|---|" + # Clean (EMPTY_STATE) renders '-' in the Git cell. + assert lines[2] == "| alpha | - | - | 1 | 0 | active | - |" + + +def test_render_portfolio_git_column_unmerged() -> None: + """An unmerged build branch renders 'unmerged:'.""" + healths = [_ph("alpha", "active", last_activity=NOW, days=1)] + md = render_portfolio(healths, {"alpha": GitState(("build42/x",), 0)}) + assert "| alpha | - | - | 1 | 0 | active | unmerged:build42/x |" in md + + +def test_render_portfolio_git_column_unpushed() -> None: + """Unpushed commits render 'unpushed:'.""" + healths = [_ph("alpha", "active", last_activity=NOW, days=1)] + md = render_portfolio(healths, {"alpha": GitState((), 3)}) + assert "| alpha | - | - | 1 | 0 | active | unpushed:3 |" in md + + +def test_render_portfolio_git_column_combined() -> None: + """Both signals render 'unmerged:+,unpushed:'.""" + healths = [_ph("alpha", "active", last_activity=NOW, days=1)] + md = render_portfolio(healths, {"alpha": GitState(("build42/x", "build43/y"), 3)}) + assert ( + "| alpha | - | - | 1 | 0 | active | unmerged:build42/x+build43/y,unpushed:3 |" in md + ) + + +def test_render_portfolio_git_column_missing_name_defaults_clean() -> None: + """A project absent from git_states renders '-' (EMPTY_STATE default).""" + healths = [_ph("alpha", "active", last_activity=NOW, days=1)] + md = render_portfolio(healths, {"beta": GitState(("build42/x",), 0)}) + # alpha is not in the dict -> EMPTY_STATE -> '-'. + assert "| alpha | - | - | 1 | 0 | active | - |" in md + + +def test_render_portfolio_empty_input_with_git_states() -> None: + """Empty input WITH git_states yields the 7-column no-projects row.""" + md = render_portfolio([], {"alpha": EMPTY_STATE}) + lines = md.splitlines() + assert lines[0] == ( + "| Project | Last Cycle | Last Outcome | Days Since Activity | Open Issues | Health | Git |" + ) + assert lines[1] == "|---|---|---|---|---|---|---|" + assert lines[2] == "| (no projects discovered) | - | - | - | - | - | - |" + assert len(lines) == 3 diff --git a/tickets/TICKET-060-report-git-column.md b/tickets/TICKET-060-report-git-column.md new file mode 100644 index 0000000..07a547d --- /dev/null +++ b/tickets/TICKET-060-report-git-column.md @@ -0,0 +1,41 @@ +# TICKET-060: fleet/report.py — opt-in `Git` column in `render_portfolio` + +## Title +Add an optional `git_states` parameter to `render_portfolio` so the status +table can render a 7th `Git` column (work-in-flight summary). Backward +compatible: `git_states=None` (default) yields byte-identical 6-column output. + +## Evidence +- `fleet/report.py` `render_portfolio(healths: list[ProjectHealth]) -> str` + (line 47) renders exactly 6 columns: + `Project | Last Cycle | Last Outcome | Days Since Activity | Open Issues | Health`. + The header (line 62), separator (line 63), and no-projects row (line 66) + are all 6-column. +- `render_portfolio` does NOT import anything from `fleet.gittest`. +- The `Last Outcome` column is ALREADY rendered from + `ProjectHealth.last_outcome` via `_fmt_outcome` (line 71) — so the only NEW + column this cycle is `Git` (the briefing's ground truth). Do NOT add a second + outcome column. +- `fleet/gittest.py` defines `GitState(unmerged_build_branches, unpushed_commits)` + and `EMPTY_STATE = GitState((), 0)` — the input type for the new column. + +## Change +In `fleet/report.py`: +- Add `from fleet.gittest import EMPTY_STATE, GitState`. +- Change the signature to `render_portfolio(healths, git_states=None)`. +- When `git_states is None`: keep the EXACT current 6-column header/separator/ + no-projects row and row format (byte-identical). +- When `git_states` is a dict: append a 7th `Git` column at the END of each row + (after Health). Header gains ` Git |`, separator gains `---|`, no-projects row + gains ` - |`. The Git cell is `_fmt_git(git_states.get(h.name, EMPTY_STATE))`. +- Add module helper `_fmt_git(gs: GitState) -> str`: + - if `not gs.unmerged_build_branches and gs.unpushed_commits == 0`: return `"-"`. + - else build parts: `unmerged:` + `+`.join(branches) (if any); + `unpushed:` + str(count) (if > 0); return `",".join(parts)`. +- Pinned examples: `GitState((), 0)` -> `"-"`; + `GitState(("build42/x",), 0)` -> `"unmerged:build42/x"`; + `GitState((), 3)` -> `"unpushed:3"`; + `GitState(("build42/x", "build43/y"), 3)` -> `"unmerged:build42/x+build43/y,unpushed:3"`. + +Do NOT change `_sort_key`, `_fmt_days`, `_fmt_cycle`, `_fmt_outcome`, or the +existing 6-column rendering. diff --git a/tickets/TICKET-061-cli-filter-stranded.md b/tickets/TICKET-061-cli-filter-stranded.md new file mode 100644 index 0000000..857e8b5 --- /dev/null +++ b/tickets/TICKET-061-cli-filter-stranded.md @@ -0,0 +1,42 @@ +# TICKET-061: fleet/cli.py — `--filter stranded`/`paused` + source the git state + +## Title +Extend the `status` subcommand to (a) accept the two v2-only filter classes +`stranded` and `paused` in addition to the v1 `active`/`stalled`/`dead`/`all`, +and (b) always render the `Git` column by sourcing a per-project `GitState` +via `read_gitstate`. The `health` column stays v1 (full v2 wiring is Cycle 17). + +## Evidence +- `fleet/cli.py` `_VALID_FILTERS = ("active", "stalled", "dead", "all")` + (line 49). `--filter` uses `choices=_VALID_FILTERS`. +- `_cmd_status` (line 116) currently does: + `healths = _assess_all(args.root)`; if filter != "all" keep rows where + `h.health == args.filter`; `print(report.render_portfolio(healths))`. + It does NOT source any git state and does NOT pass `git_states` to + `render_portfolio`. +- `fleet/cli.py` does NOT import `read_gitstate`, `EMPTY_STATE`, or + `classify_health_v2`. +- `fleet/discover.py` `Project` has `.name` and `.path` (the project dir that + contains `ai/`) — the path to feed `read_gitstate`. +- `fleet/health.py` `classify_health_v2(days, last_outcome, git_state)` is the + pure v2 classifier (Cycle 15) to reuse for the `stranded`/`paused` filter. + +## Change +In `fleet/cli.py`: +- Extend `_VALID_FILTERS` to + `("active", "stalled", "dead", "stranded", "paused", "all")`. +- Add `from fleet.gittest import EMPTY_STATE, read_gitstate` and + `from fleet.health import classify_health_v2`. +- Add helper `_git_states(root: str) -> dict[str, GitState]` returning + `{p.name: read_gitstate(p.path) for p in discover.discover(root)}`. +- In `_cmd_status`: + - `healths = _assess_all(args.root)`; `git_states = _git_states(args.root)`. + - if `args.filter != "all"`: + - if `args.filter in ("stranded", "paused")`: keep rows where + `classify_health_v2(h.days_since_activity, h.last_outcome, + git_states.get(h.name, EMPTY_STATE)) == args.filter`. + - else: keep rows where `h.health == args.filter` (unchanged v1 path). + - `print(report.render_portfolio(healths, git_states))`; return 0. +- The `status` table now ALWAYS shows the Git column (pass `git_states`). + For non-repo projects the Git cell is `-`. +- Do NOT change `_cmd_snapshot`, `_cmd_diff`, `_assess_all`, or `--version`. diff --git a/tickets/TICKET-062-report-git-column-tests.md b/tickets/TICKET-062-report-git-column-tests.md new file mode 100644 index 0000000..d289fe1 --- /dev/null +++ b/tickets/TICKET-062-report-git-column-tests.md @@ -0,0 +1,25 @@ +# TICKET-062: tests/test_report.py — Git column tests (extend, do not modify) + +## Title +Extend `tests/test_report.py` to pin the new opt-in `Git` column of +`render_portfolio`. Do NOT modify any existing test (they pin the 6-column +byte-identical output). + +## Evidence +- `tests/test_report.py` has 5 existing tests pinning the 6-column table + (empty input, mixed ordering, no-activity-last, full table with `-`, + last-activity-desc). These must stay green. +- The new `render_portfolio(healths, git_states)` behavior is untested. + +## Change +Add tests to `tests/test_report.py` (import `GitState`, `EMPTY_STATE` from +`fleet.gittest`): +- `render_portfolio(healths)` with no `git_states` is byte-identical to the + 6-column form (assert the exact 6-column header + a row). +- With a `git_states` dict: header has 7 columns ending in ` Git |`; separator + ends in `---|`; a clean project (`EMPTY_STATE`) renders `-` in the Git cell. +- `unmerged:build42/x`, `unpushed:3`, and the combined + `unmerged:build42/x+build43/y,unpushed:3` render exactly. +- A project name absent from the `git_states` dict renders `-` (EMPTY_STATE + default). +- Empty input WITH `git_states` yields the 7-column no-projects row. diff --git a/tickets/TICKET-063-cli-filter-stranded-tests.md b/tickets/TICKET-063-cli-filter-stranded-tests.md new file mode 100644 index 0000000..7805ead --- /dev/null +++ b/tickets/TICKET-063-cli-filter-stranded-tests.md @@ -0,0 +1,34 @@ +# TICKET-063: tests/test_cli.py — `--filter stranded` + Git column tests + +## Title +Extend `tests/test_cli.py` to pin the `status --filter stranded` selection and +the always-present Git column. Update the existing status expected-computations +to pass `_git_states(root)` (the tmp projects are not git repos, so every Git +cell is `-`). Do NOT break existing tests. + +## Evidence +- `tests/test_cli.py` `test_cli_status_matches_render_portfolio` and + `test_cli_status_filter_active/stalled/dead/all` currently compute the + expected table as `report.render_portfolio()` (6-column). Once + `_cmd_status` always passes `git_states`, the CLI output is 7-column, so + these expected computations must become + `report.render_portfolio(, _git_states(root))`. +- `test_cli_open_issues_always_zero` stays UNCHANGED (Git is appended at the + end, so `open_issues` is still column index 4). +- The new `--filter stranded` and the Git column are untested. + +## Change +In `tests/test_cli.py`: +- Add helper `_git_states(root)` = + `{p.name: read_gitstate(p.path) for p in discover.discover(root)}`. +- Change the expected computations in `test_cli_status_matches_render_portfolio` + and `test_cli_status_filter_active/stalled/dead/all` to + `report.render_portfolio(, _git_states(root))`. +- Add `test_cli_status_filter_stranded`: build the 3-project root, patch + `cli.read_gitstate` with a side_effect mapping `Path(path).name` -> + `GitState(("build42/x",), 0)` for "alpha" and `EMPTY_STATE` for the others, + run `status --filter stranded`, and assert only alpha's row is printed + (equals `report.render_portfolio([alpha], git_states) + "\n"`). +- Add `test_cli_status_shows_git_column`: run `status` (default filter) with a + patched `read_gitstate` giving one project an unmerged branch; assert the + header contains ` Git |` and the row contains `unmerged:build42/x`.