Skip to content

adopt-project.py: a recorded source whose .git this user cannot read is refused source-unreadable, naming that .git, so check and execute no longer call a clone plan made with its .git at mode 000 no longer whole and clone it again (#1099) - #1141

Draft
brettheap wants to merge 1 commit into
mainfrom
fix/1099-unreadable-clone-git-refused

Conversation

@brettheap

@brettheap brettheap commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

A recorded source whose .git this user cannot read is now refused as source-unreadable, naming that .git. check and execute no longer call a clone plan made with its .git at mode 000 "no longer whole", and no longer clone it again from GitHub. This uses the code and wording #1072 gave a recorded directory this user cannot enter (#918). No refusal code is new.

This PR lands only after the v1.2.0 tag.

Reproduced on main 4ed7c7bf5

Measured as uid 1000, with git 2.43.0 and Python 3.12.3. The run had a stub gh first on PATH that records and refuses every call, GH_TOKEN=bogus, an empty GH_CONFIG_DIR, GIT_SSH_COMMAND=false and an empty global git config. Remotes were local, under --local-remote-dir.

The source was a clone with origin at https://github.com/testorg/Thing.git, so plan recorded source.repository: testorg/Thing. plan --source <it> wrote the plan, and cloned: was set by hand where the row needs it. Then chmod 000 <it>/.git.

Row check on main execute --yes on main
.git a directory at 000, cloned: true exit 2: 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", then cloning testorg/Thing, then source-unclonable; 1 gh repo clone the same, 1 gh call
.git a directory at 000, cloned: false exit 2: REFUSED git-failed: git rev-parse --is-inside-git-dir in <it> exited 128: fatal: not a git repository (or any of the parent directories): .git; 0 gh the same
.git a file (gitdir:) at 000, cloned: true as the directory row: NOTE, cloning, 1 gh call the same
.git a file at 000, cloned: false exit 2: git-failed, fatal: error opening '<it>/.git': Permission denied; 0 gh the same
readable .git, cloned: true and false exit 0, plan ok; 0 gh exit 0, adoption verified; 0 gh

On this branch

Each of the four unreadable rows now exits 2 before anything is cloned or made, and gh is never asked:

REFUSED source-unreadable: the `.git` in the plan's `source.local_path`, `<it>/.git`: cannot be read (Permission denied), so whether `plan`'s clone is still whole, to be read, or no longer whole, to be cloned again, cannot be told
Remediation: a path this user cannot reach is never read as one that is not there, and nothing was cloned. Make it readable by this user: `chmod u+rx` on the directory and on every directory above it, or fix who owns them. Then run the same command again; or pass the source again with --source, which is held to the plan's `commit:`.
  • With cloned: false, what cannot be told is "whether a repository is there".
  • A .git that is a file is given chmod u+r on the file and chmod u+rx on every directory above it.
  • The readable rows are unchanged: plan ok, adoption verified, 0 gh calls.

What changed

All in adopt-project.py, plus the regenerated docs/cli.md and one new test file.

Rows: tests/test_adopt_unreadable_clone_git.py (new, 18)

Row main 4ed7c7bf5 this head
test_a_recorded_git_this_user_cannot_read_is_refused_by_name[check,execute x given,cloned x directory,file] (8, real mode 000, needs_a_mode) 8 failed: the refusal line is missing (git-failed, or source-unclonable after gh was asked) 8 passed
test_a_recorded_clone_whose_git_is_refused_is_not_cloned_again (in-process, the stat of .git/HEAD refused, so it runs as root and on Windows too) failed: check read the clone, exit 0 passed
test_a_git_that_can_be_read_or_is_not_there_is_gits_question[5] (the helper) 5 failed: no _git_refused on main 5 passed
test_a_recorded_git_this_user_can_read_is_read_as_before[given,cloned x directory,file] (4 controls) 4 passed 4 passed

In total: 14 failed and 4 passed on main; 18 passed on this head. The permission rows skip as root and on Windows, as the repository's other permission rows do. The rows' at_mode_000 asserts that the mode bites (os.access(R_OK) is False), and gives the mode back in a finally.

Mutants

Each was applied alone to the committed adopt-project.py (sha256 b2b41532c3a5...) and its rows run. The file was then restored, and its sha256 checked equal to the commit's every time. git status was clean after all of them. All 8 were killed.

Mutant Killed by
M1 the new guard removed (refused = None) 9 failed / 18: the 8 refused-by-name rows and the in-process row
M2 the guard applied to a readable .git (a PermissionError returned where the .git reads) 9 failed / 18: the 4 read-as-before rows and the 5 helper rows
M3 gh asked anyway (Source.clone of the record asked, then the same refusal raised) 9 failed / 18: the 8 refused-by-name rows (the gh log is not empty) and the in-process row (2 gh calls)
M4 a .git whose own stat is refused taken as the .git's 3 failed / 31: #1072's test_a_recorded_source_this_user_cannot_enter_is_refused_by_name[check-False], [check-True] and [execute-True] (the directory must still be named by itself)
M5 a .git file given the directory's cure 4 failed / 18: the 4 file rows
M6 a .git file never opened 4 failed / 18: the 4 file rows
M7 a .git with no HEAD refused 1 failed / 18: ..._is_gits_question[a-git-directory-with-no-head]
M8 what cannot be told swapped between cloned and given 9 failed / 18

Gate

Run at this head 7a2711d0, 16:35:44Z to 16:46:37Z, with /usr/bin/python3 -m pytest -p no:cacheprovider -o tmp_path_retention_policy=failed. The environment had EMAIL set; GIT_ALLOW_PROTOCOL, GIT_SSH_COMMAND and the LANES_* variables unset; an empty GIT_CONFIG_GLOBAL and GIT_CONFIG_NOSYSTEM=1; test author and committer identities; and a stub gh first on PATH, with GH_TOKEN=bogus and an empty GH_CONFIG_DIR.

Set Result
test_adopt_unreadable_clone_git.py, test_adopt_source_clone_record.py, test_adopt_unenterable_source_and_locale.py, test_adopt_unsearchable_source.py, #1091's test_adopt_root_shape_paths.py and test_adopt_root_symlinks.py, test_pin_source_unreadable.py, test_cli_reference.py, test_agents_md_codes.py, test_repo_hygiene.py, test_scaffold_leg_path_collisions.py 839 passed, 3 skipped, rc=0
test_shape_doctor.py, 8 shards (62, 56, 54, 56, 60, 57, 58, 45 of 448) 62, 56, 54, 56, 60, 57, 55 + 3 skipped, 45 passed; every shard rc=0

test_pin_source_unreadable.py was added from a grep of tests/ for source-unreadable, _source_unreadable, SOURCE_UNREADABLE_, _recorded_source and SOURCE_OVERRIDE_HELP. pyflakes on adopt-project.py and the new test file reports nothing.

#1111's path-guard hygiene test (lane 1's rule, #473 16:26Z)

The guard asks the .git and its HEAD through adopt-project.py's own _is_a and _is_there. It adds no bare is_file, is_dir, exists, is_symlink or os.path test. tests/test_path_guard_hygiene.py from #1111's head b4484647 was run on a scratch merge of main 4ed7c7bf5, #1111 and this head:

Merge-trees

Each was run as git merge-tree --write-tree of this head against:

Against Head Result
origin/main 4ed7c7bf5 clean, tree 5e784b0f
#1125 fix/1020-bad-id-first-problem 2a53a8e6 clean, tree 76af7e33
#1123 fix/1061-makefile-follow-ups 5c3908f8 clean, tree 12859bc5
#1122 fix/1003-adopt-unenterable-source (_source_there) c01c4d73 clean, tree 6222542f
#1121 fix/1047-next-make-bootstrap 1c517375 clean, tree e294504f
#1120 fix/1035-source-branch-shown c55e55e3 clean, tree 46bcc762
#1119 fix/1083-check-asks-the-split-clone 9b998936 clean, tree dcc12164
#1118 fix/1089-named-link-skip-tests c60066cb conflict in tests/test_adopt_root_shape_paths.py only, which this head does not touch; #1118 conflicts there with origin/main alone too
#1106 l3-1078-one-byte-locale (_recorded_source's there line) 504a1dd6 clean, tree e59c5cf4
#1105 fix/1048-makefile-follow-up-gnu-read-order b28f40de clean, tree 67d4fec4

These are every open pull request that changes adopt-project.py, docs/cli.md or the new test file, as listed at 16:33Z.

Out of scope

Each was measured on this head, and none is changed here:

Closes #1099

🤖 Generated with Claude Code
Lane: openreposhape-4 (openRepoShape-4)

Summary by Sourcery

Reject recorded sources with inaccessible .git metadata before Git or clone recovery runs, and report the precise unreadable path with actionable remediation.

Bug Fixes:

  • Refuse recorded sources whose .git cannot be read as source-unreadable, identifying the inaccessible path and preventing misleading clone recovery or additional GitHub clones.

Enhancements:

  • Preserve existing handling for readable, absent, and empty .git paths while providing file- or directory-specific remediation guidance.

Documentation:

  • Regenerate CLI documentation to describe unreadable recorded .git paths as source-unreadable.

Tests:

  • Add coverage for unreadable .git directories and files across check and execute, including verification that no clone or GitHub request occurs.

…d is refused `source-unreadable`, naming that `.git`, so `check` and `execute` no longer call a clone `plan` made with its `.git` at mode 000 no longer whole and clone it again (#1099)

Measured on main 4ed7c7b as uid 1000, git 2.43.0, Python 3.12.3, with a
stub `gh` that records and refuses every call and local remotes. The plan's
`source.local_path` is a directory this user can enter; its `.git` is at
mode 000:

- `cloned: true`: `check` and `execute --yes` exit 2 after "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 "cloning testorg/Thing", with one `gh repo clone` asked.
- `cloned: false`: `REFUSED git-failed: git rev-parse --is-inside-git-dir
  ... exited 128: fatal: not a git repository`.
- A `.git` file (`gitdir:`) at mode 000 does the same, with git's "fatal:
  error opening '.../.git': Permission denied" where `cloned: false`.

Now each is `REFUSED source-unreadable: the `.git` in the plan's
`source.local_path`, `<path>/.git`: cannot be read (Permission denied), so
<what cannot be told> cannot be told`, exit 2, nothing cloned, `gh` never
asked. This is #918's code and wording for a recorded directory this user
cannot enter.

- `_git_refused` asks the `.git` before git runs, through `_is_a` and
  `_is_there` (#430). A directory is asked whether its `HEAD` is there, a
  `stat` that needs only the directory searched, and a regular file is
  opened. A `.git` that is absent, that holds no `HEAD`, or whose own
  `stat` is refused is left to git and to #918's guards, as before.
- `_recorded_source` refuses it right after asking whether the path is
  there, for `cloned: true` and `false` alike. `Plan._git_unreadable` words
  the refusal.
- `_source_unreadable` takes the cure for a permission as `permission=`.
  A `.git` file gets `SOURCE_UNREADABLE_FILE_PERMISSION` (`chmod u+r` on the
  file).
- The `--source` help of `check` and `execute` names the `.git` case, and
  `docs/cli.md` is regenerated.
- `--source` itself is left as it is: the issue names the recorded clone.

Tests: `tests/test_adopt_unreadable_clone_git.py`, 18 rows: 14 fail on main
and pass here; the 4 readable controls pass on both.

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

sourcery-ai Bot commented Oct 21, 2026

Copy link
Copy Markdown

Reviewer's Guide

Recorded sources whose .git cannot be read are now rejected as source-unreadable before Git or GitHub operations run, with precise diagnostics and remediation; readable and previously handled source cases remain unchanged, backed by focused regression tests and regenerated CLI documentation.

Sequence diagram for rejecting a recorded source with unreadable .git

sequenceDiagram
    participant Plan
    participant Filesystem
    participant Git
    participant GitHub

    Plan->>Filesystem: _is_there(path)
    Filesystem-->>Plan: path exists
    Plan->>Filesystem: _git_refused(path)
    Filesystem-->>Plan: PermissionError for path/.git
    Plan->>Plan: _git_unreadable(local_path, exc, a_file)
    Plan-->>Plan: REFUSED source-unreadable
    Note over Git,GitHub: No Git command or GitHub clone is attempted
Loading

Flow diagram for recorded source validation with unreadable .git

flowchart TD
    A[Recorded source local_path] --> B{Path exists?}
    B -- No --> C[Existing source-gone handling]
    B -- Yes --> D["_git_refused(path)"]
    D -- Readable, absent, or no HEAD --> E{source.cloned?}
    D -- .git unreadable --> F[REFUSED source-unreadable]
    F --> G[Name path/.git and provide file or directory permissions remedy]
    E -- false --> H[Read source in place]
    E -- true --> I[Check whether clone is whole]
    I --> J{Clone whole?}
    J -- Yes --> K[Use recorded clone]
    J -- No --> L[Clone source.repository]
Loading

File-Level Changes

Change Details Files
Detect unreadable recorded .git metadata before invoking Git, and refuse safely instead of treating the source as missing or recloning it.
  • Added _git_refused to distinguish unreadable .git directories and files while preserving Git’s existing behavior for absent or readable metadata.
  • Applied the guard to both recorded cloned and in-place sources before the source.cloned branch.
  • Added a dedicated source-unreadable refusal with the relevant .git path, unknown-state explanation, and file- versus directory-specific remediation.
  • Prevented clone/reclone and gh calls when the recorded .git cannot be read.
adopt-project.py
Document the expanded source-unreadable behavior in generated CLI help.
  • Updated --source help for check and execute to mention unreadable .git metadata and that it is never recloned.
  • Regenerated the CLI reference.
docs/cli.md
Add regression coverage for unreadable recorded Git metadata and preservation of existing cases.
  • Covered directory and file .git entries, cloned and non-cloned plans, and both check and execute.
  • Verified readable metadata, absent or incomplete .git entries, no workspace creation, and no gh invocation.
  • Added an in-process refusal test for stat failures and platform-aware mode-000 tests.
tests/test_adopt_unreadable_clone_git.py

Assessment against linked issues

Issue Objective Addressed Explanation
#1099 When a recorded clone’s .git is unreadable, refuse it as source-unreadable, name the .git, and provide a remedy instead of treating the clone as no longer whole. ✅
#1099 Stop before asking GitHub to clone again or creating clone-related output when the recorded .git is unreadable. ✅

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

This was referenced Oct 21, 2026
@brettheap

Copy link
Copy Markdown
Contributor Author

RV1141: independent review of #1141 at 7a2711d0: READY

No MUST and no SHOULD; four NITs, all follow-ups. Measured as uid 1000, git 2.43.0, Python 3.12.3, with a stub gh first on PATH that logs and refuses every call, GH_TOKEN=bogus, an empty GH_CONFIG_DIR, GIT_SSH_COMMAND=false, an empty GIT_CONFIG_GLOBAL and GIT_CONFIG_NOSYSTEM=1. adopt-project.py is byte-identical on 4ed7c7bf5 and main 038dafba9, so "main" below is 038dafba9.

Reproduced, then fixed

plan --source <clone> with origin at https://github.com/testorg/Thing.git, cloned: set by hand, then chmod 000 <clone>/.git:

.git at 000 main check scratch merge check
directory, cloned: true rc 2, NOTE "no longer whole (git finds no repository there)", cloning testorg/Thing, 1 gh call rc 2, source-unreadable naming <it>/.git, directory cure, 0 gh
directory, cloned: false rc 2, git-failed ("not a git repository") rc 2, source-unreadable, "whether a repository is there", 0 gh
file (--separate-git-dir), cloned: true rc 2, NOTE, cloning, 1 gh call rc 2, source-unreadable, file cure (chmod u+r on the file), 0 gh
file, cloned: false rc 2, git-failed ("error opening ... Permission denied") rc 2, source-unreadable, file cure, 0 gh

execute --yes gave the same: main asked gh once for the directory row with cloned: true, and the merge asked it 0 times (directory with cloned: true, file with cloned: false).

False-refusal probes (scratch merge; check, cloned: true unless noted)

Case git's own answer The PR
.git at 0100, 0300, 0500 works plan ok, 0 gh (no false refusal)
.git at 0400, 0600 (no search) "not a git repository" source-unreadable, directory cure, 0 gh
worktree .git file, gitdir readable (cloned false and true) works plan ok, 0 gh
submodule .git file, readable (both) works plan ok, 0 gh
submodule .git file at 000 (both) "error opening ... Permission denied" source-unreadable, file cure, 0 gh
.git a symlink to a readable git dir (both) works plan ok, 0 gh
.git a symlink, its target dir at 000 (both) "not a git repository" source-unreadable naming <it>/.git, 0 gh
GIT_DIR set to another repo, .git readable (both, main and merge) reads GIT_DIR plan ok, as on main
GIT_DIR set to another repo, .git at 000 (both) reads GIT_DIR source-unreadable naming .git; main refused too, git-failed ("a git or gh command failed")
.git emptied (no HEAD) "not a git repository" NOTE, cloned again, 1 gh: unchanged, as intended
the clone directory itself at 000 n/a #1072's source-unreadable naming the directory, unchanged
.git/HEAD, .git/objects, .git/refs or .git/config at 000 "not a git repository" / "unable to access '.git/config'" NOTE, cloned again, 1 gh, identical on main (NIT-2)
worktree gitdir or its common .git at 000; submodule .git/modules/<m> at 000 "not a git repository: " cloned: true: cloned again, 1 gh; cloned: false: git-failed; as on main
.git a symlink whose target's parent is at 000 "not a git repository" cloned: true: cloned again, 1 gh, as on main (NIT-3)

No readable or usable .git is now refused. Every remaining clone-again row behaves exactly as on main.

Wording

Each refusal is true and actionable, and matches #1072's family: <named>: cannot be read (Permission denied), so <what> cannot be told. The directory cure "chmod u+rx on the directory" names the .git the line names. The .git file cure is chmod u+r on the file. With cloned: true the wording is "whether plan's clone is still whole, to be read, or no longer whole, to be cloned again,"; with cloned: false it is "whether a repository is there", as #1072's --source refusal says. SOURCE_OVERRIDE_HELP speaks of the recorded local_path, which matches the behaviour. test_cli_reference.py passes on the merge, so docs/cli.md agrees with the help.

Tests (scratch merge 0f3ac25c = main 038dafba9 + 7a2711d0, the gate environment)

Set Result
test_adopt_unreadable_clone_git.py + test_adopt_unenterable_source_and_locale.py 31 passed
test_path_guard_hygiene.py (#1111, now on main) + test_cli_reference.py 72 passed
test_adopt_unsearchable_source.py, test_agents_md_codes.py, test_repo_hygiene.py, test_adopt_source_clone_record.py 274 passed
test_scaffold_leg_path_collisions.py 104 passed
test_shape_doctor.py, 8 shards, every id list non-empty 445 passed, 3 skipped, 448 ids: shards 00 to 07 gave 62, 56, 54, 56, 60, 57, 55 + 3 skipped and 45. Shards 03 and 04 hit this review's 200 s limit and were rerun with no limit by the lane at 13:42-13:46Z on the same merge 0f3ac25c, 56 and 60 passed

The new file run against main's adopt-project.py (copied into a main worktree): 14 failed, 4 passed, as claimed. They fail for the right reasons:

  • the 8 mode rows: git-failed, or the NOTE plus source-unclonable, where source-unreadable is asserted;
  • the in-process row: check read the clone;
  • the 5 helper rows: no _git_refused.

The 4 controls pass on both.

Mutants (own, on the merge; 31 rows: the new file + #1072's file; sha256 b2b41532c3a5 checked after every one)

Mutant Result
M1 guard removed (refused = None) KILLED, 9 failed
M2 the .git's own refused stat taken as the .git's KILLED, 3 failed (#1072's cannot_enter rows)
M3 return (exc, a_file) without _absent SURVIVED: equivalent. _is_there already answers an absent HEAD False, so _absent is reached only by a race on the file's open
M4 every refusal swallowed KILLED, 9 failed
M5 a .git file never opened KILLED, 4 failed
M6 the directory asked by os.listdir (read permission) KILLED, but only by the in-process row (1 failed)
M7 cures swapped KILLED, 9 failed
M8 untold wording swapped KILLED, 9 failed
M9 names local_path, not its .git KILLED, 9 failed
M10 guard for cloned: true only KILLED, 4 failed
M11 guard for cloned: false only KILLED, 5 failed
M12 the directory asked for read permission, then HEAD (os.access(R_OK) else listdir) SURVIVED (NIT-1)

Merge-tree

git merge-tree --write-tree of 7a2711d0:

  • against origin/main 038dafba9: clean, tree c6a2e736. The merged tree differs from main only in adopt-project.py, docs/cli.md and the new test file.
  • against each open PR touching adopt-project.py or docs/cli.md, all clean:
PR Head
#1153 590ab96e
#1143 7af5fabc
#1125 2a53a8e6
#1123 5c3908f8
#1122 c01c4d73 (_source_there)
#1121 1c517375
#1120 c55e55e3
#1119 bba168bd
#1118 2307036a
#1106 504a1dd6
#1105 b28f40de

Findings

Lane: openreposhape-4 (openRepoShape-4)

This was referenced Oct 22, 2026
@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