Skip to content

fix(source-control): report worktrees the pruner cannot map to a PR - #1390

Merged
kyle-sexton merged 5 commits into
mainfrom
fix/555-prune-worktrees-nonstandard-names
Jul 26, 2026
Merged

fix(source-control): report worktrees the pruner cannot map to a PR#1390
kyle-sexton merged 5 commits into
mainfrom
fix/555-prune-worktrees-nonstandard-names

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

This was generated by AI during autonomous /work-items:work-loop execution.

Closes #555

Summary

prune_babysit_worktrees.py derives each worktree's PR identity from its directory name. A
directory that did not match <owner>__<repo>__pr-<number> was dropped by iter_worktrees
before the report was assembled — not kept, not removed, not an error, simply absent. A caller
reading the JSON to answer "is anything left to clean up?" got an empty list while merged PRs'
worktrees sat on disk, and had to find and git worktree remove them by hand.

Fix shape: the issue's option (b) — report the unmappable directory — not option (a), deriving
identity from git worktree list branch/remote metadata.
The issue frames these as
alternatives; they are not. (b) is a prerequisite of (a):

  • A detached worktree has no branch to map. This skill documents detached-HEAD operation as
    required whenever the PR branch is locked in a sibling checkout (reference/worktrees.md), so
    (a) still needs this fallback for the case the convention exists to cover.
  • Identity feeds the worker-lease guard, not just the removal decision. The directory name is
    fixed for the worktree's lifetime; git working state is not. A network-derived identity that
    disagreed with what the worker wrote would make an active lease invisible and delete a live
    worker's worktree.
  • (a) costs the suite its hermeticity. The only existing branch-to-PR mapper,
    find_open_prs_for_head_ref, filters to open PRs, and pruning targets merged/closed ones by
    definition — so (a) means new gh plumbing, against the hermetic regression guard source-control: babysit worktree pruner hard-depends on ghq (undeclared, author-personal repo-layout tool) #438
    deliberately established.

(a) is therefore not foreclosed by this change and not merely deferred: it is a superset that
would sit on top of this fallback.

What changed:

  • Every directory under <worktree-root> now appears in the report. An unmappable one is an
    action: unrecognized row carrying its path and reason, emitted in every mode including
    --pr — an unrecognized entry has no key to match a target against, so leaving it to that
    filter would hide it from every scoped run permanently. (A recognized worktree for another PR
    is still filtered out under --pr, as it always has been: it is outside the scope the caller
    declared in target_pr and remains visible in an unscoped run.)
  • Unrecognized entries are never removed (identity is a precondition for the PR-state and lease
    checks that authorize removal) and never fail the run, so a stray directory cannot break a dry
    run.
  • Root listing is sorted, so the report is order-stable.
  • reference/worktrees.md now states the naming convention that was previously only implied by
    the helper's regex — the drift the issue identifies as the root cause — and what becomes of a
    directory that breaks it.

Test plan

Run from plugins/source-control/skills/babysit-prs/scripts/:

  • python -m unittest tests.test_prune_babysit_worktrees -v7 tests, OK (3 new, 4
    pre-existing).
  • Full plugin suite, python -m unittest discover -s .../scripts/tests -t .../scripts351
    tests, OK
    . No regressions.

New coverage (all hermetic — no gh, no network):

  • test_iter_worktrees_pairs_recognized_and_unrecognized_directories — a root holding both a
    conforming and a non-conforming directory yields one Worktree and one UnrecognizedWorktree;
    a loose non-directory file is still ignored.
  • test_main_reports_a_non_conforming_worktree_instead_of_nothing — the source-control:babysit-prs: prune_babysit_worktrees.py silently skips non-standard worktree names (medley-<N> pattern) #555 regression itself:
    against a real on-disk git worktree named medley-1567, the report now carries the
    unrecognized row instead of the pre-fix empty list, the directory survives, exit code 0.
  • test_scoped_pr_mode_still_reports_unrecognized_directories--pr owner/repo#1 still reports
    the unrecognized directory.

Related

N/A

🤖 Generated with Claude Code

