Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,10 @@ deleted. Anything else waits for a human: `scripts/`, `tests/`, `.github/`,
on the branch tip and again on the trial-merge tree, so two branches that
each edited the same ledger are caught before either lands. `python3
scripts/ledger_merge.py classify --base origin/main` predicts the verdict
(exit 0 ledger · 1 code · 2 could not classify).
(exit 0 ledger · 1 code · 2 could not classify). `--base` reads the diff from git and
never stdin, so it is safe from a web/mobile session whose stdin is a harness
socket that never closes; piped paths are read only when neither explicit
paths nor `--base` is given.

## Check-in

Expand Down
20 changes: 16 additions & 4 deletions scripts/ledger_merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@
python3 scripts/ledger_merge.py classify path/one path/two # explicit paths
... < paths-on-stdin

Sources take precedence in that order: explicit paths, then `--base`, then
stdin. Stdin is read only when neither of the others is given, so a `--base`
run never blocks on a stdin that stays open (a harness socket).

Exit codes: 0 = ledger-only (safe to auto-merge) · 1 = holds code (a human's
call) · 2 = the script could not run. The caller must distinguish 1 from 2:
"a human should look" and "the gate is broken" are not the same answer.
Expand Down Expand Up @@ -187,19 +191,27 @@ def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
sub = parser.add_subparsers(dest="command", required=True)
cls = sub.add_parser("classify", help="ledger-only, or does it hold code?")
cls.add_argument("paths", nargs="*", help="repo-relative paths (else stdin, else --base)")
cls.add_argument("paths", nargs="*", help="repo-relative paths (else --base, else stdin)")
cls.add_argument("--base", help="diff HEAD against the merge base with this ref")
cls.add_argument("--head", default="HEAD", help="the branch tip to judge (default HEAD)")
args = parser.parse_args(argv)

# Source precedence: explicit paths, then --base, then stdin. `--base` must
# never read stdin: a Claude Code web/mobile session runs commands with a
# stdin that is not a TTY and never closes (a harness socket, not a pipe),
# so an `isatty()` test that falls through to `sys.stdin.read()` blocks
# forever, and `classify --base origin/main` hung there twice before this
# order was fixed (2026-09-17). Stdin is read only when it is the sole
# source left — the documented `... < paths` usage — where a caller that
# pipes nothing has asked for exactly the wait it gets, as with `cat`.
if args.paths:
paths = args.paths
elif not sys.stdin.isatty():
paths = sys.stdin.read().splitlines()
elif args.base:
paths = []
elif not sys.stdin.isatty():
paths = sys.stdin.read().splitlines()
else:
parser.error("give paths, pipe them in, or pass --base")
parser.error("give paths, pass --base, or pipe paths in")
return 2
# Strip blanks BEFORE the emptiness check, not inside classify(): a stdin
# of "\n" is one empty string, which is a truthy list, and an unfiltered
Expand Down
33 changes: 33 additions & 0 deletions tests/test_ledger_merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,39 @@ def test_an_empty_diff_is_not_permission_to_merge():
assert r.returncode == 1


def test_base_and_explicit_paths_never_wait_on_an_open_stdin():
"""A Claude Code web/mobile session runs commands with a stdin that is not
a TTY and never closes (a harness socket). `classify --base origin/main`
hung there twice on 2026-09-17, because the old source order tried stdin
before `--base`. With paths or `--base` given, stdin must not be touched:
hold the pipe open and never write to it, and the run must still return.
"""
for args in (["--base", "HEAD"], ["active.md" if "_cli" == "_run" else "projects/a.md"]):
proc = subprocess.Popen(
[sys.executable, str(SCRIPT), "classify", *args],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
)
try:
rc = proc.wait(timeout=30)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
raise AssertionError(f"classify {' '.join(args)} blocked on an open stdin")
finally:
proc.stdin.close()
out = proc.stdout.read()
# `--base HEAD` diffs HEAD against itself: an empty diff, which blocks
# (exit 1) — the point is that it answered, not what it answered.
assert rc == (1 if args[0] == "--base" else 0), out


def test_base_takes_precedence_over_piped_paths():
"""Piped paths are read only when nothing else names the source."""
result = _cli("--base", "HEAD", stdin="README.md\n")
assert result.returncode == 1
assert "nothing to merge" in result.stdout


def test_every_fixture_path_is_classified():
for p in (REPO / "tests" / "fixtures").rglob("*"):
if p.is_file():
Expand Down
Loading