Repository navigation
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
brettheap wants to merge 1 commit into
Conversation
…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>
Reviewer's GuideRecorded sources whose Sequence diagram for rejecting a recorded source with unreadable .gitsequenceDiagram
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
Flow diagram for recorded source validation with unreadable .gitflowchart 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]
File-Level Changes
Assessment against linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
|
RV1141: independent review of #1141 at 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 Reproduced, then fixed
False-refusal probes (scratch merge;
|
| 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 plussource-unclonable, wheresource-unreadableis asserted; - the in-process row:
checkread 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/main038dafba9: clean, treec6a2e736. The merged tree differs from main only inadopt-project.py,docs/cli.mdand the new test file. - against each open PR touching
adopt-project.pyordocs/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
-
NIT-1 (test gap).
_git_refused's docstring says the directory check needs "only search permission". No row pins that, and M12, which demands read permission, survives all 31 rows.- The behaviour is right: 0100 and 0300 measured
plan ok, 0 gh. - Fix: add a row with the
.gitat 0100 (and 0300), withcloned: true, assertingplan okand an emptyghlog.
- The behaviour is right: 0100 and 0300 measured
-
NIT-2 (out of scope, adopt-project.py:
plan --sourceof a repository whose.gitis at mode 000 is refusedgit-failedwith no name for the.git, and a clone whose.git/HEADor whosegitdir:target is at mode 000 is called no longer whole and cloned again, where #1141 refuses a recorded clone's unreadable.gitby name (see #1099) #1151). These are still "no longer whole", with 1ghcall, identical to main:HEAD,objects/,refs/orconfigin.gitat 000;- a worktree's gitdir or common directory at 000;
- a submodule's
.git/modules/<m>at 000.
adopt-project.py:
plan --sourceof a repository whose.gitis at mode 000 is refusedgit-failedwith no name for the.git, and a clone whose.git/HEADor whosegitdir:target is at mode 000 is called no longer whole and cloned again, where #1141 refuses a recorded clone's unreadable.gitby name (see #1099) #1151 namesHEADand the.gitfile's git directory. Addobjects/,refs/andconfig: git's own "is a git directory" asksHEAD,objects/andrefs/, andconfiggives "unable to access '.git/config': Permission denied". -
NIT-3 (docstring).
_git_refusedsays a.gitwhose ownstatis refused "is in apaththis user cannot search". That is false for a.gitsymlink whose target is under a directory this user cannot search. Measured: withcloned: trueit is cloned again, 1 gh, as on main.- Fix: reword the sentence, and list the case in adopt-project.py:
plan --sourceof a repository whose.gitis at mode 000 is refusedgit-failedwith no name for the.git, and a clone whose.git/HEADor whosegitdir:target is at mode 000 is called no longer whole and cloned again, where #1141 refuses a recorded clone's unreadable.gitby name (see #1099) #1151.
- Fix: reword the sentence, and list the case in adopt-project.py:
-
NIT-4 (Sonar, gate passed). The PR adds two Sonar issues:
- S3776:
_recorded_sourcecognitive complexity is now 17 > 15 (adopt-project.py:6652). - S1192: "whether a repository is there" now appears 3 times (
:2090).
Fix: move the two-line guard into a helper (or into
_git_unreadable, returning None), and make the phrase a constant. - S3776:
Lane: openreposhape-4 (openRepoShape-4)



A recorded source whose
.gitthis user cannot read is now refused assource-unreadable, naming that.git.checkandexecuteno longer call a cloneplanmade with its.gitat 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
4ed7c7bf5Measured as uid 1000, with git 2.43.0 and Python 3.12.3. The run had a stub
ghfirst on PATH that records and refuses every call,GH_TOKEN=bogus, an emptyGH_CONFIG_DIR,GIT_SSH_COMMAND=falseand an empty global git config. Remotes were local, under--local-remote-dir.The source was a clone with
originathttps://github.com/testorg/Thing.git, soplanrecordedsource.repository: testorg/Thing.plan --source <it>wrote the plan, andcloned:was set by hand where the row needs it. Thenchmod 000 <it>/.git.checkon mainexecute --yeson main.gita directory at 000,cloned: trueplanmade that is no longer whole (git finds no repository there), so itssource.repository, testorg/Thing, is cloned again from GitHub", thencloning testorg/Thing, thensource-unclonable; 1gh repo cloneghcall.gita directory at 000,cloned: falseREFUSED 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; 0gh.gita file (gitdir:) at 000,cloned: trueghcall.gita file at 000,cloned: falsegit-failed,fatal: error opening '<it>/.git': Permission denied; 0gh.git,cloned: trueandfalseplan ok; 0ghadoption verified; 0ghOn this branch
Each of the four unreadable rows now exits 2 before anything is cloned or made, and
ghis never asked:cloned: false, what cannot be told is "whether a repository is there"..gitthat is a file is givenchmod u+ron the file andchmod u+rxon every directory above it.plan ok,adoption verified, 0ghcalls.What changed
All in
adopt-project.py, plus the regenerateddocs/cli.mdand one new test file._git_refused(path)(new, after_not_a_whole_clone) asks the.gitbefore git runs, through_is_aand_is_there(EveryPath.is_file()/is_dir()/exists()guard in the shipped tools answers False to any OSError on Python 3.14 (3.12 and 3.13 raise PermissionError), so a directory this user cannot search reads as ABSENT: validate-pins.py skips its workflow@<sha>check, update-shape.py sayscopy-missingandupdate-pin-missing("adopt the project") for files that are there, the doctor says DRIFTED (5 copy-missing), and bump-leg.py's remaining guards, and every other tool's, skip or misreport instead of refusing by name #430):HEADis there. Thatstatneeds only the directory searched, and nothing ofHEADitself..gitit cannot read, as it does of an emptied clone. With a directory.git, it also climbs to the parent, which may be another repository.(the OSError, whether the .git is a file), or None. None is returned where nothing is there, where the.githolds noHEAD(git's "no repository", as before), or where the.gitis neither a directory nor a file..git's ownstatis refused. Then the directory itself cannot be searched, and adopt-project.py and family.py: a--sourcethis user cannot enter, a UTF-8 plan checked under the C locale, and a member branch that is not UTF-8 still end in a traceback or a false refusal #918'scwd=guards name it, as before.Plan._recorded_sourceasks_git_refusedright after asking whether the path is there, before it branches onsource.cloned. That coverscloned: trueandfalsealike. The newPlan._git_unreadablewords the refusal. The insertion sits clear of adopt-project.py: under a one-byte locale such as Latin-1,checkreads a plan written in UTF-8 whose source is there, asking the plan's UTF-8 bytes where the spelling as written names nothing, rather than refusingplan-source-gone(#1078) #1106's change to thethereline._source_unreadabletakes the cure for aPermissionErroras a keyword,permission=. It defaults to the directory'sSOURCE_UNREADABLE_PERMISSION, so every other caller is unchanged. A.gitfile is given the newSOURCE_UNREADABLE_FILE_PERMISSION.SOURCE_OVERRIDE_HELP(the--sourcehelp ofcheckandexecute) now says "one this user cannot reach, or one whose.gitthis user cannot read, a cloneplanmade included, assource-unreadable, never cloned again".source-unreadable.docs/cli.mdwas regenerated withscripts/render-cli-reference.py.AGENTS.mdor template sentence names this case.AGENTS.md's codes table is untouched, since no code is added.--sourceitself is unchanged. The issue names the recorded clone (see Out of scope).Rows:
tests/test_adopt_unreadable_clone_git.py(new, 18)4ed7c7bf5test_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)git-failed, orsource-unclonableafterghwas asked)test_a_recorded_clone_whose_git_is_refused_is_not_cloned_again(in-process, thestatof.git/HEADrefused, so it runs as root and on Windows too)checkread the clone, exit 0test_a_git_that_can_be_read_or_is_not_there_is_gits_question[5](the helper)_git_refusedon maintest_a_recorded_git_this_user_can_read_is_read_as_before[given,cloned x directory,file](4 controls)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_000asserts that the mode bites (os.access(R_OK)is False), and gives the mode back in afinally.Mutants
Each was applied alone to the committed
adopt-project.py(sha256b2b41532c3a5...) and its rows run. The file was then restored, and its sha256 checked equal to the commit's every time.git statuswas clean after all of them. All 8 were killed.refused = None).git(aPermissionErrorreturned where the.gitreads)ghasked anyway (Source.cloneof the record asked, then the same refusal raised)ghlog is not empty) and the in-process row (2ghcalls).gitwhose ownstatis refused taken as the.git'stest_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).gitfile given the directory's cure.gitfile never opened.gitwith noHEADrefused..._is_gits_question[a-git-directory-with-no-head]clonedandgivenGate
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 hadEMAILset;GIT_ALLOW_PROTOCOL,GIT_SSH_COMMANDand theLANES_*variables unset; an emptyGIT_CONFIG_GLOBALandGIT_CONFIG_NOSYSTEM=1; test author and committer identities; and a stubghfirst on PATH, withGH_TOKEN=bogusand an emptyGH_CONFIG_DIR.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'stest_adopt_root_shape_paths.pyandtest_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.pytest_shape_doctor.py, 8 shards (62, 56, 54, 56, 60, 57, 58, 45 of 448)test_pin_source_unreadable.pywas added from a grep oftests/forsource-unreadable,_source_unreadable,SOURCE_UNREADABLE_,_recorded_sourceandSOURCE_OVERRIDE_HELP. pyflakes onadopt-project.pyand the new test file reports nothing.#1111's path-guard hygiene test (lane 1's rule, #473 16:26Z)
The guard asks the
.gitand itsHEADthroughadopt-project.py's own_is_aand_is_there. It adds no bareis_file,is_dir,exists,is_symlinkoros.pathtest.tests/test_path_guard_hygiene.pyfrom #1111's headb4484647was run on a scratch merge of main4ed7c7bf5, #1111 and this head:_is_aor a reasoned allowlist covers it, and aproject.yamllinked into an unsearchable folder isyaml-unreadable(#430) #1111 + this head: 1 failed, 48 passed. The one failure istest_no_bare_guard_outside_a_stat_helper_or_the_allowlist[adopt-project.py], for three sites:_PlannedRoot._no_directory:self._git().is_dir(here)_PlannedRoot._into_git:self._git().is_dir(here.rpartition("/")[0])_clone_ref_store:(probe / CLONE_GIT_DIR / "reftable").is_dir()_is_aor a reasoned allowlist covers it, and aproject.yamllinked into an unsearchable folder isyaml-unreadable(#430) #1111 alone, without this head: the same three sites, the same 1 failed and 48 passed. They come from adopt-project.py:checknames what the clone's.githolds as git keeps refs, and asks a pin's file through the links the root keeps, so a link into.gitor onto a pin's file is refused before any leg or adopted with the copy beside it (#904) #1091 (4ed7c7bf5, adopt-project.pycheck: a root-kept symlink into the clone's.git, or through a kept link to a declared pin's file, passes, or is refused for a reason that is not true #904), which landed after tests/test_path_guard_hygiene.py and shape-doctor.py: a bare is_file/is_dir/exists guard in a shipped tool fails by file and line unless its_is_aor a reasoned allowlist covers it, and aproject.yamllinked into an unsearchable folder isyaml-unreadable(#430) #1111's head was cut. tests/test_path_guard_hygiene.py and shape-doctor.py: a bare is_file/is_dir/exists guard in a shipped tool fails by file and line unless its_is_aor a reasoned allowlist covers it, and aproject.yamllinked into an unsearchable folder isyaml-unreadable(#430) #1111 needs them allowlisted or fixed before it lands. This PR does not touch them._is_aor a reasoned allowlist covers it, and aproject.yamllinked into an unsearchable folder isyaml-unreadable(#430) #1111 + this head, with only those three sites allowlisted in a scratch copy of the test: 49 passed. This head adds no site.Merge-trees
Each was run as
git merge-tree --write-treeof this head against:origin/main4ed7c7bf55e784b0ffix/1020-bad-id-first-problem2a53a8e676af7e33fix/1061-makefile-follow-ups5c3908f812859bc5fix/1003-adopt-unenterable-source(_source_there)c01c4d736222542ffix/1047-next-make-bootstrap1c517375e294504ffix/1035-source-branch-shownc55e55e346bcc762fix/1083-check-asks-the-split-clone9b998936dcc12164fix/1089-named-link-skip-testsc60066cbtests/test_adopt_root_shape_paths.pyonly, which this head does not touch; #1118 conflicts there withorigin/mainalone tool3-1078-one-byte-locale(_recorded_source'sthereline)504a1dd6e59c5cf4fix/1048-makefile-follow-up-gnu-read-orderb28f40de67d4fec4These are every open pull request that changes
adopt-project.py,docs/cli.mdor the new test file, as listed at 16:33Z.Out of scope
Each was measured on this head, and none is changed here:
plan --source <a repository whose .git is at 000>, and socheck --sourceandexecute --source:REFUSED git-failed: ... fatal: not a git repository, exit 2. Noghis asked and nothing is cloned, but the refusal does not name the.git. The issue names the recorded clone only. A follow-up could give_top_levelthe same guard..gitdirectory whoseHEADis at 000 (cloned: true): still "no longer whole (git finds no repository there)", cloned again, 1ghcall..gitfile whose git directory is at 000 (cloned: true): the same, 1ghcall..gitthe issue measured. Naming every file git might be refused is a wider question, left for a ruling._is_aor a reasoned allowlist covers it, and aproject.yamllinked into an unsearchable folder isyaml-unreadable(#430) #1111 against main: see above (adopt-project.py:checknames what the clone's.githolds as git keeps refs, and asks a pin's file through the links the root keeps, so a link into.gitor onto a pin's file is refused before any leg or adopted with the copy beside it (#904) #1091's three bare guards).git merge-treeof adopt-project.py, shape_materialize.py and tests: a named bad link left out of the copy and pin findings takes no other path's finding with it, and the docstrings and the materializer's refusal name the #979 exception and what helps it (#1089) #1118's headc60066cbwith main alone conflicts intests/test_adopt_root_shape_paths.py. This head does not touch that file.Closes #1099
🤖 Generated with Claude Code
Lane: openreposhape-4 (openRepoShape-4)
Summary by Sourcery
Reject recorded sources with inaccessible
.gitmetadata before Git or clone recovery runs, and report the precise unreadable path with actionable remediation.Bug Fixes:
.gitcannot be read assource-unreadable, identifying the inaccessible path and preventing misleading clone recovery or additional GitHub clones.Enhancements:
.gitpaths while providing file- or directory-specific remediation guidance.Documentation:
.gitpaths assource-unreadable.Tests:
.gitdirectories and files acrosscheckandexecute, including verification that no clone or GitHub request occurs.