…555)

`prune_babysit_worktrees.py` derives each worktree's PR identity from its
directory name. A directory that did not match `<owner>__<repo>__pr-<number>`
was dropped by `iter_worktrees` before the report was assembled -- not kept,
not removed, not an error, simply absent. A caller reading the JSON to answer
"is anything left to clean up?" got an empty list while merged PRs' worktrees
sat on disk, and had to find and `git worktree remove` them by hand.

Fixed with the issue's option (b) -- report the unmappable directory -- rather
than option (a), deriving identity from `git worktree list` branch/remote
metadata. (b) is a prerequisite of (a), not an alternative to it: this skill
documents detached-HEAD operation as required whenever the PR branch is locked
in a sibling checkout (`reference/worktrees.md`), and a detached worktree has
no branch to map, so (a) still needs this fallback. Two further reasons (a) is
the wrong shape here:

- Identity feeds the worker-lease guard as well as the removal decision. The
  directory name is fixed for the worktree's lifetime; git working state is
  not. A network-derived identity that disagreed with what the worker wrote
  would make an active lease invisible and delete a live worker's worktree.
- The only existing branch-to-PR mapper, `find_open_prs_for_head_ref`, filters
  to open PRs, and pruning targets merged/closed ones by definition -- so (a)
  means new `gh` plumbing, and would cost this suite the hermeticity its #438
  regression guard deliberately established.

Every directory under the root now appears in the report; an unmappable one is
an `action: unrecognized` row carrying its path and reason, emitted in every
mode including `--pr` -- scoping narrows which PRs are acted on, never which
directories are accounted for. Such entries are never removed (identity is a
precondition for the PR-state and lease checks that authorize removal) and do
not fail the run, so a stray directory cannot break a dry run. Root listing is
now sorted so the report is order-stable.

`reference/worktrees.md` states the naming convention that was previously only
implied by the helper's regex -- the drift the issue identifies as the root
cause -- and what becomes of a directory that breaks it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kyle-sexton
kyle-sexton force-pushed the fix/555-prune-worktrees-nonstandard-names branch from 3620814 to f36a5eb Compare July 25, 2026 20:00
@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 1m 54s —— View job


Code Review — fix(source-control): report worktrees the pruner cannot map to a PR

  • Gather context from PR description and changed files
  • Review prune_babysit_worktrees.py — core logic changes
  • Review test_prune_babysit_worktrees.py — new test coverage
  • Review worktrees.md — documentation updates
  • Review plugin.json and CHANGELOG.md — versioning
  • Post findings

Overall verdict: Approve. The fix is correct, minimal, and well-tested. No blockers. A few observations below worth considering.


Correctness

The fix is sound. iter_worktrees now returns every directory — as either a Worktree or an UnrecognizedWorktree — and main() dispatches on the type, emitting action: unrecognized before the target_key filter so scoped mode (--pr) cannot suppress it. The early continue at line 240 correctly short-circuits past all Worktree-specific logic (lease acquisition, git status, gh pr view, remove_worktree), so unrecognized entries reach none of those code paths regardless of flags.

sorted(root.iterdir()) at line 100 gives deterministic report order without touching the rest of the function. Path comparison in Python falls back to string comparison, which is stable across platforms for the ASCII directory names this convention produces.

Minor: the worktrees JSON array is now a mixed schema — Worktree rows have "key" and UnrecognizedWorktree rows have "reason" instead. Any consumer iterating the array and unconditionally reading row["key"] will KeyError on an unrecognized entry. The CHANGELOG implicitly signals this ("a action: unrecognized row carrying its path and reason"), but if there are any known consumers that parse the JSON array directly (skill prose, CI scripts), they deserve a heads-up. Not blocking — the change is clearly documented — but worth confirming no downstream parser assumes the old homogeneous shape.

Security

No new risk surface. Unrecognized entries never reach run_command, so no subprocess is invoked on a directory that cannot be mapped to a PR identity. The path-containment guard in remove_worktree (line 159–163) is unchanged and still applies to all Worktree removals.

Test Coverage

All three new tests are well-structured and hermetic:

One small gap: there's no test that passes --apply along with an unrecognized directory to verify the directory survives even when --apply is set. The logic is correct by construction (the isinstance check exits before the apply branch), but an explicit --apply variant of the second test would be a cheap regression guard for this invariant. Not blocking.

The run_main() helper at line 66–78 is clean. Patching sys.argv and redirecting stdout is the right approach for in-process testing of an argparse-based main(). Stderr is not captured, which is correct — the tests don't assert on error output, and leaving stderr live makes failures readable.

Documentation

The worktrees.md change is well-placed. The new policy bullet at line 21–24 comes first, before the bullets that assume valid names, which is the right ordering for a reader encountering a stale worktree. Making the naming convention explicit in the opening paragraph is the fix the issue identified as root cause.

Versioning

Patch bump 0.26.4 → 0.26.5 is appropriate for a bug fix with no API additions. CHANGELOG entry is accurate and complete.

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 1m 57s —— View job


Security Review

  • Gather PR diff and changed files
  • Analyze for security vulnerabilities
  • Post findings

No security issues found.

I reviewed all five changed files with a focus on the new code paths introduced by this PR. Here is what I checked and why each is clean:

Path handling and traversal

The UnrecognizedWorktree.path is populated from root.iterdir() (line 100 of prune_babysit_worktrees.py), so it is structurally bounded to the root directory. Filesystem paths cannot contain / in a directory name component, ruling out traversal via a crafted name. The value is serialized through json.dumps (line 286–295), which correctly escapes any special characters a directory name might legally contain on Linux (quotes, backslashes, etc.).

Removal guard remains intact

Unrecognized entries are never passed to remove_worktree; the isinstance(entry, UnrecognizedWorktree) check at line 229 short-circuits to a continue before any git or gh command is constructed. The pre-existing removal path's resolved ∈ allowed_root.parents guard in remove_worktree (line 160–163) is unaffected by the diff.

No new shell execution surface

run_command (in babysit_util.py) runs subprocess without shell=True and validates the executable name against ALLOWED_EXECUTABLES = ("git", "gh"). The new code path for unrecognized entries emits no subprocess call at all — the entry is appended to rows and the loop continues.

Static reason string, no user-controlled data in output fields

UNRECOGNIZED_REASON is a module-level constant (line 22); the reason field in the JSON row is never derived from the directory name or any other external input. The only user-controlled data in the unrecognized row is str(entry.path), which mirrors the same pattern already used for recognized worktrees.

Symlink safety

child.is_dir() follows symlinks. A symlink in the root pointing outside it would be reported as UnrecognizedWorktree (its name will not match the regex). Because unrecognized entries are never removed, this introduces no new removal-via-symlink risk. The recognized-worktree path already had worktree.path.resolve() for symlink-aware bounds checking; that logic is unchanged.

No new information disclosure

The path field in the unrecognized row exposes the same scope of filesystem path already present in the root field of every report. No new filesystem structure is revealed beyond what callers already receive.

…d worktree

The report-don't-remove invariant is what makes emitting an `unrecognized` row
safe at all, and `--apply` is the only mode that can delete anything. Until now
that invariant held only by construction -- the `isinstance` check `continue`s
before the apply branch -- which is one refactor away from a silent regression
that deletes a directory the tool could not identify.

Builds a real linked git worktree named `medley-1567`, runs with `--apply`, and
asserts the directory survives, the row is `action: unrecognized`, `removed` is
False, and the exit code is 0.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 1m 18s —— View job


Code Review — fix(source-control): report worktrees the pruner cannot map to a PR

  • Gather context from PR description and changed files
  • Inspect the new commit (3816baf) — --apply guard test
  • Re-review prune_babysit_worktrees.py for correctness
  • Re-review test_prune_babysit_worktrees.py for the new test
  • Post findings

Overall verdict: Approve. The previous review raised one gap; this synchronize closes it exactly. All findings from the first pass remain resolved; nothing new introduced.


What changed in 3816baf

The previous review noted:

There's no test that passes --apply along with an unrecognized directory to verify the directory survives even when --apply is set. The logic is correct by construction (the isinstance check exits before the apply branch), but an explicit --apply variant of the second test would be a cheap regression guard for this invariant. Not blocking.

Commit 3816baf adds exactly that test: test_apply_mode_reports_but_never_removes_an_unrecognized_worktree.


New test review

The test is well-structured:

  • Builds a real linked git worktree (not a plain directory) named medley-1567 and runs with --apply.
  • self.assertTrue(wt.exists()) is placed inside the with tempfile.TemporaryDirectory(...) block (line 200), consistent with the pattern in the sibling test — it proves the worktree is still present before the temp dir is torn down.
  • The remaining assertions (code == 0, action == "unrecognized", removed == False) are outside the with block, where the teardown doesn't interfere, which is also consistent.
  • Uses [row["action"] for row in report["worktrees"]] rather than the full-row dict comparison from test_main_reports_a_non_conforming_worktree_instead_of_nothing. That's appropriate — the full schema is already pinned by the earlier test; this one is focused on the --apply invariant, so checking just the relevant fields is the right scope.

No issues. The commit message correctly articulates why this test matters ("one refactor away from a silent regression") and the code follows the existing hermetic test patterns throughout the suite.


Residual from earlier review — confirmed not blocking

The mixed-schema observation (recognized rows have "key", unrecognized rows have "reason") still stands as a consumer-facing note. Nothing in this PR worsens it, and the CHANGELOG documents it accurately. If there are any downstream callers that parse the JSON array directly, they warrant a heads-up, but that's outside the scope of this fix.


The PR is correct, minimal, safe, and now fully regression-guarded. Ready to merge.

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 1m 45s —— View job


Security Review — fix(source-control): report worktrees the pruner cannot map to a PR

  • Gather context
  • Fetch PR diff
  • Review changed files for security issues
  • Post findings

No security issues found.

I reviewed all five changed files against the diff (eab6d9b..3816baf), focusing on the new code paths introduced by this PR. Findings are security-only per the review scope.


Changed surface examined

File Nature of change
prune_babysit_worktrees.py New UnrecognizedWorktree dataclass; iter_worktrees union return; main() dispatch branch
test_prune_babysit_worktrees.py Three new hermetic tests; run_main() helper
worktrees.md Documentation only
plugin.json Version bump only
CHANGELOG.md Changelog entry only

What I checked and why each is clean

Command injection / subprocess isolation. The new isinstance(entry, UnrecognizedWorktree) branch at line 229 continues before every sensitive operation: lease acquisition, git status, gh pr view, and remove_worktree. Unrecognized entries can never reach a subprocess call regardless of flags passed. The pre-existing ALLOWED_EXECUTABLES = ("git", "gh") guard and the no-shell=True discipline in run_command are unaffected.

Path traversal / symlink risk. root.iterdir() yields only direct children; no recursive descent is introduced. child.is_dir() follows symlinks, which is an existing behavior (not introduced here). A symlink under the worktree root whose name does not match the regex becomes an UnrecognizedWorktree and is never passed to remove_worktree — so no removal-via-symlink path opens. A symlink with a conforming name falls into the unchanged Worktree branch, where the pre-existing resolved ∈ allowed_root.parents containment guard at line 160 continues to block out-of-root removal.

JSON output / injection. str(entry.path) is serialized through Python's json module, which correctly escapes all special characters a directory name can legally contain on Linux (quotes, backslashes, control characters). The only externally-derived value in the unrecognized row is the path; the reason field is UNRECOGNIZED_REASON, a static module-level constant — no user-controlled data flows into it.

Information disclosure. The path field in unrecognized rows exposes an absolute path bounded to <worktree-root>, matching the scope already exposed by the path field in every recognized-worktree row. No new filesystem structure is revealed.

sorted() on Path objects. Python Path comparison is lexicographic over string representations of each path component. Sorting introduces no security-relevant behavior change.

