diff --git a/README.md b/README.md index 85ea2e5..2afe16d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ Set a minimum release age on local package managers so installs ignore versions uvx gestate # interactive uvx gestate set 3 # 3-day minimum, installed tools only uvx gestate set 3 --all # also pre-configure file-based tools (bun, deno, uv) +uvx gestate set 3 --local # write per-project config files in the current repo uvx gestate revert # remove gestate's settings +uvx gestate revert --local # remove the per-project gate from this repo uvx gestate explain bun # show how one tool's setting is stored ``` @@ -34,6 +36,21 @@ Scope: - default — only configure installed tools - `--all` — also pre-write config files for `bun`, `deno`, `uv` even if they aren't installed yet +## Project-local policy (`--local`) + +`gestate set --local` writes each tool's **native, committed-to-the-repo** config so the gate travels with the project and applies to anyone who clones it — even collaborators who never ran gestate. It only configures tools the repo gives evidence of (a lockfile, manifest, or existing tool config in the current directory), so it won't litter a JS repo with `uv.toml` or vice versa. + +| Tool | Project file (cwd) | Key (unit) | +|---|---|---| +| npm | `.npmrc` | `min-release-age` (days) | +| pnpm | `pnpm-workspace.yaml` | `minimumReleaseAge` (minutes) | +| yarn | `.yarnrc.yml` | `npmMinimalAgeGate` (minutes) | +| bun | `bunfig.toml` | `[install] minimumReleaseAge` (seconds) | +| deno | `deno.json` | `minimumDependencyAge` (`PD`) | +| uv | `uv.toml` | `exclude-newer` (`"N days"`) | + +`pip` has no per-project config (and no dependency-age gate), so it's skipped. If a `deno.json` has comments (JSONC), gestate won't rewrite it — it prints the line to add yourself. `revert --local` removes the key and deletes any file it leaves empty. + ## Revert `uvx gestate revert` removes everything gestate set: diff --git a/pyproject.toml b/pyproject.toml index bf6d8e3..a0bc4cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,9 @@ dependencies = [ "tomli-w>=1.0", ] +[project.urls] +Repository = "https://github.com/lincolnloop/gestate" + [project.scripts] gestate = "gestate.cli:main" diff --git a/src/gestate/cli.py b/src/gestate/cli.py index 677f398..e77237c 100644 --- a/src/gestate/cli.py +++ b/src/gestate/cli.py @@ -4,6 +4,7 @@ import shutil import subprocess import sys +from pathlib import Path from rich.console import Console from rich.panel import Panel @@ -27,6 +28,13 @@ uv_config_path, yarn_major, ) +from .local import ( + PROJECT_FILES, + detect_project_tools, + read_project_config, + remove_project_config, + write_project_config, +) from .status import ( bun_current, deno_current_alias, @@ -47,6 +55,8 @@ ) TOOL_ORDER = ["npm", "pnpm", "yarn", "bun", "deno", "pip", "uv"] +# pip has no per-project config (and no dependency-age gate), so it can't go local +LOCAL_TOOL_ORDER = [t for t in TOOL_ORDER if t != "pip"] FILE_BASED = {"bun", "deno", "uv"} DENO_OUR_PREFIX = "alias deno='command deno --minimum-dependency-age=" @@ -185,6 +195,33 @@ def _print_current( console.print(table) +def _print_local_status(base: Path) -> dict[str, str]: + """Show project-local gates set in *base*. Returns {tool: value} for those set.""" + found = { + tool: val + for tool in LOCAL_TOOL_ORDER + if (val := read_project_config(tool, base)) is not None + } + if not found: + return found + console.print() + if _plain(): + print(f"Project settings ({base}):") + for tool, val in found.items(): + print(f" {tool:<6} {PROJECT_FILES[tool]} → {val}") + return found + table = Table( + title=f"Project settings ([dim]{base}[/])", header_style="bold cyan" + ) + table.add_column("Tool", style="cyan") + table.add_column("File") + table.add_column("Value") + for tool, val in found.items(): + table.add_row(tool, PROJECT_FILES[tool], val) + console.print(table) + return found + + def build_rows( tools: dict[str, bool], currents: dict[str, str | None], @@ -594,6 +631,72 @@ def _uv() -> None: return 0 +def apply_local(days: int, base: Path) -> list[tuple[str, str, str]]: + """Write project config for every detected tool. Returns (tool, file, result).""" + detected = detect_project_tools(base) + results = [] + for tool in LOCAL_TOOL_ORDER: + if tool in detected: + result = write_project_config(tool, base, days) + results.append((tool, PROJECT_FILES[tool], result)) + return results + + +def revert_local(base: Path) -> list[tuple[str, str, str]]: + """Remove project gate from every detected tool. Returns (tool, file, result).""" + detected = detect_project_tools(base) + results = [] + for tool in LOCAL_TOOL_ORDER: + if tool in detected: + result = remove_project_config(tool, base) + results.append((tool, PROJECT_FILES[tool], result)) + return results + + +def _do_set_local(days: int) -> int: + """Write each detected tool's native project config in cwd (non-interactive).""" + base = Path.cwd() + results = apply_local(days, base) + if not results: + console.print( + "[yellow]No package manager detected in this directory.[/] " + "Looked for lockfiles / manifests (package.json, pyproject.toml, deno.json, …)." + ) + return 0 + console.print(f"Project policy ([bold]{days}-day[/] minimum release age):") + failed = False + for tool, fname, result in results: + if result == "manual": + failed = True + console.print( + f" [yellow]{tool}[/] {fname} — has comments; add " + f'[bold]"minimumDependencyAge": "P{days}D"[/] yourself' + ) + elif result == "unchanged": + console.print(f" [dim]{tool} {fname} — already set[/]") + else: + verb = "created" if result == "created" else "updated" + console.print(f" [green]{tool}[/] {fname} {verb}") + console.print( + "[dim italic]Committed project files — tools honor these natively, " + "even for collaborators who never ran gestate.[/]" + ) + return 1 if failed else 0 + + +def _do_revert_local() -> int: + """Remove gestate's project gate from each detected tool's config in cwd.""" + base = Path.cwd() + results = revert_local(base) + removed = [(t, f) for t, f, r in results if r == "removed"] + if not removed: + console.print("[yellow]No project age gate found in this directory.[/]") + return 0 + for tool, fname in removed: + console.print(f" [green]{tool}[/] {fname} — gate removed") + return 0 + + def _do_explain(tool: str) -> int: meta = TOOL_META[tool] current = _read_current(tool) @@ -637,8 +740,19 @@ def _main() -> int: action="store_true", help="Also pre-configure file-based tools (bun, deno, uv) when their binary is missing.", ) + set_p.add_argument( + "--local", + action="store_true", + help="Write per-project config files (in the current directory) that the " + "detected tools honor natively, instead of configuring tools globally.", + ) - sub.add_parser("revert", help="Remove gestate's settings (non-interactive).") + revert_p = sub.add_parser("revert", help="Remove gestate's settings (non-interactive).") + revert_p.add_argument( + "--local", + action="store_true", + help="Remove the per-project age gate from config files in the current directory.", + ) explain_p = sub.add_parser( "explain", @@ -667,6 +781,11 @@ def _main() -> int: if args.days < 1: console.print("[red]days must be ≥ 1[/]") return 1 + if args.local: + if args.all: + console.print("[red]--all has no effect with --local.[/]") + return 2 + return _do_set_local(args.days) scope = "all" if args.all else "installed" return _do_set( tools, @@ -678,11 +797,15 @@ def _main() -> int: ) if args.command == "revert": + if args.local: + return _do_revert_local() return _do_revert(tools, yarn_ok, currents, confirm=False) _print_current(tools, currents, yarn_supported=yarn_ok) - if all(currents[t] is None for t in TOOL_ORDER): + local = _print_local_status(Path.cwd()) + + if all(currents[t] is None for t in TOOL_ORDER) and not local: console.print() console.print( Panel(WHY_INTRO, title="Why minimum release age?", border_style="cyan") diff --git a/src/gestate/local.py b/src/gestate/local.py new file mode 100644 index 0000000..d8e763e --- /dev/null +++ b/src/gestate/local.py @@ -0,0 +1,322 @@ +"""Project-local config: write each tool's native per-repo age gate. + +Unlike the global path (which configures a user's whole machine), these helpers +write committed-to-the-repo files that the tools honor natively — so the policy +travels with the project and applies even to collaborators who never ran gestate. + +All functions are pure over a *base* directory (the project root). pip is absent +on purpose: it has no per-project config and no dependency-age gate. +""" + +import json +import re +from pathlib import Path + +from .config import _load, set_bunfig_min_age, unset_bunfig_min_age + +# tool -> the file (relative to the project root) the tool reads +PROJECT_FILES = { + "npm": ".npmrc", + "pnpm": "pnpm-workspace.yaml", + "yarn": ".yarnrc.yml", + "bun": "bunfig.toml", + "uv": "uv.toml", + "deno": "deno.json", +} + +# how each tool names the gate key +_NPM_KEY = "min-release-age" +_PNPM_KEY = "minimumReleaseAge" +_YARN_KEY = "npmMinimalAgeGate" +_DENO_KEY = "minimumDependencyAge" +_UV_KEY = "exclude-newer" + +WriteResult = str # "created" | "updated" | "unchanged" | "manual" | "absent" | "removed" + + +def detect_project_tools(base: Path | None = None) -> set[str]: + """Return the tools a project gives evidence of (lockfile / manifest / config).""" + base = Path(base or Path.cwd()) + + def has(*names: str) -> bool: + return any((base / name).exists() for name in names) + + tools: set[str] = set() + + node_lock = { + "npm": has("package-lock.json"), + "pnpm": has("pnpm-lock.yaml", "pnpm-workspace.yaml"), + "yarn": has("yarn.lock"), + "bun": has("bun.lock", "bun.lockb"), + } + tools.update(tool for tool, present in node_lock.items() if present) + + # an existing tool config file always counts as that tool being in use + if has(".npmrc"): + tools.add("npm") + if has(".yarnrc.yml"): + tools.add("yarn") + if has("bunfig.toml"): + tools.add("bun") + + # bare package.json with no node lockfile -> assume npm (the default) + if has("package.json") and not any(node_lock.values()): + tools.add("npm") + + if has("uv.lock", "pyproject.toml", "uv.toml"): + tools.add("uv") + if has("deno.json", "deno.jsonc", "deno.lock"): + tools.add("deno") + + return tools + + +# --- INI (.npmrc) ----------------------------------------------------------- + + +def _ini_line(key: str) -> re.Pattern[str]: + return re.compile(rf"^\s*{re.escape(key)}\s*=", re.IGNORECASE) + + +def _ini_set(path: Path, key: str, value: str) -> WriteResult: + line = f"{key}={value}" + if not path.exists(): + path.write_text(line + "\n") + return "created" + lines = path.read_text().splitlines() + matcher = _ini_line(key) + for i, existing in enumerate(lines): + if matcher.match(existing): + if existing.strip() == line: + return "unchanged" + lines[i] = line + path.write_text("\n".join(lines) + "\n") + return "updated" + lines.append(line) + path.write_text("\n".join(lines) + "\n") + return "updated" + + +def _ini_get(path: Path, key: str) -> str | None: + if not path.exists(): + return None + matcher = _ini_line(key) + for existing in path.read_text().splitlines(): + if matcher.match(existing): + return existing.split("=", 1)[1].strip() + return None + + +def _ini_unset(path: Path, key: str) -> WriteResult: + if not path.exists(): + return "absent" + matcher = _ini_line(key) + lines = path.read_text().splitlines() + kept = [line for line in lines if not matcher.match(line)] + if len(kept) == len(lines): + return "absent" + if any(line.strip() for line in kept): + path.write_text("\n".join(kept) + "\n") + else: + path.unlink() + return "removed" + + +# --- YAML (pnpm-workspace.yaml, .yarnrc.yml) -------------------------------- +# Single top-level scalar key only; line-based to preserve comments/layout. + + +def _yaml_line(key: str) -> re.Pattern[str]: + return re.compile(rf"^{re.escape(key)}\s*:", re.IGNORECASE) + + +def _yaml_set(path: Path, key: str, value: str) -> WriteResult: + line = f"{key}: {value}" + if not path.exists(): + path.write_text(line + "\n") + return "created" + lines = path.read_text().splitlines() + matcher = _yaml_line(key) + for i, existing in enumerate(lines): + if matcher.match(existing): + if existing.strip() == line: + return "unchanged" + lines[i] = line + path.write_text("\n".join(lines) + "\n") + return "updated" + lines.append(line) + path.write_text("\n".join(lines) + "\n") + return "updated" + + +def _yaml_get(path: Path, key: str) -> str | None: + if not path.exists(): + return None + matcher = _yaml_line(key) + for existing in path.read_text().splitlines(): + if matcher.match(existing): + return existing.split(":", 1)[1].strip() + return None + + +def _yaml_unset(path: Path, key: str) -> WriteResult: + if not path.exists(): + return "absent" + matcher = _yaml_line(key) + lines = path.read_text().splitlines() + kept = [line for line in lines if not matcher.match(line)] + if len(kept) == len(lines): + return "absent" + if any(line.strip() for line in kept): + path.write_text("\n".join(kept) + "\n") + else: + path.unlink() + return "removed" + + +# --- JSON (deno.json) ------------------------------------------------------- + + +def _json_load_or_none(path: Path) -> dict | None: + """Parse JSON; {} if absent, None if not plain JSON (e.g. JSONC comments).""" + if not path.exists(): + return {} + try: + return json.loads(path.read_text()) + except (ValueError, OSError): + return None + + +def _json_set(path: Path, key: str, value: str) -> WriteResult: + existed = path.exists() + data = _json_load_or_none(path) + if data is None: + return "manual" + if data.get(key) == value: + return "unchanged" + data[key] = value + path.write_text(json.dumps(data, indent=2) + "\n") + return "updated" if existed else "created" + + +def _json_get(path: Path, key: str) -> str | None: + data = _json_load_or_none(path) + if not data: + return None + val = data.get(key) + return None if val is None else str(val) + + +def _json_unset(path: Path, key: str) -> WriteResult: + data = _json_load_or_none(path) + if data is None: + return "manual" + if key not in data: + return "absent" + del data[key] + if data: + path.write_text(json.dumps(data, indent=2) + "\n") + else: + path.unlink() + return "removed" + + +# --- TOML (uv.toml) --------------------------------------------------------- + + +def _uv_set(path: Path, value: str) -> WriteResult: + import tomli_w + + existed = path.exists() + data = _load(path) + if data.get(_UV_KEY) == value: + return "unchanged" + data[_UV_KEY] = value + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("wb") as f: + tomli_w.dump(data, f) + return "updated" if existed else "created" + + +def _uv_get(path: Path) -> str | None: + data = _load(path) + val = data.get(_UV_KEY) + return None if val is None else str(val) + + +def _uv_unset(path: Path) -> WriteResult: + import tomli_w + + if not path.exists(): + return "absent" + data = _load(path) + if _UV_KEY not in data: + return "absent" + del data[_UV_KEY] + if data: + with path.open("wb") as f: + tomli_w.dump(data, f) + else: + path.unlink() + return "removed" + + +# --- public dispatch -------------------------------------------------------- + + +def write_project_config(tool: str, base: Path, days: int) -> WriteResult: + """Write *tool*'s native project config under *base*. Returns what happened.""" + path = Path(base) / PROJECT_FILES[tool] + if tool == "npm": + return _ini_set(path, _NPM_KEY, str(days)) + if tool == "pnpm": + return _yaml_set(path, _PNPM_KEY, str(days * 1440)) + if tool == "yarn": + return _yaml_set(path, _YARN_KEY, str(days * 1440)) + if tool == "bun": + existed = path.exists() + changed = set_bunfig_min_age(path, days * 86400) + return ("updated" if existed else "created") if changed else "unchanged" + if tool == "uv": + return _uv_set(path, f"{days} days") + if tool == "deno": + return _json_set(path, _DENO_KEY, f"P{days}D") + raise ValueError(f"no project config for {tool!r}") + + +def read_project_config(tool: str, base: Path) -> str | None: + """Return *tool*'s stored project value under *base*, or None.""" + path = Path(base) / PROJECT_FILES[tool] + if tool == "npm": + return _ini_get(path, _NPM_KEY) + if tool == "pnpm": + return _yaml_get(path, _PNPM_KEY) + if tool == "yarn": + return _yaml_get(path, _YARN_KEY) + if tool == "bun": + data = _load(path) + val = data.get("install", {}).get("minimumReleaseAge") + return None if val is None else str(val) + if tool == "uv": + return _uv_get(path) + if tool == "deno": + return _json_get(path, _DENO_KEY) + raise ValueError(f"no project config for {tool!r}") + + +def remove_project_config(tool: str, base: Path) -> WriteResult: + """Remove *tool*'s project gate under *base*. Returns what happened.""" + path = Path(base) / PROJECT_FILES[tool] + if tool == "npm": + return _ini_unset(path, _NPM_KEY) + if tool == "pnpm": + return _yaml_unset(path, _PNPM_KEY) + if tool == "yarn": + return _yaml_unset(path, _YARN_KEY) + if tool == "bun": + return "removed" if unset_bunfig_min_age(path) else "absent" + if tool == "uv": + return _uv_unset(path) + if tool == "deno": + return _json_unset(path, _DENO_KEY) + raise ValueError(f"no project config for {tool!r}") diff --git a/tests/test_cli.py b/tests/test_cli.py index 2b3d53e..ba36122 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,14 @@ -from gestate.cli import TOOL_ORDER, build_revert_rows, build_rows, targets_for +from pathlib import Path + +from gestate.cli import ( + LOCAL_TOOL_ORDER, + TOOL_ORDER, + apply_local, + build_revert_rows, + build_rows, + revert_local, + targets_for, +) def test_targets_for_7_days() -> None: @@ -159,3 +169,50 @@ def test_revert_skips_foreign_deno_alias() -> None: deno_row = next(r for r in rows if r[0] == "deno") assert deno_row[3] is False assert "foreign" in deno_row[2] + + +def test_local_tool_order_excludes_pip() -> None: + assert "pip" not in LOCAL_TOOL_ORDER + assert set(LOCAL_TOOL_ORDER) == set(TOOL_ORDER) - {"pip"} + + +class TestApplyLocal: + def test_configures_only_detected_tools(self, tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text("") + results = apply_local(7, tmp_path) + assert {tool for tool, _, _ in results} == {"uv"} + assert (tmp_path / "uv.toml").exists() + + def test_empty_repo_configures_nothing(self, tmp_path: Path) -> None: + assert apply_local(7, tmp_path) == [] + + def test_polyglot_repo_configures_each(self, tmp_path: Path) -> None: + (tmp_path / "package-lock.json").write_text("{}") + (tmp_path / "deno.json").write_text("{}") + results = apply_local(7, tmp_path) + assert {tool for tool, _, _ in results} == {"npm", "deno"} + + def test_results_sorted_by_tool_order(self, tmp_path: Path) -> None: + (tmp_path / "deno.json").write_text("{}") + (tmp_path / "package-lock.json").write_text("{}") + results = apply_local(7, tmp_path) + tools = [tool for tool, _, _ in results] + assert tools == [t for t in LOCAL_TOOL_ORDER if t in tools] + + def test_reports_manual_for_jsonc_deno(self, tmp_path: Path) -> None: + (tmp_path / "deno.json").write_text('{\n // comment\n}\n') + results = apply_local(7, tmp_path) + deno = next(r for r in results if r[0] == "deno") + assert deno[2] == "manual" + + +class TestRevertLocal: + def test_removes_configured_tools(self, tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text("") + apply_local(7, tmp_path) + results = revert_local(tmp_path) + assert any(tool == "uv" and res == "removed" for tool, _, res in results) + assert not (tmp_path / "uv.toml").exists() + + def test_empty_repo_reverts_nothing(self, tmp_path: Path) -> None: + assert revert_local(tmp_path) == [] diff --git a/tests/test_local.py b/tests/test_local.py new file mode 100644 index 0000000..dcb74d3 --- /dev/null +++ b/tests/test_local.py @@ -0,0 +1,210 @@ +import json +import tomllib +from pathlib import Path + +from gestate.local import ( + PROJECT_FILES, + detect_project_tools, + read_project_config, + remove_project_config, + write_project_config, +) + + +class TestDetectProjectTools: + def test_empty_dir_detects_nothing(self, tmp_path: Path) -> None: + assert detect_project_tools(tmp_path) == set() + + def test_package_lock_detects_npm(self, tmp_path: Path) -> None: + (tmp_path / "package-lock.json").write_text("{}") + assert detect_project_tools(tmp_path) == {"npm"} + + def test_pnpm_lock_detects_pnpm(self, tmp_path: Path) -> None: + (tmp_path / "pnpm-lock.yaml").write_text("") + assert detect_project_tools(tmp_path) == {"pnpm"} + + def test_yarn_lock_detects_yarn(self, tmp_path: Path) -> None: + (tmp_path / "yarn.lock").write_text("") + assert detect_project_tools(tmp_path) == {"yarn"} + + def test_bun_lock_detects_bun(self, tmp_path: Path) -> None: + (tmp_path / "bun.lock").write_text("") + assert detect_project_tools(tmp_path) == {"bun"} + + def test_pyproject_detects_uv(self, tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text("") + assert detect_project_tools(tmp_path) == {"uv"} + + def test_deno_json_detects_deno(self, tmp_path: Path) -> None: + (tmp_path / "deno.json").write_text("{}") + assert detect_project_tools(tmp_path) == {"deno"} + + def test_package_json_without_lockfile_defaults_to_npm(self, tmp_path: Path) -> None: + (tmp_path / "package.json").write_text("{}") + assert detect_project_tools(tmp_path) == {"npm"} + + def test_package_json_with_pnpm_lock_is_pnpm_not_npm(self, tmp_path: Path) -> None: + (tmp_path / "package.json").write_text("{}") + (tmp_path / "pnpm-lock.yaml").write_text("") + assert detect_project_tools(tmp_path) == {"pnpm"} + + def test_existing_tool_config_triggers_that_tool(self, tmp_path: Path) -> None: + (tmp_path / ".yarnrc.yml").write_text("\n") + assert detect_project_tools(tmp_path) == {"yarn"} + + def test_polyglot_repo_detects_all(self, tmp_path: Path) -> None: + (tmp_path / "yarn.lock").write_text("") + (tmp_path / "pyproject.toml").write_text("") + (tmp_path / "deno.json").write_text("{}") + assert detect_project_tools(tmp_path) == {"yarn", "uv", "deno"} + + +class TestWriteAndReadNpm: + def test_creates_npmrc(self, tmp_path: Path) -> None: + assert write_project_config("npm", tmp_path, 7) == "created" + assert read_project_config("npm", tmp_path) == "7" + assert "min-release-age=7" in (tmp_path / ".npmrc").read_text() + + def test_preserves_other_lines(self, tmp_path: Path) -> None: + npmrc = tmp_path / ".npmrc" + npmrc.write_text("registry=https://reg.example.com\n") + assert write_project_config("npm", tmp_path, 7) == "updated" + text = npmrc.read_text() + assert "registry=https://reg.example.com" in text + assert "min-release-age=7" in text + + def test_replaces_existing_value(self, tmp_path: Path) -> None: + npmrc = tmp_path / ".npmrc" + npmrc.write_text("min-release-age=3\n") + assert write_project_config("npm", tmp_path, 7) == "updated" + assert read_project_config("npm", tmp_path) == "7" + assert npmrc.read_text().count("min-release-age") == 1 + + def test_unchanged_when_already_target(self, tmp_path: Path) -> None: + write_project_config("npm", tmp_path, 7) + assert write_project_config("npm", tmp_path, 7) == "unchanged" + + +class TestWriteAndReadYaml: + def test_pnpm_minutes(self, tmp_path: Path) -> None: + assert write_project_config("pnpm", tmp_path, 7) == "created" + assert read_project_config("pnpm", tmp_path) == "10080" + assert ( + "minimumReleaseAge: 10080" + in (tmp_path / "pnpm-workspace.yaml").read_text() + ) + + def test_yarn_minutes(self, tmp_path: Path) -> None: + assert write_project_config("yarn", tmp_path, 7) == "created" + assert read_project_config("yarn", tmp_path) == "10080" + assert "npmMinimalAgeGate: 10080" in (tmp_path / ".yarnrc.yml").read_text() + + def test_preserves_other_yaml_keys(self, tmp_path: Path) -> None: + yml = tmp_path / "pnpm-workspace.yaml" + yml.write_text("packages:\n - 'pkg/*'\n") + assert write_project_config("pnpm", tmp_path, 7) == "updated" + text = yml.read_text() + assert "packages:" in text + assert "minimumReleaseAge: 10080" in text + + def test_replaces_existing_yaml_value(self, tmp_path: Path) -> None: + yml = tmp_path / ".yarnrc.yml" + yml.write_text("npmMinimalAgeGate: 4320\n") + assert write_project_config("yarn", tmp_path, 7) == "updated" + assert read_project_config("yarn", tmp_path) == "10080" + assert yml.read_text().count("npmMinimalAgeGate") == 1 + + +class TestWriteAndReadToml: + def test_bun_seconds(self, tmp_path: Path) -> None: + assert write_project_config("bun", tmp_path, 7) == "created" + assert read_project_config("bun", tmp_path) == "604800" + with (tmp_path / "bunfig.toml").open("rb") as f: + data = tomllib.load(f) + assert data["install"]["minimumReleaseAge"] == 604800 + + def test_uv_relative_span(self, tmp_path: Path) -> None: + assert write_project_config("uv", tmp_path, 7) == "created" + assert read_project_config("uv", tmp_path) == "7 days" + with (tmp_path / "uv.toml").open("rb") as f: + data = tomllib.load(f) + assert data["exclude-newer"] == "7 days" + + def test_uv_does_not_self_exempt_gestate(self, tmp_path: Path) -> None: + write_project_config("uv", tmp_path, 7) + with (tmp_path / "uv.toml").open("rb") as f: + data = tomllib.load(f) + assert "exclude-newer-package" not in data + + +class TestWriteAndReadDeno: + def test_creates_deno_json(self, tmp_path: Path) -> None: + assert write_project_config("deno", tmp_path, 7) == "created" + assert read_project_config("deno", tmp_path) == "P7D" + data = json.loads((tmp_path / "deno.json").read_text()) + assert data["minimumDependencyAge"] == "P7D" + + def test_preserves_other_json_keys(self, tmp_path: Path) -> None: + deno = tmp_path / "deno.json" + deno.write_text('{\n "tasks": {"dev": "deno run main.ts"}\n}\n') + assert write_project_config("deno", tmp_path, 7) == "updated" + data = json.loads(deno.read_text()) + assert data["tasks"] == {"dev": "deno run main.ts"} + assert data["minimumDependencyAge"] == "P7D" + + def test_jsonc_with_comments_returns_manual_and_does_not_clobber( + self, tmp_path: Path + ) -> None: + deno = tmp_path / "deno.json" + original = '{\n // keep my comments\n "tasks": {}\n}\n' + deno.write_text(original) + assert write_project_config("deno", tmp_path, 7) == "manual" + assert deno.read_text() == original + + +class TestRemoveProjectConfig: + def test_removes_npm_key_keeps_other_lines(self, tmp_path: Path) -> None: + npmrc = tmp_path / ".npmrc" + npmrc.write_text("registry=https://r.example.com\nmin-release-age=7\n") + assert remove_project_config("npm", tmp_path) == "removed" + text = npmrc.read_text() + assert "min-release-age" not in text + assert "registry=https://r.example.com" in text + + def test_deletes_npmrc_when_only_key(self, tmp_path: Path) -> None: + write_project_config("npm", tmp_path, 7) + assert remove_project_config("npm", tmp_path) == "removed" + assert not (tmp_path / ".npmrc").exists() + + def test_removes_yaml_key(self, tmp_path: Path) -> None: + write_project_config("yarn", tmp_path, 7) + assert remove_project_config("yarn", tmp_path) == "removed" + assert read_project_config("yarn", tmp_path) is None + + def test_removes_bun_key(self, tmp_path: Path) -> None: + write_project_config("bun", tmp_path, 7) + assert remove_project_config("bun", tmp_path) == "removed" + assert not (tmp_path / "bunfig.toml").exists() + + def test_removes_uv_key(self, tmp_path: Path) -> None: + write_project_config("uv", tmp_path, 7) + assert remove_project_config("uv", tmp_path) == "removed" + assert not (tmp_path / "uv.toml").exists() + + def test_removes_deno_key_keeps_file_with_other_keys(self, tmp_path: Path) -> None: + deno = tmp_path / "deno.json" + deno.write_text('{"tasks": {}}') + write_project_config("deno", tmp_path, 7) + assert remove_project_config("deno", tmp_path) == "removed" + data = json.loads(deno.read_text()) + assert "minimumDependencyAge" not in data + assert "tasks" in data + + def test_absent_when_nothing_to_remove(self, tmp_path: Path) -> None: + assert remove_project_config("npm", tmp_path) == "absent" + + +class TestReadAbsent: + def test_read_returns_none_when_file_absent(self, tmp_path: Path) -> None: + for tool in PROJECT_FILES: + assert read_project_config(tool, tmp_path) is None diff --git a/uv.lock b/uv.lock index 2349678..2db9edd 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,9 @@ requires-python = ">=3.11" exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P3D" +[options.exclude-newer-package] +gestate = false + [[package]] name = "click" version = "8.4.1" @@ -38,7 +41,7 @@ wheels = [ [[package]] name = "gestate" -version = "1.0.0" +version = "1.0.1" source = { editable = "." } dependencies = [ { name = "rich" },