Skip to content

adopt-project.py: a --source, or a plan's source.local_path, with a part of its repository git reads at mode 000 (the .git, its HEAD, objects, refs or config, the git directory a gitdir: line names, or a directory above it) is refused source-unreadable naming that part, never git-failed and never cloned again (#1151) - #1186

Draft
brettheap wants to merge 2 commits into
mainfrom
l3-1151-unreadable-git-named

Conversation

@brettheap

@brettheap brettheap commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

What changed

A follow-up outside v1.2.0 (Brett Heap, 2026-10-21T10:32:04Z). It lands only after the v1.2.0 tag, on a later merge word.

adopt-project.py now refuses a part of a repository that git reads and this user cannot read as source-unreadable, naming that part. This holds for --source and for a plan's source.local_path. Such a part was git-failed, or "no longer whole" and cloned again from GitHub.

Before and after

I measured each case on main 961640c68 as uid 1000, with git 2.43.0, Python 3.12.3, a stub gh that logs and refuses every call, and local remotes. Each case puts one path at mode 000 after plan has run.

#1151 case at mode 000 before (main) after
1 .git (a directory or a file), plan --source and check --plan --source git-failed ... not a git repository or error opening '.../.git', rc 2 "the .git in --source '', <src>/.git", rc 2
2 .git/HEAD, cloned: true NOTE "no longer whole (git finds no repository there)", cloning testorg/Thing, 1 gh call "the .git/HEAD in the plan's source.local_path, <lp>/.git/HEAD", file cure, 0 gh calls
3 the gitdir: target (--separate-git-dir) the same NOTE and clone "the git directory of the plan's source.local_path, <root>/store.git"
4 .git/objects, .git/refs, .git/config the same NOTE and clone "the .git/objects in" / "the .git/refs in" (directory cure) / "the .git/config in" (file cure)
5 a worktree's gitdir, or the common .git above it the same NOTE and clone "the git directory of ..., <main>/.git/worktrees/WT"; "a directory above the git directory of ..., <main>/.git"
6 a submodule's .git/modules/sub the same NOTE and clone "the git directory of ..., <Super>/.git/modules/sub"
7 the directory above a .git link's target the same NOTE and clone "a directory above the git directory of ..., <root>/far/locked"
2, inside another repository .git/HEAD of a clone that sits inside another repository's work tree (git climbs to that one) plan and given: source-not-top-level, "is Thing/ inside the repository at <outer>"; cloned: NOTE "no longer whole (git reads it as part of the repository at <outer>)", cloning testorg/Thing, 1 gh call "the .git/HEAD in ...", file cure, 0 gh calls

With cloned: false, main said git-failed in each of cases 2 to 7. Now it says the same source-unreadable line, with "whether a repository is there" as what cannot be told. Every refusal exits 2 and asks gh 0 times. A recorded source's refusal ends "; or pass the source again with --source, which is held to the plan's commit:." All five readable controls (directory, file, worktree, submodule, symlink) still say plan ok, with 0 gh calls, before and after.

How it works

  • _refused_git_path asks the parts git 2.43 asks, in git's order (read_gitfile_gently, is_git_directory):
    • the .git, followed where it is a link;
    • a .git file, opened, and its gitdir: line read;
    • HEAD, lstated, and opened unless it is a link;
    • commondir;
    • objects and refs, each searched (access(X_OK));
    • config, opened.
  • A refused stat names the directory this user cannot search, found by _where_refused. A refused open names the file and gives the file's cure.
  • A part that is not there, or one that is read but not as git reads it, stays git's question, as on main. Examples: a HEAD that is a directory, or a NUL in a gitdir: line.
  • It is asked ONLY where git found no repository whose top level is the path:
    • in _top_level, after --is-inside-git-dir fails, and before source-not-top-level;
    • in _not_a_whole_clone, before "git finds no repository there" and before "git reads it as part of the repository at".
      So a repository git can read is never refused here, and a readable source costs nothing extra.
  • Plan._git_part_unreadable words the refusal for a recorded source. _source_spec words it for --source.

How it divides with #1141

Decisions the issue left open

Out of scope

Tests

Each file ran alone, one at a time, under timeout 2400, with a stub gh first on PATH (exit 97), GH_TOKEN=bogus, an empty GH_CONFIG_DIR, GIT_SSH_COMMAND=false, an empty GIT_CONFIG_GLOBAL and EMAIL set: /usr/bin/python3 -m pytest -q -p no:cacheprovider --basetemp=<scratch>/bt-<name> -o tmp_path_retention_policy=failed <file> (Python 3.12.3, git 2.43.0).

