diff --git a/AGENTS.md b/AGENTS.md
index 4b94a0a7..0719e9ab 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -85,15 +85,15 @@ to confirm on`. Give the human the same command for THEIR OWN terminal, where
it asks them, the one-liner too: setup reads the answer from that terminal and
never from the pipe. Never pass `--yes` yourself.
-If it refuses, read the refusal: each one names what to run instead, or, for
-some, the VALUE to change. Two you can foresee from your flags, both before
-anything is created: `scaffold-leg-path-collides` (a `--spec-path` or
-`--code-path` that is, holds or lies inside a FILE the shape writes into the
-assembly root: `project.yaml` is one, and `contracts` holds
-`contracts/spec-pin.yaml`; the defaults `spec` and `code` pass) and
-`scaffold-pin-collides` (a `--pin` the shape cannot write, below). Both go
-after `--`. Relay the refusal and ask the human for the value; never choose
-another yourself. Do not retry with `--force`; there is none, deliberately.
+If it refuses, read the refusal: each one names what to run instead, or, for some, the
+VALUE to change. Three you can foresee, all before anything is created:
+`scaffold-leg-path-collides` (a `--spec-path` or `--code-path` that is, holds or lies
+inside a FILE the shape writes into the assembly root: `project.yaml` is one, and
+`contracts` holds `contracts/spec-pin.yaml`; the defaults `spec` and `code` pass) and
+`scaffold-pin-collides` (a `--pin` the shape cannot write, below), both after `--`;
+and anything already at ` Honestly: it still reaches GitHub before the run starts — to
fetch Rehearse first, creating nothing
setup.sh, then for the self-bootstrap clone of this standard into a
temporary directory. Run the same flag on ./setup.sh from a checkout
- instead and nothing touches the network at all. What it leaves behind is
- ./Atlas, three bare repositories in ./rehearsal, and the
- scaffold’s three working trees in the temporary directory its
- Working trees are at line names, all of which you can delete../Atlas, three
+ bare repositories in ./rehearsal, and the scaffold’s three working
+ trees in the temporary directory its Working trees are at line names, all
+ deletable; a real run here refuses up front while ./Atlas is there.
Honestly: it still reaches GitHub before the run starts — to
fetch setup.sh, then for the self-bootstrap clone of this standard into a
temporary directory. Run the same flag on ./setup.sh from a checkout
@@ -953,8 +953,8 @@
setup.sh actually runsIt is a shim that finds a Python 3.9 or newer, clones this standard into a temporary
directory when you are not already in a checkout of it, and hands over to
setup-project.py, which IS the flow. These are the commands that flow runs,
- in order, with no behaviour of its own (the first only in self-bootstrap mode, from the
- one-liner):
<into>/Atlas:
git clone --depth 1 https://github.com/opensoft/openRepoShape.git <tmp-dir>
python3 scripts/validate-repository-naming.py --explain Atlas Atlas-spec Atlas-code
python3 scaffold-project.py --org <your-org> --project Atlas --dry-run
@@ -1815,14 +1815,14 @@ For AI assistants
it asks them, the one-liner too — setup reads the answer from that terminal and never
from the pipe — and never pass --yes yourself. Read a refusal rather than
retrying: each one names what to run instead, or, for some, the VALUE to change —
- among them a --spec-path or
- --code-path that is, holds or lies inside a file the shape writes, and a
- --pin the shape cannot write, both before anything is created — and there
- is no --force, deliberately. Relay the finished block — the clone
- path, the three repository URLs and the next commands — and say that the line
- authority is not wallet-carried in this org is a report and not a fault. The new
- project carries AGENTS-shape.md at its assembly root, and you read that before
- touching the project.
+ among them a --spec-path or --code-path that is, holds or lies
+ inside a file the shape writes, a --pin the shape cannot write, and anything
+ already at <into>/<Project>, for the human to move or delete, all
+ before anything is created — and there is no --force, deliberately. Relay
+ the finished block — the clone path, the three repository URLs and the next commands
+ — and say that the line authority is not wallet-carried in this org is a
+ report and not a fault. The new project carries AGENTS-shape.md at its assembly
+ root, and you read that before touching the project.
What you must not tell them: that the shape confers anything — or
that membership of a family does — or that a <user>-wip workspace
repository does, which holds no code, elects nothing and is membership of nothing. Electing
@@ -1869,10 +1869,10 @@
For AI assistants
added (#411); the doctor’s merge row, and INVALID (merge)
above drift, added (#424); the assistant’s route when its shell has no terminal, added
(#476); the one-liner asking at the person’s own terminal, never reading the answer
- from the pipe, added (#492); the statements the v1.0.0 accuracy pass found false,
- corrected here as in the README, added (#483) — a page cannot name the commit
- that contains it, and the blob below is the fact a test checks.
- README.md blob 1417b9ec0235675da8f73ca023756411bc3fbe8a. Every command, flag, exit code
+ from the pipe, added (#492); the statements the v1.0.0 accuracy pass found false, corrected
+ here as in the README, added (#483); a taken clone path refused up front, added (#498) —
+ a page cannot name the commit that contains it, and the blob below is the fact a test checks.
+ README.md blob 43df8f47b8a70678a2ff1836f01468a686e41eb6. Every command, flag, exit code
and file name on this page is quoted from that README and from AGENTS.md.
diff --git a/setup-project.py b/setup-project.py
index d820d8f8..1f226080 100755
--- a/setup-project.py
+++ b/setup-project.py
@@ -1662,6 +1662,143 @@ def resolve_family(opts: Options, shape_root: Path) -> Landing:
return Landing(landing, family, landing / family)
+# ---------------------------------------------------------------------------
+# 3c. the clone path, while nothing exists
+# ---------------------------------------------------------------------------
+
+#: THE WAYS OUT OF A TAKEN CLONE PATH. This tool deletes and moves nothing
+#: itself, and it offers deleting only as strongly as it KNOWS that nothing
+#: is lost. An empty directory holds nothing: move or delete it. What looks
+#: like a rehearsal's clone is a GUESS - a real working clone of a local, NAS
+#: or UNC repository named `.git` looks the same, unpushed commits
+#: and all - so deleting is offered on the person's own condition (#498,
+#: review round 1). Anything else is somebody's: move it, or put the project
+#: somewhere else.
+DISCARD_IT = "Move or delete it, then run this again."
+DISCARD_IT_IF_UNUSED = ("Move it, or delete it if nothing in it is yours, then "
+ "run this again.")
+KEEP_IT = "Move it, or choose another --into, then run this again."
+
+
+def origin_recorded_in(clone: str) -> str:
+ """The `origin` URL `/.git/config` records, or "" when it records
+ none this can read.
+
+ THE FILE, NEVER A DISCOVERY. `git -C ` would look for a
+ repository and, finding none in `` itself - an empty `.git`, a
+ broken one - would walk up and answer for whatever repository holds
+ ``, naming somebody else's `origin` as this directory's. `git
+ config --file` reads that one file, with no includes and no URL
+ rewriting. A `.git` FILE (a worktree, a submodule) keeps its config
+ elsewhere, so there is nothing here to read and the caller says less.
+ And no git runs INSIDE a directory this tool did not make: its config
+ can name a command (`core.fsmonitor`) that `git status` executes.
+
+ A value carrying a control character is not echoed: it would print a
+ line this tool did not write (`CONTROL_RE`).
+ """
+ config = os.path.join(clone, ".git", "config")
+ if not os.path.isfile(config):
+ return ""
+ proc = run(["git", "config", "--file", config, "--get",
+ "remote.origin.url"], capture=True)
+ url = (proc.stdout or "").strip() if proc.returncode == 0 else ""
+ return "" if CONTROL_RE.search(url) else url
+
+
+def is_on_this_disk(url: str) -> bool:
+ """An `origin` that is a path rather than a host: what `git clone` of a
+ local repository records (it makes the path absolute), which is what a
+ `--local-remote-dir` rehearsal's clone has."""
+ return url.startswith("file:") or os.path.isabs(url)
+
+
+def without_credentials(url: str) -> str:
+ """`url` with any `user:token@` after its scheme dropped: an `origin` is
+ echoed into a refusal an assistant may relay, and a token is not part of
+ what is there. Up to the LAST `@` before the path, because a password
+ holding a bare `@` would otherwise leave its tail on the screen."""
+ return re.sub(r"(?<=://)[^/]*@", "", url, count=1)
+
+
+def what_stands_at(clone: str, project: str) -> tuple:
+ """`(one sentence saying what is at clone, the way out)`; the sentence
+ is "" when this cannot tell.
+
+ A REHEARSAL'S CLONE IS THE CASE THIS EXISTS FOR (#498). README's
+ "Rehearse first" leaves `./` behind, cloned from the bare
+ `/.git` that `--local-remote-dir` made, and the real run
+ that follows it in the same folder lands on exactly that path. It LOOKS
+ like one when all three hold: an `origin` on this disk, named
+ `.git`, and a `project.yaml` at its root. That is said as a
+ look, never as a fact, and deleting it is offered only "if nothing in
+ it is yours": a working clone of a local or network repository meets
+ all three, and this tool cannot see its unpushed work without running
+ git inside it (`origin_recorded_in` says why it does not).
+
+ A SYMBOLIC LINK IS SAID TO BE ONE, first, and never offered for
+ deletion: `os.path.isdir` follows it, and `rm -r /` on the
+ advice "delete it" would act on its target.
+ """
+ try:
+ if os.path.islink(clone):
+ target = os.readlink(clone)
+ if not target or CONTROL_RE.search(target):
+ return "It is a symbolic link.", KEEP_IT
+ return "It is a symbolic link to %s." % target, KEEP_IT
+ if not os.path.isdir(clone):
+ return "", KEEP_IT
+ if not os.path.lexists(os.path.join(clone, ".git")):
+ if os.listdir(clone):
+ return "It is a directory that is not a git clone.", KEEP_IT
+ return "It is an empty directory.", DISCARD_IT
+ except OSError:
+ return "", KEEP_IT
+ origin = origin_recorded_in(clone)
+ if not origin:
+ return "It is a directory holding a `.git`.", KEEP_IT
+ shown = without_credentials(origin)
+ if (is_on_this_disk(origin)
+ and os.path.basename(origin.rstrip("/\\")) == project + ".git"
+ and os.path.isfile(os.path.join(clone, "project.yaml"))):
+ return ("It looks like the clone a rehearsal left behind: its `origin` "
+ "is %s, a repository on this disk, as `--local-remote-dir` "
+ "makes one." % shown), DISCARD_IT_IF_UNUSED
+ return "It is a clone of %s." % shown, KEEP_IT
+
+
+def refuse_a_taken_clone_path(clone: str, project: str) -> None:
+ """Refuse, HERE, a clone path that anything at all already stands at.
+
+ THE QUESTION IS ASKED WHERE THE ANSWER IS DECIDED (#498). `resolve_family`
+ has just fixed `/` (or `//`), and
+ nothing exists yet. Asked only in step (7), as it used to be, the same
+ question came after step (6) had created all three repositories and
+ pushed them: a person who rehearsed and then ran for real in the same
+ folder - README's own order - got three new repositories on GitHub, no
+ project, a hand clone that failed on the same directory, and a re-run
+ refused `scaffold-remote-exists`.
+
+ ANY ENTRY AT THAT NAME, `lexists`: a directory, a file, and a symbolic
+ link to nothing, which `exists` reads as absent and `git clone` still
+ refuses. An EMPTY directory is refused too, although `git clone` would
+ take one (#498's option 1, put to Brett Heap on 2026-10-13, who did not
+ object): the rule is "nothing there", said once, rather than one more
+ case to explain.
+
+ The refusal says nothing was created, names the path, and says what is
+ there where `what_stands_at` can tell. Step (7) keeps its own check, for
+ a path that appears while the run is going.
+ """
+ if not os.path.lexists(clone):
+ return
+ what, way_out = what_stands_at(clone, project)
+ die(" ".join(part for part in (
+ "%s already exists, and that is where this project would be cloned, "
+ "so the run stops here and nothing was created." % clone,
+ what, way_out) if part))
+
+
# ---------------------------------------------------------------------------
# 4. the names, before anything exists
# ---------------------------------------------------------------------------
@@ -1741,7 +1878,7 @@ def scaffold_args(opts: Options, org: str) -> list:
def plan_and_confirm(opts: Options, shape_root: Path, org: str,
- args: list) -> None:
+ args: list, clone: str) -> None:
say("")
say("(5) the plan")
planned = run([PYTHON, str(shape_root / SCAFFOLD), *args,
@@ -1750,6 +1887,18 @@ def plan_and_confirm(opts: Options, shape_root: Path, org: str,
raise Refusal("the plan could not be produced (see above).",
shell_status(planned.returncode))
+ # THE ONE THING THE RUN WRITES ON THIS DISK IS IN THE PLAN TOO (#498).
+ # The scaffold's plan names the three repositories; the clone path - set
+ # by `--into`, by the developer path's `..`, by a family folder - was
+ # said before the `yes` only by `--family`'s `lands at`, far above, so a
+ # person learned where the project went from `DONE`. Step (3) has
+ # already refused a path that is taken, so this line is where to look
+ # afterwards, not a warning; it is printed on every run, `--yes` and no
+ # terminal included, since the plan is what an assistant relays to the
+ # person deciding.
+ say("")
+ say("clone into %s" % clone)
+
if opts.assume_yes:
return
# THE QUESTION GOES TO THE PERSON'S TERMINAL, NEVER TO A PIPE (#492).
@@ -1863,12 +2012,20 @@ def clone_and_bootstrap(opts: Options, org: str, landing: Landing) -> dict:
# The three repositories now EXIST. Everything from here is recoverable by
# hand, so a failure names the command that finishes the job rather than
# leaving the person wondering what was created.
- if os.path.exists(clone):
- die("%s already exists, so there is nowhere to clone into. The three "
- "repositories WERE created. Finish by hand:\n"
+ #
+ # THE BACKSTOP (#498). `refuse_a_taken_clone_path` asked this in step (3),
+ # before anything existed, so a path taken HERE appeared while the run
+ # was going. `lexists` for the same reason it is there. The way out moves
+ # the thing aside FIRST and runs the clone from the clone's parent: as it
+ # used to read, the hand clone met the same directory and failed, and
+ # `cd ` from anywhere else entered whatever was there.
+ if os.path.lexists(clone):
+ die("%s already exists - it was not there when step (3) looked - so "
+ "there is nowhere to clone into. The three repositories WERE "
+ "created. Move it aside, then finish by hand in %s:\n"
" git clone --recurse-submodules %s\n"
" cd %s && %s %s"
- % (clone, urls["assembly"], opts.project, PYTHON_CMD,
+ % (clone, parent, urls["assembly"], opts.project, PYTHON_CMD,
BOOTSTRAP))
say("")
@@ -2049,9 +2206,11 @@ def _main(argv, invocation_dir: str) -> int:
org = resolve_org(opts, invocation_dir)
resolve_project_and_elector(opts, shape_root)
landing = resolve_family(opts, shape_root)
+ clone = landing.clone_path(opts.project)
+ refuse_a_taken_clone_path(clone, opts.project)
check_names(shape_root, opts.project, opts.family)
args = scaffold_args(opts, org)
- plan_and_confirm(opts, shape_root, org, args)
+ plan_and_confirm(opts, shape_root, org, args, clone)
scaffold(shape_root, args)
created = clone_and_bootstrap(opts, org, landing)
hand_over(opts.project, created["clone"], created["urls"], org, landing)
diff --git a/tests/conftest.py b/tests/conftest.py
index 82b96e45..f6672b8c 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -841,6 +841,105 @@ def assert_refuses_for_want_of_a_terminal(stderr: str) -> None:
f"the exit that keeps the human in charge comes first: {stderr!r}")
+# --- a clone path that is already taken (#498) -------------------------------
+#
+# ONE READING, TWO ENTRY POINTS. `tests/test_setup_sh.py` and
+# `tests/test_setup_project_py.py` each hand their own runner (`run_setup`,
+# `run_entry`) to the two helpers below, so the same runs are asserted once
+# whichever name a person typed. Every run here passes `--local-remote-dir`,
+# which is what those files' `test_no_test_here_depends_on_gh_being_
+# authenticated` looks for in each call it can see; it cannot see these, so
+# they keep it themselves.
+
+#: What can stand where the project would be cloned, as
+#: `assert_a_taken_clone_path_is_refused` makes each one.
+TAKEN_CLONE_PATHS = ("empty directory", "file", "directory")
+
+#: The refusal's ways out: deleting is offered outright only for what holds
+#: nothing of anybody's, and for what only LOOKS like a rehearsal's clone on
+#: the person's own condition (#498, review round 1).
+DISCARD_IT = "Move or delete it, then run this again."
+DISCARD_IT_IF_UNUSED = ("Move it, or delete it if nothing in it is yours, then "
+ "run this again.")
+KEEP_IT = "Move it, or choose another --into, then run this again."
+
+
+def refused_in_step_three(result, clone: str) -> str:
+ """`result` refused in step (3), before the naming check, the plan or
+ the scaffold, naming `clone` and saying nothing was created; returns the
+ refusal's text after that first sentence."""
+ head = (f"REFUSED: {clone} already exists, and that is where this project "
+ "would be cloned, so the run stops here and nothing was created.")
+ assert result.returncode == 2, result.stderr + result.stdout
+ assert head in result.stderr, result.stderr
+ assert "(3) project" in result.stdout, result.stdout
+ for later in ("(4) naming policy", "(5) the plan", "(6) scaffold"):
+ assert later not in result.stdout, f"the run reached {later}"
+ return result.stderr.split(head, 1)[1].strip()
+
+
+def assert_a_rehearsals_clone_stops_the_real_run(run, tmp_path: Path) -> None:
+ """README's own order, in one folder: rehearse with `--local-remote-dir
+ ./rehearsal`, then run for real (a second `--local-remote-dir` stands in
+ for GitHub). The rehearsal names its clone path in the plan and leaves
+ `./Sample` behind; the real run refuses in step (3), says that clone
+ LOOKS like the rehearsal's, offers deleting it only if nothing in it is
+ the person's, and creates nothing. On main it created all three
+ repositories and only then refused, in step (7)."""
+ here = tmp_path / "here"
+ clone = os.path.abspath(str(here / "Sample"))
+ rehearsal = run("--project", "Sample", "--yes", "--org", "demoorg",
+ "--local-remote-dir", str(here / "rehearsal"),
+ "--into", str(here))
+ assert rehearsal.returncode == 0, rehearsal.stderr + rehearsal.stdout
+ out = rehearsal.stdout
+ assert (out.index("--dry-run: nothing was created.")
+ < out.index(f"\nclone into {clone}\n")
+ < out.index("(6) scaffold")), out
+ real = run("--project", "Sample", "--yes", "--org", "demoorg",
+ "--local-remote-dir", str(here / "real"), "--into", str(here))
+ rest = refused_in_step_three(real, clone)
+ assert rest.startswith("It looks like the clone a rehearsal left behind: "
+ "its `origin` is "), rest
+ assert rest.endswith("a repository on this disk, as `--local-remote-dir` "
+ "makes one. " + DISCARD_IT_IF_UNUSED), rest
+ assert not (here / "real").exists(), "the real run created repositories"
+ assert (here / "Sample" / "project.yaml").is_file()
+
+
+def assert_a_taken_clone_path_is_refused(run, tmp_path: Path,
+ kind: str) -> None:
+ """Anything at `/` - one of `TAKEN_CLONE_PATHS` - is
+ refused in step (3) of a run told `--yes`, with what is there said where
+ the tool can tell, and left exactly as it was. An EMPTY directory too,
+ though `git clone` would take one: #498's option 1. On main each of
+ these created all three repositories first."""
+ work = tmp_path / "work"
+ taken = work / "Sample"
+ if kind == "file":
+ work.mkdir()
+ taken.write_bytes(b"not a project\n")
+ said, way_out = "", KEEP_IT
+ elif kind == "directory":
+ taken.mkdir(parents=True)
+ (taken / "notes.txt").write_bytes(b"somebody's\n")
+ said, way_out = "It is a directory that is not a git clone. ", KEEP_IT
+ else:
+ taken.mkdir(parents=True)
+ said, way_out = "It is an empty directory. ", DISCARD_IT
+ before = sorted(p.name for p in taken.iterdir()) if taken.is_dir() else (
+ taken.read_bytes())
+ result = run("--project", "Sample", "--yes", "--org", "demoorg",
+ "--local-remote-dir", str(tmp_path / "remotes"),
+ "--into", str(work))
+ rest = refused_in_step_three(result, os.path.abspath(str(taken)))
+ assert rest == said + way_out, rest
+ assert not (tmp_path / "remotes").exists()
+ after = sorted(p.name for p in taken.iterdir()) if taken.is_dir() else (
+ taken.read_bytes())
+ assert after == before, f"{taken} was changed by a run that refused"
+
+
#: THE PERSON'S OWN TERMINAL, IN A TEST (#492). The one-liner reads its yes
#: from the CONTROLLING terminal when its stdin is the pipe the script came
#: down, so a test that says it asks has to give the run one: a
diff --git a/tests/test_setup_project_py.py b/tests/test_setup_project_py.py
index 1784628e..95481f11 100644
--- a/tests/test_setup_project_py.py
+++ b/tests/test_setup_project_py.py
@@ -40,6 +40,11 @@
from conftest import PTY_SKIP, run_in_a_terminal
+from conftest import (DISCARD_IT, DISCARD_IT_IF_UNUSED, KEEP_IT,
+ TAKEN_CLONE_PATHS,
+ assert_a_rehearsals_clone_stops_the_real_run,
+ assert_a_taken_clone_path_is_refused)
+
SETUP_PROJECT = REPO / "setup-project.py"
SETUP_SH = REPO / "setup.sh"
DEGRADE_LINE = "authority is not wallet-carried in this org"
@@ -462,6 +467,19 @@ def test_passthrough_reaches_the_scaffold(tmp_path):
assert 'reference: "a-staged-fragment.md"' in manifest
+def test_a_rehearsals_clone_stops_the_real_run_before_anything(tmp_path):
+ """#498, on an interpreter alone: `conftest.assert_a_rehearsals_clone_
+ stops_the_real_run`."""
+ assert_a_rehearsals_clone_stops_the_real_run(run_entry, tmp_path)
+
+
+@pytest.mark.parametrize("kind", TAKEN_CLONE_PATHS)
+def test_a_taken_clone_path_is_refused_before_anything(tmp_path, kind):
+ """#498, on an interpreter alone: `conftest.assert_a_taken_clone_path_
+ is_refused`."""
+ assert_a_taken_clone_path_is_refused(run_entry, tmp_path, kind)
+
+
def test_anything_but_yes_refuses_and_creates_nothing(monkeypatch, tmp_path):
"""The one `yes` is EXACT: `no`, `y`, `Yes` and an empty line all refuse.
@@ -483,14 +501,15 @@ def isatty(self):
args=[], returncode=0, stdout="", stderr=""))
opts = module.parse_args(["--project", "Sample", "--org", "demoorg"],
str(tmp_path))
+ clone = str(tmp_path / "Sample")
for answer in ("no", "y", "Yes", ""):
monkeypatch.setattr(module, "ask", lambda prompt, a=answer: a)
with pytest.raises(module.Refusal) as refused:
- module.plan_and_confirm(opts, REPO, "demoorg", [])
+ module.plan_and_confirm(opts, REPO, "demoorg", [], clone)
assert refused.value.code == 1, answer
assert "not confirmed; nothing was created." in str(refused.value)
monkeypatch.setattr(module, "ask", lambda prompt: "yes")
- module.plan_and_confirm(opts, REPO, "demoorg", [])
+ module.plan_and_confirm(opts, REPO, "demoorg", [], clone)
def test_no_test_here_depends_on_gh_being_authenticated():
@@ -1657,10 +1676,229 @@ def test_a_rehearsal_that_stops_at_the_plan_makes_no_family_folder(tmp_path):
assert "no terminal to confirm on" in result.stderr
assert "lands at %s" % (work / FAMILY / "Sample") in result.stdout
assert "--dry-run: nothing was created." in result.stdout
+ # The plan an assistant relays names the clone path too, family level
+ # included (#498), though the confirm refuses.
+ assert "clone into %s" % (work / FAMILY / "Sample") in result.stdout
assert not work.exists(), f"{work} was created by a run that created nothing"
assert not (tmp_path / "remotes").exists()
+# --- what stands at a taken clone path (#498) --------------------------------
+#
+# The runs through both entry points are `conftest`'s, wrapped above. These
+# ask the pieces in-process: what the refusal can tell about a clone, a
+# symbolic link, and the step (7) backstop, which a run reaches only when the
+# path appears while it is going.
+
+#: An `origin` carrying a token, as an HTTPS clone can record one.
+WITH_A_TOKEN = "https://dana:ghp_notreal@github.com/Northwind/Sample.git"
+
+#: A password holding a bare `@`: a malformed URL, and still no part of it
+#: is echoed (#509's review, NIT-1).
+WITH_AN_AT = "https://dana:p@SECRET3@github.com/Northwind/Sample.git"
+
+REHEARSALS = ("It looks like the clone a rehearsal left behind: its `origin` "
+ "is %s, a repository on this disk, as `--local-remote-dir` "
+ "makes one.")
+
+HOLDS_A_GIT = "It is a directory holding a `.git`."
+
+
+def _a_clone(taken: Path, origin=None, shape_root: bool = False) -> None:
+ """`taken` as `git init` leaves it, with `origin` and a `project.yaml`
+ if asked; no commit, so no identity is needed."""
+ taken.mkdir(parents=True)
+ git("init", "-q", cwd=taken)
+ if origin is not None:
+ git("remote", "add", "origin", str(origin), cwd=taken)
+ if shape_root:
+ (taken / "project.yaml").write_bytes(b"schema_version: 1\n")
+
+
+def _an_origin_with_controls(taken: Path) -> None:
+ """`taken` with a `project.yaml` and an `origin` named `Sample.git` on
+ this disk whose value holds an ESC sequence and a newline before a
+ forged `REFUSED:` line. Appended to `.git/config` as BYTES, so no argv
+ and no code page carries it; git reads it back as written, which the
+ test asserts first, so the filter and not a parse failure is what keeps
+ it off the screen."""
+ _a_clone(taken, shape_root=True)
+ with open(taken / ".git" / "config", "ab") as config:
+ config.write(b'[remote "origin"]\n'
+ b'\turl = "/srv\x1b[2J\\nREFUSED: forged/Sample.git"\n')
+
+
+@pytest.mark.parametrize("case", [
+ "github", "a-password-with-an-at", "the-rehearsals",
+ "the-rehearsals-as-a-file-url", "a-file-url-with-credentials",
+ "a-mirror-under-another-name", "rehearsal-shaped-with-no-project-yaml",
+ "no-origin", "a-git-file", "an-empty-git-inside-another-repository",
+ "an-origin-with-control-characters"])
+def test_what_stands_at_a_taken_clone_path(tmp_path, case):
+ """`what_stands_at`: a sentence, and the way out it offers.
+
+ What LOOKS like a rehearsal's clone - an `origin` on this disk, named
+ `.git`, and a `project.yaml` - is said to look like one, and
+ deleting it is offered only if nothing in it is the person's (#509's
+ review, MUST-1). A mirror under another name and a clone with no
+ manifest are somebody's, and so is anything this cannot read. No
+ credential and no control character is echoed. And the `origin` is
+ read from the directory's OWN `.git/config`: an empty `.git` inside a
+ repository that has one must not be reported as a clone of THAT
+ repository, which is what `git -C` finds by walking up."""
+ module = entry_point_module()
+ taken = tmp_path / "here" / "Sample"
+ rehearsal = tmp_path / "here" / "rehearsal" / "Sample.git"
+ mirror = tmp_path / "mirrors" / "Mirror.git"
+ on_disk = "/" + rehearsal.as_posix().lstrip("/")
+ github = ("It is a clone of https://github.com/Northwind/Sample.git.",
+ KEEP_IT)
+ if case == "github":
+ _a_clone(taken, WITH_A_TOKEN, shape_root=True)
+ expected = github
+ elif case == "a-password-with-an-at":
+ _a_clone(taken, WITH_AN_AT, shape_root=True)
+ expected = github
+ elif case == "the-rehearsals":
+ _a_clone(taken, rehearsal, shape_root=True)
+ expected = (REHEARSALS % rehearsal, DISCARD_IT_IF_UNUSED)
+ elif case == "the-rehearsals-as-a-file-url":
+ _a_clone(taken, rehearsal.as_uri(), shape_root=True)
+ expected = (REHEARSALS % rehearsal.as_uri(), DISCARD_IT_IF_UNUSED)
+ elif case == "a-file-url-with-credentials":
+ _a_clone(taken, "file://dana:SECRET7@" + on_disk, shape_root=True)
+ expected = (REHEARSALS % ("file://" + on_disk), DISCARD_IT_IF_UNUSED)
+ elif case == "a-mirror-under-another-name":
+ _a_clone(taken, mirror, shape_root=True)
+ expected = ("It is a clone of %s." % mirror, KEEP_IT)
+ elif case == "rehearsal-shaped-with-no-project-yaml":
+ _a_clone(taken, rehearsal)
+ expected = ("It is a clone of %s." % rehearsal, KEEP_IT)
+ elif case == "no-origin":
+ _a_clone(taken)
+ expected = (HOLDS_A_GIT, KEEP_IT)
+ elif case == "a-git-file":
+ taken.mkdir(parents=True)
+ (taken / ".git").write_bytes(b"gitdir: ../elsewhere/.git\n")
+ expected = (HOLDS_A_GIT, KEEP_IT)
+ elif case == "an-empty-git-inside-another-repository":
+ _a_clone(taken.parent, "https://example.invalid/Outer.git")
+ (taken / ".git").mkdir(parents=True)
+ expected = (HOLDS_A_GIT, KEEP_IT)
+ else:
+ _an_origin_with_controls(taken)
+ read = git("config", "--file", str(taken / ".git" / "config"),
+ "--get", "remote.origin.url", cwd=tmp_path).stdout
+ assert "\x1b[2J" in read and "\nREFUSED: forged" in read, repr(read)
+ expected = (HOLDS_A_GIT, KEEP_IT)
+ said = module.what_stands_at(str(taken), "Sample")
+ assert said == expected
+ for leaked in ("ghp_notreal", "SECRET3", "SECRET7", "\x1b", "forged"):
+ assert leaked not in said[0], said
+
+
+def test_a_working_clone_of_a_local_repository_is_not_called_a_rehearsals(
+ tmp_path):
+ """#509's review, MUST-1: a clone of a local bare repository setup never
+ made, holding a commit nobody has pushed, meets every sign a rehearsal's
+ clone does - an `origin` on this disk named `Sample.git`, and a
+ `project.yaml`. It may be somebody's work, so the refusal says only that
+ it LOOKS like a rehearsal's clone, and offers deleting it only if
+ nothing in it is theirs: never "It is the clone a rehearsal left
+ behind", and never the bare "Move or delete it"."""
+ module = entry_point_module()
+ bare = tmp_path / "srv" / "Sample.git"
+ git("init", "-q", "--bare", str(bare), cwd=tmp_path)
+ work = tmp_path / "here" / "Sample"
+ work.parent.mkdir()
+ git("clone", "-q", str(bare), str(work), cwd=tmp_path)
+ (work / "project.yaml").write_bytes(b"schema_version: 1\n")
+ git("add", "project.yaml", cwd=work)
+ git("commit", "-q", "-m", "real work, not pushed", cwd=work)
+ with pytest.raises(module.Refusal) as refused:
+ module.refuse_a_taken_clone_path(str(work), "Sample")
+ said = str(refused.value)
+ assert ("It looks like the clone a rehearsal left behind: its `origin` "
+ "is ") in said, said
+ assert said.endswith("makes one. " + DISCARD_IT_IF_UNUSED), said
+ assert "It is the clone a rehearsal left behind" not in said, said
+ assert DISCARD_IT not in said, said
+
+
+@pytest.mark.parametrize("target", ["nowhere", "an-empty-directory"])
+def test_a_symbolic_link_takes_the_clone_path_and_is_said_to_be_one(tmp_path,
+ target):
+ """`os.path.exists` reads a link to nothing as absent, and `git clone`
+ still refuses it (`could not create work tree dir`), so step (3) asks
+ `lexists`. A link is said to be a link, and is never offered for
+ deletion: `os.path.isdir` follows one to an empty directory, and `rm -r
+ Sample/` on "delete it" would act on the target (#509's review, NIT-2).
+ A free path is the control: it is not refused."""
+ module = entry_point_module()
+ aim = tmp_path / target
+ if target != "nowhere":
+ aim.mkdir()
+ link = tmp_path / "Sample"
+ try:
+ os.symlink(aim, link, target_is_directory=True)
+ except (NotImplementedError, OSError) as exc:
+ pytest.skip(f"this platform cannot create a symlink here: {exc}")
+ assert module.refuse_a_taken_clone_path(str(tmp_path / "Free"),
+ "Free") is None
+ with pytest.raises(module.Refusal) as refused:
+ module.refuse_a_taken_clone_path(str(link), "Sample")
+ assert refused.value.code == 2
+ said = str(refused.value)
+ assert said.startswith(
+ "%s already exists, and that is where this project would be cloned, "
+ "so the run stops here and nothing was created. It is a symbolic "
+ "link to " % link), said
+ assert said.endswith("%s. %s" % (target, KEEP_IT)), said
+
+
+@pytest.mark.parametrize("taken", ["a-directory", "a-link-to-nothing"])
+def test_a_path_taken_after_step_three_is_the_backstops(monkeypatch, tmp_path,
+ taken):
+ """Step (7) still asks, for a path that appeared while the run was going,
+ after all three repositories exist, and asks `lexists` as step (3) does:
+ a link to nothing is a path `git clone` refuses (#509's review,
+ SHOULD-2). Its way out moves the thing aside and clones from the clone's
+ PARENT: as it used to read, the printed `git clone` met the same
+ directory, and `cd ` from anywhere else entered whatever stood
+ there. Nothing is cloned."""
+ module = entry_point_module()
+ runs = _Runs()
+ monkeypatch.setattr(module, "run", runs)
+ opts = module.parse_args(["--project", "Sample", "--org", "demoorg",
+ "--local-remote-dir", str(tmp_path / "remotes"),
+ "--into", str(tmp_path / "work")],
+ str(tmp_path))
+ parent = Path(opts.into)
+ parent.mkdir(parents=True)
+ if taken == "a-directory":
+ (parent / "Sample").mkdir()
+ else:
+ try:
+ os.symlink(tmp_path / "nowhere", parent / "Sample",
+ target_is_directory=True)
+ except (NotImplementedError, OSError) as exc:
+ pytest.skip(f"this platform cannot create a symlink here: {exc}")
+ with pytest.raises(module.Refusal) as refused:
+ module.clone_and_bootstrap(opts, "demoorg",
+ module.Landing(parent, "", None))
+ assert refused.value.code == 2
+ assert str(refused.value) == (
+ "%s already exists - it was not there when step (3) looked - so "
+ "there is nowhere to clone into. The three repositories WERE "
+ "created. Move it aside, then finish by hand in %s:\n"
+ " git clone --recurse-submodules %s\n"
+ " cd Sample && %s scripts/bootstrap.py"
+ % (parent / "Sample", parent,
+ os.path.join(opts.local_remote_dir, "Sample.git"),
+ module.PYTHON_CMD))
+ assert runs.calls == [], f"something was run: {runs.calls}"
+
+
# --- setup.sh is a shim, not a second flag list -----------------------------
#: The only flags `setup.sh` may still NAME, and why each of them is about the
@@ -1867,7 +2105,8 @@ def test_a_controlling_terminal_that_is_a_file_never_answers(monkeypatch,
opts = module.parse_args(["--project", "Sample", "--org", "demoorg"],
str(tmp_path))
with pytest.raises(module.Refusal) as refused:
- module.plan_and_confirm(opts, REPO, "demoorg", [])
+ module.plan_and_confirm(opts, REPO, "demoorg", [],
+ str(tmp_path / "Sample"))
assert refused.value.code == 2
assert_refuses_for_want_of_a_terminal(str(refused.value))
diff --git a/tests/test_setup_sh.py b/tests/test_setup_sh.py
index 8e3227ab..9e2c300f 100644
--- a/tests/test_setup_sh.py
+++ b/tests/test_setup_sh.py
@@ -28,6 +28,10 @@
from conftest import PTY_SKIP, run_in_a_terminal
+from conftest import (TAKEN_CLONE_PATHS,
+ assert_a_rehearsals_clone_stops_the_real_run,
+ assert_a_taken_clone_path_is_refused)
+
SETUP = REPO / "setup.sh"
DEGRADE_LINE = "authority is not wallet-carried in this org"
@@ -393,6 +397,19 @@ def test_passthrough_reaches_the_scaffold(tmp_path):
assert 'reference: "a-staged-fragment.md"' in manifest
+def test_a_rehearsals_clone_stops_the_real_run_before_anything(tmp_path):
+ """#498, through the shim: `conftest.assert_a_rehearsals_clone_stops_
+ the_real_run`."""
+ assert_a_rehearsals_clone_stops_the_real_run(run_setup, tmp_path)
+
+
+@pytest.mark.parametrize("kind", TAKEN_CLONE_PATHS)
+def test_a_taken_clone_path_is_refused_before_anything(tmp_path, kind):
+ """#498, through the shim: `conftest.assert_a_taken_clone_path_is_
+ refused`."""
+ assert_a_taken_clone_path_is_refused(run_setup, tmp_path, kind)
+
+
def test_no_test_here_depends_on_gh_being_authenticated():
"""Every invocation either stops before the preflight or runs offline.