Test file security. mock.patch.object(sys, 'argv', argv) and contextlib.redirect_stdout are standard in-process test patterns. tempfile.TemporaryDirectory is OS-managed. No test credential, token, or secret appears in the diff.

GitHub Actions / supply chain. No workflow files changed; no dependency pins altered.


@kyle-sexton

Copy link
Copy Markdown
Contributor Author

This was generated by AI during autonomous /work-items:work-loop execution.

Review triage — claude[bot] code review + security review

Both reviews fetched and every finding classified individually against the actual code, not the
assertion.

# Source Finding Classification Evidence
1 Code review — Correctness / Minor The worktrees JSON array is now a mixed schema; a consumer unconditionally reading row["key"] would KeyError on an unrecognized row. Asks to confirm no downstream parser assumes the old homogeneous shape. VALID — confirmed, no change required Swept every reference to prune_babysit_worktrees in the repo. All call sites are prose invocations the model reads, not programmatic parsers: skills/babysit-prs/SKILL.md:379, reference/worktrees.md:49/56/63/70, reference/orchestration.md:620. None indexes a row, none pipes the output through jq, none names a per-row key. There is no downstream parser to break.
2 Code review — Test Coverage / One small gap No test passes --apply alongside an unrecognized directory, so the "survives even under --apply" invariant is guarded only by construction. VALID — fixed Correct. --apply is the only mode that can delete anything, so the report-don't-remove invariant is exactly what makes emitting an unrecognized row safe. Guarding it by construction alone (the isinstance continue preceding the apply branch) is one refactor away from silent regression. Added test_apply_mode_reports_but_never_removes_an_unrecognized_worktree in 3816bafdd2536fe892e5429a210253cc69e3970e, which builds a real linked git worktree named medley-1567, runs with --apply, and asserts the directory still exists, action: unrecognized, removed: False, exit 0. Suite green locally: 8/8 in test_prune_babysit_worktrees.
3 Security review No security issues found. Informational — acknowledged Independently consistent with the diff: unrecognized entries continue before any run_command, so no subprocess is constructed for a directory that carries no PR identity; UNRECOGNIZED_REASON is a module-level constant, never derived from the directory name.

Note on finding 1

Worth stating explicitly since it is a contract question rather than a defect: the heterogeneous
row shape is intended, not incidental. A row's action is the discriminator — unrecognized
rows carry path / action / reason / removed and deliberately have no key, because key
is the PR identity the tool could not derive. Emitting a placeholder key would reintroduce the
exact false-confidence #555 is about. Filed nothing to defer here: there is no affected consumer,
and a schema-versioning change would be scope creep on a bug fix.

Not adopted

Nothing. Both findings were non-blocking and both are resolved above — one by verification, one by
a new test.

…es-nonstandard-names

# Conflicts:
#	plugins/source-control/.claude-plugin/plugin.json
#	plugins/source-control/CHANGELOG.md
@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 1m 51s —— View job


Code Review — fix(source-control): report worktrees the pruner cannot map to a PR

  • Gather context from PR description and prior reviews
  • Fetch PR diff (git diff origin/main...HEAD)
  • Review prune_babysit_worktrees.py — core logic
  • Review test_prune_babysit_worktrees.py — test coverage
  • Review worktrees.md — documentation
  • Review plugin.json + CHANGELOG.md — versioning
  • Post findings

Overall verdict: Approve. Three commits, all clean. The fix is correct, minimal, and fully regression-guarded. This review found no issues not already addressed in prior passes.


What this PR does

iter_worktrees previously dropped directories whose names didn't match <owner>__<repo>__pr-<number>, giving callers a false all-clear. The fix:

  1. Adds UnrecognizedWorktree — a dataclass carrying path and a static reason string.
  2. Rewrites iter_worktrees to yield every directory as one of the two types, in sorted order.
  3. Dispatches on type in main(): an UnrecognizedWorktree emits action: unrecognized and continues before every sensitive code path (lease acquisition, git status, gh pr view, remove_worktree).
  4. Documents the naming convention in worktrees.md — the drift the issue identifies as root cause.

Correctness