The head is two commits. The second changes one comment line in the new test file (a section rule 80 columns wide, now 79). The new test file and tests/test_shape_doctor.py ran on the head itself; every other row above ran on its parent, whose only difference from the head is that one comment line.

#1141's test file on this head WITHOUT #1141's code (wt-cand): all 12 of its child-process rows pass, with #1141's wording, because this PR words a recorded .git at mode 000 as #1141 does. Six rows fail, as expected, because they test #1141's own code:

On the tree merged with #1141, all 18 of #1141's rows pass, and so do this PR's 79.

Mutants

Each mutant was applied to adopt-project.py and run with tests/test_adopt_unreadable_git_parts.py -x -k <selection>. It counts as KILLED only where pytest exits 1. The file was then restored from a saved copy of the edited file and checked with cmp, because the change was not yet committed.

  • M01 _where_refused names the part whose stat is refused, not the directory above it: KILLED by -k 'where_a_stat or worktree-common or symlink-above' (1 failed, 66 deselected in 0.70s); restored, cmp clean.
  • M02 _where_refused names the directory above a part refused for another reason: KILLED by -k 'where_a_stat' (1 failed, 78 deselected in 0.40s); restored, cmp clean.
  • M03 a .git file's gitdir: line is not followed: KILLED by -k 'file-gitdir' (1 failed, 72 deselected in 0.59s); restored, cmp clean.
  • M04 HEAD is lstated but never opened: KILLED by -k 'directory-HEAD or gitdir-HEAD or inside_another' (1 failed, 69 deselected in 0.66s); restored, cmp clean.
  • M05 HEAD is stated through a link (os.stat, not os.lstat), so a link is opened: KILLED by -k 'head_that_is_a_link' (1 failed, 78 deselected in 0.65s); restored, cmp clean.
  • M06 objects and refs are not asked access(X_OK): KILLED by -k 'objects or refs' (1 failed, 68 deselected in 0.67s); restored, cmp clean.
  • M07 config is not opened: KILLED by -k 'config' (1 failed, 75 deselected in 0.63s); restored, cmp clean.
  • M08 commondir is ignored: KILLED by -k 'common-objects or commondir' (1 failed, 73 deselected in 0.61s); restored, cmp clean.
  • M09 "the git directory of" and "the common git directory of" swapped: KILLED by -k 'file-gitdir or submodule or worktree-gitdir' (1 failed, 63 deselected in 0.62s); restored, cmp clean.
  • M10 a file's cure and a directory's swapped: KILLED by -k 'HEAD or config or gitdir' (1 failed, 54 deselected in 0.58s); restored, cmp clean.
  • M11 _top_level does not ask after --is-inside-git-dir fails: KILLED by -k 'source_part or given or source_whose_head' (1 failed, 40 deselected in 0.67s); restored, cmp clean.
  • M12 _top_level does not ask before source-not-top-level: KILLED by -k 'inside_another' (1 failed, 76 deselected in 0.76s); restored, cmp clean.
  • M13 _not_a_whole_clone does not ask where git refuses (git-failed): KILLED by -k 'recorded_part and cloned' (1 failed, 65 deselected in 1.20s); restored, cmp clean.
  • M14 _not_a_whole_clone does not ask where git cannot be started: KILLED by -k 'objects_cannot_be_searched' (1 failed, 1 passed, 77 deselected in 1.61s); restored, cmp clean.
  • M15 _not_a_whole_clone does not ask before "git reads it as part of the repository at": KILLED by -k 'inside_another' (1 failed, 2 passed, 76 deselected in 2.68s); restored, cmp clean.
  • M16 a file not there (config) is refused, not left to git: KILLED by -k 'gits_question' (1 failed, 3 passed, 65 deselected in 0.62s); restored, cmp clean.
  • M17 a HEAD not there is refused, not left to git: KILLED by -k 'gits_question' (1 failed, 2 passed, 65 deselected in 0.45s); restored, cmp clean.
  • M18 what cannot be told, swapped between cloned: true and false: KILLED by -k 'recorded_part' (1 failed, 51 deselected in 0.98s); restored, cmp clean.
  • M19 an OSError from a part read but not as git reads it is raised, not left to git: KILLED by -k 'gits_question' (1 failed, 4 passed, 65 deselected in 1.21s); restored, cmp clean.

Merges

git merge-tree --write-tree of this head with origin/main, and with every open PR that touches adopt-project.py (from gh pr list --state open --json number,headRefOid,files), #1141 first:

Closes #1151

Lane: openreposhape-3 (openRepoShape-3)

🤖 Generated with Claude Code

