Skip to content

Commit 75b8174

Browse files
feat(cli): non-zero exit codes on partial export failure (#55)
* feat(cli): non-zero exit codes on partial export failure * fix(cli): apply exit codes on --since last early returns Call _exit_bulk_export(export_result) when cmd_export returns early for latest_day is None or zero overlap, matching the empty-export path so recorded failures map to exit 1/2 instead of always exiting 0. * fix(cli): skip stderr summary on incremental no-op exports Only print the Exported N of M summary when exported_session_count or failure_count is non-zero, so clean --since incremental cron runs with unchanged mtimes stay silent on stderr. Add subprocess test for the no-op path; tighten since-last early-return test to realistic 0/0 counts. * fix(cli): accurate attempt count, elif, stdout on success in exit summary - m = n + k (exported + failed) instead of total_candidates; avoids misleading counts for incremental no-ops and --since last date-filtered sessions - Use elif so mutual exclusion of exit-1/exit-2 is explicit - Route summary to stdout when k==0 (clean success), stderr when k>0; prevents cron monitors treating clean exports as failures - Add test_since_last_early_return_exits_one_on_failure: validates actual exit code 1 without mocking _exit_bulk_export - Use explicit base/test-project instead of next(base.iterdir()) - Add comment on sys.exit branches and --session doc note in docstring
1 parent 5e4df43 commit 75b8174

3 files changed

Lines changed: 251 additions & 0 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,14 @@ python scripts/export.py --project boost-capy
9393

9494
The `--project` flag matches a **case-insensitive substring** of either the **Project** column from `list` (derived from the session working directory) or the internal directory name under `~/.claude/projects/` (for example `F--boost-capy` or `d--harbor-forge`). A substring like `boost-capy` matches `F--boost-capy`; you can also paste the friendly name shown in `list`.
9595

96+
**Exit codes** (`export` subcommand; stderr prints `Exported N of M sessions (K failed)` when any session was attempted):
97+
98+
| Code | Meaning |
99+
|------|---------|
100+
| 0 | All attempted sessions exported successfully, or nothing to export with no errors |
101+
| 1 | Total failure — no sessions exported, one or more errors |
102+
| 2 | Partial failure — some sessions exported, some failed |
103+
96104
## Data Source
97105

98106
Reads from `~/.claude/projects/` which contains JSONL session files created by Claude Code.

scripts/export.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@
1010
export.py --format json --no-zip # JSON files instead of zip
1111
export.py --since incremental # only sessions new/changed since last run (mtime)
1212
export.py --since last # all sessions active on latest UTC calendar day
13+
14+
Exit codes (export subcommand):
15+
0 — all sessions exported successfully (or nothing to export, no errors)
16+
1 — total failure (no sessions exported; one or more errors)
17+
2 — partial failure (some sessions exported, some failed)
18+
(exit codes apply to bulk export only; --session single-export always exits 0)
1319
"""
1420

1521
import argparse
@@ -33,6 +39,7 @@
3339
from utils.exclusion_rules import resolve_exclusion_rules_path, load_rules
3440
from utils.slugify import slugify
3541
from utils.export_engine import (
42+
BulkExportResult,
3643
ExportFormat,
3744
NoopSink,
3845
SinceMode,
@@ -393,6 +400,25 @@ def _aggregate_stats(base_dir: str, project_filter: str, fmt: str):
393400
print(f" Est. cost: ~${totals['total_cost']:.2f} USD")
394401

395402

403+
def _exit_bulk_export(result: BulkExportResult) -> None:
404+
"""Map bulk-export counts to process exit code (CLI wrapper only).
405+
406+
Prints a summary to stderr on any failure, stdout on clean success.
407+
Raises SystemExit(1) for total failure, SystemExit(2) for partial.
408+
"""
409+
n = result.exported_session_count
410+
k = result.failure_count
411+
# "attempted" = exported + failed; excludes untitled/excluded/mtime-skipped
412+
m = n + k
413+
if n > 0 or k > 0:
414+
dest = sys.stderr if k > 0 else sys.stdout
415+
print(f"Exported {n} of {m} sessions ({k} failed)", file=dest)
416+
if n == 0 and k > 0: # total failure
417+
sys.exit(1)
418+
elif k > 0: # partial failure
419+
sys.exit(2)
420+
421+
396422
def cmd_export(args):
397423
"""The main export command. Writes md/json files, optionally zipped."""
398424
base_dir = getattr(args, "base_dir", None) or get_claude_projects_dir()
@@ -460,6 +486,7 @@ def _on_export_error(sid: str, exc: Exception) -> None:
460486
if since == "last":
461487
if latest_day is None:
462488
print("Nothing to export (no qualifying sessions in scope).")
489+
_exit_bulk_export(export_result)
463490
return
464491
print(
465492
f"Latest activity end-date (UTC): {latest_day.isoformat()} — "
@@ -470,6 +497,7 @@ def _on_export_error(sid: str, exc: Exception) -> None:
470497
f"No sessions overlap {latest_day.isoformat()} (UTC); "
471498
"nothing to export."
472499
)
500+
_exit_bulk_export(export_result)
473501
return
474502
elif since == "incremental":
475503
skipped_mtime_unchanged = export_result.skipped_mtime_unchanged_count
@@ -494,6 +522,7 @@ def _on_export_error(sid: str, exc: Exception) -> None:
494522
"All sessions on disk were already at or before the last "
495523
"recorded export time (nothing new to write)."
496524
)
525+
_exit_bulk_export(export_result)
497526
return
498527

499528
os.makedirs(out_dir, exist_ok=True)
@@ -526,6 +555,7 @@ def _on_export_error(sid: str, exc: Exception) -> None:
526555

527556
_save_state(last_export, count=len(manifest), out_dir=out_dir)
528557
print(f"State saved to {STATE_FILE}")
558+
_exit_bulk_export(export_result)
529559

530560

531561
def _export_single(session: dict, stats: dict, fmt: str, out_dir: str):
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
"""CLI export exit codes for bulk export (partial / total failure)."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import re
7+
import sys
8+
import types
9+
from pathlib import Path
10+
11+
import pytest
12+
13+
REPO_ROOT = Path(__file__).resolve().parent.parent
14+
sys.path.insert(0, str(REPO_ROOT))
15+
16+
import scripts.export as export # noqa: E402
17+
from tests.test_cli_e2e import _run_cli, _seed_base_dir # noqa: E402
18+
from utils.export_engine import BulkExportResult # noqa: E402
19+
from utils.jsonl_parser import parse_session # noqa: E402
20+
21+
_SUMMARY_RE = re.compile(
22+
r"Exported \d+ of \d+ sessions \(\d+ failed\)",
23+
)
24+
25+
26+
def _isolated_home_env(tmp_path: Path) -> dict[str, str]:
27+
"""Redirect ~/.claude-code-chat-browser export state for subprocess CLI runs."""
28+
home = str(tmp_path / "home")
29+
return {"HOME": home, "USERPROFILE": home}
30+
31+
32+
def _export_args(tmp_path: Path, base: Path, out_dir: Path) -> types.SimpleNamespace:
33+
return types.SimpleNamespace(
34+
base_dir=str(base),
35+
out=str(out_dir),
36+
since="all",
37+
no_zip=True,
38+
project=None,
39+
format="md",
40+
session=None,
41+
exclude_rules=None,
42+
)
43+
44+
45+
def test_cli_export_clean_exits_zero(tmp_path):
46+
base = _seed_base_dir(tmp_path)
47+
out_dir = tmp_path / "out"
48+
proc = _run_cli([
49+
"export",
50+
"--base-dir",
51+
str(base),
52+
"--since",
53+
"all",
54+
"--no-zip",
55+
"--out",
56+
str(out_dir),
57+
])
58+
assert proc.returncode == 0, proc.stderr
59+
assert list(out_dir.rglob("*.md"))
60+
# Success summary must go to stdout, not stderr
61+
assert "Exported" not in proc.stderr
62+
assert "Exported 1 of 1 sessions (0 failed)" in proc.stdout
63+
64+
65+
def test_cli_export_partial_failure_exits_two(
66+
tmp_path, monkeypatch, capsys
67+
):
68+
"""One session exports; a second fails parse (simulated corrupt file)."""
69+
base = _seed_base_dir(tmp_path)
70+
project_dir = base / "test-project"
71+
bad = project_dir / "session_bad.jsonl"
72+
bad.write_text('{"type": "user"}\n', encoding="utf-8")
73+
out_dir = tmp_path / "out"
74+
75+
state_dir = tmp_path / "state"
76+
state_dir.mkdir()
77+
monkeypatch.setattr(export, "STATE_FILE", str(state_dir / "export_state.json"))
78+
monkeypatch.setattr(export, "STATE_DIR", str(state_dir))
79+
80+
real_parse = parse_session
81+
82+
def _parse(path: str):
83+
if bad.name in path.replace("\\", "/"):
84+
raise ValueError("simulated corrupt jsonl")
85+
return real_parse(path)
86+
87+
monkeypatch.setattr("utils.export_engine.parse_session", _parse)
88+
89+
with pytest.raises(SystemExit) as exc_info:
90+
export.cmd_export(_export_args(tmp_path, base, out_dir))
91+
92+
assert exc_info.value.code == 2
93+
captured = capsys.readouterr()
94+
assert _SUMMARY_RE.search(captured.err), captured.err
95+
assert "Exported 1 of 2 sessions (1 failed)" in captured.err
96+
assert len(list(out_dir.rglob("*.md"))) == 1
97+
98+
99+
def test_since_last_early_return_invokes_exit_bulk_export(
100+
tmp_path, monkeypatch, capsys
101+
):
102+
"""cmd_export --since last must call _exit_bulk_export on early-return paths."""
103+
exit_calls: list[BulkExportResult] = []
104+
105+
def _track_exit(result: BulkExportResult) -> None:
106+
exit_calls.append(result)
107+
108+
fake_result = BulkExportResult(latest_day=None)
109+
110+
monkeypatch.setattr(export, "_exit_bulk_export", _track_exit)
111+
monkeypatch.setattr(
112+
export,
113+
"run_bulk_export",
114+
lambda **kwargs: fake_result,
115+
)
116+
monkeypatch.setattr(export, "list_projects", lambda base: [{"name": "p", "path": "/p"}])
117+
118+
args = types.SimpleNamespace(
119+
base_dir=str(tmp_path),
120+
out=str(tmp_path / "out"),
121+
since="last",
122+
no_zip=True,
123+
project=None,
124+
format="md",
125+
session=None,
126+
exclude_rules=None,
127+
)
128+
129+
export.cmd_export(args)
130+
131+
assert len(exit_calls) == 1
132+
assert exit_calls[0] is fake_result
133+
captured = capsys.readouterr()
134+
assert "no qualifying sessions" in captured.out.lower()
135+
assert "Exported" not in captured.err
136+
137+
138+
def test_since_last_early_return_exits_one_on_failure(
139+
tmp_path, monkeypatch, capsys
140+
):
141+
"""Since-last early-return with failure_count>0 must produce real exit code 1."""
142+
fake_result = BulkExportResult(latest_day=None, failure_count=1)
143+
144+
monkeypatch.setattr(export, "run_bulk_export", lambda **kwargs: fake_result)
145+
monkeypatch.setattr(export, "list_projects", lambda base: [{"name": "p", "path": "/p"}])
146+
147+
args = types.SimpleNamespace(
148+
base_dir=str(tmp_path),
149+
out=str(tmp_path / "out"),
150+
since="last",
151+
no_zip=True,
152+
project=None,
153+
format="md",
154+
session=None,
155+
exclude_rules=None,
156+
)
157+
158+
with pytest.raises(SystemExit) as exc_info:
159+
export.cmd_export(args)
160+
161+
assert exc_info.value.code == 1
162+
captured = capsys.readouterr()
163+
assert "Exported 0 of 1 sessions (1 failed)" in captured.err
164+
165+
166+
def test_cli_export_incremental_noop_no_stderr_summary(tmp_path):
167+
"""Second incremental run after state is saved: exit 0, no stderr summary."""
168+
base = _seed_base_dir(tmp_path)
169+
out_dir = tmp_path / "out"
170+
home_env = _isolated_home_env(tmp_path)
171+
argv = [
172+
"export",
173+
"--base-dir",
174+
str(base),
175+
"--no-zip",
176+
"--out",
177+
str(out_dir),
178+
]
179+
first = _run_cli([*argv, "--since", "all"], env=home_env)
180+
assert first.returncode == 0, first.stderr
181+
assert list(out_dir.rglob("*.md"))
182+
183+
second = _run_cli([*argv, "--since", "incremental"], env=home_env)
184+
assert second.returncode == 0, second.stderr
185+
assert "Exported" not in second.stderr
186+
assert "Nothing to export" in second.stdout
187+
188+
189+
def test_cli_export_total_failure_exits_one(tmp_path, monkeypatch, capsys):
190+
project_dir = tmp_path / "test-project"
191+
project_dir.mkdir(parents=True)
192+
(project_dir / "bad_a.jsonl").write_text("{}", encoding="utf-8")
193+
(project_dir / "bad_b.jsonl").write_text("{}", encoding="utf-8")
194+
out_dir = tmp_path / "out"
195+
196+
state_dir = tmp_path / "state"
197+
state_dir.mkdir()
198+
monkeypatch.setattr(export, "STATE_FILE", str(state_dir / "export_state.json"))
199+
monkeypatch.setattr(export, "STATE_DIR", str(state_dir))
200+
201+
def _parse(_path: str):
202+
raise ValueError("simulated corrupt jsonl")
203+
204+
monkeypatch.setattr("utils.export_engine.parse_session", _parse)
205+
206+
with pytest.raises(SystemExit) as exc_info:
207+
export.cmd_export(_export_args(tmp_path, tmp_path, out_dir))
208+
209+
assert exc_info.value.code == 1
210+
captured = capsys.readouterr()
211+
assert "Exported 0 of 2 sessions (2 failed)" in captured.err
212+
assert "Nothing to export." in captured.out
213+
assert list(out_dir.rglob("*.md")) == []

0 commit comments

Comments
 (0)