The isinstance check at line 229 fires before the target_key filter at line 242. This is the key invariant: --pr scoping narrows which recognized worktrees are acted on, but cannot suppress an unrecognized entry from the report. Correct.

sorted(root.iterdir()) at line 100 gives deterministic report order. Python Path comparison falls back to lexicographic string comparison, which is stable for the ASCII names this convention produces.

The UnrecognizedWorktree row deliberately omits keykey is the PR identity that could not be derived. Emitting a placeholder would reintroduce the false-confidence the fix exists to prevent. The schema divergence is intentional and documented in the CHANGELOG.

Security

No new risk surface. Previous security review confirmed this in detail; nothing in the final commit (3816baf) changes that assessment. Unrecognized entries never reach run_command. The containment guard in remove_worktree at lines 158–163 is unchanged and applies only to the recognized-worktree path.

Test coverage

All four new tests are hermetic (no gh, no network):

Test What it guards
test_iter_worktrees_pairs_recognized_and_unrecognized_directories Sorted output, type dispatch, loose files skipped
test_main_reports_a_non_conforming_worktree_instead_of_nothing The #555 regression itself — real linked worktree, full row schema pinned
test_apply_mode_reports_but_never_removes_an_unrecognized_worktree --apply cannot delete what it cannot identify
test_scoped_pr_mode_still_reports_unrecognized_directories --pr scoping cannot suppress an unrecognized entry

The run_main() helper at lines 66–78 is the right pattern for in-process testing of an argparse-based main(). self.assertTrue(wt.exists()) is correctly placed inside the temp-dir with block in both worktree-based tests, proving the directory survives before teardown, not just that the variable once held a path.

Documentation

The new policy bullet in worktrees.md at lines 21–24 is correctly ordered before the existing policy bullets that assume a valid name. Explicitly naming the convention in the opening paragraph (previously only implied by the regex) fixes the documentation drift the issue identified as root cause.

Versioning

Patch bump 0.26.80.26.9 is correct for a bug fix. CHANGELOG entry is accurate and complete.

Residual from prior reviews

Finding Status
Mixed-schema JSON array (key vs reason) — any downstream parser? Confirmed not blocking: all call sites are prose invocations the model reads; no programmatic parser exists in this repo.
--apply test gap Fixed in 3816baf.
Security review No issues found in either pass.

No open findings. The PR is correct, minimal, safe, and fully regression-guarded. Ready to merge.

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 1m 12s —— View job


Security Review — fix(source-control): report worktrees the pruner cannot map to a PR

  • Gather PR diff and changed files
  • Analyze for security vulnerabilities
  • Post findings

No security issues found.

I reviewed all five changed files for the security surfaces most likely to be affected by this kind of change. Details below.


Changed surface examined

File Nature of change
prune_babysit_worktrees.py New UnrecognizedWorktree dataclass; iter_worktrees union return; main() dispatch branch
test_prune_babysit_worktrees.py Four new hermetic tests; run_main() helper
worktrees.md Documentation only
plugin.json Version bump only
CHANGELOG.md Changelog entry only

What I checked and why each is clean

Command injection / subprocess isolation.
The new isinstance(entry, UnrecognizedWorktree) branch issues a continue before every sensitive operation: lease acquisition, git status, gh pr view, and remove_worktree. Unrecognized entries are structurally incapable of reaching a subprocess call under any flag combination. The pre-existing ALLOWED_EXECUTABLES = ("git", "gh") guard and no-shell=True discipline in run_command are unaffected by this diff.

Path traversal / out-of-root removal.
root.iterdir() yields only direct children — no recursive descent is introduced. A symlink under the root whose name does not match the regex becomes an UnrecognizedWorktree and is never passed to remove_worktree, so no removal-via-symlink path opens. A conforming-named symlink falls into the unchanged Worktree branch where the pre-existing resolved ∈ allowed_root.parents containment guard at line 160 continues to block out-of-root removal.

JSON output / injection.
The only externally-derived value in the unrecognized row is str(entry.path), serialized through Python's json module, which escapes all special characters legally present in a Linux directory name. The reason field is UNRECOGNIZED_REASON, a module-level constant — no user-controlled data flows into it.

