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
uvx gestate trust my-pkg --python # exempt a package from the age delay
uvx gestate trust --rm my-pkg # remove an exemption
Expand Down Expand Up @@ -37,6 +39,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 @@ -25,6 +25,9 @@ dependencies = [
"tomli-w>=1.0",
]

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

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

Expand Down
124 changes: 122 additions & 2 deletions src/gestate/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@
uv_config_path,
yarn_major,
)
from .local import (
PROJECT_FILES,
detect_project_tools,
read_project_config,
remove_project_config,
write_project_config,
)
from .trust import (
SUPPORTED,
UNSUPPORTED,
Expand Down Expand Up @@ -202,6 +209,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 @@ -783,6 +817,72 @@ def _do_trusted(ecosystem: str | None, local: bool) -> int:
console.print()
for t in unsup:
console.print(f"[yellow]{t}[/]: {UNSUPPORTED[t]}")


# --- local-flag functions ---
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


Expand Down Expand Up @@ -829,8 +929,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 @@ -879,6 +990,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 @@ -890,11 +1006,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