graphban-cli 0.7.0__tar.gz → 0.7.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {graphban_cli-0.7.0 → graphban_cli-0.7.1}/PKG-INFO +1 -1
  2. {graphban_cli-0.7.0 → graphban_cli-0.7.1}/pyproject.toml +1 -1
  3. {graphban_cli-0.7.0 → graphban_cli-0.7.1}/src/gban/cli.py +25 -1
  4. {graphban_cli-0.7.0 → graphban_cli-0.7.1}/src/gban/client.py +4 -0
  5. {graphban_cli-0.7.0 → graphban_cli-0.7.1}/src/gban/stackmap.py +109 -16
  6. {graphban_cli-0.7.0 → graphban_cli-0.7.1}/tests/test_stackmap.py +181 -0
  7. {graphban_cli-0.7.0 → graphban_cli-0.7.1}/.gitignore +0 -0
  8. {graphban_cli-0.7.0 → graphban_cli-0.7.1}/LICENSE +0 -0
  9. {graphban_cli-0.7.0 → graphban_cli-0.7.1}/README.md +0 -0
  10. {graphban_cli-0.7.0 → graphban_cli-0.7.1}/src/gban/__init__.py +0 -0
  11. {graphban_cli-0.7.0 → graphban_cli-0.7.1}/src/gban/config.py +0 -0
  12. {graphban_cli-0.7.0 → graphban_cli-0.7.1}/src/gban/data/stack_sdks.toml +0 -0
  13. {graphban_cli-0.7.0 → graphban_cli-0.7.1}/src/gban/doctor.py +0 -0
  14. {graphban_cli-0.7.0 → graphban_cli-0.7.1}/src/gban/gitignore.py +0 -0
  15. {graphban_cli-0.7.0 → graphban_cli-0.7.1}/src/gban/setup.py +0 -0
  16. {graphban_cli-0.7.0 → graphban_cli-0.7.1}/src/gban/skills/graphban-delegation/SKILL.md +0 -0
  17. {graphban_cli-0.7.0 → graphban_cli-0.7.1}/src/gban/skills/watch-wave/SKILL.md +0 -0
  18. {graphban_cli-0.7.0 → graphban_cli-0.7.1}/src/gban/swamp.py +0 -0
  19. {graphban_cli-0.7.0 → graphban_cli-0.7.1}/tests/test_acts.py +0 -0
  20. {graphban_cli-0.7.0 → graphban_cli-0.7.1}/tests/test_doctor.py +0 -0
  21. {graphban_cli-0.7.0 → graphban_cli-0.7.1}/tests/test_login_projects.py +0 -0
  22. {graphban_cli-0.7.0 → graphban_cli-0.7.1}/tests/test_packaging.py +0 -0
  23. {graphban_cli-0.7.0 → graphban_cli-0.7.1}/tests/test_session.py +0 -0
  24. {graphban_cli-0.7.0 → graphban_cli-0.7.1}/tests/test_setup.py +0 -0
  25. {graphban_cli-0.7.0 → graphban_cli-0.7.1}/tests/test_swamp.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: graphban-cli
3
- Version: 0.7.0
3
+ Version: 0.7.1
4
4
  Summary: gban — a Graphban client for the human at a terminal
5
5
  Project-URL: Homepage, https://github.com/asc-me/graphban
6
6
  Project-URL: Repository, https://github.com/asc-me/graphban
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "graphban-cli"
3
- version = "0.7.0"
3
+ version = "0.7.1"
4
4
  description = "gban — a Graphban client for the human at a terminal"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -442,6 +442,7 @@ def cmd_stack(args) -> int:
442
442
  text = stackmap.canonical(record)
443
443
  if args.dry_run:
444
444
  sys.stdout.write(text)
445
+ print(f"{PROG}: dry run, nothing posted", file=sys.stderr)
445
446
  return 0
446
447
  if not project:
447
448
  print(f"{PROG}: `{PROG} stack map` needs a project to post. Pass --project, set "
@@ -463,8 +464,31 @@ def cmd_stack(args) -> int:
463
464
  f"${config.API_KEY_ENV}, or run `{PROG} setup`. The login session is not a "
464
465
  f"key and is not sent.", file=sys.stderr)
