From a0acd577073a5b0c708e15d133dc65f392833bcf Mon Sep 17 00:00:00 2001 From: lmoresi Date: Mon, 26 Oct 2026 14:12:11 +1100 Subject: [PATCH 1/2] The workbench: every note in one checkout, each branch built from its own files scripts/note.py keeps all notes in progress side by side on a local branch, workbench, and commits each note's directory onto its own branch without checking it out, so several sessions and one editor can work in a single checkout. pull brings in a pull request (a contributor's fork too) or a branch a hub session pushed; sync merges main and anything pushed to a tracked branch. classification.yml and acknowledgements.yml merge entry by entry, so notes adding their entries side by side do not conflict. scripts/workbench_guard.py and .claude/settings.json refuse the git commands that would move every session's files, and only on the workbench. CLAUDE.md and CONTRIBUTING.md say how to work on the workbench and from anywhere else. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011S876ckjYqqth1aPyojrhh --- .claude/settings.json | 16 + CLAUDE.md | 34 ++ CONTRIBUTING.md | 76 ++-- pixi.toml | 5 + scripts/note.py | 814 +++++++++++++++++++++++++++++++++++++ scripts/workbench_guard.py | 64 +++ 6 files changed, 984 insertions(+), 25 deletions(-) create mode 100644 .claude/settings.json create mode 100644 CLAUDE.md create mode 100644 scripts/note.py create mode 100644 scripts/workbench_guard.py diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..66231ec --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "f=\"$CLAUDE_PROJECT_DIR/scripts/workbench_guard.py\"; [ -f \"$f\" ] && python3 \"$f\" || true", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9f6a623 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,34 @@ +# Working in this repository + +## The workbench + +This checkout may be the workbench: the local branch `workbench`, holding every +note in progress side by side, with several sessions writing in it at once. +`git branch --show-current` says whether it is. CONTRIBUTING.md has the whole +workflow; on the workbench: + +- Work in your own note's directory, `articles//`, and its entry in + `classification.yml`. The other directories are other sessions' work. +- Commit with `pixi run note save -m "..."`, adding `--push` to publish + the branch. Anything outside a note: `pixi run note save -m "..."`. +- Bring in a pull request with `pixi run note pull `, and main with + `pixi run note sync`. `pixi run note list` says what each branch needs. +- Never `git checkout`, `switch`, `stash`, `reset` (except `-- `), + `restore`, `clean`, `rebase`, `pull` or `commit -a`. Each changes or hides + every session's files; a hook refuses them. +- Do not run `pixi run build`: it rewrites the `myst.yml` contents list that + `pixi run start` is serving. + +## Anywhere else: a hub session, another machine + +A checkout that is not the workbench is an ordinary one, and contributes by +branch and pull request on this repository: + +- A note's branch is `note/`. If it exists on origin, work on it and push + to it; otherwise branch from `origin/main`. Its pull request is the note's. +- Work for someone else to fold into a note — figures from the runs, a draft + section — may go on a branch of its own instead, changing only + `articles//` and the note's entry in `classification.yml`. The + workbench carries such a branch into the note with its commits intact. +- Figures ship with the script that made them, in the note's `figures/src/`. +- Push and open a pull request. Never push to `main`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b3c0064..35a3c20 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,22 +7,27 @@ in the repository. Keep them consistent — if the process changes, both move. ## Writing a note -Give it its own working tree. Writing and building are the same repository -doing two things at once, and they collide over generated files — `myst.yml` -holds the toc, and a production build run in another terminal takes notes at -draft and review out of the toc that `myst start` is watching. The dev server -then reports *File is not in project* for the file you have open. Nothing is -lost; it just stops updating, which is worse, because it looks like your -editor. +Write every note in one checkout, the **workbench**, and let `pixi run note` +make each note's branch from that note's own files. Notes do not collide: each +lives in `articles//`, and the one file they share, `classification.yml`, +has an entry per note. So they can sit side by side in one editor window, with +a session or a terminal on each, and one `pixi run start` serving all of them. ```bash -pixi run worktree create particle-level-sets # branch note/particle-level-sets -cd ../underworld-technical-notes-worktrees/particle-level-sets -pixi install # its own environment, once +pixi run note init # once: this checkout becomes the workbench pixi run new --slug particle-level-sets --title "…" --author louis -pixi run start # localhost:3000, hot reload +pixi run start # localhost:3000, hot reload, every draft +pixi run note save particle-level-sets -m "First draft" --push +gh pr create --head note/particle-level-sets ``` +`note save` commits the note's directory, and its entry in `classification.yml`, +onto `note/`, on top of whatever that branch already had — and nothing +else. Other notes' unsaved edits stay where they are. Without `--push` it is a +local save point. A change outside any note — a template, a script, this file — +goes the same way with its paths named: +`pixi run note save docs/pdf-template templates/pdf -m "…"`. + Three cycles, three tools, and they do not interfere: | | | | @@ -31,20 +36,41 @@ Three cycles, three tools, and they do not interfere: | **Review** | push the branch → link on the pull request | ~3 minutes | | **Publishing** | merge to `main` | the live site | -`pixi run worktree list` shows what is in flight; `remove` takes the working -copy away and leaves the branch, since those are different decisions. - -**Picking up a note that already exists** — one somebody started on the web, or -that you left a fortnight ago — is the same command. It adopts the branch -rather than creating one, and tells you which note is on it: - -```bash -pixi run worktree create binder-note # adopts note/binder-note -cd ../underworld-technical-notes-worktrees/binder-note -git pull -pixi install # first time only -pixi run start -``` +**A note is written in more than one place.** The machine that has the runs — +usually a hub session — makes the skeleton and the figures, and the prose is +finished somewhere else. Everything travels as a branch and a pull request on +this repository. Away from the workbench a checkout is an ordinary one: work on +`note/` and push to it, or put a contribution — figures, a section — on a +branch of its own that changes only that note, and open a pull request. Never +push to `main`. + +The workbench is where the pieces meet. `pixi run note pull 58` merges pull +request 58 into it, fork or not, and `pixi run note pull note/binder-note` a +branch. Unsaved edits to the same files are committed to the workbench first so +that git merges the two; a real conflict stops before anything changes and says +how to resolve it. From then on the note saves like your own, and a save refuses +to overwrite commits somebody pushed after your last pull. A contribution whose +branch changes only one note is carried into that note's next save as a parent +commit: its author stays on its commits, and its pull request is marked merged +when the note's is. + +`pixi run note sync` gathers: it merges main, merges every tracked branch that +somebody has pushed to, stops tracking branches whose pull requests have merged, +and lists open pull requests not yet on the bench. A conflict on one branch is +reported and the rest go ahead. `pixi run note list` shows what each tracked +branch needs — unsaved edits, saved but not pushed, pushed to since you pulled — +and any edited note that is on no branch yet. + +**What not to do in the workbench:** `git checkout`, `switch`, `stash`, `reset`, +`restore`, `clean`, `rebase`, `pull` or `commit -a`. Each changes or hides every +session's files at once; a Claude Code hook (`scripts/workbench_guard.py`) +refuses them, and `note` does what they would have been wanted for. Nor run a +production build (`pixi run build`) there: it rewrites the contents list in +`myst.yml` that `pixi run start` is serving, and the dev server then reports +*File is not in project* for the file you have open. Nothing is lost; it just +stops updating, which is worse, because it looks like your editor. CI builds; +for a local one, use a checkout of its own that you never edit in +(`git worktree add --detach ../underworld-technical-notes-build origin/main`). **Editing on GitHub is fine for a typo** and no worse than doing it locally. It is a poor way to fix formatting, because you cannot see the built page — diff --git a/pixi.toml b/pixi.toml index cce2b00..a3a249e 100644 --- a/pixi.toml +++ b/pixi.toml @@ -63,6 +63,11 @@ build = { depends-on = ["build-pdf", "build-html"] } # pixi run worktree create particle-level-sets worktree = "python3 scripts/worktree.py" +# Or write every note in one checkout -- the workbench -- and build each note's +# branch from its own directory, without checking it out. CONTRIBUTING.md: +# pixi run note save particle-level-sets -m "First draft" --push +note = "python3 scripts/note.py" + # Push the site you already built straight to the preview host, instead of # making a runner rebuild it. Removes everything before GitHub's own ~60s # deployment, which is the floor and is the same either way. diff --git a/scripts/note.py b/scripts/note.py new file mode 100644 index 0000000..de4fe6d --- /dev/null +++ b/scripts/note.py @@ -0,0 +1,814 @@ +#!/usr/bin/env python3 +"""Write every note in one checkout, and make each branch from its own files. + +A branch is a line of commits; a checkout is a directory with one branch in +it. Keeping a note per branch therefore meant a checkout per note -- a +worktree each, sixteen of them by October 2026, each wanting its own editor +window. Several sessions in one checkout was worse: one of them switching +branch moved the files out from under the others. + +The notes do not actually collide. Each lives in its own ``articles//``, +and the only files they share are registers of one entry per note, +classification.yml and acknowledgements.yml. So this keeps them all in one +checkout -- the workbench, a local branch that is never pushed -- and builds +each note's branch from that note's directory without checking it out: + + pixi run note init # once: this checkout becomes the workbench + pixi run note save particle-level-sets -m "First draft" --push + pixi run note save docs/pdf-template templates/pdf -m "..." # anything else: name the paths + pixi run note pull 58 # a pull request, someone else's or yours + pixi run note sync # main, into the workbench + pixi run note list # what is on the bench, and what it needs + +``save`` commits one snapshot twice: onto the note's branch, on top of whatever +the branch already had, and into the workbench as a merge of that commit, so +the workbench's history records that the branch is in it. That is what keeps +``sync`` clean once the pull request is merged -- main brings back commits the +workbench already has -- and it is how ``save`` knows when someone else has +pushed to the branch since: their commit is not in the workbench, and the save +stops rather than overwrite it. + +Nothing here checks out, stashes or resets the working tree. A save builds its +commits in a private index and then refreshes only its own paths in the shared +one, so a session saving one note cannot sweep another session's files into +its commit. The rule that goes with it, for people and sessions alike: in the +workbench, never ``git checkout``, ``switch``, ``stash``, ``reset --hard`` or +``commit -a``. +""" + +import argparse +import json +import os +import pathlib +import re +import subprocess +import sys +import tempfile +import time + +ROOT = pathlib.Path(__file__).resolve().parent.parent +BENCH = "refs/heads/workbench" +MAIN = "refs/remotes/origin/main" +ZERO = "0" * 40 + +# Rewritten by `pixi run start` and by every build, so it belongs to no note. +# Put back without asking when only its generated blocks differ. +GENERATED = "myst.yml" + +KEY = re.compile(r"^([a-z0-9][a-z0-9-]*):\s*$") +ITEM = re.compile(r"^- ([a-z0-9][a-z0-9-]*)\s*$") + + +def run(*args, env=None, stdin=None): + for attempt in range(20): + done = subprocess.run(["git", *args], cwd=ROOT, env=env, input=stdin, + capture_output=True, text=True) + # Another session holds the shared index for a moment. The lock lasts + # milliseconds; waiting it out is better than failing the save. + if "index.lock" not in done.stderr: + break + time.sleep(0.25) + return done + + +def git(*args, env=None, stdin=None): + done = run(*args, env=env, stdin=stdin) + if done.returncode: + sys.exit("git %s failed:\n%s" % (" ".join(args), done.stderr.strip())) + return done.stdout + + +def line(*args, **kwargs): + return git(*args, **kwargs).strip() + + +def commit_of(ref): + return run("rev-parse", "-q", "--verify", ref + "^{commit}").stdout.strip() or None + + +def contains(ancestor, descendant): + return run("merge-base", "--is-ancestor", ancestor, descendant).returncode == 0 + + +class Stop(Exception): + """A merge that cannot go ahead. Fatal for one pull; one line of a sync.""" + + +def show(rev, path): + done = run("show", "%s:%s" % (rev, path)) + return done.stdout if done.returncode == 0 else "" + + +class PrivateIndex: + """An index of our own, so building a commit never touches the shared one.""" + + def __enter__(self): + handle, self.path = tempfile.mkstemp( + prefix="note-index-", dir=line("rev-parse", "--absolute-git-dir")) + os.close(handle) + os.unlink(self.path) + return dict(os.environ, GIT_INDEX_FILE=self.path) + + def __exit__(self, *exc): + for leftover in (self.path, self.path + ".lock"): + try: + os.unlink(leftover) + except FileNotFoundError: + pass + + +# -- what is tracked --------------------------------------------------------- +# +# Kept in the repository's own git config, under [note ""]: tracked, +# and for a pull request its number, plus the fork it came from if it did. + +def setting(branch, key): + return run("config", "--get", "note.%s.%s" % (branch, key)).stdout.strip() or None + + +def remember(branch, **values): + for key, value in values.items(): + git("config", "note.%s.%s" % (branch, key), str(value)) + + +def forget(branch): + run("config", "--remove-section", "note.%s" % branch) + + +def tracked(): + found = run("config", "--get-regexp", r"^note\..*\.tracked$").stdout + return sorted(entry.split()[0][len("note."):-len(".tracked")] + for entry in found.splitlines()) + + +def require_bench(): + head = run("symbolic-ref", "-q", "HEAD").stdout.strip() + if head != BENCH: + sys.exit("This checkout is on %s, not the workbench. `pixi run note init` " + "makes it one." % (head.replace("refs/heads/", "") or "a detached HEAD")) + + +def elsewhere(branch): + """The other worktree that has this branch checked out, if one does.""" + path = None + for entry in git("worktree", "list", "--porcelain").splitlines(): + if entry.startswith("worktree "): + path = pathlib.Path(entry[len("worktree "):]) + elif entry == "branch refs/heads/%s" % branch and path != ROOT: + return path + return None + + +# -- naming a branch --------------------------------------------------------- + +def adopt(number): + """Look a pull request up, and track its branch under the name we use for it.""" + view = subprocess.run( + ["gh", "pr", "view", str(number), "--json", + "headRefName,isCrossRepository,headRepository,headRepositoryOwner,title"], + cwd=ROOT, capture_output=True, text=True) + if view.returncode: + sys.exit(view.stderr.strip()) + pr = json.loads(view.stdout) + if pr["isCrossRepository"]: + # A contributor's fork. Their branch name can be anything, including + # one of ours, so it is held here under the pull request's number. + branch = "pr/%d" % number + remember(branch, pr=number, forkbranch=pr["headRefName"], + fork="https://github.com/%s/%s.git" + % (pr["headRepositoryOwner"]["login"], pr["headRepository"]["name"])) + else: + branch = pr["headRefName"] + remember(branch, pr=number) + print("#%d %s -> %s" % (number, pr["title"], branch)) + return branch + + +def resolve(target): + """A slug, a branch or a pull request number -> (branch, slug or None).""" + number = target.lstrip("#") + if number.isdigit(): + for branch in tracked(): + if setting(branch, "pr") == number: + return branch, None + return adopt(int(number)), None + if "/" in target: + return target, None + return "note/%s" % target, target + + +def published_ref(branch): + if setting(branch, "fork"): + return "refs/remotes/pull/%s" % setting(branch, "pr") + return "refs/remotes/origin/%s" % branch + + +def fetch(branch): + """Bring the published tip of a branch here; None if it was never pushed.""" + if setting(branch, "fork"): + # GitHub mirrors the head of every pull request on our side, fork or not. + source = "refs/pull/%s/head" % setting(branch, "pr") + else: + source = "refs/heads/%s" % branch + probe = run("ls-remote", "--exit-code", "origin", source) + if probe.returncode == 2: + return None + if probe.returncode: + sys.exit("cannot reach origin:\n%s" % probe.stderr.strip()) + git("fetch", "--quiet", "origin", "+%s:%s" % (source, published_ref(branch))) + return commit_of(published_ref(branch)) + + +# -- what a branch owns ------------------------------------------------------ + +def owned(tip, named): + """The paths a branch is responsible for: what it has changed, and what is named. + + A note owns its whole directory, so a file added to it is carried without + being named. The registers and myst.yml come only by name: a branch that + once committed them does not thereby own everyone's entries. + """ + named = {path.rstrip("/") for path in named} + paths = set(named) + if tip: + base = line("merge-base", MAIN, tip) + paths |= set(git("diff", "--name-only", "--no-renames", base, tip).splitlines()) + collapsed = set() + for path in paths: + parts = path.split("/") + collapsed.add("/".join(parts[:2]) if parts[0] == "articles" and len(parts) > 2 else path) + return sorted(collapsed - ((set(REGISTERS) | {GENERATED}) - named)) + + +def slugs_in(paths): + return sorted({path.split("/")[1] for path in paths + if path.startswith("articles/") and path.count("/") == 1}) + + +def entries(lines): + """Where each top-level entry of classification.yml starts and ends, by line.""" + spans, name, start, last = {}, None, 0, 0 + for number, text in enumerate(lines): + if text.strip() and not text[0].isspace(): + # Column 0 is a new entry, or a comment between entries: either + # way the entry before it has ended at its last non-blank line. + if name: + spans[name] = (start, last + 1) + match = KEY.match(text) + name, start = (match.group(1), number) if match else (None, 0) + if text.strip(): + last = number + if name: + spans[name] = (start, last + 1) + return spans + + +def items(lines): + """Where each `- slug` line of acknowledgements.yml is, by line.""" + return {match.group(1): (number, number + 1) + for number, match in enumerate(map(ITEM.match, lines)) if match} + + +# The files every note has an entry in, and how to find the entries. A note's +# save carries its own entries and nobody else's; a merge takes each entry from +# whichever side changed it. +REGISTERS = {"classification.yml": entries, "acknowledgements.yml": items} + + +def splice(path, theirs, ours, slugs): + """theirs, with the entries for these slugs as they are in ours: added, replaced or removed.""" + where = REGISTERS[path] + result = theirs.splitlines(keepends=True) + mine = ours.splitlines(keepends=True) + for slug in slugs: + have, want = where(result), where(mine) + block = [text if text.endswith("\n") else text + "\n" + for text in (mine[slice(*want[slug])] if slug in want else [])] + if slug in have: + start, end = have[slug] + if not block and end < len(result) and not result[end].strip(): + end += 1 + result[start:end] = block + elif block: + if result and not result[-1].endswith("\n"): + result[-1] += "\n" + if where is items and have: + # A list: the new line goes after the last one, with no gap. + end = max(stop for _, stop in have.values()) + result[end:end] = block + else: + result += (["\n"] if result else []) + block + return "".join(result) + + +def blocks(path, text): + lines = text.splitlines(keepends=True) + return {slug: "".join(lines[slice(*span)]).rstrip() + for slug, span in REGISTERS[path](lines).items()} + + +def merge_entries(path, base, ours, theirs): + """A register merged entry by entry, each entry from whichever side changed it. + + Two notes adding their entries at the end of the file conflict as lines, + but not as entries. None if both sides changed one entry differently, or + theirs changed the text between entries: those want a person. + """ + was, mine, new = (blocks(path, text) for text in (base, ours, theirs)) + moved = sorted(slug for slug in set(was) | set(new) if was.get(slug) != new.get(slug)) + if any(mine.get(slug) not in (was.get(slug), new.get(slug)) for slug in moved): + return None + every = sorted(set(was) | set(mine) | set(new)) + if splice(path, base, "", every) != splice(path, theirs, "", every): + return None + return splice(path, ours, theirs, moved) + + +def present(paths, env): + """The paths git can stage: on disk, or in the index so their removal can be.""" + known = [entry for entry in git("ls-files", "-z", "--", *paths, env=env).split("\0") if entry] + return [path for path in paths if (ROOT / path).exists() + or any(entry == path or entry.startswith(path + "/") for entry in known)] + + +def dirty(): + """Every path whose working copy differs from the workbench, untracked ones included.""" + out = git("status", "--porcelain", "-z", "-uall", "--no-renames") + return {entry[3:] for entry in out.split("\0") if entry} + + +def under(path, paths): + return any(path == owner or path.startswith(owner + "/") for owner in paths) + + +# -- save -------------------------------------------------------------------- + +def contributions(branch, paths, slugs, parents): + """Pulled branches whose work lies wholly inside this branch's paths. + + A contribution to a note often arrives on a branch of its own: figures + from the machine that has the runs, a section from a hub session. Once it + is merged into the workbench, the note's next save carries its commits as + further parents, so their authorship survives and GitHub marks that pull + request merged when the note's is. Only a branch that changes nothing + outside the note qualifies -- anything else would be recorded as merged + while part of it was left behind. + """ + found = [] + for other in tracked(): + if other == branch: + continue + tip = commit_of(published_ref(other)) or commit_of("refs/heads/%s" % other) + if (not tip or not contains(tip, BENCH) + or any(contains(tip, seen) for seen in parents + [t for _, t in found])): + continue + base = line("merge-base", MAIN, tip) + changed = git("diff", "--name-only", "--no-renames", base, tip).splitlines() + + def inside(path): + if under(path, paths): + return True + # Its own entries in a register are inside; anyone else's are not. + return (path in REGISTERS and bool(slugs) + and splice(path, show(base, path), "", slugs) == splice(path, show(tip, path), "", slugs)) + + if changed and all(inside(path) for path in changed): + found.append((other, tip)) + return found + + +def fork_destination(branch): + """Where `save --push` sends a commit on a contributor's pull request. + + Their pull request comes from their fork, setting(branch, "fork"), on the + branch setting(branch, "forkbranch"). There are two places a commit of + ours can go: + + - straight onto their branch. GitHub allows it when they ticked "allow + edits from maintainers"; the pull request updates at once, and they must + pull before they write again or their next push is rejected. + - onto a branch of ours on origin, which they can merge or not. Pushing to + origin also gets it a preview site, which a push to a fork does not. + + Returns (remote URL, branch name on that remote). + """ + # TODO(louis): choose. + sys.exit("pushing to a contributor's pull request is not decided yet: see " + "fork_destination() in scripts/note.py. The commit is saved locally on %s." + % branch) + + +def save(target, named, message, push): + require_bench() + branch, slug = resolve(target) + if slug: + if not (ROOT / "articles" / slug).is_dir() and not commit_of("refs/heads/%s" % branch): + sys.exit("there is no articles/%s here. `pixi run new` makes one." % slug) + named = list(named) + ["articles/%s" % slug] + if elsewhere(branch): + sys.exit("%s is checked out in %s. Remove that worktree first; the branch " + "is not touched by removing it." % (branch, elsewhere(branch))) + + git("fetch", "--quiet", "origin", "+refs/heads/main:%s" % MAIN) + published = fetch(branch) + local = commit_of("refs/heads/%s" % branch) + # Anything on the branch that the workbench has not seen would be + # overwritten by the save, which carries the workbench's copy of every path + # the branch owns. Merge it in first; that is what pull is for. + for tip, where in ((published, "on origin"), (local, "here")): + if tip and not contains(tip, BENCH): + sys.exit("%s has commits %s that the workbench has not seen. " + "`pixi run note pull %s` first, so they are merged rather than " + "overwritten." % (branch, where, target)) + + for attempt in range(5): + local = commit_of("refs/heads/%s" % branch) + if published and local: + if contains(published, local): + parents = [local] + elif contains(local, published): + parents = [published] + else: + parents = [published, local] + else: + # A new branch starts from the newest main the workbench has, not + # the newest there is: the workbench has to contain every parent + # of what it merges, or a later sync would think main was in it. + parents = [published or local or line("merge-base", BENCH, MAIN)] + paths = owned(parents[0], named) + if not paths: + sys.exit("%s owns nothing yet. Name what it is for: " + "`pixi run note save %s ...`" % (branch, target)) + slugs = slugs_in(paths) + # A register named outright goes whole; otherwise just these notes' entries. + spliced = [path for path in REGISTERS if path not in paths] if slugs else [] + snapshot = paths + spliced + carried = contributions(branch, paths, slugs, parents) + parents += [tip for _, tip in carried] + + head = commit_of(BENCH) + with PrivateIndex() as env: + git("read-tree", head, env=env) + staged = present(snapshot, env) + if staged: + git("add", "-A", "--", *staged, env=env) + bench_tree = line("write-tree", env=env) + + with PrivateIndex() as env: + git("read-tree", parents[0], env=env) + stale = git("ls-files", "-z", "--", *paths, env=env) + if stale: + git("update-index", "--force-remove", "-z", "--stdin", env=env, stdin=stale) + fresh = git("ls-tree", "-r", "-z", "--full-tree", bench_tree, *paths) + if fresh: + git("update-index", "-z", "--index-info", env=env, stdin=fresh) + for path in spliced: + theirs = show(parents[0], path) + text = splice(path, theirs, show(bench_tree, path), slugs) + if text != theirs: + blob = line("hash-object", "-w", "--stdin", stdin=text) + git("update-index", "--add", "--cacheinfo", + "100644,%s,%s" % (blob, path), env=env) + branch_tree = line("write-tree", env=env) + + if len(parents) == 1 and branch_tree == line("rev-parse", parents[0] + "^{tree}"): + tip = parents[0] + else: + tip = line("commit-tree", branch_tree, + *[flag for parent in parents for flag in ("-p", parent)], "-m", message) + merge = [] if contains(tip, head) else ["-p", tip] + if not merge and bench_tree == line("rev-parse", head + "^{tree}"): + new_head = head + else: + new_head = line("commit-tree", bench_tree, "-p", head, *merge, + "-m", "%s: %s" % (branch, message)) + + if tip != local: + git("update-ref", "-m", "note save", "refs/heads/%s" % branch, tip, local or ZERO) + if new_head == head or run("update-ref", "-m", "note save: %s" % branch, + BENCH, new_head, head).returncode == 0: + break + # Another session saved in between. Build again on top of theirs; the + # branch already has this commit, so only the workbench's half repeats. + else: + sys.exit("the workbench kept moving under this save; run it again") + + # The workbench now has these paths as they are on disk. Say so in the + # shared index too, for these paths only, or they would show as staged + # changes back to what they were. + git("reset", "-q", "--pathspec-from-file=-", "--pathspec-file-nul", + stdin="\0".join(snapshot) + "\0") + + remember(branch, tracked="true") + for other, _ in carried: + remember(other, carriedby=branch) + print("carries %s: its pull request closes as merged when %s's does" % (other, branch)) + if tip == parents[0] and len(parents) == 1: + print("%s already has this; nothing new to commit." % branch) + else: + print("%s %s %s" % (branch, tip[:8], message)) + print(git("diff", "--stat", parents[0], tip).rstrip()) + + if push: + if setting(branch, "fork"): + url, ref = fork_destination(branch) + else: + url, ref = "origin", branch + git("push", "--quiet", url, "%s:refs/heads/%s" % (tip, ref)) + if url == "origin": + git("update-ref", published_ref(branch), tip) + print("pushed to %s %s" % (url, ref)) + if not setting(branch, "pr") and url == "origin": + print("No pull request yet: gh pr create --head %s" % ref) + + +# -- pull and sync ----------------------------------------------------------- + +def put_back_generated(paths): + """Restore myst.yml if the only edits to it are its generated blocks.""" + if GENERATED not in paths: + return + def bare(text): + return re.sub(r"# BEGIN GENERATED (\w+).*?# END GENERATED \1", "", text, flags=re.S) + current = (ROOT / GENERATED).read_text(encoding="utf-8") + committed = show(BENCH, GENERATED) + if current != committed and bare(current) == bare(committed): + (ROOT / GENERATED).write_text(committed, encoding="utf-8") + print("myst.yml: put its committed contents list back so the merge can " + "proceed. `pixi run start` regenerates it.") + + +def checkpoint(paths, message): + """Commit these paths, as they are on disk, to the workbench alone.""" + for attempt in range(5): + head = commit_of(BENCH) + with PrivateIndex() as env: + git("read-tree", head, env=env) + staged = present(paths, env) + if staged: + # Never without paths: a bare `add -A` would take everyone's files. + git("add", "-A", "--", *staged, env=env) + tree = line("write-tree", env=env) + if tree == line("rev-parse", head + "^{tree}"): + return + new_head = line("commit-tree", tree, "-p", head, "-m", "workbench: %s" % message) + if run("update-ref", "-m", message, BENCH, new_head, head).returncode == 0: + git("reset", "-q", "--pathspec-from-file=-", "--pathspec-file-nul", + stdin="\0".join(paths) + "\0") + return + sys.exit("the workbench kept moving under this checkpoint; run it again") + + +def settle(tree, head, tip, conflicted): + """The merged tree with each conflicted register merged entry by entry, or None.""" + base = line("merge-base", head, tip) + with PrivateIndex() as env: + git("read-tree", tree, env=env) + for path in conflicted: + text = merge_entries(path, show(base, path), show(head, path), show(tip, path)) + if text is None: + return None + blob = line("hash-object", "-w", "--stdin", stdin=text) + git("update-index", "--cacheinfo", "100644,%s,%s" % (blob, path), env=env) + return line("write-tree", env=env) + + +def trial_merge(tip, label): + """(workbench, merged tree, paths the merge changes), without touching a file.""" + head = commit_of(BENCH) + trial = run("merge-tree", "--write-tree", "--name-only", "--no-messages", head, tip) + if trial.returncode > 1: + sys.exit(trial.stderr.strip()) + merged, *conflicted = [path for path in trial.stdout.splitlines() if path] + if conflicted: + merged = all(path in REGISTERS for path in conflicted) and settle(merged, head, tip, conflicted) + if not merged: + raise Stop("%s conflicts with the workbench in:\n %s\nNothing in the working " + "tree was changed. To resolve it by hand, as an ordinary merge:\n" + " git merge %s # fix the files, then git commit\n" + "and nobody else may save while it is half done." + % (label, "\n ".join(conflicted), tip[:12])) + touched = set(git("diff", "--name-only", "--no-renames", "-z", head, merged).split("\0")) - {""} + return head, merged, touched + + +def absorb(tip, label): + """Merge a commit into the workbench, or stop before touching anything. + + Unsaved edits in files the merge changes are committed to the workbench + first -- not to any branch -- so that git merges them three ways rather + than refusing. Without that, a branch somebody else pushed to while you + were editing it could be neither saved (it has commits you have not seen) + nor pulled (you have edits it would overwrite). + """ + if contains(tip, BENCH): + return False + head, merged, touched = trial_merge(tip, label) + put_back_generated(touched) + blocked = sorted(touched & dirty()) + if blocked: + checkpoint(blocked, "before %s" % label) + head, merged, touched = trial_merge(tip, label) + put_back_generated(touched) + still = sorted(touched & dirty()) + if still: + raise Stop("%s would overwrite edits made while it ran:\n %s" + % (label, "\n ".join(still))) + # The commit is built from the trial's tree, since `git merge` would stop at + # the register conflicts settle() has already resolved. read-tree then + # writes just the files the merge changes, and refuses, writing none, if + # one of them has edits or an untracked file is in the way. + commit = line("commit-tree", merged, "-p", head, "-p", tip, "-m", "workbench: %s" % label) + written = run("read-tree", "-m", "-u", head, commit) + if written.returncode: + raise Stop("%s: %s" % (label, written.stderr.strip())) + if run("update-ref", "-m", "workbench: %s" % label, BENCH, commit, head).returncode: + # Another session saved in between. Put the files back; the next run merges onto theirs. + run("read-tree", "-m", "-u", commit, head) + raise Stop("%s: the workbench moved while it ran; run it again" % label) + return True + + +def pull(target): + require_bench() + branch, _ = resolve(target) + published = fetch(branch) + local = commit_of("refs/heads/%s" % branch) + if not (published or local): + sys.exit("no branch %s here or on origin" % branch) + took = False + # A branch from the worktree days can have local commits it never pushed. + # Those are the note too, so they come in alongside the published ones. + for tip in [published] + ([local] if local and local != published else []): + if tip: + try: + took = absorb(tip, "pull %s" % branch) or took + except Stop as stop: + sys.exit(str(stop)) + # Keep the local branch at the published tip, so the next save builds on it. + if published and not elsewhere(branch) and (not local or contains(local, published)): + git("update-ref", "refs/heads/%s" % branch, published, local or ZERO) + remember(branch, tracked="true") + print("%s: %s" % (branch, "merged into the workbench" if took else "the workbench already has it")) + + +def pull_requests(): + view = subprocess.run( + ["gh", "pr", "list", "--state", "all", "--limit", "200", "--json", + "number,state,headRefName,isCrossRepository,title,author"], + cwd=ROOT, capture_output=True, text=True) + if view.returncode: + return {} + found = {} + for pr in json.loads(view.stdout): + branch = "pr/%d" % pr["number"] if pr["isCrossRepository"] else pr["headRefName"] + found.setdefault(branch, pr) + return found + + +def unclaimed(prs): + """Open pull requests, other than the deposit robot's, that nothing here tracks.""" + mine = set(tracked()) + return [(branch, pr) for branch, pr in sorted(prs.items(), key=lambda item: item[1]["number"]) + if pr["state"] == "OPEN" and branch not in mine + and not pr.get("author", {}).get("login", "").startswith("app/")] + + +def sync(): + """Gather everything: main, and whatever has been pushed to a tracked branch. + + Notes are started wherever the runs are -- a hub session with the data + makes the skeleton and the figures -- and finished somewhere else, so the + workbench is where the pieces meet. A conflict on one branch is reported + and the rest carry on. + """ + require_bench() + git("fetch", "--quiet", "--prune", "origin") + try: + print("main: %s" % ("merged into the workbench" if absorb(commit_of(MAIN), "sync with main") + else "the workbench already has it")) + except Stop as stop: + print("main: %s" % stop) + prs = pull_requests() + for branch in tracked(): + pr = prs.get(branch) + if pr and pr["state"] == "MERGED": + forget(branch) + print("%s: merged in #%d, no longer tracked. The branch itself is " + "untouched." % (branch, pr["number"])) + continue + if pr and pr["state"] == "CLOSED": + print("%s: #%d was closed without merging. `pixi run note drop %s` " + "if it is finished with." % (branch, pr["number"], branch)) + continue + published = fetch(branch) if setting(branch, "fork") else commit_of(published_ref(branch)) + if published and not contains(published, BENCH): + try: + absorb(published, "pull %s" % branch) + print("%s: new commits merged into the workbench" % branch) + except Stop as stop: + print("%s: %s" % (branch, stop)) + waiting = unclaimed(prs) + if waiting: + print("\nOpen, and not on the bench (`pixi run note pull `):") + for branch, pr in waiting: + print(" #%-4d %-40s %s" % (pr["number"], branch, pr["title"])) + + +# -- list, init, drop -------------------------------------------------------- + +def listing(): + require_bench() + git("fetch", "--quiet", "--prune", "origin") + changed = dirty() + prs = pull_requests() + claimed = [] + for branch in tracked(): + if setting(branch, "fork"): + fetch(branch) + published = commit_of(published_ref(branch)) + local = commit_of("refs/heads/%s" % branch) + paths = owned(local or published, []) + claimed += paths + state = [] + if (local or published) and contains(local or published, MAIN): + state.append("all in main: `pixi run note sync` stops tracking it") + if any(under(path, paths) for path in changed): + state.append("unsaved edits") + if published and not contains(published, BENCH): + state.append("pushed to since you pulled: pull") + if local and local != published and (not published or contains(published, local)): + state.append("saved, not pushed") + if setting(branch, "carriedby"): + state.append("carried by %s" % setting(branch, "carriedby")) + pr = prs.get(branch) + print(" %-44s %-14s %s" % (branch, "#%d %s" % (pr["number"], pr["state"].lower()) if pr else "no PR", + ", ".join(state) or "in step")) + loose = sorted({"/".join(path.split("/")[:2]) if path.startswith("articles/") else path + for path in changed if not under(path, claimed) and path != GENERATED}) + if loose: + print("\nEdited, but on no branch yet:") + for path in loose: + print(" %s" % path) + waiting = unclaimed(prs) + if waiting: + print("\nOpen, and not on the bench (`pixi run note pull `):") + for branch, pr in waiting: + print(" #%-4d %-40s %s" % (pr["number"], branch, pr["title"])) + + +def init(): + head = run("symbolic-ref", "-q", "HEAD").stdout.strip() + if head == BENCH: + print("This checkout is already the workbench.") + return + if commit_of(BENCH): + sys.exit("A workbench branch exists, but this checkout is on %s. Switch to " + "it when nothing here is unsaved." % head.replace("refs/heads/", "")) + # Same commit, so no file changes: whatever was being edited stays put. + git("switch", "-q", "-c", "workbench") + if head.startswith("refs/heads/") and head != "refs/heads/main": + remember(head[len("refs/heads/"):], tracked="true") + print("This checkout is now the workbench (it was on %s)." % head.replace("refs/heads/", "")) + + +def drop(target): + branch, _ = resolve(target) + forget(branch) + print("%s is no longer tracked. Its files and the branch are untouched." % branch) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + sub = parser.add_subparsers(dest="command", required=True) + sub.add_parser("init", help="make this checkout the workbench") + saving = sub.add_parser("save", help="commit a note, or named paths, onto its branch") + saving.add_argument("target", help="a slug, a branch, or a pull request number") + saving.add_argument("paths", nargs="*", help="further paths the branch owns") + saving.add_argument("-m", "--message", required=True) + saving.add_argument("--push", action="store_true") + pulling = sub.add_parser("pull", help="merge a branch or pull request into the workbench") + pulling.add_argument("target") + sub.add_parser("sync", help="merge main into the workbench") + sub.add_parser("list", help="what is tracked, and what each needs") + dropping = sub.add_parser("drop", help="stop tracking a branch") + dropping.add_argument("target") + args = parser.parse_args() + + if args.command == "init": + init() + elif args.command == "save": + save(args.target, args.paths, args.message, args.push) + elif args.command == "pull": + pull(args.target) + elif args.command == "sync": + sync() + elif args.command == "list": + listing() + else: + drop(args.target) + + +if __name__ == "__main__": + main() diff --git a/scripts/workbench_guard.py b/scripts/workbench_guard.py new file mode 100644 index 0000000..cf75a2e --- /dev/null +++ b/scripts/workbench_guard.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Stop a Claude session from moving the workbench out from under the others. + +A PreToolUse hook on Bash (see .claude/settings.json). Several sessions share +the workbench checkout, each writing its own note; one of them running +``git checkout`` or ``git stash`` changes or hides every other session's files +at once. scripts/note.py does what those commands would otherwise be wanted +for, without touching the working tree. + +It acts only when the checkout is on the workbench branch, so a checkout on +an ordinary branch -- a contributor's, or a worktree -- is left alone. +""" + +import json +import re +import subprocess +import sys + +# Each is (pattern on the arguments after the subcommand, what to do instead). +RULES = { + "checkout": (r"", "it changes files other sessions are editing. " + "Save with `pixi run note save`; a branch never needs checking out here."), + "switch": (r"", "the workbench stays on the workbench branch. `pixi run note save` builds branches without switching."), + "stash": (r"^(?!\s*(list|show)\b)", "it hides every session's edits, not just yours."), + "reset": (r"--hard|--keep|--merge|--soft|^(?!.*\s--(\s|$))\s*\S", + "only the path form (`git reset -- `) is safe here; anything else moves the workbench or other sessions' files."), + "restore": (r"^(?!.*--staged)|--worktree|\s-W\b", "it discards working-tree edits, which may be another session's."), + "clean": (r"", "it deletes untracked files, which include other sessions' new notes."), + "rebase": (r"", "the workbench's history is how `note save` knows what it has seen; rewriting it breaks that."), + "pull": (r"", "use `pixi run note pull ` or `pixi run note sync`."), + "commit": (r"(^|\s)(-a|--all|-[a-zA-Z]*a[a-zA-Z]*)(\s|$)", "`-a` commits every session's edits together. Use `pixi run note save`."), +} + +GIT = re.compile(r"(?:^|[;&|(\n]|&&|\|\|)\s*git((?:\s+-[Cc]\s+\S+)*)\s+([a-z-]+)([^;&|\n]*)") + + +def on_workbench(cwd, options): + target = re.search(r"-C\s+(\S+)", options or "") + path = target.group(1).strip("'\"") if target else cwd + head = subprocess.run(["git", "-C", path, "symbolic-ref", "-q", "HEAD"], + capture_output=True, text=True).stdout.strip() + return head == "refs/heads/workbench" + + +def main(): + event = json.load(sys.stdin) + command = event.get("tool_input", {}).get("command", "") + for options, sub, rest in GIT.findall(command): + rule = RULES.get(sub) + if not rule or not re.search(rule[0], rest): + continue + if not on_workbench(event.get("cwd", "."), options): + return + print(json.dumps({"hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": + "`git %s` is blocked in the workbench: %s See CLAUDE.md." % (sub, rule[1]), + }})) + return + + +if __name__ == "__main__": + main() From bbff50d40dae43c5bac639103bb33810ac8f1b28 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Mon, 26 Oct 2026 16:20:42 +1100 Subject: [PATCH 2/2] note save: a note owns its directory, not every file it once touched A save carries the workbench's copy of every path the branch owns, and a note's branch owned any file it had ever changed. 2026-022 had edited WRITING-PLAN.md, so saving it carried #24's edits to that file too, and carried #24 itself as a contribution, since #24 edits nothing else. Now a branch with an article in it owns only articles/ paths unless others are named; and any branch leaves out a file outside articles/ that another tracked branch also edits, saying so, unless that file is named. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011S876ckjYqqth1aPyojrhh --- scripts/note.py | 44 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/scripts/note.py b/scripts/note.py index de4fe6d..73d39ab 100644 --- a/scripts/note.py +++ b/scripts/note.py @@ -226,13 +226,21 @@ def owned(tip, named): A note owns its whole directory, so a file added to it is carried without being named. The registers and myst.yml come only by name: a branch that - once committed them does not thereby own everyone's entries. + once committed them does not thereby own everyone's entries. Nor does a + note's branch own a file outside articles/ that it once edited -- + WRITING-PLAN.md, say: saving the note would carry the workbench's copy, with + every other branch's edits to it, and would carry as a contribution any + branch that edits nothing else. A branch with no article in it owns all it + changed, having nothing else to own. """ named = {path.rstrip("/") for path in named} paths = set(named) if tip: base = line("merge-base", MAIN, tip) - paths |= set(git("diff", "--name-only", "--no-renames", base, tip).splitlines()) + changed = set(git("diff", "--name-only", "--no-renames", base, tip).splitlines()) + if any(path.startswith("articles/") for path in changed | paths): + changed = {path for path in changed if path.startswith("articles/")} + paths |= changed collapsed = set() for path in paths: parts = path.split("/") @@ -240,6 +248,24 @@ def owned(tip, named): return sorted(collapsed - ((set(REGISTERS) | {GENERATED}) - named)) +def edited_elsewhere(branch, paths): + """Those of these paths outside articles/ that another tracked branch edits too. + + The workbench's copy of such a file holds both branches' edits, so saving + it whole would hand one branch's work to the other. + """ + outside = {path for path in paths if not path.startswith("articles/")} + found = {} + for other in tracked(): + tip = commit_of(published_ref(other)) or commit_of("refs/heads/%s" % other) + if other == branch or not tip or not outside: + continue + base = line("merge-base", MAIN, tip) + for path in outside & set(git("diff", "--name-only", "--no-renames", base, tip).splitlines()): + found.setdefault(path, []).append(other) + return found + + def slugs_in(paths): return sorted({path.split("/")[1] for path in paths if path.startswith("articles/") and path.count("/") == 1}) @@ -436,6 +462,16 @@ def save(target, named, message, push): # of what it merges, or a later sync would think main was in it. parents = [published or local or line("merge-base", BENCH, MAIN)] paths = owned(parents[0], named) + # Named outright, a file goes whole: whoever names it has looked. + shared = {path: others for path, others in edited_elsewhere(branch, paths).items() + if path not in {name.rstrip("/") for name in named}} + paths = [path for path in paths if path not in shared] + if not paths and shared: + sys.exit("Nothing saved: every file %s changes is edited by another branch too:\n%s\n" + "Name the ones to save the workbench's copy of, whole: " + "`pixi run note save %s ...`" + % (branch, "\n".join(" %s (%s)" % (path, ", ".join(others)) + for path, others in sorted(shared.items())), target)) if not paths: sys.exit("%s owns nothing yet. Name what it is for: " "`pixi run note save %s ...`" % (branch, target)) @@ -500,6 +536,10 @@ def save(target, named, message, push): stdin="\0".join(snapshot) + "\0") remember(branch, tracked="true") + for path, others in sorted(shared.items()): + print("left %s as it was on %s: %s edit%s it too. Name it to save the " + "workbench's copy whole." % (path, branch, " and ".join(others), + "s" if len(others) == 1 else "")) for other, _ in carried: remember(other, carriedby=branch) print("carries %s: its pull request closes as merged when %s's does" % (other, branch))