diff --git a/.claude/settings.json b/.claude/settings.json index 6407e7c..8712d0b 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -25,5 +25,19 @@ "mcp__claude_ai_Postiz__integrationList", "mcp__Mzizi__mzizi_get_tokens" ] + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "mcp__github__merge_pull_request", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/scripts/clean.mjs\" --hook", + "timeout": 120 + } + ] + } + ] } } diff --git a/.githooks/post-merge b/.githooks/post-merge new file mode 100755 index 0000000..cd0ff5e --- /dev/null +++ b/.githooks/post-merge @@ -0,0 +1,5 @@ +#!/bin/sh +# After a `git pull` that merges: keep the clone light (scripts/clean.mjs). +# Turned on by `npm ci` (scripts/install-hooks.mjs). It never fails a pull. +command -v node >/dev/null 2>&1 || exit 0 +node scripts/clean.mjs --quiet || true diff --git a/CHANGELOG.md b/CHANGELOG.md index 51ce917..b2c58af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ numbers below were the site's earlier, hand-kept numbering and are retired; the ## Unreleased +### Added + +- **`npm run clean`, and after every merge.** `scripts/clean.mjs` removes + the build output (`dist/`, `.astro/`, `.vercel/output/`), the QA report, + the tools' caches, finished Claude Code worktrees and local branches + already in `main` or `staging`, touching only what Git ignores. It runs + after a `git pull` that merges (`.githooks/post-merge`, turned on by + `npm ci` through the new `prepare` script) and after Claude Code merges a + pull request (`.claude/settings.json`). See CONTRIBUTING.md. + ### Changed - **The Toddle Enhancement Extension 0.9.0 is on the Chrome Web Store**, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6767e67..b57b738 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,6 +43,25 @@ npm run format # prettier --write npm run format:check # the org lint gate runs this ``` +## Keeping the clone light + +`npm run clean` (`scripts/clean.mjs`) removes what the build and the tools +generate (`dist/`, `.astro/`, `.vercel/output/`, `qa-report.json`, the tools' caches), Claude Code worktrees with nothing uncommitted, and local +branches that are done: everything on them is already in `origin/main` or +`origin/staging` (a squash merge included), or their remote branch was +deleted after the pull request merged while they held nothing unpushed. It +touches only what Git ignores, never `main`, `staging`, the branch checked +out or one in a worktree, and names anything it leaves. `--dry-run` lists, +`--deep` also removes `node_modules` and npm's cache, `--no-fetch` works +offline. + +It runs on its own after a `git pull` that merges (`.githooks/post-merge`, +turned on by `npm ci`), and after Claude Code merges a pull request +(`.claude/settings.json`, a `PostToolUse` hook) in whichever of the three +clones (`toddle-enhancement-extension`, `learning`, `nyuchi-licence-server`) +the pull request belongs to. The same script is in all three; only its list +of generated paths differs. + ## If you touch the theme bootstrap The one inline script in `BaseLayout.astro` is allowed by a hash in the diff --git a/package.json b/package.json index b108052..edd81b0 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,8 @@ "type": "module", "description": "learning.nyuchi.com — the Nyuchi Learning surface and the home of the Toddle Enhancement Extension.", "scripts": { + "clean": "node scripts/clean.mjs", + "prepare": "node scripts/install-hooks.mjs", "dev": "astro dev", "build": "astro build", "preview": "astro preview", diff --git a/scripts/clean.mjs b/scripts/clean.mjs new file mode 100644 index 0000000..82e4204 --- /dev/null +++ b/scripts/clean.mjs @@ -0,0 +1,251 @@ +#!/usr/bin/env node +/** + * Keeps this clone light: `npm run clean`, and run on its own after a pull + * request merges (the Claude Code hook in .claude/settings.json) and after + * every `git pull` that merges (.githooks/post-merge). + * + * The same script is in nyuchi/toddle-enhancement-extension, nyuchi/learning + * and nyuchi/nyuchi-licence-server; only GENERATED differs. It removes: + * + * 1. what the build, the tests and the tools generate (GENERATED below), + * and only where Git ignores it, so a tracked or new file is never + * touched; + * 2. Claude Code worktrees (.claude/worktrees/) with nothing uncommitted; + * one with changes is left, and named; + * 3. local branches that are done: everything on them is already in + * origin/main or origin/staging (merged, or squash-merged: merging the + * branch in would change nothing), or their remote branch was deleted + * after its pull request merged (GitHub deletes head branches on merge) + * while the local branch held nothing that had not been pushed. main, staging, the branch + * checked out and any branch in a worktree stay. + * + * --deep also removes node_modules and empties npm's cache (`npm ci` brings + * both back). --dry-run lists what it would remove. --no-fetch skips + * `git fetch --prune` (offline). --quiet prints the summary line only. + * --hook is for Claude Code's PostToolUse hook: it reads the hook's JSON on + * stdin and cleans the repository whose pull request was merged, when that + * clone sits next to this one. It never fails: a hook or a pull must not + * break on a cleanup. + */ +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +/* this repository's generated paths, relative to its root */ +const GENERATED = [ + "dist", + ".astro", + ".vercel/output", + "qa-report.json", + "node_modules/.cache", + "node_modules/.vite", + "node_modules/.astro", +]; +const KEEP = new Set(["main", "staging"]); + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const args = new Set(process.argv.slice(2)); +const dry = args.has("--dry-run"); +const quiet = args.has("--quiet"); +const say = (line) => { + if (!quiet) console.log(`clean: ${line}`); +}; + +function git(...a) { + return execFileSync("git", a, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 60_000, + }).trim(); +} +function tryGit(...a) { + try { + return git(...a); + } catch { + return null; + } +} +const lines = (s) => (s ? s.split("\n").filter(Boolean) : []); + +/* --hook: clean the repository the merged pull request belongs to */ +function fromHook() { + let input = {}; + try { + input = JSON.parse(readFileSync(0, "utf8") || "{}"); + } catch { + /* no input: clean this clone */ + } + const out = JSON.stringify(input.tool_response ?? ""); + if (!/merged\\?"\s*:\s*true/.test(out)) return; // not merged: nothing to do + const repo = String(input.tool_input?.repo ?? ""); + const project = process.env.CLAUDE_PROJECT_DIR || root; + const candidates = [ + project, + join(project, repo), + join(dirname(project), repo), + join(dirname(root), repo), + ]; + const target = + candidates.find( + (d) => + repo && + basename(d) === repo && + existsSync(join(d, "scripts", "clean.mjs")), + ) ?? (repo ? null : root); + if (!target) return; // that repository is not cloned here + spawnSync(process.execPath, [join(target, "scripts", "clean.mjs")], { + cwd: target, + stdio: ["ignore", "inherit", "inherit"], + timeout: 120_000, + }); +} + +function sizeOf(path) { + const r = spawnSync("du", ["-sk", path], { encoding: "utf8" }); + return r.status === 0 ? Number(r.stdout.split("\t")[0]) * 1024 : 0; +} +const mb = (bytes) => `${(bytes / 1024 / 1024).toFixed(0)} MB`; + +function clean() { + if (tryGit("rev-parse", "--show-toplevel") === null) + return say("not a Git clone; nothing to do"); + let freed = 0; + const removed = []; + const left = []; + + /* 1. generated paths, only where Git ignores them */ + const paths = [...GENERATED]; + if (args.has("--deep")) paths.push("node_modules"); + for (const rel of paths) { + const abs = join(root, rel); + if (!existsSync(abs)) continue; + if (tryGit("check-ignore", "-q", rel) === null) { + left.push(`${rel} (not ignored by Git)`); + continue; + } + freed += sizeOf(abs); + if (!dry) rmSync(abs, { recursive: true, force: true }); + removed.push(rel); + } + + /* 2. Claude Code worktrees with nothing uncommitted */ + const inWorktree = new Set(); + const trees = lines(tryGit("worktree", "list", "--porcelain")).reduce( + (all, l) => { + if (l.startsWith("worktree ")) all.push({ path: l.slice(9) }); + else if (l.startsWith("branch ") && all.length) + all[all.length - 1].branch = l.slice(7).replace("refs/heads/", ""); + return all; + }, + [], + ); + for (const t of trees) { + const ours = t.path.includes(join(".claude", "worktrees")); + if (!ours) { + if (t.branch) inWorktree.add(t.branch); + continue; + } + const dirty = spawnSync("git", ["status", "--porcelain"], { + cwd: t.path, + encoding: "utf8", + }); + if (dirty.status !== 0 || dirty.stdout.trim()) { + if (t.branch) inWorktree.add(t.branch); + left.push(`${t.path} (uncommitted changes)`); + continue; + } + freed += sizeOf(t.path); + if (!dry) tryGit("worktree", "remove", t.path); + removed.push(`worktree ${basename(t.path)}`); + } + if (!dry) tryGit("worktree", "prune"); + + /* 3. local branches that are done */ + const before = new Map( + lines( + tryGit( + "for-each-ref", + "--format=%(refname:strip=3) %(objectname)", + "refs/remotes/origin", + ), + ).map((l) => l.split(" ")), + ); + if (!args.has("--no-fetch")) + tryGit( + "fetch", + "--quiet", + "--prune", + "origin", + "+refs/heads/*:refs/remotes/origin/*", + ); + const after = new Set( + lines( + tryGit( + "for-each-ref", + "--format=%(refname:strip=3)", + "refs/remotes/origin", + ), + ), + ); + const current = tryGit("branch", "--show-current"); + const bases = ["origin/main", "origin/staging"].filter( + (b) => tryGit("rev-parse", "--verify", "-q", b) !== null, + ); + const isAncestor = (a, b) => + tryGit("merge-base", "--is-ancestor", a, b) !== null; + /* nothing to bring in: merging the branch into the base changes nothing, + which holds for a squash merge too */ + const alreadyIn = (a, b) => { + const tree = tryGit("merge-tree", "--write-tree", b, a); + return tree !== null && tree === tryGit("rev-parse", `${b}^{tree}`); + }; + for (const l of lines( + tryGit( + "for-each-ref", + "--format=%(refname:short) %(upstream:short) %(upstream:track)", + "refs/heads", + ), + )) { + const [branch, upstream = "", ...track] = l.split(" "); + if (KEEP.has(branch) || branch === current || inWorktree.has(branch)) + continue; + const remote = upstream.replace(/^origin\//, ""); + const pushedThenDeleted = + upstream.startsWith("origin/") && + before.has(remote) && + !after.has(remote) && + isAncestor(branch, before.get(remote)); + const merged = bases.some( + (b) => isAncestor(branch, b) || alreadyIn(branch, b), + ); + if (pushedThenDeleted || merged) { + if (!dry) tryGit("branch", "-D", branch); + removed.push(`branch ${branch}`); + } else if (track.join(" ").includes("gone")) { + left.push( + `branch ${branch} (its remote branch is gone; check it holds nothing unpushed)`, + ); + } + } + + if (args.has("--deep") && !dry) { + spawnSync("npm", ["cache", "clean", "--force"], { stdio: "ignore" }); + removed.push("npm cache"); + } + + const verb = dry ? "would remove" : "removed"; + console.log( + `clean (${basename(root)}): ${removed.length ? `${verb} ${removed.join(", ")}` : "nothing to remove"}${freed ? `, ${mb(freed)}` : ""}`, + ); + for (const l of left) say(`left ${l}`); +} + +try { + if (args.has("--hook")) fromHook(); + else clean(); +} catch (e) { + console.log(`clean: stopped (${e?.message ?? e}); nothing else was changed`); +} +process.exit(0); diff --git a/scripts/install-hooks.mjs b/scripts/install-hooks.mjs new file mode 100644 index 0000000..3609c1f --- /dev/null +++ b/scripts/install-hooks.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * Turns on this repository's Git hooks (`.githooks/`) in this clone. + * `npm ci` and `npm install` run it, as `prepare`. + * + * All it does is set `core.hooksPath` to `.githooks` in this clone's own + * `.git/config`: it enables hooks and bypasses nothing. Because the hooks + * are tracked files, Git runs the version on the branch you have + * checked out, as `npm test` and `npm ci` already run that branch's code: + * check out a branch you don't trust only in a clone you'd run its tests + * in (CONTRIBUTING.md). It changes nothing, + * and says so, where that would silence hooks a developer already has: + * `core.hooksPath` set at any level (a global secret scanner, say), or a + * hook of their own in `.git/hooks/`. Outside a Git clone of this + * repository (a packed copy, or no git at all) it does nothing, and it + * never fails an install. + * + * The one hook, .githooks/post-merge, runs scripts/clean.mjs after a + * `git pull` that merges. + */ +import { execFileSync } from "node:child_process"; +import { existsSync, readdirSync, realpathSync } from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const HOOKS = ".githooks"; + +const git = (...args) => + execFileSync("git", args, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); +const quiet = (fn) => { + try { + return fn(); + } catch { + return ""; + } +}; +const note = (text) => + console.log(`git hooks: ${text} This repository's hooks are in ${HOOKS}/; + to use them: git config core.hooksPath ${HOOKS} (CONTRIBUTING.md).`); + +function install() { + const top = quiet(() => git("rev-parse", "--show-toplevel")); + /* not a Git clone, no git, or a clone of something else around us + (native realpath: the case on disk, so the two spellings agree) */ + if (!top || realpathSync.native(top) !== realpathSync.native(root)) return; + + /* core.hooksPath from any level: system, global or this clone */ + const current = quiet(() => git("config", "--get", "core.hooksPath")); + if (current === HOOKS) return; + if (current) return note(`core.hooksPath is ${current}; left as it is.`); + + /* hooks of a developer's own in .git/hooks/ (Git's *.sample files are + not hooks) */ + const dir = quiet(() => git("rev-parse", "--git-path", "hooks")); + const hooksDir = dir && (isAbsolute(dir) ? dir : join(root, dir)); + const own = + hooksDir && existsSync(hooksDir) + ? readdirSync(hooksDir).filter((n) => !n.endsWith(".sample")) + : []; + if (own.length) + return note( + `${hooksDir} has hooks of its own (${own.join(", ")}); left as it is.`, + ); + + try { + git("config", "--local", "core.hooksPath", HOOKS); + console.log(`git hooks: on (${HOOKS}/, see CONTRIBUTING.md)`); + } catch { + note("could not set core.hooksPath (is .git/config writable?)."); + } +} + +try { + install(); +} catch { + /* an install never fails on an optional hook */ +}