465
466
  return EXIT_REFUSED
466
- stackmap.post_record(url, key, record)
467
+ # The record goes to stdout only after the server took it: a refused post must not
468
+ # leave behind something that reads like a record that was sent (GRPH-1103).
469
+ try:
470
+ receipt = stackmap.post_record(url, key, record)
471
+ except Refused as exc:
472
+ try:
473
+ reasons = json.loads(exc.detail)
474
+ except ValueError:
475
+ reasons = None
476
+ if not isinstance(reasons, list):
477
+ raise
478
+ print(f"{PROG}: the server refused the app map (HTTP {exc.status}):", file=sys.stderr)
479
+ for reason in reasons:
480
+ if isinstance(reason, dict):
481
+ print(f" {reason.get('path', '?')}: {reason.get('reason', '')}",
482
+ file=sys.stderr)
483
+ else:
484
+ print(f" {reason}", file=sys.stderr)
485
+ return EXIT_REFUSED
467
486
  sys.stdout.write(text)
487
+ receipt = receipt if isinstance(receipt, dict) else {}
488
+ sha = str(receipt.get("mapped_sha") or record.get("mapped_sha") or "")[:7]
489
+ said = [f"{k} {receipt[k]}" for k in ("mapped_at", "posted_by") if receipt.get(k)]
490
+ print(f"{PROG}: posted {receipt.get('project_id') or project} app map at {sha} to {url}"
491
+ + (f" ({', '.join(said)})" if said else ""), file=sys.stderr)
468
492
  return 0
469
493
 
470
494
 
@@ -115,6 +115,10 @@ class Client:
115
115
  detail = payload.get("detail") or payload.get("message") or f"HTTP {status}"
116
116
  if isinstance(detail, dict):
117
117
  detail, hint = (detail.get("message") or str(detail)), detail.get("hint", "")
118
+ elif isinstance(detail, list):
119
+ # A list of reasons (e.g. `{path, reason}` from a refused app map) stays JSON,
120
+ # so a caller can read it back rather than parse a Python repr.
121
+ detail, hint = json.dumps(detail), payload.get("hint", "")
118
122
  else:
119
123
  hint = payload.get("hint", "")
120
124
  raise Refused(status, str(detail), str(hint))
@@ -853,6 +853,13 @@ class _Builder:
853
853
  self.by_dir: dict[str, str] = {}
854
854
  self.kind_of: dict[str, str] = {}
855
855
  self.warnings: list[str] = []
856
+ # Which compose services name an env variable, and which read a template
857
+ # through `env_file:`. Every service counts, not only the ones that build a
858
+ # component: a repo-root template is attributed from these.
859
+ self.env_users: dict[str, set[str]] = {}
860
+ self.env_file_users: dict[str, set[str]] = {}
861
+ # Names each `.env.example` declares, keyed by its directory.
862
+ self.template_names: dict[str, set[str]] = {}
856
863
 
857
864
  def _ids(self) -> set[str]:
858
865
  return set(self.components) | set(self.infra) | set(self.external) | set(self.unresolved)
@@ -1109,6 +1116,32 @@ def _extends(service: Marked) -> tuple[str, str, int] | None:
1109
1116
  return None
1110
1117
 
1111
1118
 
1119
+ def _env_files(service: Marked, compose: Path, repo: Path) -> list[str]:
1120
+ """Repo-relative paths a service's `env_file:` names."""
1121
+ if not isinstance(service.value, dict):
1122
+ return []
1123
+ node = service.value.get("env_file")
1124
+ if not isinstance(node, Marked):
1125
+ return []
1126
+ items = node.value if isinstance(node.value, list) else [node]
1127
+ out: list[str] = []
1128
+ for item in items:
1129
+ if not isinstance(item, Marked):
1130
+ continue
1131
+ raw = item.value
1132
+ if isinstance(raw, dict):
1133
+ # The long form: `- path: ./x.env`.
1134
+ raw = raw.get("path").value if isinstance(raw.get("path"), Marked) else None
1135
+ if not isinstance(raw, str) or not raw.strip():
1136
+ continue
1137
+ try:
1138
+ target = (compose.parent / raw.strip().strip("'\"")).resolve()
1139
+ out.append(target.relative_to(repo.resolve()).as_posix())
1140
+ except ValueError:
1141
+ continue
1142
+ return out
1143
+
1144
+
1112
1145
  def _walk_compose(builder: _Builder, path: Path) -> None:
1113
1146
  text = _read_text(path)
1114
1147
  if text is None:
@@ -1185,7 +1218,13 @@ def _walk_compose(builder: _Builder, path: Path) -> None:
1185
1218
  builder.add_edge(src, target, protocol, "depends_on", prov)
1186
1219
  env_node = service.value.get("environment")
1187
1220
  for ename, raw, line in _as_env(env_node if isinstance(env_node, Marked) else None):
1221
+ for mentioned in [ename] + _INTERP.findall(raw):
1222
+ mentioned = re.split(r"[:?+-]", mentioned, maxsplit=1)[0].strip()
1223
+ if mentioned:
1224
+ builder.env_users.setdefault(mentioned, set()).add(src)
1188
1225
  builder.apply_env_name(ename, raw, line, file, [src], "compose.env")
1226
+ for template in _env_files(service, path, builder.repo):
1227
+ builder.env_file_users.setdefault(template, set()).add(src)
1189
1228
 
1190
1229
 
1191
1230
  def _walk_dockerfile(builder: _Builder, path: Path) -> None:
@@ -1247,6 +1286,61 @@ def _walk_manifest(builder: _Builder, path: Path) -> None:
1247
1286
  str(row.get("label") or ""), prov)
1248
1287
 
1249
1288
 
1289
+ def _template_entries(text: str) -> list[tuple[str, str, int]]:
1290
+ """`(name, raw_value, line)` for each assignment in an env template."""
1291
+ entries: list[tuple[str, str, int]] = []
1292
+ for lineno, raw in enumerate(text.splitlines(), 1):
1293
+ line = raw.strip()
1294
+ if not line or line.startswith("#"):
1295
+ continue
1296
+ if line.startswith("export "):
1297
+ line = line[len("export "):].strip()
1298
+ if "=" not in line:
1299
+ continue
1300
+ name, _, raw_value = line.partition("=")
1301
+ name = name.strip()
1302
+ if len(raw_value) >= 2 and raw_value[0] == raw_value[-1] and raw_value[0] in ("'", '"'):
1303
+ raw_value = raw_value[1:-1]
1304
+ entries.append((name, raw_value, lineno))
1305
+ return entries
1306
+
1307
+
1308
+ def _note_template(builder: _Builder, path: Path) -> None:
1309
+ text = _read_text(path)
1310
+ if text is None:
1311
+ return
1312
+ names = {name for name, _raw, _line in _template_entries(text)}
1313
+ builder.template_names.setdefault(_rel(path.parent, builder.repo), set()).update(names)
1314
+
1315
+
1316
+ def _root_consumers(builder: _Builder, file: str,
1317
+ entries: list[tuple[str, str, int]]) -> dict[str, list[str]] | None:
1318
+ """Per name, the compose services a repo-root template's variable belongs to.
1319
+
1320
+ Any compose service counts, a plain `image:` one too. None only when no
1321
+ service names any of the template's variables or reads it: the caller then
1322
+ falls back to every component and says so. A name compose does not mention goes to
1323
+ the components whose own directory template declares it, else to the
1324
+ component at the repo root, else to nobody. The node is still drawn.
1325
+ """
1326
+ readers = builder.env_file_users.get(file, set())
1327
+ names = [name for name, _raw, _line in entries if _interesting_env(name)]
1328
+ if not readers and not any(name in builder.env_users for name in names):
1329
+ return None
1330
+ root = builder.by_dir.get("")
1331
+ out: dict[str, list[str]] = {}
1332
+ for name in names:
1333
+ users = readers | builder.env_users.get(name, set())
1334
+ if not users:
1335
+ users = {builder.by_dir[folder]
1336
+ for folder, declared in builder.template_names.items()
1337
+ if folder and name in declared and folder in builder.by_dir}
1338
+ if not users and root:
1339
+ users = {root}
1340
+ out[name] = sorted(users)
1341
+ return out
1342
+
1343
+
1250
1344
  def _walk_env_template(builder: _Builder, path: Path) -> None:
1251
1345
  text = _read_text(path)
1252
1346
  if text is None:
@@ -1254,8 +1348,15 @@ def _walk_env_template(builder: _Builder, path: Path) -> None:
1254
1348
  return
1255
1349
  file = _rel(path, builder.repo)
1256
1350
  directory = _rel(path.parent, builder.repo)
1351
+ entries = _template_entries(text)
1352
+ consumers: dict[str, list[str]] | None = None
1257
1353
  if directory == "":
1354
+ consumers = _root_consumers(builder, file, entries)
1258
1355
  sources = sorted(builder.components)
1356
+ if consumers is None and len(sources) > 1:
1357
+ builder.warnings.append(
1358
+ f"{file}: no compose service names its variables or reads it; "
1359
+ f"its names are attributed to every component")
1259
1360
  else:
1260
1361
  owner = builder.owner(directory)
1261
1362
  sources = [owner] if owner else []
@@ -1266,19 +1367,9 @@ def _walk_env_template(builder: _Builder, path: Path) -> None:
1266
1367
  # the names belong to, so the edges have somewhere to start.
1267
1368
  sources = [builder.add_component(
1268
1369
  ".", "", ".", _provenance(file, "env.template", 1))]
1269
- for lineno, raw in enumerate(text.splitlines(), 1):
1270
- line = raw.strip()
1271
- if not line or line.startswith("#"):
1272
- continue
1273
- if line.startswith("export "):
1274
- line = line[len("export "):].strip()
1275
- if "=" not in line:
1276
- continue
1277
- name, _, raw_value = line.partition("=")
1278
- name = name.strip()
1279
- if len(raw_value) >= 2 and raw_value[0] == raw_value[-1] and raw_value[0] in ("'", '"'):
1280
- raw_value = raw_value[1:-1]
1281
- builder.apply_env_name(name, raw_value, lineno, file, sources, "env.template")
1370
+ for name, raw_value, lineno in entries:
1371
+ named = consumers.get(name, sources) if consumers is not None else sources
1372
+ builder.apply_env_name(name, raw_value, lineno, file, named, "env.template")
1282
1373
 
1283
1374
 
1284
1375
  def _sha(repo: Path) -> str:
@@ -1333,9 +1424,11 @@ def build_record(start: Path, *, project_id: str = "") -> tuple[dict, list[str]]
1333
1424
  for path in files:
1334
1425
  if _is_manifest(path):
1335
1426
  _walk_manifest(builder, path)
1336
- for path in files:
1337
- if path.name == ".env.example":
1338
- _walk_env_template(builder, path)
1427
+ templates = [path for path in files if path.name == ".env.example"]
1428
+ for path in templates:
1429
+ _note_template(builder, path)
1430
+ for path in templates:
1431
+ _walk_env_template(builder, path)
1339
1432
  if not (builder.components or builder.infra or builder.external or builder.unresolved):
1340
1433
  raise MapError("nothing to map: no compose service, Dockerfile or known dependency")
1341
1434
  return builder.finish(sha), builder.warnings
@@ -537,3 +537,184 @@ def test_ignored_and_untracked_files_are_not_mapped(tmp_path):
537
537
  (repo / stray / "Dockerfile").write_text("FROM python:3.12\n", encoding="utf-8")
538
538
  record, _ = stackmap.build_record(repo)
539
539
  assert [n["dir"] for n in record["components"]] == ["app"]
