Skip to content
Draft
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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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 <days> --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` (`P<N>D`) |
| 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:
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ dependencies = [
"tomli-w>=1.0",
]

[project.urls]
Repository = "https://github.com/lincolnloop/gestate"

[project.scripts]
gestate = "gestate.cli:main"

Expand Down
127 changes: 125 additions & 2 deletions src/gestate/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import shutil
import subprocess
import sys
from pathlib import Path

from rich.console import Console
from rich.panel import Panel
Expand All @@ -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,
Expand All @@ -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="

Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -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")
Expand Down
Loading