Brett Heap and others added 2 commits October 22, 2026 21:20
… a part of its repository git reads at mode 000 (the `.git`, its `HEAD`, `objects`, `refs` or `config`, the git directory a `gitdir:` line names, or a directory above it) is refused `source-unreadable` naming that part, never `git-failed` and never cloned again (#1151)

Measured on main 961640c as uid 1000, git 2.43.0, Python 3.12.3, with a
stub `gh` that records and refuses every call and local remotes:

- `plan --source` and `check --plan --source` of a repository whose `.git`
  is at mode 000: `REFUSED git-failed: git rev-parse --is-inside-git-dir
  ... fatal: not a git repository`, or "fatal: error opening '.../.git':
  Permission denied" for a `.git` file, naming no `.git`.
- a plan's `source.local_path` recorded `cloned: true` whose `.git/HEAD`,
  `.git/objects`, `.git/refs` or `.git/config`, whose `gitdir:` target, a
  linked worktree's git directory or the common `.git` above it, a
  submodule's `.git/modules/<m>`, or a directory above a `.git` link's
  target is at mode 000: "NOTE ... is a clone `plan` made that is no longer
  whole (git finds no repository there), so its `source.repository`,
  testorg/Thing, is cloned again from GitHub", and one `gh repo clone`.
- the same with `cloned: false`: `git-failed`, in git's words.
- a clone inside another repository's work tree with its `.git/HEAD` at
  mode 000: `source-not-top-level` for `plan --source` and `cloned:
  false`; "no longer whole (git reads it as part of the repository at
  ...)" and cloned again for `cloned: true`.

Now each is `REFUSED source-unreadable: <the part> <the source>, <its
path>: cannot be read (Permission denied), so <what cannot be told> cannot
be told`, exit 2, nothing cloned, `gh` never asked. The part is named as
"the `.git/HEAD` in --source '<src>'", "the git directory of the plan's
`source.local_path`" or "a directory above the git directory of ...", and
a file's cure (`chmod u+r` on the file) or a directory's follows.

- `_refused_git_path` asks the parts git 2.43 asks, in its order
  (`read_gitfile_gently`, `is_git_directory`): the `.git`, followed where
  it is a link; a `.git` file opened and its `gitdir:` line read; `HEAD`,
  `lstat`ed and opened unless it is a link; `commondir`; `objects` and
  `refs` searched; `config` opened. A refused `stat` names the directory
  this user cannot search (`_where_refused`); a refused open names the
  file. A part that is not there, or one read but not as git reads it, is
  left to git, as before.
- It is asked ONLY where git found no repository whose top level is the
  path: in `_top_level` after `--is-inside-git-dir` fails and before
  `source-not-top-level`, and in `_not_a_whole_clone` before "git finds no
  repository there" and "git reads it as part of the repository at". A
  repository git reads is never refused here.
- `Plan._git_part_unreadable` words it for a recorded source, with
  `RECORDED_SOURCE_INSTEAD`; `_source_spec` for `--source`.
- #1141's two shared hunks (`SOURCE_UNREADABLE_FILE_PERMISSION`, and
  `_source_unreadable(..., permission=)`) are copied byte for byte, and a
  recorded `.git` at mode 000 is worded as #1141 words it, so the two agree
  whichever lands first. `git merge-tree` with #1141 is clean.

