diff --git a/docs/contracts.md b/docs/contracts.md index be4ebdf..eb9b85f 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -708,7 +708,7 @@ Pinned by: tests/test_sandbox.py::test_the_no_standard_scenario_quotes_works_no_ ## The profile -Version: 16 +Version: 17 Fields: each key is, first, the repository's standard's, read in the working directory's checkout when it is that repository, the owner being the argument's, or the `origin` remote's, never the @@ -742,7 +742,12 @@ directory's checkout, when it is the repository: the fetched standard its pointe standard`, and any other exit 1 is a refusal: a working directory that is not the repository's checkout, a pointer that cannot be read, a standard not read, and anything `profile.py ` refuses but what is detected, such as a standard that does not cover the repository or that the -schema refuses (version 15). +schema refuses (version 15). In every mode, `--defaults` included, a working directory that is +the plugin's top level, `--root`, or inside it exits 1 before anything is read, with `profile.py: +the working directory is inside the plugin (), not a repository being worked on`, `` +the plugin's resolved top level, since `--defaults` too reads the working directory's standard; +a working directory whose git checkout is the plugin's top level or inside it, such as a +worktree, is read as that repository instead (version 17). Sections a standard's project settings and the default profile set, and a standard's `profiles/.yaml` does not: `issue_tracker` (`kind`, `jira` or `github`; `location`, optional, `owner/repo`, the repository whose GitHub issues the project's are, each repository's diff --git a/scripts/profile.py b/scripts/profile.py index eb9ff6b..23d7769 100755 --- a/scripts/profile.py +++ b/scripts/profile.py @@ -33,7 +33,10 @@ its message ending "the repository has no standard"; any other exit 1 is a refusal: a working directory that is not the repository's checkout, a pointer that cannot be read, a standard not read, and anything the repository's profile refuses but what is detected, such as a standard that -does not cover it or that the schema refuses. +does not cover it or that the schema refuses. In every mode, `--defaults` included, a working +directory inside the top level of the plugin this script belongs to, `--root`, exits 1 before +anything is read, unless the working directory's git checkout is that top level or inside it, +such as a worktree, which is then read as the repository. Every one of these files takes the same schema. Any key, or any whole section, may read `not yet recorded`, which is printed as it stands: a value nobody has looked up is never guessed. The default profile and a @@ -815,6 +818,20 @@ def answer(args: argparse.Namespace, root: Path, found, mine) -> tuple[int, str, return 0, render(v) + "\n", "" +def in_plugin(root: Path) -> bool: + """Whether the working directory is `root` or inside it, compared by file identity so a path's + letter case does not matter, and the working directory's git checkout is not `root` or inside + it: a plugin checkout, or a worktree under it, worked on as a repository is read like any other.""" + cwd = Path.cwd().resolve() + if not any(d.samefile(root) for d in (cwd, *cwd.parents)): + return False + top = git("rev-parse", "--show-toplevel") + if not top: + return True + top_path = Path(top).resolve() + return not any(d.samefile(root) for d in (top_path, *top_path.parents)) + + def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("repo", nargs="?", help="owner/repo, as `git remote get-url origin` names it, without `.git`") @@ -824,17 +841,25 @@ def main(argv: list[str] | None = None) -> int: p.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1], help="the plugin's top level") args = p.parse_args(argv) args.root = args.root.resolve() + if args.standard and (args.defaults or args.field): + p.error("--standard takes no --defaults or --field") + if not args.standard and bool(args.repo) == args.defaults: + p.error("give a repository or --defaults, one of them") + try: + inside = in_plugin(args.root) + except (ProfileError, OSError) as e: + print(f"profile.py: {e}", file=sys.stderr) + return 1 + if inside: + print(f"profile.py: the working directory is inside the plugin ({args.root}), not a repository being worked on", file=sys.stderr) + return 1 if args.standard: - if args.defaults or args.field: - p.error("--standard takes no --defaults or --field") try: print(standard_directory(args.repo, args.root)) except (ProfileError, OSError) as e: print(f"profile.py: {e}", file=sys.stderr) return 1 return 0 - if bool(args.repo) == args.defaults: - p.error("give a repository or --defaults, one of them") try: run_owners = owners(args.repo) except (ProfileError, OSError) as e: diff --git a/tests/test_profile.py b/tests/test_profile.py index 38ca6a8..5e9e2f7 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -23,9 +23,15 @@ def run(*args: str) -> tuple[int, str, str]: + """profile.main run from a temporary directory, which is no checkout.""" out, err = io.StringIO(), io.StringIO() - with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): - rc = profile.main(list(args)) + here = os.getcwd() + with tempfile.TemporaryDirectory() as tmp, contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + os.chdir(tmp) + try: + rc = profile.main(list(args)) + finally: + os.chdir(here) return rc, out.getvalue(), err.getvalue() @@ -162,6 +168,40 @@ def test_an_unknown_field_exits_1(self): rc, _, err = run("someone/x", "--field", "ticket.host") self.assertEqual((rc, err), (1, "profile.py: no field 'ticket.host'\n")) + def test_a_working_directory_inside_the_plugin_is_refused(self): + with tempfile.TemporaryDirectory() as tmp: + plugin = Path(tmp).resolve() / "plugin" + for d in ("scripts", "standards"): + shutil.copytree(ROOT / d, plugin / d) + script = str(plugin / "scripts/profile.py") + message = f"profile.py: the working directory is inside the plugin ({plugin}), not a repository being worked on\n" + for cwd, args in ((plugin, ["someone/x"]), (plugin / "scripts", ["--standard"]), (plugin, ["--defaults", "--field", "issue_tracker.kind"])): + out = subprocess.run([sys.executable, script, *args], cwd=cwd, capture_output=True, text=True) + self.assertEqual((out.returncode, out.stdout, out.stderr), (1, "", message), (cwd, args)) + subprocess.run(["git", "init", "-q", tmp], check=True) + for cwd in (plugin, plugin / "scripts"): + out = subprocess.run([sys.executable, script, "--defaults", "--field", "issue_tracker.kind"], cwd=cwd, capture_output=True, text=True) + self.assertEqual((out.returncode, out.stdout, out.stderr), (1, "", message), cwd) + + def test_a_plugin_checkout_worked_on_as_a_repository_is_read(self): + with tempfile.TemporaryDirectory() as tmp: + plugin = Path(tmp) / "plugin" + for d in ("scripts", "standards"): + shutil.copytree(ROOT / d, plugin / d) + shutil.copy(ROOT / "sandbox/tracker-github/fixture/standard-profile.yaml", plugin / "standards/profile.yaml") + for cmd in (["init", "-q"], ["remote", "add", "origin", "git@github.com:someone/plugin.git"], ["add", "-A"], + ["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "x"]): + subprocess.run(["git", "-C", str(plugin), *cmd], check=True) + worktree = plugin / ".claude/worktrees/w" + subprocess.run(["git", "-C", str(plugin), "worktree", "add", "-q", str(worktree)], check=True) + script = str(plugin / "scripts/profile.py") + for cwd in (plugin, worktree): + out = subprocess.run([sys.executable, script, "--defaults", "--field", "issue_tracker.kind"], cwd=cwd, capture_output=True, text=True) + self.assertEqual((out.returncode, out.stdout, out.stderr), (0, "github\n", ""), cwd) + out = subprocess.run([sys.executable, script, "someone/plugin"], cwd=cwd, capture_output=True, text=True) + self.assertEqual((out.returncode, out.stderr), (0, ""), cwd) + self.assertIn("standards/profile.yaml", out.stdout.splitlines()[0], cwd) + class Defaults(unittest.TestCase): def test_the_defaults_set_every_key_and_fill_what_a_standard_leaves_out(self):