diff --git a/.harness/docs/ARCHITECTURE.md b/.harness/docs/ARCHITECTURE.md index 1a92036..3bb1cca 100644 --- a/.harness/docs/ARCHITECTURE.md +++ b/.harness/docs/ARCHITECTURE.md @@ -1448,9 +1448,17 @@ A test that needs a repository it may mutate copies this one. `tests/test_git_hi **A pattern list is the wrong instrument here and would have looked right, which is why the module says so where the next author will be standing.** `shutil.ignore_patterns` fnmatches **bare entry names**, so a pattern carrying a slash — `.harness/runs/`, the spelling `.gitignore` actually uses — matches nothing, and an exclusion derived from the text of `.gitignore` would have excluded nothing while reading as though it excluded everything. `copy_repository(destination, source=REPO_ROOT)` passes a callable instead, which joins each entry onto its directory's repository-relative path and compares that against git's answer, because the repository-relative path is the form git reports. -**`.git` comes along, and that is a requirement rather than an oversight.** Every assertion the copy exists for bounds itself at its own story's commit range and cannot resolve one without the history — this module is one of the four declared subject readers in the section above. Eleven megabytes of `.git` is not what made the copy expensive, and the question of whether the module needs history at all is settled: it does. +**The history comes along, and that is a requirement rather than an oversight.** Every assertion the copy exists for bounds itself at its own story's commit range and cannot resolve one without the history — this module is one of the four declared subject readers in the section above. Eleven megabytes of `.git` is not what made the copy expensive, and the question of whether the module needs history at all is settled: it does. *How* it comes along changed with story-153, next. -**What `.git` *is* here decides what running git inside the copy answers about, and a checkout under a linked worktree is not the case a reader assumes.** In a linked worktree `.git` is not a directory but a short pointer file naming an admin directory elsewhere, and the copying — before this story and after it — copies that file verbatim. A linked worktree's admin directory carries no `core.worktree`, so git derives the working tree from where it found the `.git` file: run with its working directory set to the copy, git reports about the copy. That is why an assertion reading git's ignored entries *inside* the copy answers about the copy rather than about the worktree the copy was made from, and why the module's bounded history reads resolve there at all. The cost of the arrangement is that the copy's history is reached *through* the source worktree's admin directory rather than held in the copy, so a copy whose pointer no longer resolved would not be caught by the presence check on `.git` — the nested pytest runs inside the copy would redden instead. That is not a property this story introduced and not one it repaired; it is written here because the next author to reason about what the copy contains will otherwise reason about a directory that is a file. +**What `.git` *is* in the source decides what a copy of it would be, and a checkout under a linked worktree is not the case a reader assumes.** In a linked worktree `.git` is not a directory but a one-line pointer file, `gitdir: `, naming an admin directory inside the real repository. Until story-153 the copying copied that file verbatim, so from a worktree the copy's `.git` was a pointer back into the repository it was copied from: git run in the copy reported about the copy's working tree — a linked worktree's admin directory carries no `core.worktree`, so git derives the tree from where it found the file — but read the source's refs and objects and wrote them too. Every harness run of the suite works in a worktree (see "Planning and running each stand on a worktree of their own"), so that was the case the copy met on every run the harness made of it, and only a developer's main checkout, where `.git` is a real directory, produced a genuine copy. The module's whole safety argument is that the repository is copied rather than mutated in place because the one the suite runs from is the one being validated; a copied pointer satisfied the letter of that and none of the substance. The Inspector found it on story-151, the finding was routed to that story and lost (the story-152 section records how), and it was filed by hand. + +**story-153 gives the copy a git directory of its own: clone, then overlay.** `copy_repository` now runs `git clone --no-checkout` from the source into the destination first, removes the `origin` remote the clone recorded so the copy holds no reference back, lays the source's working tree over the clone with the same `leave_behind` callable — extended to leave `.git` behind at the root — and runs `git reset` in the copy so its index is rebuilt from HEAD and git there reports the overlaid edits as modifications against the same commit. A clone resolves the `gitdir:` pointer itself, which is why it is the form chosen: nothing in the test spells git's worktree layout. The overlay is not optional. A clone alone would give the copy HEAD's tree, and on every harness run the story's own uncommitted edits are what the nested pytest runs must see. Two forms were considered and refused: cutting a worktree from the source with git's own machinery, because that produces another pointer into the same real repository, which is the defect; and resolving the pointer by hand, which works but bakes the worktree layout into the test. + +**The standing assertion is driven from a worktree the test builds, because that is the only place it bites.** `test_a_copy_of_a_worktree_has_a_git_directory_of_its_own` builds a repository with `conftest.init_repository`, cuts a worktree from it through the harness's own `worktrees.add` — so the source is the shape of worktree the harness works in — copies the worktree, and requires `git rev-parse --git-dir` and `--git-common-dir` run in the copy to resolve beneath the copy. Its control is the same two readings in the worktree itself, which must resolve outside it and beneath the origin, so a reading that stopped seeing or a resolution that put every answer under its root fails as itself. Built rather than borrowed for the reason the previous story's built-repository test gives: an assertion that depended on whether the suite happened to be running from a worktree would be green on one machine and red on another with nothing changed. Beside it, `test_the_copy_is_isolated_from_its_source_and_starts_where_it_stood` holds the sameness that makes the isolation worth having — HEAD equal to the worktree's and the copy's symbolic HEAD on the worktree's branch, the uncommitted edit present as text and as what `git status` reports — and then commits inside the copy and requires the origin to hold neither the commit nor the object, with the copy's own history as the control. **The sameness is only a reading if the worktree stands somewhere the origin does not.** A worktree cut fresh from a branch stands on that branch's commit, so a helper that cloned the origin's base branch instead of the worktree would answer a HEAD comparison identically; `worktree_and_copy` therefore commits on the worktree's branch before copying, and the test first asserts that the origin's base branch and the worktree's HEAD differ, so that a copy standing on the origin's HEAD fails rather than passing as sameness. + +**The commit made inside the copy carries an identity the test supplies, under an environment where nothing else can.** A clone carries none of its source's local config — `conftest.init_repository` writes `user.name` and `user.email` into the origin's `.git/config`, and the worktree shares that config, but the copy does not — so a commit in the copy finds an identity only where the machine supplies one: a developer's `~/.gitconfig`, a runner's environment, or git's own guess from the account name and hostname, which git accepts whenever the hostname carries a dot and refuses otherwise. A commit left to that would be green on a laptop whose hostname ends in `.local` and red on a runner with nothing in the test changed. So `environment_with_no_identity` points the global and system config files at the null device, removes the `GIT_AUTHOR_*`, `GIT_COMMITTER_*` and `EMAIL` variables, and sets `user.useConfigOnly` through the `GIT_CONFIG_COUNT` slots to switch the hostname guess off; the test makes the commit once under that environment with no identity and requires it to be refused, then makes it with `-c user.email` and `-c user.name` on the command line and requires it to land. The control comes first for the usual reason: without it, an identity leaking in from the machine would let the second commit pass and say nothing about what it stood on. This is the same shape the coordinator uses for its own clone-then-commit (`story_coordinator.py` passes `-c user.email` and `-c user.name` to the commit it makes in a clean clone), and the consequence for the next author is the general one: **anything that commits inside a copy must supply its identity, because the copy has none.** `test_a_copy_of_a_main_checkout_carries_what_it_carried_before` pins the case that was always right, on a source whose `.git` is a real directory with more than one commit, so the clone-and-overlay cannot have narrowed it. + +**Two consequences for the next author of anything that runs git inside the copy.** The copy is a repository with HEAD at the source's commit — on the source's branch if the source was on one; if the source was detached, `git clone` puts the copy on whichever branch names that commit and leaves it detached only when none does — with no remote and none of the source's other branches; a branch name that resolved in the source does not necessarily resolve in the copy, so bounded reads should walk from HEAD and name commits, which `story_commit_range` in `tests/conftest.py` already does. And the copy's index is what `git reset` from HEAD produced, so a path the source had staged but not committed arrives in the copy unstaged; the tree is the same, the staging is not. **One copying implementation, two fixtures, and the second exists because of what the first's consumers do to it.** `repo_copy` calls `copy_repository`; so does `pristine_copy`, which nothing mutates. The standing assertion takes the pristine one deliberately — the subprocess pytest runs inside `repo_copy` write `__pycache__` into it, and `__pycache__` is itself a path the repository ignores, so an assertion about what the *copying* put there would be answered by what the tests did to it afterwards. **A fixture that the tests under it modify cannot be the subject of an assertion about how it was built.** diff --git a/.harness/history/execution-log.jsonl b/.harness/history/execution-log.jsonl index 2519bf4..56b3ec9 100644 --- a/.harness/history/execution-log.jsonl +++ b/.harness/history/execution-log.jsonl @@ -87,3 +87,6 @@ {"story_id": "story-151", "status": "resumed", "retry_count": 0, "timestamp": "2026-09-16 07:36:54"} {"story_id": "story-151", "status": "completed", "retry_count": 1, "timestamp": "2026-09-16 09:07:08"} {"story_id": "story-152", "status": "completed", "retry_count": 1, "timestamp": "2026-09-16 10:49:18"} +{"story_id": "story-153", "status": "escalated", "retry_count": 1, "timestamp": "2026-09-16 12:27:50"} +{"story_id": "story-153", "status": "resumed", "retry_count": 1, "timestamp": "2026-09-16 12:32:22"} +{"story_id": "story-153", "status": "completed", "retry_count": 1, "timestamp": "2026-09-16 12:55:13"} diff --git a/.harness/history/inspection-log.jsonl b/.harness/history/inspection-log.jsonl index 365a4f8..8d8b837 100644 --- a/.harness/history/inspection-log.jsonl +++ b/.harness/history/inspection-log.jsonl @@ -47,3 +47,7 @@ {"story_id": "story-151", "mode": "narrow", "cost_usd": 3.063886, "scope_files": 60, "invocations": 1, "timestamp": "2026-09-16 08:31:20", "findings": 1, "filed": 0, "dropped": 0, "dedupe_ran": true} {"story_id": "story-152", "mode": "narrow", "cost_usd": 3.002376, "scope_files": 60, "invocations": 1, "timestamp": "2026-09-16 10:28:07", "findings": 2, "filed": 0, "dropped": 0, "dedupe_ran": true} {"story_id": "story-152", "mode": "narrow", "cost_usd": 2.6565825000000003, "scope_files": 60, "invocations": 1, "timestamp": "2026-09-16 10:40:25", "findings": 2, "filed": 0, "dropped": 0, "dedupe_ran": true} +{"story_id": "story-153", "mode": "narrow", "cost_usd": 2.7320615000000004, "scope_files": 60, "invocations": 1, "timestamp": "2026-09-16 12:22:07", "findings": 3, "filed": 0, "dropped": 0, "dedupe_ran": true} +{"story_id": "story-153", "timestamp": "2026-09-16 12:27:41", "filed": 1, "dropped": 0, "routed": 3, "acted_on": 2} +{"story_id": "story-153", "mode": "narrow", "cost_usd": 2.5583735, "scope_files": 60, "invocations": 1, "timestamp": "2026-09-16 12:46:28", "findings": 1, "filed": 0, "dropped": 0, "dedupe_ran": true} +{"story_id": "story-153", "timestamp": "2026-09-16 12:49:39", "filed": 1, "dropped": 0, "routed": 1, "acted_on": 0} diff --git a/.harness/history/mandate-log.jsonl b/.harness/history/mandate-log.jsonl index eed3bff..41aa24e 100644 --- a/.harness/history/mandate-log.jsonl +++ b/.harness/history/mandate-log.jsonl @@ -58,3 +58,4 @@ {"story_id": "story-150", "timestamp": "2026-09-15 12:12:00", "conferred_by": "jerod.wilkerson <30474318+jerodw@users.noreply.github.com>", "source_kind": "human", "recorded_by": "l5-plan", "discarded_session_block": false} {"story_id": "story-151", "timestamp": "2026-09-15 14:59:07", "conferred_by": "jerod.wilkerson <30474318+jerodw@users.noreply.github.com>", "source_kind": "human", "recorded_by": "l5-plan", "discarded_session_block": false} {"story_id": "story-152", "timestamp": "2026-09-16 09:53:13", "conferred_by": "jerod.wilkerson <30474318+jerodw@users.noreply.github.com>", "source_kind": "human", "recorded_by": "l5-plan", "discarded_session_block": false} +{"story_id": "story-153", "timestamp": "2026-09-16 12:03:52", "conferred_by": "jerod.wilkerson <30474318+jerodw@users.noreply.github.com>", "source_kind": "human", "recorded_by": "l5-plan", "discarded_session_block": false} diff --git a/.harness/history/retry-log.jsonl b/.harness/history/retry-log.jsonl index cdfd87c..d8b6c54 100644 --- a/.harness/history/retry-log.jsonl +++ b/.harness/history/retry-log.jsonl @@ -63,3 +63,5 @@ {"story_id": "story-147", "timestamp": "2026-09-14 18:26:38", "stage": "verifier", "retry_decision": "retry", "retry_category": "validation", "retry_stage": "tester"} {"story_id": "story-151", "timestamp": "2026-09-15 15:47:59", "stage": "tester", "retry_stage": "tester"} {"story_id": "story-152", "timestamp": "2026-09-16 10:28:50", "stage": "verifier", "retry_decision": "retry", "retry_category": "validation", "retry_stage": "tester"} +{"story_id": "story-153", "timestamp": "2026-09-16 12:27:41", "stage": "verifier", "retry_decision": "retry", "retry_category": "validation", "retry_stage": "tester"} +{"story_id": "story-153", "timestamp": "2026-09-16 12:27:49", "stage": "tester", "retry_stage": "tester"} diff --git a/.harness/stories/story-153.yaml b/.harness/stories/story-153.yaml new file mode 100644 index 0000000..b3d7d23 --- /dev/null +++ b/.harness/stories/story-153.yaml @@ -0,0 +1,113 @@ +story: + id: story-153 + title: A copy of this repository made for a test carries a git directory of its own + description: | + Planned from the brief filed under 304. + + `copy_repository` in `tests/test_git_history_loading_retired.py` copies + this repository so that the module can mutate the coordinator in the copy + and run pytest there, establishing that each restated assertion goes red + when its subject is violated. The copy is the module's whole safety + argument: its own docstring says the repository is copied rather than + mutated in place because the one this suite runs from is the one being + validated. + + In a worktree `.git` is not a directory. It is a file holding one line — + `gitdir: ` — naming the real repository's git directory. The helper + copies the tree with that file in it, so the copy's `.git` is a pointer + into the repository it was copied from: anything in the copy that reads + git reads the original's refs and objects, and anything that writes git + writes them. In the developer's main checkout `.git` is a real directory + and the copy is genuine, which is why this passes there and has not been + noticed. Every story run works in a worktree, so the suite meets the + pointer case on every run the harness makes of it. + + This story gives the copy a git directory of its own. The copy is made by + cloning the source into the destination — which is the form that does not + depend on this test knowing how git spells a worktree — and then laying + the source's working tree over it the way the helper already copies, with + the same exclusions and `.git` no longer carried as a tree entry. The + overlay is not optional: on every harness run the story's own uncommitted + edits are what the nested pytest runs must see, and a clone alone would + give the copy HEAD's tree rather than the tree under test. + + Alongside it the story leaves a standing assertion, driven from a + worktree so that it bites, that the copy's git directory resolves inside + the copy. It fails against today's helper when the suite runs from a + worktree and passes when it runs from the main checkout, which is why the + test builds its own repository and cuts a worktree from it rather than + relying on where the suite happens to be running. + + Cutting a worktree from the source with git's own machinery was + considered and is not the fix: it produces another pointer into the same + real repository, which is the defect. Resolving the pointer by hand would + work but bakes git's worktree layout into the test. + + The whole change is under `tests/`, so the tester stage owns all of it, + as it did in story-151. + + This was found by the Inspector on story-151, routed to that story as a + finding about its own change, passed over by the verifier and lost; it + was filed by hand. + workflow: story-workflow + +tasks: + - Change copy_repository in tests/test_git_history_loading_retired.py so the copy's `.git` is a git directory of its own rather than whatever the source's `.git` entry is - obtained by cloning the source into the destination, with the source's working tree then copied over it the way the helper already copies, keeping the exclusions story-151 derived from git and no longer copying `.git` as a tree entry. + - Keep the copy's working tree and history what they were - HEAD at the source's commit, the history reachable from it, the source's uncommitted edits present - so the module's bounded history reads and the nested pytest runs see what they saw before. + - Add a standing assertion, driven from a worktree, that the copy's git directory resolves inside the copy - build a repository with init_repository from tests/conftest.py, cut a worktree from it with the harness's own orchestration/worktrees.py helper, copy the worktree through copy_repository, and assert that `git rev-parse --git-common-dir` and `git rev-parse --git-dir` run in the copy resolve to paths beneath the copy. Its control is the same reading on the worktree itself, which resolves outside it, so a reading that stopped seeing would fail as itself. + - Beside it, assert the isolation the copy exists for - a commit made inside the copy does not appear in the source repository's history, the copy's HEAD names the same commit as the source's, and an uncommitted edit made in the source worktree before copying is present in the copy. + - Rewrite the docstrings on copy_repository, repo_copy and pristine_copy so they say what the copy's `.git` is, why a copied pointer is not a copy, and why the working tree is laid over the clone rather than taken from it. + +acceptance_criteria: + - Given a source whose `.git` is a `gitdir:` file, copy_repository produces a copy whose `.git` is a directory and in which `git rev-parse --git-common-dir` resolves to a path inside the copy. + - Given a source whose `.git` is a real directory, the copy carries what it carried before this story - the history reachable from HEAD, HEAD at the source's commit, the working tree as it stood including uncommitted edits, and no path the source's git ignores. + - A test in tests/test_git_history_loading_retired.py drives the worktree case on a repository it builds and cuts a worktree from, and that test fails when the helper copies the pointer as it does today. + - A commit made inside the copy is not reachable in the source repository afterwards. + - The existing assertions that a fresh copy holds no ignored path, that it carries `.git`, and that a directory newly ignored is excluded with no edit to the module all still pass. + - Every parametrized case in tests/test_git_history_loading_retired.py that runs one test green in the unmutated copy and red in the mutated one still passes. + +technical_plan: + implementation_steps: + - Read copy_repository, repo_copy, pristine_copy and the three tests about the copy at tests/test_git_history_loading_retired.py:877-1044, and run_one_test below them, so the change keeps the destination layout every consumer expects. + - Make copy_repository clone the source into the destination first - `git clone` with checkout suppressed is enough, since the working tree comes from the overlay - then copy the source's working tree over it with the existing leave_behind callable, extended to leave `.git` behind at the root, and rebuild the copy's index from its HEAD so git in the copy reports the overlaid tree as the source would. + - Confirm on a worktree with a detached HEAD that the clone's HEAD resolves to the same commit as the source's, whether or not a branch points at it, and that `git log` inside the copy reaches the history the module's bounded reads depend on. + - Write the worktree-driven assertion - init_repository for the origin, orchestration/worktrees.py to cut a worktree from it, an uncommitted edit in the worktree, copy_repository on the worktree - and read `git rev-parse --git-common-dir` and `--git-dir` in the copy and in the worktree, requiring the copy's to lie beneath the copy and the worktree's to lie outside it. + - Beside it, commit inside the copy and assert the origin's history does not carry that commit; assert the copy's HEAD equals the worktree's; assert the uncommitted edit is present in the copy. + - Rewrite the docstrings, and re-run the module's existing copy tests and the restated-assertion cases before handing over. + likely_file_changes: + - file: tests/test_git_history_loading_retired.py + stage: tester + reason: The helper, the fixtures, the docstrings and the new assertions all live in this module, and the tester stage may change files under tests/. + +scope: + modify: + - tests/test_git_history_loading_retired.py + do_not_modify: + - tests/conftest.py + - orchestration/worktrees.py + - orchestration/story_coordinator.py + - .gitignore + +verification_requirements: + - Confirm the full suite passes under the configured test command. + - Confirm the new worktree-driven assertion is a real check rather than a tautology - that it fails against the pre-change helper, which copies the `gitdir:` file, and passes with the changed one. + - Confirm the copy's `.git` resolves inside the copy when the source is a worktree, and that a commit made in the copy does not reach the source. + - Confirm the copy still carries the history and the working tree the module depends on - the existing copy assertions pass and the parametrized restated-assertion cases still run green in the unmutated copy and red in the mutated one. + - Confirm the exclusion list is still derived from git as story-151 left it, and that no file outside tests/test_git_history_loading_retired.py changed. + +constraints: + - The module continues to reach this repository's history only through the shared reader in tests/conftest.py, which is the rule it states about itself in its own docstring; a clone made to build a copy is not a read of a file's text out of git. + - No existing assertion in the module is weakened, skipped or removed. + - No wall-clock bound is added. The subject is what the copy's `.git` is, not how fast the machine that makes it is. + - What the copy excludes is settled by story-151 and not reopened - the exclusion list stays derived from what git reports as ignored. + - The nested pytest runs themselves, and what they cost, are out of scope; this story changes what the copy includes and nothing about what runs inside it. + - Cutting a worktree from the source is not an acceptable form of the fix, because it produces another pointer into the repository being copied. + +mandate: + source: + kind: human + conferred_at: 2026-09-16 12:03:52 + conferred_by: jerod.wilkerson <30474318+jerodw@users.noreply.github.com> + recorded_by: l5-plan + +brief_key: 304 diff --git a/tests/test_git_history_loading_retired.py b/tests/test_git_history_loading_retired.py index c0e0825..c2c44e3 100644 --- a/tests/test_git_history_loading_retired.py +++ b/tests/test_git_history_loading_retired.py @@ -48,8 +48,10 @@ import pytest -from conftest import (BASELINE, ENDPOINT, function_source, init_repository, - repository_file_at, story_commit_range) +import worktrees +from conftest import (BASE_BRANCH_FALLBACK, BASELINE, ENDPOINT, + function_source, init_repository, repository_file_at, + story_commit_range) REPO_ROOT = Path(__file__).resolve().parents[1] TESTS_DIR = REPO_ROOT / "tests" @@ -899,15 +901,40 @@ def copy_repository(destination: Path, source: Path = REPO_ROOT) -> Path: """Copy `source` to `destination`, carrying what the repository is and leaving behind what the runs left in it. - What the copy exists for is the history and the working tree: `.git` comes - along, because every assertion the copy is made for bounds itself at its - own story's commit range and cannot resolve one without it, and so do the - tracked source and tests a subprocess pytest run in the copy imports. - - What it leaves behind is everything git ignores. `.harness/runs` and - `.harness/logs` are the reason that matters: they are where this harness's - own runs accumulate, they are not part of the repository this copy - reproduces, and they grow with every story that runs — so a copy that + What the copy exists for is the history and the working tree: every + assertion the copy is made for bounds itself at its own story's commit + range and cannot resolve one without the history, and a subprocess pytest + run in the copy imports the tracked source and tests as they stand. + + The copy's `.git` is a git directory of its own, made by cloning `source` + into `destination` before anything else is put there. It is not the + source's `.git` entry copied across, because that entry is not always a + directory: in a worktree `.git` is a one-line file, `gitdir: `, + naming the real repository's git directory. A copy that carried that file + would look like a repository and be a pointer into the one it was copied + from — every read of git in the copy would read the source's refs and + objects, and every write would write them — which is exactly what copying + rather than mutating in place exists to prevent. Every harness run of this + suite happens in a worktree, so that is the case the copy meets on every + run the harness makes of it. A clone resolves the pointer itself, so + nothing here spells git's worktree layout, and the remote the clone + records is removed afterwards so the copy holds no reference back to the + source at all. + + The working tree is then laid over the clone rather than taken from it. + A clone checks out HEAD's tree, and what the nested pytest runs must see + is the tree under test: on every harness run the story's own uncommitted + edits are what is being validated, and a checkout would hand the copy the + last commit instead. So the clone is made without a checkout, the source's + working tree is copied over it with `.git` left behind at the root, and + the copy's index is rebuilt from its HEAD so git in the copy reports the + overlaid tree as the source would — the same edits, as modifications + against the same commit. + + What the overlay leaves behind is everything git ignores. `.harness/runs` + and `.harness/logs` are the reason that matters: they are where this + harness's own runs accumulate, they are not part of the repository this + copy reproduces, and they grow with every story that runs — so a copy that carried them would get slower without a line here changing. The venvs go for the same reason, and so does any directory `.gitignore` gains later, because the exclusion list is `ignored_paths` above and not a list of @@ -923,10 +950,21 @@ def copy_repository(destination: Path, source: Path = REPO_ROOT) -> Path: def leave_behind(directory, names): relative = os.path.relpath(directory, source) - prefix = "" if relative == os.curdir else f"{Path(relative).as_posix()}/" + if relative == os.curdir: + return {name for name in names + if name == ".git" or name in excluded} + prefix = f"{Path(relative).as_posix()}/" return {name for name in names if f"{prefix}{name}" in excluded} - shutil.copytree(source, destination, ignore=leave_behind) + subprocess.run(["git", "clone", "--quiet", "--no-checkout", str(source), + str(destination)], cwd=source, capture_output=True, + text=True, check=True) + subprocess.run(["git", "remote", "remove", "origin"], cwd=destination, + capture_output=True, text=True, check=True) + shutil.copytree(source, destination, ignore=leave_behind, + dirs_exist_ok=True) + subprocess.run(["git", "reset", "--quiet"], cwd=destination, + capture_output=True, text=True, check=True) return destination @@ -936,12 +974,17 @@ def repo_copy(tmp_path_factory) -> Path: Copied rather than mutated in place because the subject of every case below is a coordinator edit, and the repository this suite is running from - is the one being validated. The history comes along because the assertions - under test bound themselves at their own story's commit range; the run and - log directories do not, because they are not part of the repository being - validated. `copy_repository` above decides that, and it is the only copying - in this module, so what this fixture carries and what the assertion below - inspects cannot drift apart. + is the one being validated. That argument only holds if the copy's `.git` + is its own: a copied `gitdir:` pointer — which is what `.git` is in the + worktree every harness run works in — would make the copy's git *be* the + source's, so a mutation committed or reset in the copy would land in the + repository being validated. `copy_repository` above clones the history + into a git directory of the copy's own and lays this working tree over it, + uncommitted edits included, so the copy is a repository and not a name for + this one. The run and log directories do not come along, because they are + not part of the repository being validated. That helper is the only + copying in this module, so what this fixture carries and what the + assertions below inspect cannot drift apart. """ return copy_repository(tmp_path_factory.mktemp("repo") / "level-five") @@ -954,6 +997,12 @@ def pristine_copy(tmp_path_factory) -> Path: `__pycache__` and mutate the coordinator inside that one, and `__pycache__` is a path the repository ignores — so an assertion about what the copying put there would be answered by what the tests did to it afterwards. + + Made the same way means its `.git` is a git directory of its own, cloned + rather than copied as a tree entry, with the working tree laid over the + clone: the assertions on it read git *inside* the copy, and a copy whose + `.git` pointed back at the source would answer those reads out of the + repository the suite is running from. """ return copy_repository(tmp_path_factory.mktemp("pristine") / "level-five") @@ -1044,6 +1093,274 @@ def test_a_directory_newly_ignored_is_excluded_with_no_edit_to_this_module( assert (after / ".git").exists(), "the history went with it" +# -------------------------------------------------------------------------- +# What the copy's `.git` is +# -------------------------------------------------------------------------- + + +def git_in(root: Path, *args: str, + env: dict[str, str] | None = None) -> subprocess.CompletedProcess: + """One git reading inside `root`, output captured, nothing raised: the + tests below read exit codes as answers as often as they read stdout. + `env`, when given, replaces the inherited environment wholesale.""" + return subprocess.run(["git", "-C", str(root), *args], + capture_output=True, text=True, env=env) + + +#: The variables git reads an author or committer identity from when no +#: config supplies one, taken out of the environment below so that what a +#: commit stands on is what the test handed it. +IDENTITY_VARIABLES = ("GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", + "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", "EMAIL") + + +def environment_with_no_identity() -> dict[str, str]: + """The current environment with every route to a git identity closed: + the global and system config files pointed at the null device, the + identity variables absent, and git's guess from the account name and + hostname switched off. + + A clone carries none of its source's local config, so a commit made in a + copy finds an identity only where the machine supplies one — the + developer's `~/.gitconfig`, a runner's environment. Closing those is not + enough on its own: with nothing configured git invents an identity from + the password database and the hostname, and accepts it whenever the + hostname carries a dot, which a laptop's `.local` name does. So + `user.useConfigOnly` is set as well, through the environment's config + slots rather than on any one command line, and under this environment + the copy has no identity unless the test supplies it — which is what lets + the test show that the supply is load-bearing rather than the machine. + """ + env = {name: value for name, value in os.environ.items() + if name not in IDENTITY_VARIABLES} + env["GIT_CONFIG_GLOBAL"] = os.devnull + env["GIT_CONFIG_SYSTEM"] = os.devnull + env["GIT_CONFIG_COUNT"] = "1" + env["GIT_CONFIG_KEY_0"] = "user.useConfigOnly" + env["GIT_CONFIG_VALUE_0"] = "true" + return env + + +def git_dir_resolved(root: Path, option: str) -> Path: + """Where `git rev-parse