After a PR is opened from a git worktree, tells Claude if the worktree can be closed (clean and pushed) or what is still not saved.

Four small Claude Code mods. Two of them check the Bash commands Claude runs and block commits without a ticket and CI reruns. One tells Claude when a git worktree can be removed after it opens a pull request. One warns you when memory or disk is low.
![A terminal where commit-ticket blocks git commit -m "fix stuff" on the branch feature/ABC-12-login-redirect and allows "[ABC-12] Fix login redirect", and guard-actions blocks gh run rerun and allows gh run view.](docs/demo.gif)
The demo runs the real decision code of two mods with demo/run-hook.sh, so the commands are only checked and never run. Inside Claude Code the same code runs on every Bash command that Claude tries to run.
Claude Code loads every plugin in ~/.claude/skills/<name> in the next session. Clone this repository, then link the mods you want from its root:
for m in commit-ticket guard-actions close-worktree live-resources; do
ln -s "$PWD/$m" ~/.claude/skills/$m
done
To disable a mod, remove its link. To check a mod before you link it, run claude plugin validate commit-ticket and claude plugin test commit-ticket. The requirements are below.
You can write rules like "always put the ticket in the commit message" in CLAUDE.md, but Claude can forget them in a long session. commit-ticket and guard-actions check every Bash command before it runs, block the ones that break a rule, and tell Claude the reason and what to do instead. The logic of each mod is plain TypeScript in hooks/lib.ts, with unit tests next to it.
The rules are fixed in the code. There is no config file. If your ticket format or your CI rules are different, fork the repository and change hooks/lib.ts.
These are personal tools that I use every day. There are no releases and no stability promise, and the behavior can change.
claude plugin test --help works, your version has them. I use Claude Code 2.1.296.live-resources works only on macOS, because it reads memory with vm_stat.guard-actions and close-worktree are about the GitHub CLI (gh). commit-ticket and close-worktree need git.This mod reads the message of every git commit that Claude runs. When the branch name has a ticket, like feature/ABC-5-something, the message must start with a ticket in brackets, like [ABC-5] Fix overflow. If it does not, the mod blocks the commit and suggests the message with the ticket of the branch. It also blocks commits that only fix linting or formatting, like [ABC-5] Fix linting, because the result of an autofix (eslint --fix, prettier --write) should go inside the commit of the change that caused it.
The mod does not check branches without a ticket, merge commits, or messages that it cannot read with confidence, like -F file or --amend --no-edit. To skip the check once, add the comment # ok-commit at the end of the command.
This mod blocks gh run watch, gh run rerun and gh pr checks --watch, because every rerun or wait for CI uses GitHub Actions minutes, and many organizations pay for them. Read-only commands like gh run view or gh run list are allowed, also inside a sleep loop. When Claude opens a second pull request in the same session, the mod shows a message that suggests one integration pull request instead.
To run a blocked command once, add the comment # ok-actions at the end of the command.
After Claude runs gh pr create from a git worktree, this mod checks the worktree. If it has no uncommitted files and no unpushed commits, it tells Claude that the worktree can be removed and gives the exact git worktree remove command. If something is still pending, it tells Claude what is missing and asks it not to remove the worktree. The mod only gives advice. It never deletes anything.
Worktrees that nobody removes stay on disk, and many of them can fill it. The local and remote branches stay after the worktree is removed, so you can create it again with git worktree add if the review asks for changes.
In interactive sessions, this mod checks free memory and free disk space every 30 seconds. When memory goes under 2.5 GB or disk goes under 10 GB, it shows a status line and a short message, and the status line also counts the running test runners (jest, vitest, rspec, playwright, pytest and others). Under 1 GB of memory or 5 GB of disk, the status line is marked as critical. When everything is fine, it shows nothing.
It exists because test runners that run in parallel can use all the memory of a laptop and freeze it.
demo/run-hook.sh builds the Bash tool call that Claude Code would pass to a mod, pipes it as JSON into demo/run-hook.ts, and prints the decision. It supports commit-ticket and guard-actions, and it never runs the command:
demo/run-hook.sh guard-actions 'gh run watch 123'
To record the GIF again, install VHS and run vhs docs/demo.tape from the root of the repository. The tape works in a new temporary git repository.
The logic of each mod is in hooks/lib.ts, with its tests in hooks/lib.test.ts. The file hooks/register.ts only connects that logic to the hook events of Claude Code.
claude plugin validate <mod> # checks the manifest and the hooks
claude plugin test <mod> # runs the *.test.ts files of the mod
MIT. See LICENSE.
hooks/register.ts 57 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { advice, commandDir, parseLayout, PR_CREATE } from './lib'
4import type { Probe } from './lib'
5
6async function probe($: EngineInterface, cwd: string): Promise<Probe | null> {
7 const run = (argv: string[]) => $.process.run(argv, { cwd, timeoutMs: 10_000 })
8
9 // Absolute paths: in a subdirectory, git-dir is absolute but git-common-dir is relative.
10 const layout = await run([
11 'git',
12 'rev-parse',
13 '--path-format=absolute',
14 '--git-dir',
15 '--git-common-dir',
16 '--show-toplevel',
17 ])
18 if (layout.exitCode !== 0) return null
19
20 const [status, ahead] = await Promise.all([
21 run(['git', 'status', '--porcelain']),
22 run(['git', 'rev-list', '--count', '@{u}..HEAD']),
23 ])
24
25 return {
26 ...parseLayout(layout.stdout),
27 dirtyFiles: status.stdout.split('\n').filter(Boolean).length,
28 unpushed: ahead.exitCode === 0 ? Number(ahead.stdout.trim()) : null,
29 }
30}
31
32export const register: Register = on => {
33 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
34 const ran = await next(e)
35
36 if (!PR_CREATE.test(e.command) || ran.deny !== undefined || ran.isError === true) {
37 return ran
38 }
39
40 try {
41 const home = (await $.env.get('HOME')) ?? ''
42 const cwd = commandDir(e.command, home) ?? (await $.session.cwd())
43 const found = await probe($, cwd)
44 const message = found && advice(found)
45
46 if (message) {
47 $.ui.toast('PR opened from a worktree: check if it can be closed now', { timeoutMs: 8_000 })
48 return { ...ran, context: [...(ran.context ?? []), message] }
49 }
50 } catch {
51 // If the worktree could not be checked, the PR is already open: do not get in the way.
52 }
53
54 return ran
55 })
56}
57hooks/lib.ts 51 lines1export const PR_CREATE = /\bgh\s+pr\s+create\b/
2
3// Directory where the command runs: the first `cd X` or the `git -C X`.
4// If there is none, the command runs in the session directory.
5export function commandDir(command: string, home: string): string | null {
6 const raw =
7 /^\s*cd\s+("[^"]+"|'[^']+'|[^\s&;]+)/m.exec(command)?.[1] ??
8 /\bgit\s+-C\s+("[^"]+"|'[^']+'|\S+)/.exec(command)?.[1]
9 if (!raw) return null
10 const dir = raw.replace(/^["']|["']$/g, '')
11 return dir.startsWith('~') ? home + dir.slice(1) : dir
12}
13
14export type Probe = {
15 isWorktree: boolean
16 root: string
17 mainRepo: string
18 dirtyFiles: number
19 unpushed: number | null
20}
21
22// Output of `git rev-parse --git-dir --git-common-dir --show-toplevel`.
23export function parseLayout(revParse: string): { isWorktree: boolean; root: string; mainRepo: string } {
24 const [gitDir = '', common = '', root = ''] = revParse.trim().split('\n').map(s => s.trim())
25 const absolute = (p: string): string => (p.startsWith('/') ? p : `${root}/${p}`)
26 const isWorktree = Boolean(gitDir && common) && absolute(gitDir) !== absolute(common)
27 return { isWorktree, root, mainRepo: absolute(common).replace(/\/\.git\/?$/, '') }
28}
29
30export function advice(probe: Probe): string | null {
31 if (!probe.isWorktree) return null
32
33 const pending: string[] = []
34 if (probe.dirtyFiles > 0) pending.push(`${probe.dirtyFiles} uncommitted files`)
35 if (probe.unpushed === null) pending.push('unpushed commits (the branch has no upstream)')
36 else if (probe.unpushed > 0) pending.push(`${probe.unpushed} unpushed commits`)
37
38 if (pending.length > 0) {
39 return (
40 `close-worktree: the PR was opened from the worktree ${probe.root}, but it still has ${pending.join(' and ')}. ` +
41 'Do not remove it: fix that first and tell the user.'
42 )
43 }
44
45 return (
46 `close-worktree: the PR was opened from the worktree ${probe.root} and it is clean and pushed. ` +
47 `Remove it now with \`git -C ${probe.mainRepo} worktree remove ${probe.root}\` ` +
48 '(the local and remote branches stay; if the review asks for changes, create it again with `git worktree add`).'
49 )
50}
51