From 5c14155ee4b14aae7066abb44c2b6c750a09a2a4 Mon Sep 17 00:00:00 2001 From: Bryan Fawcett Date: Tue, 13 Oct 2026 00:21:56 +0000 Subject: [PATCH] chore: clean the clone after every merge scripts/clean.mjs (the same script as the extension's and the licence server's) removes the build 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 a new prepare script) and after Claude Code merges a pull request (.claude/settings.json, PostToolUse), and as npm run clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CoDNYz27iJ7o8PPztCzTpq --- .claude/settings.json | 14 +++ .githooks/post-merge | 5 + CHANGELOG.md | 10 ++ CONTRIBUTING.md | 19 +++ package.json | 2 + scripts/clean.mjs | 251 ++++++++++++++++++++++++++++++++++++++ scripts/install-hooks.mjs | 82 +++++++++++++ 7 files changed, 383 insertions(+) create mode 100755 .githooks/post-merge create mode 100644 scripts/clean.mjs create mode 100644 scripts/install-hooks.mjs 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 */ +}