Information disclosure.
The path field in unrecognized rows exposes a filesystem path bounded to <worktree-root>, matching the scope already exposed by the path field in every recognized-worktree row. No new filesystem structure is revealed beyond what callers already receive.

sorted() on Path objects.
Python Path comparison is lexicographic over string representations. Sorting introduces no security-relevant behavior change; in particular, it cannot cause a different entry to be selected for removal.

Test file security.
mock.patch.object(sys, 'argv', argv) and contextlib.redirect_stdout are standard in-process test patterns. tempfile.TemporaryDirectory is OS-managed. No credential, token, or secret appears in the diff.

GitHub Actions / supply chain.
No workflow files changed. No dependency pins altered.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 893a11616a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

The rationale for reporting an unrecognized worktree before the `--pr`
filter claimed "no directory under the root goes unreported". That is
false: a recognized worktree whose key does not match the target is
dropped by that same filter without a row, and always has been.

The real reason the unrecognized branch precedes the filter is narrower
and is what the code actually relies on. An unrecognized entry carries no
key, so it can never match a target key -- leaving it to the filter would
hide it from every scoped run, permanently, which is the #555 silent drop
in a different mode. A recognized non-target entry is merely outside the
scope the caller declared via `target_pr` and remains visible in an
unscoped run, so scoping it out loses no inventory.

Behavior is unchanged; the comment and the changelog entry now describe
the invariant the code enforces rather than a stronger one it does not.
@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 1m 35s —— View job


Code Review — fix(source-control): report worktrees the pruner cannot map to a PR

  • Read changed files
  • Evaluate Codex P2 finding (scoped mode, conforming non-target directories)
  • Review new docs commit (07fe7c0)
  • Post findings

Overall verdict: Approve. The latest commit (07fe7c0) is the only new thing to assess; it is correct. The Codex P2 finding describes a pre-existing intentional behavior and is not a defect in this PR. Nothing is outstanding.


New commit: 07fe7c0 — docs correction

This commit fixes an overstatement in the code comment and CHANGELOG: the previous text claimed the invariant was "no directory under the root goes unreported," which is false for recognized-but-out-of-scope worktrees filtered by --pr. The corrected text correctly describes the narrower invariant the code actually enforces:

An unrecognized entry has no key and so could never match a target: leaving it to that filter would hide it from every scoped run forever. A recognized non-target entry is merely out of the caller's declared scope and still appears in an unscoped run.

This is accurate. The isinstance(entry, UnrecognizedWorktree) branch at line 229 fires before the target_key filter at line 245 — because an unrecognized entry carries no key, it could never satisfy worktree.key.casefold() != target_key and would be silently continued on every scoped run. The corrected comment is why, not just what.

The CHANGELOG entry at 0.26.9 now consistently reflects the same narrower guarantee.

Behavior is unchanged; the documentation is now accurate.


Codex P2 finding: conforming non-target worktrees silently dropped under --pr

The finding is:

When --pr owner/repo#1 is used and the root also contains a conforming other/repo#2 directory, the report silently drops the conforming non-target directory. Emit an explicit out-of-scope row for these entries.

This is not a defect introduced by this PR. The silent scoping of recognized non-target worktrees under --pr is the pre-existing, intentional behavior — unchanged by this fix. The 07fe7c0 commit explicitly acknowledges this: it corrects the earlier overstatement ("no directory under the root goes unreported") to the narrower, accurate guarantee. A --pr-scoped call is a declaration by the caller that they want inventory for one specific PR; emitting all other recognized worktrees as noise would contradict the purpose of scoping.

The fix's claim is narrower and correct: an unrecognized directory can never be accounted for by any scoped run (it has no key to match), so it must be reported before the filter. A recognized non-target worktree is visible in unscoped runs and explicitly excluded by the caller's own --pr flag.

If emitting explicit out-of-scope rows for recognized non-target entries is desirable, that is a separate feature change, not a correctness issue here.


Summary of all findings across the PR's lifecycle