Tests: `tests/test_adopt_unreadable_git_parts.py`, 79 rows; 19 mutants,
each killed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ent is 79 columns wide, like its neighbours (#1151)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 22, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR introduces a Git-behavior-compatible preflight that identifies unreadable repository components and reports them as precise source-unreadable refusals for both --source and recorded plan sources, preventing misleading git-failed results and automatic recloning while preserving Git’s behavior for missing or malformed components.

Sequence diagram for unreadable Git-part source validation

sequenceDiagram
    participant Caller
    participant SourceSpec as _source_spec
    participant TopLevel as _top_level
    participant GitProbe as _refused_git_path
    participant Git as Git
    participant Refusal as source-unreadable

    Caller->>SourceSpec: validate --source
    SourceSpec->>TopLevel: _top_level(path)
    TopLevel->>Git: rev-parse
    Git-->>TopLevel: no repository or nested repository
    TopLevel->>GitProbe: _refused_git_path(path)
    GitProbe->>GitProbe: inspect .git, HEAD, commondir, objects, refs, config
    alt unreadable Git part
        GitProbe-->>TopLevel: _RefusedGitPath
        TopLevel->>Refusal: _source_unreadable(...)
        Refusal-->>Caller: source-unreadable naming part and cure
    else missing or malformed part
        GitProbe-->>TopLevel: None
        TopLevel-->>Caller: preserve Git-derived refusal
    end
Loading

Flow diagram for Git-part refusal decision

flowchart TD
    A[Source or recorded local path] --> B[Run Git repository query]
    B -->|Repository readable| C[Continue normal processing]
    B -->|No repository or nested repository| D[_refused_git_path]
    D --> E{Git-read part exists and is unreadable?}
    E -->|No| F[Preserve Git question or existing refusal]
    E -->|Yes| G[Name exact part and permission cure]
    G --> H[source-unreadable]
    H --> I{Recorded clone?}
    I -->|Yes| J[Do not reclone]
    I -->|No| K[Return refusal]
Loading

File-Level Changes

Change Details Files
Added a Git-aware permission probe that identifies unreadable repository components before Git failures can trigger misleading errors or recloning.
  • Mirrors Git 2.43's repository discovery order across .git, gitfile targets, HEAD, commondir, objects, refs, and config.
  • Distinguishes missing or malformed components, which remain Git's responsibility, from components refused by the operating system.
  • Reports the refused component using source-relative or external git-directory roles, with directory- versus file-specific remediation.
  • Handles symlinks, linked worktrees, submodules, separate git directories, common directories, and directories above resolved git paths.
adopt-project.py
Integrated unreadable-part detection into both explicit sources and recorded plan sources without changing normal repository handling.
  • Checks refused Git parts after Git finds no repository at the requested top level and before source-not-top-level or clone-integrity explanations.
  • Converts recorded-source failures to source-unreadable, preserves the plan commit escape hatch, and prevents GitHub clone retries.
  • Uses distinct wording for cloned versus non-cloned sources while retaining existing source-level unreadable handling.
adopt-project.py
Added comprehensive regression coverage for refusal behavior, path naming, no-clone guarantees, and compatibility with readable repositories.
  • Covers directory, file, separate-git-dir, linked-worktree, submodule, symlink, nested-repository, and --source scenarios.
  • Verifies exit status, remediation text, absence of legacy git-failed/clone messages, zero gh calls, and no generated work.
  • Tests permission and non-permission errors, Git's question boundaries, symlinked HEAD, and readable controls across child-process and in-process paths.
tests/test_adopt_unreadable_git_parts.py

Assessment against linked issues

Issue Objective Addressed Explanation
#1151 For plan --source and check --source, detect when the source repository’s .git is unreadable and refuse it as source-unreadable, naming the .git rather than exposing a generic git-failed error. ✅
#1151 For a recorded clone whose .git/HEAD is unreadable, refuse it as source-unreadable and name HEAD instead of treating the clone as incomplete and cloning it again. ✅
#1151 For a recorded clone whose .git file points to an unreadable git directory, follow the gitdir: target and refuse as source-unreadable, naming the unreadable git directory rather than treating the clone as incomplete and cloning it again. ✅

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sonarqubecloud

Copy link
Copy Markdown

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-3, 2026-10-23T00:20:09Z) Independent review (RV1186, opus, ruling 8) of #1186 at head 07486a49, main 961640c6.

Verdict: READY. A draft outside v1.2.0; it lands only after the v1.2.0 tag, on a later merge word. No MUST, no SHOULD; NITs and follow-ups below.

Measured with my own probe (uid 1000, git 2.43.0, Python 3.12.3, stub gh, local remotes): 75 cases on main, this head, this head merged with #1141, and #1141 alone.

Tests at the head, one at a time, stub env:

Mutants: 28 of my own, on the whole new file with -x. 27 KILLED; 1 SURVIVED (K21: the "a directory above the common git directory of" branch). Each was restored, cmp clean, tree clean.

Root: the 29 rows without needs_a_mode run as root and on Windows. Against those rows alone, 7 of 16 mutants survive: gitdir not followed, config not read, commondir ignored, refs not asked, the --show-prefix ask, the "git reads it as part of" ask, and the cloned: false path through _top_level. CI runs non-root Linux, so all of these are covered there.

Merges: 19 open PRs touch adopt-project.py, re-listed live.

Sonar: gate OK, 0 new issues, duplication 0.0. Plan._recorded_source stays at 15, the limit. #1141's own S3776 there (17) is #1141's. REPOSITORY_UNTOLD clears #1141's S1192 on the merged tree.

CI: tests, tests-git-bash, package and SonarCloud all pass on 07486a49.

Body and commits: a draft with no milestone and the follow-up sentence first. It ends Closes #1151, the Lane line, then the robot line; its closing refs are [1151] only, it is ASCII but the robot, and there is no bot block. Both commits are by Brett Heap <service@opensoft.dev>, with a Co-Authored-By line.

NITs (none blocks):

Follow-ups:

Skipped: test_shape_doctor.py (the writer ran it at this head, 445 passed, and CI passed), and the other files the writer ran on the parent, whose adopt-project.py is identical. A root run was not possible on this host.

Lane: openreposhape-3 (openRepoShape-3)

@brettheap brettheap added this to the v1.2.1 milestone Oct 23, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant