Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions docs/contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <repo>`
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 (<root>), not a repository being worked on`, `<root>`
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/<repo>.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
Expand Down
35 changes: 30 additions & 5 deletions scripts/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`")
Expand All @@ -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:
Expand Down
44 changes: 42 additions & 2 deletions tests/test_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()


Expand Down Expand Up @@ -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):
Expand Down
Loading