Finding Status
Mixed-schema JSON array (key vs reason) — any downstream parser? Not blocking: confirmed all call sites are prose invocations; no programmatic parser in the repo.
--apply test gap Fixed in 3816baf.
Code comment overstated the invariant Fixed in 07fe7c0.
CHANGELOG entry overstated the invariant Fixed in 07fe7c0.
Codex P2: conforming non-target worktrees silently dropped under --pr Pre-existing intentional behavior, not introduced or worsened by this PR.
Security review (two passes) No issues found.

No open findings. The PR is correct, minimal, safe, and the documentation now precisely describes what the code enforces.

@claude

claude Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 1m 39s —— View job


Security Review — fix(source-control): report worktrees the pruner cannot map to a PR

  • Gather PR context
  • Fetch PR diff
  • Read changed security-relevant files
  • Analyze for vulnerabilities
  • Post findings

No security issues found.

I reviewed all five changed files against the diff, focusing on the surfaces most likely to be affected. Details by area:


Command injection / subprocess isolation

The new isinstance(entry, UnrecognizedWorktree) branch issues a continue before every sensitive operation — lease acquisition, git status, gh pr view, and remove_worktree. An unrecognized entry is structurally incapable of reaching run_command under any flag combination. The pre-existing ALLOWED_EXECUTABLES = ("git", "gh") guard and no-shell=True discipline in run_command (confirmed in babysit_util.py:119) are unaffected by this diff.

Path traversal / out-of-root removal

root.iterdir() at line 100 yields only direct children — no recursive descent is introduced. A symlink under the root whose name does not match the regex becomes an UnrecognizedWorktree and is never passed to remove_worktree, so no new removal-via-symlink path opens. A conforming-named symlink falls into the unchanged Worktree branch where the pre-existing resolved ∈ allowed_root.parents containment guard at lines 158–163 continues to block out-of-root removal.

JSON output / injection

The only externally-derived value in the unrecognized row is str(entry.path), serialized through Python's json module, which escapes all special characters legally present in a Linux directory name. The reason field is UNRECOGNIZED_REASON, a module-level constant — no user-controlled data flows into it.

Information disclosure

The path field in unrecognized rows exposes a filesystem path bounded to <worktree-root>, matching the scope already exposed by the root field and the path field of every recognized-worktree row. No new filesystem structure is revealed beyond what callers already receive.

sorted() on Path objects

Python Path comparison is lexicographic over string representations of each path component. Sorting introduces no security-relevant behavior change — in particular, it cannot cause a different entry to be selected for removal.

Test file security

mock.patch.object(sys, 'argv', argv) and contextlib.redirect_stdout are standard in-process test patterns. tempfile.TemporaryDirectory is OS-managed. No credential, token, or secret appears in the diff.

GitHub Actions / supply chain

No workflow files changed. No dependency pins altered.

# Conflicts:
#	plugins/source-control/.claude-plugin/plugin.json
#	plugins/source-control/CHANGELOG.md
@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 2s —— View job


I'll analyze this and get back to you.

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 2s —— View job


I'll analyze this and get back to you.

@github-actions

Copy link
Copy Markdown

Warning

Automated review did not complete — this is an infrastructure failure, not a review verdict.

Treat any Claude comment on this PR (including a placeholder like "I'll analyze this and get back to you") as incomplete, not "no findings."

Re-running the job, or pushing a new commit, will retry the review.

@github-actions

Copy link
Copy Markdown

Warning

Automated security review did not complete — this is an infrastructure failure, not a review verdict.

Treat any Claude comment on this PR (including a placeholder like "I'll analyze this and get back to you") as incomplete, not "no findings."

Re-running the job, or pushing a new commit, will retry the review.

@kyle-sexton
kyle-sexton merged commit fa50980 into main Jul 26, 2026
27 checks passed
@kyle-sexton
kyle-sexton deleted the fix/555-prune-worktrees-nonstandard-names branch July 26, 2026 00:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

source-control:babysit-prs: prune_babysit_worktrees.py silently skips non-standard worktree names (medley-<N> pattern)

1 participant