540
+
541
+
542
+ def _root_template_repo(tmp_path, compose: str | None) -> Path:
543
+ repo = tmp_path / "rooted"
544
+ repo.mkdir()
545
+ for name in ("api", "web"):
546
+ (repo / name).mkdir()
547
+ (repo / name / "requirements.txt").write_text("stripe==8.0\n", encoding="utf-8")
548
+ if compose is not None:
549
+ (repo / "compose.yml").write_text(compose, encoding="utf-8")
550
+ (repo / ".env.example").write_text("OPENAI_API_KEY=\n", encoding="utf-8")
551
+ _commit(repo)
552
+ return repo
553
+
554
+
555
+ def test_a_root_template_name_goes_to_the_service_that_declares_it(tmp_path):
556
+ """GRPH-1104: a root .env.example drew web -> openai when only api reads it."""
557
+ repo = _root_template_repo(tmp_path,
558
+ "services:\n"
559
+ " api:\n"
560
+ " build: ./api\n"
561
+ " environment:\n"
562
+ " - OPENAI_API_KEY\n"
563
+ " web:\n"
564
+ " build: ./web\n")
565
+ record, warnings = stackmap.build_record(repo)
566
+ to_openai = [(e["from"], e["to"]) for e in record["edges"] if e["to"] == "openai"]
567
+ assert to_openai == [("api", "openai")]
568
+ assert not [e for e in record["edges"] if e["from"] == "web" and e["to"] != "stripe"]
569
+ assert warnings == []
570
+ assert canonical(record) == canonical(stackmap.build_record(repo)[0])
571
+
572
+
573
+ def test_a_root_template_with_no_compose_goes_to_every_component_and_says_so(tmp_path):
574
+ repo = _root_template_repo(tmp_path, None)
575
+ record, warnings = stackmap.build_record(repo)
576
+ assert sorted(n["id"] for n in record["components"]) == ["api", "web"]
577
+ assert sorted(e["from"] for e in record["edges"] if e["to"] == "openai") == ["api", "web"]
578
+ assert [w for w in warnings if ".env.example" in w]
579
+
580
+
581
+ def test_a_root_template_read_through_env_file_goes_to_its_reader(tmp_path):
582
+ repo = _root_template_repo(tmp_path,
583
+ "services:\n"
584
+ " api:\n"
585
+ " build: ./api\n"
586
+ " web:\n"
587
+ " build: ./web\n"
588
+ " env_file: .env.example\n")
589
+ record, warnings = stackmap.build_record(repo)
590
+ assert [e["from"] for e in record["edges"] if e["to"] == "openai"] == ["web"]
591
+ assert warnings == []
592
+
593
+
594
+ @pytest.mark.parametrize("worker_env", [
595
+ " environment:\n - OPENAI_API_KEY\n",
596
+ " env_file: .env.example\n",
597
+ ])
598
+ def test_a_root_template_named_only_by_an_image_service_is_not_every_component(
599
+ tmp_path, worker_env):
600
+ """GRPH-1104 bounce: a service that builds nothing still counts as compose naming it."""
601
+ repo = tmp_path / "imaged"
602
+ repo.mkdir()
603
+ for name in ("api", "web"):
604
+ (repo / name).mkdir()
605
+ (repo / name / "requirements.txt").write_text("stripe==8.0\n", encoding="utf-8")
606
+ (repo / "compose.yml").write_text(
607
+ "services:\n"
608
+ " api:\n"
609
+ " build: ./api\n"
610
+ " web:\n"
611
+ " build: ./web\n"
612
+ " worker:\n"
613
+ " image: alpine\n" + worker_env, encoding="utf-8")
614
+ (repo / ".env.example").write_text("OPENAI_API_KEY=\nANTHROPIC_API_KEY=\n",
615
+ encoding="utf-8")
616
+ _commit(repo)
617
+ record, warnings = stackmap.build_record(repo)
618
+ assert not [e for e in record["edges"]
619
+ if e["from"] in ("api", "web") and e["to"] in ("openai", "anthropic")]
620
+ assert [e["from"] for e in record["edges"] if e["to"] == "openai"] == ["worker"]
621
+ assert not [w for w in warnings if ".env.example" in w]
622
+ assert {"openai", "anthropic"} <= {n["id"] for n in record["external"]}
623
+
624
+
625
+ def test_this_repo_root_template_draws_no_web_edge_to_a_model_provider():
626
+ record, _ = stackmap.build_record(ROOT)
627
+ providers = {"anthropic", "openai", stackmap.LOCAL_NODE}
628
+ assert not [e for e in record["edges"] if e["from"] == "web" and e["to"] in providers]
629
+ assert _edge(record, "web", "api", "depends_on")
630
+ ids = {n["id"] for n in record["external"] + record["infrastructure"]}
631
+ assert providers <= ids
632
+
633
+
634
+ # ---- what a post says it did (GRPH-1103) ----------------------------------------------------
635
+ # A real HTTP server on loopback, so the confirmation and the refusal go through the same
636
+ # transport and error parsing a person's post does.
637
+
638
+
639
+ @pytest.fixture()
640
+ def fake_server():
641
+ import threading
642
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
643
+
644
+ state = {"requests": [], "status": 200, "reply": {}}
645
+
646
+ class Handler(BaseHTTPRequestHandler):
647
+ def do_POST(self): # noqa: N802
648
+ raw = self.rfile.read(int(self.headers.get("Content-Length") or 0))
649
+ state["requests"].append((self.path, self.headers.get("X-API-Key"), json.loads(raw)))
650
+ reply = state["reply"](json.loads(raw)) if callable(state["reply"]) else state["reply"]
651
+ data = json.dumps(reply).encode()
652
+ self.send_response(state["status"])
653
+ self.send_header("Content-Type", "application/json")
654
+ self.send_header("Content-Length", str(len(data)))
655
+ self.end_headers()
656
+ self.wfile.write(data)
657
+
658
+ def log_message(self, *_args):
659
+ pass
660
+
661
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
662
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
663
+ thread.start()
664
+ state["url"] = f"http://127.0.0.1:{server.server_address[1]}"
665
+ try:
666
+ yield state
667
+ finally:
668
+ server.shutdown()
669
+ server.server_close()
670
+
671
+
672
+ def _mapped_app(isolated, monkeypatch) -> tuple[Path, str]:
673
+ repo = isolated / "app"
674
+ repo.mkdir()
675
+ _write_app(repo)
676
+ sha = _commit(repo)
677
+ monkeypatch.setenv("GBFLEET_API_KEY", "gb_sk_seat")
678
+ return repo, sha
679
+
680
+
681
+ def test_a_real_post_says_it_posted_and_where(isolated, monkeypatch, capsys, fake_server):
682
+ repo, sha = _mapped_app(isolated, monkeypatch)
683
+ fake_server["reply"] = lambda body: {**body, "mapped_at": "2026-10-23T10:00:00Z",
684
+ "posted_by": "seat-1"}
685
+ assert main(["--server", fake_server["url"], "--project", "demo",
686
+ "stack", "map", "--repo", str(repo)]) == 0
687
+ assert len(fake_server["requests"]) == 1
688
+ captured = capsys.readouterr()
689
+ assert captured.out == canonical(fake_server["requests"][0][2])
690
+ assert (f"gban: posted demo app map at {sha[:7]} to {fake_server['url']}"
691
+ in captured.err)
692
+ assert "posted_by seat-1" in captured.err
693
+ assert "dry run" not in captured.err
694
+
695
+
696
+ def test_dry_run_says_nothing_was_posted(isolated, monkeypatch, capsys, fake_server):
697
+ repo, _ = _mapped_app(isolated, monkeypatch)
698
+ assert main(["--server", fake_server["url"], "--project", "demo",
699
+ "stack", "map", "--dry-run", "--repo", str(repo)]) == 0
700
+ captured = capsys.readouterr()
701
+ assert fake_server["requests"] == []
702
+ assert "gban: dry run, nothing posted" in captured.err
703
+ assert "posted demo" not in captured.err
704
+ assert json.loads(captured.out)["project_id"] == "demo"
705
+
706
+
707
+ def test_a_refused_post_prints_the_reasons_and_not_the_record(isolated, monkeypatch, capsys,
708
+ fake_server):
709
+ repo, _ = _mapped_app(isolated, monkeypatch)
710
+ fake_server["status"] = 422
711
+ fake_server["reply"] = {"detail": [{"path": "edges[2].to", "reason": "unresolved node"},
712
+ {"path": "mapped_sha", "reason": "unknown is not a commit"}]}
713
+ assert main(["--server", fake_server["url"], "--project", "demo",
714
+ "stack", "map", "--repo", str(repo)]) == EXIT_REFUSED
715
+ assert len(fake_server["requests"]) == 1
716
+ captured = capsys.readouterr()
717
+ assert captured.out == ""
718
+ assert "edges[2].to: unresolved node" in captured.err
719
+ assert "mapped_sha: unknown is not a commit" in captured.err
720
+ assert "posted demo" not in captured.err
File without changes
File without changes
File without changes