forge-orkes 0.77.0 → 0.79.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-forge.js +27 -0
- package/experimental/m10/README.md +4 -4
- package/experimental/m10/install.sh +10 -9
- package/experimental/m10/source/hooks/protect-primary-checkout.sh +149 -0
- package/package.json +1 -1
- package/template/.claude/hooks/protect-primary-checkout.sh +149 -0
- package/template/.claude/hooks/tests/protect-primary-checkout.test.sh +147 -0
- package/template/.claude/settings.json +9 -0
- package/template/.claude/skills/initializing/SKILL.md +20 -1
- package/template/.claude/skills/reviewing/SKILL.md +17 -0
- package/template/.claude/skills/upgrading/SKILL.md +22 -2
- package/template/.forge/FORGE.md +7 -4
- package/template/.forge/bin/new-worktree.sh +61 -0
- package/template/.forge/bin/tests/new-worktree.test.sh +70 -0
- package/template/.forge/git-hooks/pre-commit +57 -0
- package/template/.forge/git-hooks/tests/pre-commit.test.sh +97 -0
- package/template/.forge/migrations/0.78.0-weld-primary-checkout.md +66 -0
- package/template/.forge/templates/state/desire-path.yml +3 -0
- package/experimental/m10/source/hooks/forge-branch-guard.sh +0 -61
package/bin/create-forge.js
CHANGED
|
@@ -124,6 +124,24 @@ function makeHookScriptsExecutable(hooksDir) {
|
|
|
124
124
|
try { walk(hooksDir); } catch { /* best-effort */ }
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Make the tracked git hooks under .forge/git-hooks/ executable (issue #21 / ADR-031).
|
|
129
|
+
* These are git hooks (pre-commit, …) with NO .sh extension, so makeHookScriptsExecutable
|
|
130
|
+
* (which only chmods *.sh) misses them — chmod them explicitly. Best-effort; never throws.
|
|
131
|
+
* NOTE: this function deliberately does NO `git config core.hooksPath` — per-clone wiring
|
|
132
|
+
* (with the Husky/lefthook conflict warning) is owned by the initializing/upgrading skills,
|
|
133
|
+
* not the installer, which stays git-config-free and runs unattended in CI.
|
|
134
|
+
*/
|
|
135
|
+
function makeGitHooksExecutable(forgeDir) {
|
|
136
|
+
const gitHooksDir = path.join(forgeDir, 'git-hooks');
|
|
137
|
+
if (!fs.existsSync(gitHooksDir)) return;
|
|
138
|
+
for (const entry of fs.readdirSync(gitHooksDir, { withFileTypes: true })) {
|
|
139
|
+
if (entry.isFile() && !entry.name.startsWith('.')) {
|
|
140
|
+
try { fs.chmodSync(path.join(gitHooksDir, entry.name), 0o755); } catch { /* best-effort */ }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
127
145
|
/**
|
|
128
146
|
* Additively sync a framework-managed `.claude/` dir into an existing project on
|
|
129
147
|
* upgrade (ADR-017/018). Copies/updates the template's files, but NEVER deletes
|
|
@@ -498,6 +516,7 @@ async function install() {
|
|
|
498
516
|
const srcForge = path.join(templateDir, '.forge');
|
|
499
517
|
const destForge = path.join(targetDir, '.forge');
|
|
500
518
|
const forgeCount = copyDirRecursive(srcForge, destForge);
|
|
519
|
+
makeGitHooksExecutable(destForge); // extensionless git hooks (.forge/git-hooks/pre-commit)
|
|
501
520
|
console.log(` Installed .forge/templates/ (${forgeCount} files)`);
|
|
502
521
|
|
|
503
522
|
// npm strips files literally named `.gitignore` from the published tarball, so
|
|
@@ -858,6 +877,14 @@ async function upgrade() {
|
|
|
858
877
|
// protected set (.forge/checks/**) can guard them. Additive for the same
|
|
859
878
|
// reason as above: never delete a project-local extra check.
|
|
860
879
|
upgradeAdditiveDir('.forge/checks', results);
|
|
880
|
+
// 2-git-hooks. Sync the tracked git hooks (issue #21 / ADR-031: the primary-checkout
|
|
881
|
+
// weld pre-commit backstop). Tracked at .forge/git-hooks/ for the same reason as
|
|
882
|
+
// .forge/checks/ — .claude/hooks/ is commonly gitignored, and a git hook must be
|
|
883
|
+
// tracked to run for humans/CI. Additive; then chmod the extensionless hooks
|
|
884
|
+
// (upgradeAdditiveDir only +x's *.sh). Per-clone core.hooksPath wiring is the
|
|
885
|
+
// initializing/upgrading skills' job, NOT the installer's — see makeGitHooksExecutable.
|
|
886
|
+
upgradeAdditiveDir('.forge/git-hooks', results);
|
|
887
|
+
makeGitHooksExecutable(path.join(targetDir, '.forge'));
|
|
861
888
|
|
|
862
889
|
// 2a. Confirm stale framework-file removals before deleting (R066a). Defaults
|
|
863
890
|
// to yes (cleanup is the normal case); auto-confirms when non-interactive
|
|
@@ -55,7 +55,7 @@ Idempotent — re-running emits warnings instead of errors.
|
|
|
55
55
|
If you'd rather see exactly what changes:
|
|
56
56
|
|
|
57
57
|
1. Copy `source/mcp-server/` → `<target>/.forge/.mcp-server/`
|
|
58
|
-
2. Copy `source/hooks/forge-claim-check.sh`, `source/hooks/forge-claim-check-doctor.sh`, `source/hooks/forge-session-id.sh`, and `source/hooks/
|
|
58
|
+
2. Copy `source/hooks/forge-claim-check.sh`, `source/hooks/forge-claim-check-doctor.sh`, `source/hooks/forge-session-id.sh`, and `source/hooks/protect-primary-checkout.sh` → `<target>/.claude/hooks/` (`chmod +x`)
|
|
59
59
|
3. Copy `source/skills/orchestrating/` → `<target>/.claude/skills/orchestrating/`
|
|
60
60
|
4. Merge `forge-orchestrator` entry into `<target>/.mcp.json`:
|
|
61
61
|
```json
|
|
@@ -71,12 +71,12 @@ If you'd rather see exactly what changes:
|
|
|
71
71
|
{ "hooks": [{ "type": "command", "command": ".claude/hooks/forge-session-id.sh" }] }
|
|
72
72
|
]
|
|
73
73
|
```
|
|
74
|
-
Also add the
|
|
74
|
+
Also add the primary-checkout guardrail under `PreToolUse`:
|
|
75
75
|
```json
|
|
76
76
|
{ "matcher": "Bash",
|
|
77
|
-
"hooks": [{ "type": "command", "command": ".claude/hooks/
|
|
77
|
+
"hooks": [{ "type": "command", "command": ".claude/hooks/protect-primary-checkout.sh" }] }
|
|
78
78
|
```
|
|
79
|
-
The SessionStart hook captures the Claude session id (claim identity); without it the enforcement gate fail-opens (ADR-007). The
|
|
79
|
+
The SessionStart hook captures the Claude session id (claim identity); without it the enforcement gate fail-opens (ADR-007). The primary-checkout guard welds the primary checkout to the default branch — all branch work happens in worktrees (issue #21, ADR-031) — orchestration-agnostic, independent of the claim layer. It supersedes the old forge-branch-guard.sh (ADR-008) and folds in its hard-reset protection.
|
|
80
80
|
6. `cd <target>/.forge/.mcp-server && npm install`
|
|
81
81
|
|
|
82
82
|
## Verify
|
|
@@ -72,7 +72,7 @@ echo
|
|
|
72
72
|
# Step 3: copy hooks
|
|
73
73
|
echo "==> Hooks → .claude/hooks/"
|
|
74
74
|
do_or_say "mkdir -p '$TARGET/.claude/hooks'"
|
|
75
|
-
for h in forge-claim-check.sh forge-claim-check-doctor.sh forge-session-id.sh
|
|
75
|
+
for h in forge-claim-check.sh forge-claim-check-doctor.sh forge-session-id.sh protect-primary-checkout.sh; do
|
|
76
76
|
if [[ -f "$TARGET/.claude/hooks/$h" ]]; then
|
|
77
77
|
say "skip: $h already exists"
|
|
78
78
|
else
|
|
@@ -161,21 +161,22 @@ else
|
|
|
161
161
|
fi
|
|
162
162
|
echo
|
|
163
163
|
|
|
164
|
-
# Step 6c: register
|
|
165
|
-
#
|
|
166
|
-
|
|
167
|
-
|
|
164
|
+
# Step 6c: register the primary-checkout branch weld (PreToolUse on Bash). Welds the
|
|
165
|
+
# primary checkout to the default branch — all branch work happens in worktrees (issue
|
|
166
|
+
# #21). Orchestration-agnostic; supersedes the old forge-branch-guard.sh.
|
|
167
|
+
echo "==> .claude/settings.json primary-checkout guard hook"
|
|
168
|
+
BG_CMD='.claude/hooks/protect-primary-checkout.sh'
|
|
168
169
|
if [[ ! -f "$SETTINGS" ]]; then
|
|
169
|
-
say "[dry-run] settings.json not yet created —
|
|
170
|
+
say "[dry-run] settings.json not yet created — primary-checkout guard would be added alongside the others"
|
|
170
171
|
elif jq -e --arg cmd "$BG_CMD" '.hooks.PreToolUse[]?.hooks[]? | select(.command == $cmd)' "$SETTINGS" >/dev/null 2>&1; then
|
|
171
|
-
say "skip:
|
|
172
|
+
say "skip: protect-primary-checkout.sh hook already registered"
|
|
172
173
|
else
|
|
173
174
|
if [[ $DRY_RUN -eq 0 ]]; then
|
|
174
175
|
tmp=$(mktemp)
|
|
175
|
-
jq '.hooks.PreToolUse += [{"matcher":"Bash","hooks":[{"type":"command","command":".claude/hooks/
|
|
176
|
+
jq '.hooks.PreToolUse += [{"matcher":"Bash","hooks":[{"type":"command","command":".claude/hooks/protect-primary-checkout.sh"}]}]' "$SETTINGS" > "$tmp"
|
|
176
177
|
mv "$tmp" "$SETTINGS"
|
|
177
178
|
fi
|
|
178
|
-
say "appended
|
|
179
|
+
say "appended primary-checkout guard PreToolUse(Bash) hook entry"
|
|
179
180
|
fi
|
|
180
181
|
echo
|
|
181
182
|
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Weld the primary checkout to the default branch — deny branch create/switch there.
|
|
3
|
+
#
|
|
4
|
+
# PreToolUse(Bash) hook. Exit 2 (+ JSON permissionDecision:deny) = block. Exit 0 = allow.
|
|
5
|
+
#
|
|
6
|
+
# WHY: the primary checkout's HEAD is shared mutable state across every concurrent
|
|
7
|
+
# Claude session running in that directory. One session running `git checkout -b`
|
|
8
|
+
# silently redirects every OTHER session's subsequent commits onto its branch — an
|
|
9
|
+
# invisible clobber that surfaces later as wrong-branch commits, contaminated PRs,
|
|
10
|
+
# stranded milestone state, or dropped history (issue #21: 3 confirmed incidents —
|
|
11
|
+
# m85's 12-commit drop, m62's stranded phase commits, a Quick-tier checkout -b that
|
|
12
|
+
# contaminated a concurrent PR). Branch work belongs in worktrees, which each own
|
|
13
|
+
# their own HEAD.
|
|
14
|
+
#
|
|
15
|
+
# Scope: fires ONLY in the primary checkout (git-dir == git-common-dir). Linked
|
|
16
|
+
# worktrees are unaffected (their git-dir differs). Unlike the dormant
|
|
17
|
+
# forge-branch-guard.sh this supersedes, the branch weld is UNCONDITIONAL — it fires
|
|
18
|
+
# even when no sibling worktree exists yet, because the second session's worktree is
|
|
19
|
+
# typically created AFTER the offending `checkout -b`. This hook also folds in the
|
|
20
|
+
# one distinct protection the old guard provided: denying a hard-reset in the primary
|
|
21
|
+
# checkout while sibling worktrees are branched off it (the history-rewrite class).
|
|
22
|
+
#
|
|
23
|
+
# Default branch is resolved dynamically (main/master + init.defaultBranch), never
|
|
24
|
+
# hardcoded — Forge ships to repos on any default branch.
|
|
25
|
+
#
|
|
26
|
+
# Fails OPEN (exit 0 / allow) on any malfunction (no jq, empty command, non-git cwd,
|
|
27
|
+
# git probe error) — a guard must never wedge the agent on its own failure.
|
|
28
|
+
#
|
|
29
|
+
# Escape hatch (deliberate, human use): ALLOW_MAIN_BRANCHING=1
|
|
30
|
+
|
|
31
|
+
set -uo pipefail
|
|
32
|
+
|
|
33
|
+
emit_deny() {
|
|
34
|
+
local reason="${1//\"/\\\"}"
|
|
35
|
+
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$reason"
|
|
36
|
+
exit 2
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
[ "${ALLOW_MAIN_BRANCHING:-0}" = "1" ] && exit 0
|
|
40
|
+
|
|
41
|
+
command -v jq >/dev/null 2>&1 || exit 0
|
|
42
|
+
|
|
43
|
+
INPUT=$(cat)
|
|
44
|
+
COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true)
|
|
45
|
+
[ -z "$COMMAND" ] && exit 0
|
|
46
|
+
|
|
47
|
+
# Normalize the cheapest whitespace-substitution evasion (`git checkout$IFS-b`) to a
|
|
48
|
+
# real space before matching. A string matcher on an un-expanded shell command can
|
|
49
|
+
# never be complete against expansion — the pre-commit backstop is the sound layer —
|
|
50
|
+
# but closing the trivial $IFS trick keeps the PreToolUse layer honest.
|
|
51
|
+
COMMAND=$(printf '%s' "$COMMAND" | sed -E 's/\$\{?IFS\}?/ /g')
|
|
52
|
+
|
|
53
|
+
# Git allows global options BEFORE the subcommand: `git -c k=v checkout`,
|
|
54
|
+
# `git --no-pager switch`, `git -C <path> -p checkout`, etc. The guard must tolerate
|
|
55
|
+
# ANY run of them, not just a single `-C <path>` — otherwise `git -c x=y checkout -b`
|
|
56
|
+
# sails past (issue #21 regression). GITPRE matches `git` + zero-or-more global options
|
|
57
|
+
# (a `-x`/`--long`/`--long=val`/`-C <path>`/`-c <kv>` token, each optionally taking the
|
|
58
|
+
# next word as its value) up to the subcommand.
|
|
59
|
+
GITPRE='git[[:space:]]+((-[A-Za-z]|--[A-Za-z-]+)(=[^[:space:]]+)?[[:space:]]+([^-][^[:space:]]*[[:space:]]+)?)*'
|
|
60
|
+
|
|
61
|
+
# Fast path: only inspect commands that touch checkout/switch/reset at all. Anything
|
|
62
|
+
# else exits immediately WITHOUT any git probe (keeps the hook cheap on every Bash call).
|
|
63
|
+
printf '%s' "$COMMAND" | grep -qE "${GITPRE}(checkout|switch|reset)([[:space:]]|\$)" || exit 0
|
|
64
|
+
|
|
65
|
+
# Resolve which repo dir the command targets: honor `git -C <path>`, else the hook cwd.
|
|
66
|
+
CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)
|
|
67
|
+
[ -z "$CWD" ] && CWD=$(pwd)
|
|
68
|
+
# Honor a GLOBAL `git -C <path>` (before the subcommand) only. Isolate the pre-subcommand
|
|
69
|
+
# segment first so `switch -C`/`checkout -C` (create-force, AFTER the subcommand) is never
|
|
70
|
+
# mistaken for a target-dir option.
|
|
71
|
+
PRE=$(printf '%s' "$COMMAND" | sed -E 's/[[:space:]](checkout|switch|reset)[[:space:]].*$//')
|
|
72
|
+
GC_PATH=$(printf '%s' "$PRE" | grep -oE '(^|[[:space:]])-C[[:space:]]+[^[:space:]]+' | head -1 | awk '{print $2}' || true)
|
|
73
|
+
# A flag-shaped -C value (e.g. `-C --foo`) is not a real target dir → ignore it.
|
|
74
|
+
case "$GC_PATH" in -*) GC_PATH="" ;; esac
|
|
75
|
+
TARGET_DIR="${GC_PATH:-$CWD}"
|
|
76
|
+
|
|
77
|
+
GIT_DIR=$(git -C "$TARGET_DIR" rev-parse --git-dir 2>/dev/null) || exit 0
|
|
78
|
+
COMMON_DIR=$(git -C "$TARGET_DIR" rev-parse --git-common-dir 2>/dev/null) || exit 0
|
|
79
|
+
GIT_DIR_ABS=$(cd "$TARGET_DIR" 2>/dev/null && cd "$GIT_DIR" 2>/dev/null && pwd) || exit 0
|
|
80
|
+
COMMON_DIR_ABS=$(cd "$TARGET_DIR" 2>/dev/null && cd "$COMMON_DIR" 2>/dev/null && pwd) || exit 0
|
|
81
|
+
|
|
82
|
+
# In a linked worktree git-dir != git-common-dir → branch ops are fine there.
|
|
83
|
+
[ "$GIT_DIR_ABS" != "$COMMON_DIR_ABS" ] && exit 0
|
|
84
|
+
|
|
85
|
+
# ── Default-branch set (dynamic — mirrors block-dangerous-commands.sh) ───────────
|
|
86
|
+
# Seed main+master, add the configured init.defaultBranch. A switch/checkout TO any
|
|
87
|
+
# of these names is "returning home" and always allowed.
|
|
88
|
+
DEFAULT_BRANCHES="main master"
|
|
89
|
+
GIT_DEFAULT=$(git -C "$TARGET_DIR" config --get init.defaultBranch 2>/dev/null || true)
|
|
90
|
+
[ -n "$GIT_DEFAULT" ] && DEFAULT_BRANCHES="$DEFAULT_BRANCHES $GIT_DEFAULT"
|
|
91
|
+
is_default_branch() {
|
|
92
|
+
for b in $DEFAULT_BRANCHES; do [ "$1" = "$b" ] && return 0; done
|
|
93
|
+
return 1
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
DENY_MSG="Blocked: the primary checkout's HEAD stays on the default branch — branch create/switch here silently redirects every other concurrent session's commits onto your branch (recurring clobber, issue #21). Do branch work in a worktree: bash .forge/bin/new-worktree.sh <slug> (or git worktree add). To operate on an existing worktree from here, use git -C <worktree-path>. Human escape hatch: ALLOW_MAIN_BRANCHING=1."
|
|
97
|
+
|
|
98
|
+
# ── 1) Branch creation / orphan / detach / track: always deny in primary checkout ──
|
|
99
|
+
if printf '%s' "$COMMAND" | grep -qE "${GITPRE}checkout[[:space:]]+((-[a-zA-Z]*[bB][a-zA-Z]*|--orphan|--detach|--track)([[:space:]=]|\$))"; then
|
|
100
|
+
emit_deny "$DENY_MSG"
|
|
101
|
+
fi
|
|
102
|
+
if printf '%s' "$COMMAND" | grep -qE "${GITPRE}switch[[:space:]]+((-[cC]|--create|--force-create|--orphan|--detach|--track)([[:space:]=]|\$))"; then
|
|
103
|
+
emit_deny "$DENY_MSG"
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
# ── 2) hard/keep/merge reset in the primary checkout while worktrees are branched ──
|
|
107
|
+
# Folded from the superseded forge-branch-guard.sh: a reset that rewrites HEAD +
|
|
108
|
+
# working tree here can drop verified work committed under sibling worktrees. Gated
|
|
109
|
+
# on >1 worktree (unlike the branch weld, which is unconditional).
|
|
110
|
+
if printf '%s' "$COMMAND" | grep -qE "${GITPRE}reset[[:space:]]+(--hard|--keep|--merge)([[:space:]]|\$)"; then
|
|
111
|
+
wt_count=$(git -C "$TARGET_DIR" worktree list 2>/dev/null | wc -l | tr -d ' ')
|
|
112
|
+
if [ "${wt_count:-1}" -gt 1 ]; then
|
|
113
|
+
emit_deny "Refusing to move the shared primary HEAD via reset — $wt_count worktrees are branched off it (the 'burned twice' history-rewrite failure, issue #21). Reset inside a worktree, or stop the other sessions first. Human escape hatch: ALLOW_MAIN_BRANCHING=1."
|
|
114
|
+
fi
|
|
115
|
+
exit 0 # single-worktree hard-reset is the operator's own business — allow.
|
|
116
|
+
fi
|
|
117
|
+
|
|
118
|
+
# ── 3) checkout/switch to a ref other than the default branch ───────────────────
|
|
119
|
+
# Only checkout/switch move HEAD to a ref. A reset that reached this far (--soft /
|
|
120
|
+
# --mixed, or a hard/keep/merge reset in a single-worktree repo) is not a branch move
|
|
121
|
+
# off the default → allow it and stop.
|
|
122
|
+
printf '%s' "$COMMAND" | grep -qE "${GITPRE}(checkout|switch)([[:space:]]|\$)" || exit 0
|
|
123
|
+
|
|
124
|
+
# `git checkout <ref> -- <paths>` and `git checkout -- <paths>` never move HEAD →
|
|
125
|
+
# allow anything containing a ` -- ` pathspec separator.
|
|
126
|
+
printf '%s' "$COMMAND" | grep -qE '[[:space:]]--([[:space:]]|$)' && exit 0
|
|
127
|
+
|
|
128
|
+
# Extract the first non-flag argument after checkout/switch. Strip everything up to and
|
|
129
|
+
# including the subcommand keyword — whatever git global options preceded it (`-c`,
|
|
130
|
+
# `--no-pager`, `-C <path>`, …) are consumed by the greedy prefix. The trailing separator
|
|
131
|
+
# is optional so a BARE `git checkout` (no target) yields empty ARGS → allowed below.
|
|
132
|
+
ARGS=$(printf '%s' "$COMMAND" | sed -E 's/.*[[:space:]](checkout|switch)([[:space:]]+|$)/ /' | head -1)
|
|
133
|
+
FIRST_REF=""
|
|
134
|
+
for word in $ARGS; do
|
|
135
|
+
case "$word" in
|
|
136
|
+
-*) continue ;; # flags (creation flags already denied above)
|
|
137
|
+
\;|\&\&|\|\||\||\&) break ;; # end of this git command
|
|
138
|
+
*) FIRST_REF="$word"; break ;;
|
|
139
|
+
esac
|
|
140
|
+
done
|
|
141
|
+
|
|
142
|
+
# Bare `git checkout`/`git switch` (no target) is a no-op/error → allow.
|
|
143
|
+
[ -z "$FIRST_REF" ] && exit 0
|
|
144
|
+
|
|
145
|
+
# Returning home (default branch) or file-restore forms → allow. Any other ref → deny.
|
|
146
|
+
case "$FIRST_REF" in
|
|
147
|
+
HEAD|.) exit 0 ;; # file restore forms
|
|
148
|
+
*) is_default_branch "$FIRST_REF" && exit 0 || emit_deny "$DENY_MSG" ;;
|
|
149
|
+
esac
|
package/package.json
CHANGED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Weld the primary checkout to the default branch — deny branch create/switch there.
|
|
3
|
+
#
|
|
4
|
+
# PreToolUse(Bash) hook. Exit 2 (+ JSON permissionDecision:deny) = block. Exit 0 = allow.
|
|
5
|
+
#
|
|
6
|
+
# WHY: the primary checkout's HEAD is shared mutable state across every concurrent
|
|
7
|
+
# Claude session running in that directory. One session running `git checkout -b`
|
|
8
|
+
# silently redirects every OTHER session's subsequent commits onto its branch — an
|
|
9
|
+
# invisible clobber that surfaces later as wrong-branch commits, contaminated PRs,
|
|
10
|
+
# stranded milestone state, or dropped history (issue #21: 3 confirmed incidents —
|
|
11
|
+
# m85's 12-commit drop, m62's stranded phase commits, a Quick-tier checkout -b that
|
|
12
|
+
# contaminated a concurrent PR). Branch work belongs in worktrees, which each own
|
|
13
|
+
# their own HEAD.
|
|
14
|
+
#
|
|
15
|
+
# Scope: fires ONLY in the primary checkout (git-dir == git-common-dir). Linked
|
|
16
|
+
# worktrees are unaffected (their git-dir differs). Unlike the dormant
|
|
17
|
+
# forge-branch-guard.sh this supersedes, the branch weld is UNCONDITIONAL — it fires
|
|
18
|
+
# even when no sibling worktree exists yet, because the second session's worktree is
|
|
19
|
+
# typically created AFTER the offending `checkout -b`. This hook also folds in the
|
|
20
|
+
# one distinct protection the old guard provided: denying a hard-reset in the primary
|
|
21
|
+
# checkout while sibling worktrees are branched off it (the history-rewrite class).
|
|
22
|
+
#
|
|
23
|
+
# Default branch is resolved dynamically (main/master + init.defaultBranch), never
|
|
24
|
+
# hardcoded — Forge ships to repos on any default branch.
|
|
25
|
+
#
|
|
26
|
+
# Fails OPEN (exit 0 / allow) on any malfunction (no jq, empty command, non-git cwd,
|
|
27
|
+
# git probe error) — a guard must never wedge the agent on its own failure.
|
|
28
|
+
#
|
|
29
|
+
# Escape hatch (deliberate, human use): ALLOW_MAIN_BRANCHING=1
|
|
30
|
+
|
|
31
|
+
set -uo pipefail
|
|
32
|
+
|
|
33
|
+
emit_deny() {
|
|
34
|
+
local reason="${1//\"/\\\"}"
|
|
35
|
+
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$reason"
|
|
36
|
+
exit 2
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
[ "${ALLOW_MAIN_BRANCHING:-0}" = "1" ] && exit 0
|
|
40
|
+
|
|
41
|
+
command -v jq >/dev/null 2>&1 || exit 0
|
|
42
|
+
|
|
43
|
+
INPUT=$(cat)
|
|
44
|
+
COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true)
|
|
45
|
+
[ -z "$COMMAND" ] && exit 0
|
|
46
|
+
|
|
47
|
+
# Normalize the cheapest whitespace-substitution evasion (`git checkout$IFS-b`) to a
|
|
48
|
+
# real space before matching. A string matcher on an un-expanded shell command can
|
|
49
|
+
# never be complete against expansion — the pre-commit backstop is the sound layer —
|
|
50
|
+
# but closing the trivial $IFS trick keeps the PreToolUse layer honest.
|
|
51
|
+
COMMAND=$(printf '%s' "$COMMAND" | sed -E 's/\$\{?IFS\}?/ /g')
|
|
52
|
+
|
|
53
|
+
# Git allows global options BEFORE the subcommand: `git -c k=v checkout`,
|
|
54
|
+
# `git --no-pager switch`, `git -C <path> -p checkout`, etc. The guard must tolerate
|
|
55
|
+
# ANY run of them, not just a single `-C <path>` — otherwise `git -c x=y checkout -b`
|
|
56
|
+
# sails past (issue #21 regression). GITPRE matches `git` + zero-or-more global options
|
|
57
|
+
# (a `-x`/`--long`/`--long=val`/`-C <path>`/`-c <kv>` token, each optionally taking the
|
|
58
|
+
# next word as its value) up to the subcommand.
|
|
59
|
+
GITPRE='git[[:space:]]+((-[A-Za-z]|--[A-Za-z-]+)(=[^[:space:]]+)?[[:space:]]+([^-][^[:space:]]*[[:space:]]+)?)*'
|
|
60
|
+
|
|
61
|
+
# Fast path: only inspect commands that touch checkout/switch/reset at all. Anything
|
|
62
|
+
# else exits immediately WITHOUT any git probe (keeps the hook cheap on every Bash call).
|
|
63
|
+
printf '%s' "$COMMAND" | grep -qE "${GITPRE}(checkout|switch|reset)([[:space:]]|\$)" || exit 0
|
|
64
|
+
|
|
65
|
+
# Resolve which repo dir the command targets: honor `git -C <path>`, else the hook cwd.
|
|
66
|
+
CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)
|
|
67
|
+
[ -z "$CWD" ] && CWD=$(pwd)
|
|
68
|
+
# Honor a GLOBAL `git -C <path>` (before the subcommand) only. Isolate the pre-subcommand
|
|
69
|
+
# segment first so `switch -C`/`checkout -C` (create-force, AFTER the subcommand) is never
|
|
70
|
+
# mistaken for a target-dir option.
|
|
71
|
+
PRE=$(printf '%s' "$COMMAND" | sed -E 's/[[:space:]](checkout|switch|reset)[[:space:]].*$//')
|
|
72
|
+
GC_PATH=$(printf '%s' "$PRE" | grep -oE '(^|[[:space:]])-C[[:space:]]+[^[:space:]]+' | head -1 | awk '{print $2}' || true)
|
|
73
|
+
# A flag-shaped -C value (e.g. `-C --foo`) is not a real target dir → ignore it.
|
|
74
|
+
case "$GC_PATH" in -*) GC_PATH="" ;; esac
|
|
75
|
+
TARGET_DIR="${GC_PATH:-$CWD}"
|
|
76
|
+
|
|
77
|
+
GIT_DIR=$(git -C "$TARGET_DIR" rev-parse --git-dir 2>/dev/null) || exit 0
|
|
78
|
+
COMMON_DIR=$(git -C "$TARGET_DIR" rev-parse --git-common-dir 2>/dev/null) || exit 0
|
|
79
|
+
GIT_DIR_ABS=$(cd "$TARGET_DIR" 2>/dev/null && cd "$GIT_DIR" 2>/dev/null && pwd) || exit 0
|
|
80
|
+
COMMON_DIR_ABS=$(cd "$TARGET_DIR" 2>/dev/null && cd "$COMMON_DIR" 2>/dev/null && pwd) || exit 0
|
|
81
|
+
|
|
82
|
+
# In a linked worktree git-dir != git-common-dir → branch ops are fine there.
|
|
83
|
+
[ "$GIT_DIR_ABS" != "$COMMON_DIR_ABS" ] && exit 0
|
|
84
|
+
|
|
85
|
+
# ── Default-branch set (dynamic — mirrors block-dangerous-commands.sh) ───────────
|
|
86
|
+
# Seed main+master, add the configured init.defaultBranch. A switch/checkout TO any
|
|
87
|
+
# of these names is "returning home" and always allowed.
|
|
88
|
+
DEFAULT_BRANCHES="main master"
|
|
89
|
+
GIT_DEFAULT=$(git -C "$TARGET_DIR" config --get init.defaultBranch 2>/dev/null || true)
|
|
90
|
+
[ -n "$GIT_DEFAULT" ] && DEFAULT_BRANCHES="$DEFAULT_BRANCHES $GIT_DEFAULT"
|
|
91
|
+
is_default_branch() {
|
|
92
|
+
for b in $DEFAULT_BRANCHES; do [ "$1" = "$b" ] && return 0; done
|
|
93
|
+
return 1
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
DENY_MSG="Blocked: the primary checkout's HEAD stays on the default branch — branch create/switch here silently redirects every other concurrent session's commits onto your branch (recurring clobber, issue #21). Do branch work in a worktree: bash .forge/bin/new-worktree.sh <slug> (or git worktree add). To operate on an existing worktree from here, use git -C <worktree-path>. Human escape hatch: ALLOW_MAIN_BRANCHING=1."
|
|
97
|
+
|
|
98
|
+
# ── 1) Branch creation / orphan / detach / track: always deny in primary checkout ──
|
|
99
|
+
if printf '%s' "$COMMAND" | grep -qE "${GITPRE}checkout[[:space:]]+((-[a-zA-Z]*[bB][a-zA-Z]*|--orphan|--detach|--track)([[:space:]=]|\$))"; then
|
|
100
|
+
emit_deny "$DENY_MSG"
|
|
101
|
+
fi
|
|
102
|
+
if printf '%s' "$COMMAND" | grep -qE "${GITPRE}switch[[:space:]]+((-[cC]|--create|--force-create|--orphan|--detach|--track)([[:space:]=]|\$))"; then
|
|
103
|
+
emit_deny "$DENY_MSG"
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
# ── 2) hard/keep/merge reset in the primary checkout while worktrees are branched ──
|
|
107
|
+
# Folded from the superseded forge-branch-guard.sh: a reset that rewrites HEAD +
|
|
108
|
+
# working tree here can drop verified work committed under sibling worktrees. Gated
|
|
109
|
+
# on >1 worktree (unlike the branch weld, which is unconditional).
|
|
110
|
+
if printf '%s' "$COMMAND" | grep -qE "${GITPRE}reset[[:space:]]+(--hard|--keep|--merge)([[:space:]]|\$)"; then
|
|
111
|
+
wt_count=$(git -C "$TARGET_DIR" worktree list 2>/dev/null | wc -l | tr -d ' ')
|
|
112
|
+
if [ "${wt_count:-1}" -gt 1 ]; then
|
|
113
|
+
emit_deny "Refusing to move the shared primary HEAD via reset — $wt_count worktrees are branched off it (the 'burned twice' history-rewrite failure, issue #21). Reset inside a worktree, or stop the other sessions first. Human escape hatch: ALLOW_MAIN_BRANCHING=1."
|
|
114
|
+
fi
|
|
115
|
+
exit 0 # single-worktree hard-reset is the operator's own business — allow.
|
|
116
|
+
fi
|
|
117
|
+
|
|
118
|
+
# ── 3) checkout/switch to a ref other than the default branch ───────────────────
|
|
119
|
+
# Only checkout/switch move HEAD to a ref. A reset that reached this far (--soft /
|
|
120
|
+
# --mixed, or a hard/keep/merge reset in a single-worktree repo) is not a branch move
|
|
121
|
+
# off the default → allow it and stop.
|
|
122
|
+
printf '%s' "$COMMAND" | grep -qE "${GITPRE}(checkout|switch)([[:space:]]|\$)" || exit 0
|
|
123
|
+
|
|
124
|
+
# `git checkout <ref> -- <paths>` and `git checkout -- <paths>` never move HEAD →
|
|
125
|
+
# allow anything containing a ` -- ` pathspec separator.
|
|
126
|
+
printf '%s' "$COMMAND" | grep -qE '[[:space:]]--([[:space:]]|$)' && exit 0
|
|
127
|
+
|
|
128
|
+
# Extract the first non-flag argument after checkout/switch. Strip everything up to and
|
|
129
|
+
# including the subcommand keyword — whatever git global options preceded it (`-c`,
|
|
130
|
+
# `--no-pager`, `-C <path>`, …) are consumed by the greedy prefix. The trailing separator
|
|
131
|
+
# is optional so a BARE `git checkout` (no target) yields empty ARGS → allowed below.
|
|
132
|
+
ARGS=$(printf '%s' "$COMMAND" | sed -E 's/.*[[:space:]](checkout|switch)([[:space:]]+|$)/ /' | head -1)
|
|
133
|
+
FIRST_REF=""
|
|
134
|
+
for word in $ARGS; do
|
|
135
|
+
case "$word" in
|
|
136
|
+
-*) continue ;; # flags (creation flags already denied above)
|
|
137
|
+
\;|\&\&|\|\||\||\&) break ;; # end of this git command
|
|
138
|
+
*) FIRST_REF="$word"; break ;;
|
|
139
|
+
esac
|
|
140
|
+
done
|
|
141
|
+
|
|
142
|
+
# Bare `git checkout`/`git switch` (no target) is a no-op/error → allow.
|
|
143
|
+
[ -z "$FIRST_REF" ] && exit 0
|
|
144
|
+
|
|
145
|
+
# Returning home (default branch) or file-restore forms → allow. Any other ref → deny.
|
|
146
|
+
case "$FIRST_REF" in
|
|
147
|
+
HEAD|.) exit 0 ;; # file restore forms
|
|
148
|
+
*) is_default_branch "$FIRST_REF" && exit 0 || emit_deny "$DENY_MSG" ;;
|
|
149
|
+
esac
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Scratch-repo test suite for .claude/hooks/protect-primary-checkout.sh (issue #21).
|
|
3
|
+
#
|
|
4
|
+
# Each case builds a throwaway git repo (and, where needed, a linked worktree), pipes
|
|
5
|
+
# a PreToolUse JSON payload to the hook, and asserts the exit code. Exit 2 = deny,
|
|
6
|
+
# exit 0 = allow. No network, no fixtures. Self-cleans on exit. Run:
|
|
7
|
+
# ./.claude/hooks/tests/protect-primary-checkout.test.sh
|
|
8
|
+
# Prints `ALL PASS` and exits 0 iff every case passes; non-zero on any failure.
|
|
9
|
+
|
|
10
|
+
set -uo pipefail
|
|
11
|
+
|
|
12
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
13
|
+
HOOK="$SCRIPT_DIR/../protect-primary-checkout.sh"
|
|
14
|
+
[ -x "$HOOK" ] || { printf 'FATAL: hook not executable: %s\n' "$HOOK" >&2; exit 1; }
|
|
15
|
+
command -v jq >/dev/null 2>&1 || { printf 'FATAL: jq required\n' >&2; exit 1; }
|
|
16
|
+
|
|
17
|
+
ROOT="$(mktemp -d)"
|
|
18
|
+
trap 'rm -rf "$ROOT"' EXIT INT TERM
|
|
19
|
+
|
|
20
|
+
PASSED=0
|
|
21
|
+
FAILED=0
|
|
22
|
+
|
|
23
|
+
# new_repo <name> [default-branch] — fresh repo with one commit. Prints its path.
|
|
24
|
+
new_repo() {
|
|
25
|
+
local d="$ROOT/$1" br="${2:-main}"
|
|
26
|
+
mkdir -p "$d"
|
|
27
|
+
git -C "$d" init -q
|
|
28
|
+
git -C "$d" symbolic-ref HEAD "refs/heads/$br"
|
|
29
|
+
[ "$br" != "main" ] && git -C "$d" config init.defaultBranch "$br"
|
|
30
|
+
git -C "$d" config user.email t@example.com
|
|
31
|
+
git -C "$d" config user.name tester
|
|
32
|
+
: > "$d/seed"
|
|
33
|
+
git -C "$d" add -A && git -C "$d" commit -qm init
|
|
34
|
+
printf '%s\n' "$d"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# run_hook <dir> <command> [env-assignment] — pipes a payload, returns the hook exit code.
|
|
38
|
+
run_hook() {
|
|
39
|
+
local dir="$1" cmd="$2" envset="${3:-}"
|
|
40
|
+
local payload
|
|
41
|
+
payload=$(jq -nc --arg c "$cmd" --arg d "$dir" '{tool_input:{command:$c},cwd:$d}')
|
|
42
|
+
if [ -n "$envset" ]; then
|
|
43
|
+
printf '%s' "$payload" | env "$envset" "$HOOK" >/dev/null 2>&1
|
|
44
|
+
else
|
|
45
|
+
printf '%s' "$payload" | "$HOOK" >/dev/null 2>&1
|
|
46
|
+
fi
|
|
47
|
+
return $?
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# assert <desc> <expected-exit> <dir> <command> [env]
|
|
51
|
+
assert() {
|
|
52
|
+
local desc="$1" want="$2" dir="$3" cmd="$4" envset="${5:-}"
|
|
53
|
+
local got
|
|
54
|
+
run_hook "$dir" "$cmd" "$envset"; got=$?
|
|
55
|
+
if [ "$got" -eq "$want" ]; then
|
|
56
|
+
PASSED=$((PASSED + 1))
|
|
57
|
+
# printf ' PASS: %s\n' "$desc"
|
|
58
|
+
else
|
|
59
|
+
FAILED=$((FAILED + 1))
|
|
60
|
+
printf ' FAIL: %s (got exit %s, want %s)\n' "$desc" "$got" "$want" >&2
|
|
61
|
+
fi
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
DENY=2
|
|
65
|
+
ALLOW=0
|
|
66
|
+
|
|
67
|
+
# ── Primary checkout: DENY branch creation / orphan / detach / track ──────────────
|
|
68
|
+
P="$(new_repo primary)"
|
|
69
|
+
assert "primary: checkout -b denies" "$DENY" "$P" "git checkout -b feature"
|
|
70
|
+
assert "primary: checkout -B denies" "$DENY" "$P" "git checkout -B feature"
|
|
71
|
+
assert "primary: switch -c denies" "$DENY" "$P" "git switch -c feature"
|
|
72
|
+
assert "primary: switch -C denies" "$DENY" "$P" "git switch -C feature"
|
|
73
|
+
assert "primary: switch --create denies" "$DENY" "$P" "git switch --create feature"
|
|
74
|
+
assert "primary: checkout --orphan denies" "$DENY" "$P" "git checkout --orphan gh-pages"
|
|
75
|
+
assert "primary: checkout --detach denies" "$DENY" "$P" "git checkout --detach"
|
|
76
|
+
assert "primary: checkout --track denies" "$DENY" "$P" "git checkout --track origin/x"
|
|
77
|
+
assert "primary: checkout other-branch denies" "$DENY" "$P" "git checkout somebranch"
|
|
78
|
+
assert "primary: switch other-branch denies" "$DENY" "$P" "git switch somebranch"
|
|
79
|
+
|
|
80
|
+
# ── Global-option bypass regression (review m-49 F1/F2): git global options before ──
|
|
81
|
+
# the subcommand must NOT slip the guard. These were live bypasses before the fix.
|
|
82
|
+
assert "primary: -c global opt + checkout -b denies" "$DENY" "$P" "git -c core.editor=vi checkout -b evil"
|
|
83
|
+
assert "primary: --no-pager + checkout -b denies" "$DENY" "$P" "git --no-pager checkout -b evil"
|
|
84
|
+
assert "primary: -p + checkout -b denies" "$DENY" "$P" "git -p checkout -b evil"
|
|
85
|
+
assert "primary: multi -c + switch -c denies" "$DENY" "$P" "git -c a=b -c c=d switch -c evil"
|
|
86
|
+
assert "primary: \$IFS substitution + -b denies" "$DENY" "$P" "git checkout\$IFS-b evil"
|
|
87
|
+
assert "primary: -c global opt + checkout main allows" "$ALLOW" "$P" "git -c x=y checkout main"
|
|
88
|
+
|
|
89
|
+
# ── The UNCONDITIONAL delta: deny even with NO sibling worktree (P has exactly 1). ─
|
|
90
|
+
# (P above already has no siblings — the denies above ARE the no-sibling case. Assert
|
|
91
|
+
# explicitly that worktree count is 1 here, so the intent is documented.)
|
|
92
|
+
wt_n=$(git -C "$P" worktree list | wc -l | tr -d ' ')
|
|
93
|
+
if [ "$wt_n" -eq 1 ]; then PASSED=$((PASSED+1)); else FAILED=$((FAILED+1)); printf ' FAIL: expected 1 worktree in fresh primary, got %s\n' "$wt_n" >&2; fi
|
|
94
|
+
|
|
95
|
+
# ── Primary checkout: ALLOW safe forms ───────────────────────────────────────────
|
|
96
|
+
assert "primary: checkout main allows (home)" "$ALLOW" "$P" "git checkout main"
|
|
97
|
+
assert "primary: switch main allows (home)" "$ALLOW" "$P" "git switch main"
|
|
98
|
+
assert "primary: checkout -- file allows (restore)" "$ALLOW" "$P" "git checkout -- seed"
|
|
99
|
+
assert "primary: checkout . allows (restore)" "$ALLOW" "$P" "git checkout ."
|
|
100
|
+
assert "primary: checkout HEAD -- file allows" "$ALLOW" "$P" "git checkout HEAD -- seed"
|
|
101
|
+
assert "primary: bare checkout allows" "$ALLOW" "$P" "git checkout"
|
|
102
|
+
assert "primary: git status allows (fast-path)" "$ALLOW" "$P" "git status"
|
|
103
|
+
assert "primary: git commit allows (fast-path)" "$ALLOW" "$P" "git commit -m x"
|
|
104
|
+
assert "primary: git log allows (fast-path)" "$ALLOW" "$P" "git log --oneline"
|
|
105
|
+
|
|
106
|
+
# ── Worktree pass-through: git-dir != git-common-dir → never blocked ──────────────
|
|
107
|
+
WT="$ROOT/primary-wt"
|
|
108
|
+
git -C "$P" worktree add -q -b wtbranch "$WT" >/dev/null 2>&1
|
|
109
|
+
assert "worktree cwd: checkout -b allows" "$ALLOW" "$WT" "git checkout -b anything"
|
|
110
|
+
assert "worktree cwd: switch -c allows" "$ALLOW" "$WT" "git switch -c anything"
|
|
111
|
+
# From the PRIMARY checkout, `git -C <worktree>` targets the worktree → allow.
|
|
112
|
+
assert "primary + git -C worktree: checkout -b allows" "$ALLOW" "$P" "git -C $WT checkout -b anything"
|
|
113
|
+
|
|
114
|
+
# ── master-default repo: home = master; create still denied ───────────────────────
|
|
115
|
+
M="$(new_repo master-primary master)"
|
|
116
|
+
assert "master repo: checkout master allows (home)" "$ALLOW" "$M" "git checkout master"
|
|
117
|
+
assert "master repo: checkout -b denies" "$DENY" "$M" "git checkout -b x"
|
|
118
|
+
assert "master repo: checkout main denies (main is not this repo's default... but seeded)" "$ALLOW" "$M" "git checkout main"
|
|
119
|
+
# ^ main+master are BOTH always in the seed set, so `checkout main` is allowed even in
|
|
120
|
+
# a master-default repo. That's intentional (both are canonical default names).
|
|
121
|
+
|
|
122
|
+
# ── Escape hatch ─────────────────────────────────────────────────────────────────
|
|
123
|
+
assert "escape hatch: checkout -b allows" "$ALLOW" "$P" "git checkout -b feature" "ALLOW_MAIN_BRANCHING=1"
|
|
124
|
+
|
|
125
|
+
# ── Folded hard-reset guard (worktree-count gated) ───────────────────────────────
|
|
126
|
+
# P now has 1 sibling worktree (WT added above) → count > 1 → hard reset denied.
|
|
127
|
+
assert "primary +wt: reset --hard denies" "$DENY" "$P" "git reset --hard HEAD"
|
|
128
|
+
assert "primary +wt: reset --keep denies" "$DENY" "$P" "git reset --keep HEAD"
|
|
129
|
+
assert "primary +wt: reset --soft allows (not hard)" "$ALLOW" "$P" "git reset --soft HEAD"
|
|
130
|
+
# A repo with a single worktree → hard reset is the operator's business → allow.
|
|
131
|
+
S="$(new_repo solo)"
|
|
132
|
+
assert "solo (1 wt): reset --hard allows" "$ALLOW" "$S" "git reset --hard HEAD"
|
|
133
|
+
|
|
134
|
+
# ── Fail-open cases ──────────────────────────────────────────────────────────────
|
|
135
|
+
assert "fail-open: empty command allows" "$ALLOW" "$P" ""
|
|
136
|
+
assert "fail-open: non-checkout command allows" "$ALLOW" "$P" "ls -la"
|
|
137
|
+
# Non-git cwd → fail open. Use a plain temp dir with a checkout-shaped command.
|
|
138
|
+
NG="$ROOT/notgit"; mkdir -p "$NG"
|
|
139
|
+
assert "fail-open: non-git cwd allows" "$ALLOW" "$NG" "git checkout -b x"
|
|
140
|
+
|
|
141
|
+
# ── Summary ──────────────────────────────────────────────────────────────────────
|
|
142
|
+
printf '\n%d passed, %d failed\n' "$PASSED" "$FAILED"
|
|
143
|
+
if [ "$FAILED" -eq 0 ]; then
|
|
144
|
+
printf 'ALL PASS\n'
|
|
145
|
+
exit 0
|
|
146
|
+
fi
|
|
147
|
+
exit 1
|
|
@@ -141,6 +141,15 @@
|
|
|
141
141
|
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/block-dangerous-commands.sh\""
|
|
142
142
|
}
|
|
143
143
|
]
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"matcher": "Bash",
|
|
147
|
+
"hooks": [
|
|
148
|
+
{
|
|
149
|
+
"type": "command",
|
|
150
|
+
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/protect-primary-checkout.sh\""
|
|
151
|
+
}
|
|
152
|
+
]
|
|
144
153
|
}
|
|
145
154
|
]
|
|
146
155
|
}
|
|
@@ -390,7 +390,26 @@ User selects per stack.
|
|
|
390
390
|
|
|
391
391
|
**Confirm with the user first** in brownfield (this edits a user-owned file). In greenfield with no existing `.gitignore`, write it directly. Re-run is idempotent — the carve-out lines `grep` to themselves.
|
|
392
392
|
|
|
393
|
-
8. **
|
|
393
|
+
8. **Wire the primary-checkout weld (`core.hooksPath`).** Welds the primary checkout to the default branch so all branch work happens in worktrees — every tier (issue #21, ADR-031). The tracked `.forge/git-hooks/pre-commit` backstop only fires once `core.hooksPath` points at it. This is per-clone git config, so init owns it. **Detect + warn, never clobber:**
|
|
394
|
+
|
|
395
|
+
```bash
|
|
396
|
+
want=".forge/git-hooks"
|
|
397
|
+
cur=$(git config --get core.hooksPath 2>/dev/null || true)
|
|
398
|
+
if [ -z "$cur" ] || [ "$cur" = "$want" ]; then
|
|
399
|
+
git config core.hooksPath "$want" # repo-relative → resolves inside every worktree
|
|
400
|
+
echo "[Forge] core.hooksPath → $want (primary-checkout weld active)."
|
|
401
|
+
else
|
|
402
|
+
echo "[Forge] core.hooksPath is already set to '$cur' (Husky/lefthook?). NOT overwriting."
|
|
403
|
+
echo " The primary-checkout pre-commit backstop (.forge/git-hooks/pre-commit) is"
|
|
404
|
+
echo " therefore inactive. To keep the weld, chain it into your existing hooks dir"
|
|
405
|
+
echo " (call .forge/git-hooks/pre-commit from your pre-commit), or accept the"
|
|
406
|
+
echo " PreToolUse-only coverage (Claude sessions are still guarded)."
|
|
407
|
+
fi
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
**WHY repo-relative** (`.forge/git-hooks`, not an absolute path): an absolute `core.hooksPath` silently skips in linked worktrees (the documented Husky/lefthook worktree hazard — see `orchestrating/bootstrap-checks.md`); a repo-relative path resolves under each worktree checkout. **WHY never clobber:** silently disabling a consumer's existing hook framework is unacceptable.
|
|
411
|
+
|
|
412
|
+
9. **State-sync commit**: `git add .gitignore .claude/settings.json .claude/skills/ .claude/agents/ .claude/hooks/ .forge/` then `git commit -m "chore(forge): initialize forge state + track product files"` (scoped; never `git add .`) — so the new project's state and Forge product files are both in git from the start. (The tracked `.forge/git-hooks/` is included by the `.forge/` path; `core.hooksPath` itself is local git config, not a tracked file, so it needs no staging.)
|
|
394
413
|
|
|
395
414
|
*"Initialized. Ready?"*
|
|
396
415
|
|
|
@@ -312,6 +312,22 @@ Deferred issues triage: Glob `.forge/deferred-issues/*.md` (else legacy `.forge/
|
|
|
312
312
|
- **Fix-now** → route to `planning` fix mode before completing milestone
|
|
313
313
|
- **Dismiss** → set `status: dismissed` + `dismissed_reason` in the issue's file (or the legacy file)
|
|
314
314
|
|
|
315
|
+
Close-the-loop triage (advisory — never blocks): catches shipped-but-still-open GitHub issues. Forge produces resolution artifacts (ADRs, desire-paths, CHANGELOG/commits) that cite the issue they resolve, but nobody circles back to close the issue — the open list accumulates already-done work. This step cross-references those artifacts against **open** issues and surfaces likely-resolved ones for the operator to close. **Detect-and-surface only — NEVER auto-close** (closing a GitHub issue is outward-facing → operator confirmation, agent safety rules).
|
|
316
|
+
|
|
317
|
+
1. **Gate / degrade first.** Resolve `forge.upstream_repo` from `project.yml`. If absent, OR `gh` not on PATH, OR `gh auth status` fails, OR offline → **skip the whole step** (at most one log line: *"close-the-loop: gh unavailable — skipped"*). Never blocks reviewing or completion. (Same posture as `forge/desire-paths-review.md`'s resolve→check-gh→offer and notion-integration's advisory-everywhere.)
|
|
318
|
+
2. **Collect resolution-intent `#N` refs produced THIS milestone** (milestone-scoped default — cheap, fires when the artifact is fresh):
|
|
319
|
+
- **ADRs** authored/touched this milestone whose `**Context source:**` / `resolves` / `closes` line cites `forge#N` (or the `…/issues/N` URL). **Exclude bare supersession / see-also mentions** — a *"Superseded by ADR-021 ([forge#19](…))"* note references #19, it does not resolve it. Only resolution-intent lines count.
|
|
320
|
+
- **desire-path** files with `resolves_issue: N`.
|
|
321
|
+
- **CHANGELOG / commit** lines landed this milestone with `closes|fixes|resolves #N`.
|
|
322
|
+
Scope by this milestone's git range / this milestone's artifacts — prose-directed grep, no executable.
|
|
323
|
+
3. **Intersect cited ∩ still-open.** `gh issue view N --json state,title` each; **keep only `state == OPEN`**. An `#N` gh can't resolve (nonexistent / transferred / errors on that call) OR reports CLOSED → **no candidate** (surface only gh-confirmed-open — zero false positives). Skip any `#N` with a `.forge/state/issue-loop/declined/{N}.yml` marker (glob first — mirrors the desire-path `declined/` skip).
|
|
324
|
+
4. **Surface survivors** (existing triage idiom): *"**Close-the-loop** ({N}): 1. #N '{title}' — looks resolved by {artifact} — close it? [y/N]"*.
|
|
325
|
+
- **y** (or "close") → **do NOT close autonomously.** Print the exact command for the operator to run (or to approve Forge running as an explicit per-action step): `gh issue close N --comment "Resolved by {artifact} ({ref}). Closing via Forge close-the-loop."`
|
|
326
|
+
- **N** (or "still open") → write `.forge/state/issue-loop/declined/{N}.yml` (`{issue: N, declined_at: <ISO date>, reason: "<one line>"}`; create the dir if absent) so it is not re-nagged. The marker is inert once the issue actually closes (gh stops returning it as open).
|
|
327
|
+
5. **Optional full sweep** (operator-invoked, natural language — no new command): if the operator asks *"run a full close-the-loop sweep"* / *"check for orphaned issues"*, run steps 1–4 over **all** open issues × **all** shipped resolution artifacts repo-wide (not just this milestone), same rules + `declined/` markers. This is the periodic-cleanup path; the milestone-scoped default stays quiet.
|
|
328
|
+
|
|
329
|
+
Zero candidates → silent (the common case). This step is **advisory — it never gates milestone completion** (see Gates).
|
|
330
|
+
|
|
315
331
|
## Step 7: Backlog + Route
|
|
316
332
|
|
|
317
333
|
### Backlog
|
|
@@ -371,6 +387,7 @@ Idempotent: a backlog with no terminal items and all-canonical statuses is left
|
|
|
371
387
|
- **Security/arch critical** -> soft gate (accept risk)
|
|
372
388
|
- **Warnings** -> advisory
|
|
373
389
|
- **Refactoring** -> never block
|
|
390
|
+
- **Close-the-loop** -> advisory, never blocks (detect-and-surface only; never auto-closes)
|
|
374
391
|
|
|
375
392
|
Report = audit trail.
|
|
376
393
|
|
|
@@ -119,6 +119,25 @@ Rebase now? (each / list-only / skip)
|
|
|
119
119
|
- **list-only / skip** → print the commands; the operator runs them at a safe stopping point.
|
|
120
120
|
- Gitignored carve-outs (`.mcp.json`, experimental hooks) are not carried by a rebase — whenever layer (b) re-synced anything, add: each worktree must re-run `experimental/{pkg}/install.sh`.
|
|
121
121
|
|
|
122
|
+
### e. Primary-checkout weld (`core.hooksPath`)
|
|
123
|
+
|
|
124
|
+
Runs on every upgrade (registry and Dev Mode alike) — the bin syncs the `.forge/git-hooks/` body, but `core.hooksPath` is per-clone git config the bin never touches (issue #21, ADR-031). Wire it with the **detect + warn, never clobber** policy — identical to `initializing` Finalize step 8. Main checkout only (`git rev-parse --git-dir` == `--git-common-dir`; from a worktree, skip — the primary checkout owns the config):
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
want=".forge/git-hooks"
|
|
128
|
+
cur=$(git config --get core.hooksPath 2>/dev/null || true)
|
|
129
|
+
if [ -z "$cur" ] || [ "$cur" = "$want" ]; then
|
|
130
|
+
git config core.hooksPath "$want" # repo-relative → resolves inside every worktree
|
|
131
|
+
echo "[Forge] core.hooksPath → $want (primary-checkout weld active)."
|
|
132
|
+
else
|
|
133
|
+
echo "[Forge] core.hooksPath is already '$cur' (Husky/lefthook?). NOT overwriting — the"
|
|
134
|
+
echo " primary-checkout pre-commit backstop is inactive. Chain .forge/git-hooks/pre-commit"
|
|
135
|
+
echo " into your existing hooks dir to keep the weld, or accept PreToolUse-only coverage."
|
|
136
|
+
fi
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Idempotent — re-running an upgrade that already wired it is a silent no-op. **WHY repo-relative + never-clobber:** same reasons as `initializing` step 8 (worktree-resolvable path; a consumer's Husky/lefthook must never be silently disabled).
|
|
140
|
+
|
|
122
141
|
## Dev Mode (`/upgrading dev` — explicit only)
|
|
123
142
|
|
|
124
143
|
Local-clone sync for developing Forge itself: test framework changes in a project without publishing to npm. Presence of `.forge/dev-source` NEVER selects this mode — only the explicit `dev` argument does.
|
|
@@ -141,7 +160,7 @@ Dev mode syncs from a checkout, so it keeps file-classification semantics — **
|
|
|
141
160
|
|
|
142
161
|
- **Framework-owned** (`.claude/agents/*.md`, `.claude/skills/*/SKILL.md`, `.forge/FORGE.md`) → overwrite when different. Ownership is per top-level **unit** — a skill DIR under `.claude/skills/`, a flat agent FILE under `.claude/agents/`: a unit the template doesn't ship (user-authored, vendored-parity, or an opt-in experimental skill) is **preserved, never deleted**; only stale files *inside* a still-shipped skill dir are cleaned. Experimental skills additionally get the staleness pass below.
|
|
143
162
|
- **Template-only** (`.forge/templates/**`, `.forge/migrations/**`, `.forge/adapters/**`, `.forge/gitignore` → `.forge/.gitignore`) → overwrite; `.forge/FORGE.md` lands BEFORE the CLAUDE.md pass.
|
|
144
|
-
- **Additive** (`.claude/hooks/**` incl. `tests/`, `.claude/rules/**`, `.forge/checks/**`) → add + overwrite template-shipped files, never delete local extras, `chmod +x` synced `*.sh`.
|
|
163
|
+
- **Additive** (`.claude/hooks/**` incl. `tests/`, `.claude/rules/**`, `.forge/checks/**`, `.forge/git-hooks/**`) → add + overwrite template-shipped files, never delete local extras, `chmod +x` synced `*.sh` (and the extensionless git hooks under `.forge/git-hooks/`).
|
|
145
164
|
- **Import-managed** `CLAUDE.md` → the bin's `ensureClaudeMdImport()` cases verbatim; user content outside a forge section is never modified.
|
|
146
165
|
- **Merge-owned** `.claude/settings.json` → `forge.*` keys + additive hook-group wiring only; stamp `forge.version` last.
|
|
147
166
|
- **Never touch** user-generated files: `.forge/project.yml`, `.forge/state/`, `.forge/constitution.md`, `.forge/context.md`, `.forge/requirements/`, `.forge/roadmap.yml`, `.forge/design-system.md`, `.forge/refactor-backlog.yml`.
|
|
@@ -150,11 +169,12 @@ Render the same report shape as the default path, with `Source: {path}` instead
|
|
|
150
169
|
|
|
151
170
|
### Post-sync layers
|
|
152
171
|
|
|
153
|
-
Re-use layers (a)–(
|
|
172
|
+
Re-use layers (a)–(e) with dev-derived inputs — this is the one mode that computes them itself, since there is no engine JSON:
|
|
154
173
|
|
|
155
174
|
- **(a) Migrations** — the data-driven Detection loop: select freshly-synced guides `.forge/migrations/{v}-*.md` where `installed < v <= source` (semver on the PRE-stamp installed version), run each guide's `## Detection` bash block from the project root; `MIGRATE` on stdout → the layer-(a) prompt with the text after `MIGRATE —` as the reason; no output → `{v}: no action`; block errors → `{v}: detection error (skipped)`, never abort the loop. Range bookkeeping is implicit: once `forge.version` is stamped, crossed guides fall out of the next run's range — no ledger.
|
|
156
175
|
- **(b) Experimental staleness** — diff installed non-template skill dirs against `{source}/packages/create-forge/experimental/*/source/skills/{name}/`; stale → the layer-(b) offer with `{source}` as the source path.
|
|
157
176
|
- **(c) Codex** — presence glob (`.agents/` / `.codex/`) → the layer-(c) offer.
|
|
158
177
|
- **(d) Worktrees** — `git worktree list --porcelain` filtered to `refs/heads/forge/m-*`, main checkout only (`git rev-parse --git-dir` == `--git-common-dir`; from a worktree, skip and say so) → the layer-(d) offer.
|
|
178
|
+
- **(e) Primary-checkout weld** — wire `core.hooksPath` with the detect+warn+never-clobber policy above (main checkout only). Same behavior in Dev Mode as on the registry path.
|
|
159
179
|
|
|
160
180
|
Same consent gates, same report lines as the registry path.
|
package/template/.forge/FORGE.md
CHANGED
|
@@ -59,6 +59,8 @@ Where worktrees live and what they're named — single source of truth, base con
|
|
|
59
59
|
|
|
60
60
|
**Recorded path is authoritative once set** — `lifecycle.worktree_path` (milestone) / `runtime.worktree` (stream) is read verbatim, never re-derived; changing `worktree_root` affects only new worktrees; move a live worktree only with `git worktree move` + update the record. No recorded path → resolve from the convention. `forge` boot and `chief-of-staff` adoption **validate advisorily**: recorded path outside the resolved root → warn (never block, never move); an explicit `worktree_root` is honored.
|
|
61
61
|
|
|
62
|
+
**Primary checkout is welded to the default branch — all tiers** ([ADR-031](../docs/decisions/ADR-031-weld-primary-checkout-to-default-branch.md)). Its HEAD is shared across concurrent sessions; a `checkout -b` there silently redirects others' commits. Enforced (`git-dir == git-common-dir` scoped, never touches worktrees): `.claude/hooks/protect-primary-checkout.sh` (PreToolUse — denies branch create/switch/detach + hard-reset) + `.forge/git-hooks/pre-commit` (off-default-commit backstop; wired per-clone by `initializing`/`upgrading` via repo-relative `core.hooksPath`, detect+warn+never-clobber). Branch work: `.forge/bin/new-worktree.sh <slug>`. Escape: `ALLOW_MAIN_BRANCHING=1`.
|
|
63
|
+
|
|
62
64
|
**`orchestration.worktree_hydrate`** (optional, `project.yml`; forge#18) lists superproject-relative paths to required-but-gitignored artifacts (e.g. `.mcp.json`) a fresh worktree's tracked-only checkout lacks. Copy is guarded `source-exists && dest-absent` — never overwrites, never `git add`s, so the secret stays untracked in both trees. Applied by `orchestrating` at creation and re-asserted idempotently in `executing`'s Workspace Prerequisites, covering worktrees of any origin. Omit ⇒ no propagation. The per-worktree runtime counterpart of the install-time carve-out propagation below (State Ownership § Stable shared).
|
|
63
65
|
|
|
64
66
|
## Workflow Tiers
|
|
@@ -99,7 +101,7 @@ Auto-detects complexity. Override: "Use Quick/Standard/Full tier."
|
|
|
99
101
|
| Break work into tasks with gates | `planning` | Standard, Full |
|
|
100
102
|
| Build with deviation rules + atomic commits | `executing` | All |
|
|
101
103
|
| Prove work delivers on goals (+ M9 e2e validation gate; captures the Human Verification Gate sign-off) | `verifying` | Standard, Full |
|
|
102
|
-
| Audit health + catalog refactoring (+ M9 e2e audits; hard-blocks completion on the Human Verification Gate) | `reviewing` | Standard, Full |
|
|
104
|
+
| Audit health + catalog refactoring (+ M9 e2e audits; hard-blocks completion on the Human Verification Gate) + close-the-loop (surface resolved-but-open issues) | `reviewing` | Standard, Full |
|
|
103
105
|
| Small scoped fix | `quick-tasking` | Quick |
|
|
104
106
|
| Rapid disposable UI mockup behind an alpha flag (no verify/review gate) | `prototyping` | Prototyping |
|
|
105
107
|
| UI with design system | `designing` | When UI |
|
|
@@ -195,10 +197,10 @@ State lives in `.forge/`. One line per artifact; protocol detail lives in the na
|
|
|
195
197
|
- `design-system.md` — component mapping table
|
|
196
198
|
- `requirements/m{N}.yml` — per-milestone structured requirements with `[NEEDS CLARIFICATION]` markers. **FR/DEF/NFR ids are globally unique across all milestone files** — reserve via the ID Reservation Protocol before writing. M9 e2e gate fields (`e2e`, `observable_outcome`, `validated`, hash) lazy-migrate; absent ⇒ `false`.
|
|
197
199
|
- `roadmap.yml` — phases, milestones, dependencies
|
|
198
|
-
- `state/index.yml` — DERIVED milestone registry, **git-ignored render-on-read cache** (0.53.0): regenerated at every boot by the **`forge-state-rollup.sh` SessionStart hook** (
|
|
200
|
+
- `state/index.yml` — DERIVED milestone registry, **git-ignored render-on-read cache** (0.53.0): regenerated at every boot from the milestone files by the **`forge-state-rollup.sh` SessionStart hook** (issue #22; skills only *read* it); never committed, never hand-edited. Rendered in any checkout (gitignored local write), but worktree agents never write *committed* derived-source state; absent on a fresh clone (sentinel is `project.yml`). Rows carry the derived `release_state`.
|
|
199
201
|
- **Derived `release_state`** ([ADR-024](../docs/decisions/ADR-024-merged-validated-fold.md)) — `{unmerged, merged, validated}` computed at rollup by `.claude/hooks/forge-release-fold.sh` (git-only, offline; the fold binary is the only source — agent prose never re-derives it), never stored. Validation binding via `forge.validation_event`: `in_session_signoff` (default; absent ⇒ this) or `promotion_approval` (CI-signed `promotion/{sha}` tag). Inert until a repo opts in; full spec in ADR-024 + the fold header.
|
|
200
202
|
- `state/milestone-{id}.yml` — per-milestone cursor (**single source of truth**): position, decisions, blockers. One owner at a time.
|
|
201
|
-
- `state/desire-paths/` — append-only framework-usage observations, one file per observation, `scope: project|framework` (absent ⇒ project); occurrence counts derived by globbing. **`forge` boot is the single review owner** — surfaces groups at 3+, persists declines (no re-nag), routes `framework`-scope signals upstream ([ADR-012](../docs/decisions/ADR-012-upstream-desire-path-feedback-channel.md)); `verifying` captures but never surfaces.
|
|
203
|
+
- `state/desire-paths/` — append-only framework-usage observations, one file per observation, `scope: project|framework` (absent ⇒ project); occurrence counts derived by globbing. **`forge` boot is the single review owner** — surfaces groups at 3+, persists declines (no re-nag), routes `framework`-scope signals upstream ([ADR-012](../docs/decisions/ADR-012-upstream-desire-path-feedback-channel.md)); `verifying` captures but never surfaces. An issue-sourced entry SHOULD carry a back-reference (`resolves_issue: N`; ADRs use `**Context source:** forge#N`) — close-the-loop keys off it.
|
|
202
204
|
- `context.md` — active locked decisions + deferred ideas (≤12 KB); history archives to `context-archive.md`
|
|
203
205
|
- `streams/active.yml` — DERIVED Project Chief traffic map, git-ignored render-on-read cache, rendered by the same `forge-state-rollup.sh` hook — see Stream Rollup below ([ADR-015](../docs/decisions/ADR-015-derived-stream-registry.md))
|
|
204
206
|
- `streams/{stream}.yml` (+ `brief.md`, `packages/{id}.yml`) — per-stream **source of truth**: coordination lifecycle, ownership, shared surfaces, blockers, merge readiness, optional `stream.milestone` link (milestone-backed streams omit workflow phase — the rollup reads it from the milestone)
|
|
@@ -207,6 +209,7 @@ State lives in `.forge/`. One line per artifact; protocol detail lives in the na
|
|
|
207
209
|
- `refactor-backlog.yml` — refactoring catalog worked via quick-tasking; status vocab `pending | in_progress | done | dismissed | deferred`; terminal items auto-archive to `refactor-backlog-archive.yml` on the next reviewing/quick-tasking write
|
|
208
210
|
- `releases.yml` — append-only version-reservation registry (Version Reservation Protocol)
|
|
209
211
|
- `reservations.yml` — append-only id-reservation audit trail + cross-machine floor (ID Reservation Protocol); `forge-reserve compact` (R104) moves dropped rows verbatim to `reservations-archive.yml`, retaining each kind's max + a 14-day tail
|
|
212
|
+
- `state/issue-loop/declined/{N}.yml` — append-only close-the-loop decline markers; `reviewing` writes one when the operator declines a surfaced resolved-but-open issue (no re-nag). Inert once the issue closes.
|
|
210
213
|
- `archive/milestone-{id}/` — archived milestone artifacts (archive-delete)
|
|
211
214
|
- `prototypes/{slug}.md` — resumable disposable-mockup state (`exploring | parked | graduated | discarded`); terminal → `prototypes/archive/`. Single-owner, one driver. See `Skill(prototyping)`.
|
|
212
215
|
|
|
@@ -216,7 +219,7 @@ State lives in `.forge/`. One line per artifact; protocol detail lives in the na
|
|
|
216
219
|
|
|
217
220
|
### Stream Rollup
|
|
218
221
|
|
|
219
|
-
`active.yml` is a pure projection
|
|
222
|
+
`active.yml` is a pure projection by a deterministic, idempotent **join**: per-stream files contribute coordination state; active milestones contribute `phase` (any active milestone with no stream file gets a derived `implicit` row — coverage is computed, not maintained); `merge_queue` derives from `merge.readiness: ready`. A **step-0 completed-stream auto-close sweep** runs first: a milestone-backed stream whose milestone is `complete`, not yet `closed`, with no live worktree is auto-closed (no zombie "ready to merge" rows). Rendered by the same `forge-state-rollup.sh` hook (above) at boot and at `chief-of-staff` Show/Sync / mid-session milestone CRUD; it runs Rollup 1.0 **first** (the join reads the fresh `index.yml`), and confines the committed-source sweep to the main checkout. **Numbered procedure: the ADR-015 addendum.** Each field has exactly one source and coverage is computed, so two-registry drift cannot occur.
|
|
220
223
|
|
|
221
224
|
### State Commit Protocol
|
|
222
225
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# One-command worktree for any commit-producing task (issue #21, ADR-031).
|
|
3
|
+
#
|
|
4
|
+
# bash .forge/bin/new-worktree.sh <slug> [base-ref]
|
|
5
|
+
#
|
|
6
|
+
# Creates {worktree_root}/{slug} on branch forge/{slug} off origin/main (or [base-ref]).
|
|
7
|
+
# The primary checkout's HEAD never moves — that is the whole point of the weld: branch
|
|
8
|
+
# work lives in worktrees, each with its own independent HEAD, so concurrent sessions
|
|
9
|
+
# never clobber each other's commits.
|
|
10
|
+
#
|
|
11
|
+
# worktree_root follows the FORGE.md Worktree Convention: `orchestration.worktree_root`
|
|
12
|
+
# from .forge/project.yml, default `../<repo-basename>-worktrees/`. Relative paths resolve
|
|
13
|
+
# against the repo root; absolute / ~ honored verbatim.
|
|
14
|
+
|
|
15
|
+
set -euo pipefail
|
|
16
|
+
|
|
17
|
+
SLUG=${1:?usage: new-worktree.sh <slug> [base-ref]}
|
|
18
|
+
BASE=${2:-origin/main}
|
|
19
|
+
|
|
20
|
+
REPO_ROOT=$(git rev-parse --show-toplevel)
|
|
21
|
+
REPO_BASE=$(basename "$REPO_ROOT")
|
|
22
|
+
|
|
23
|
+
# Resolve worktree_root from project.yml (light grep — no yaml dependency). The value
|
|
24
|
+
# lives under an `orchestration:` block as `worktree_root: <path>`.
|
|
25
|
+
WT_ROOT_CFG=$(grep -E '^[[:space:]]*worktree_root:' "$REPO_ROOT/.forge/project.yml" 2>/dev/null \
|
|
26
|
+
| head -1 | sed -E 's/^[[:space:]]*worktree_root:[[:space:]]*//; s/^["'\'']//; s/["'\'']$//; s/[[:space:]]*(#.*)?$//' || true)
|
|
27
|
+
|
|
28
|
+
if [ -z "$WT_ROOT_CFG" ]; then
|
|
29
|
+
WORKTREE_ROOT="$(dirname "$REPO_ROOT")/${REPO_BASE}-worktrees"
|
|
30
|
+
else
|
|
31
|
+
case "$WT_ROOT_CFG" in
|
|
32
|
+
/*) WORKTREE_ROOT="$WT_ROOT_CFG" ;; # absolute — verbatim
|
|
33
|
+
"~"*) WORKTREE_ROOT="${WT_ROOT_CFG/#\~/$HOME}" ;; # ~ — expand
|
|
34
|
+
*) WORKTREE_ROOT="$REPO_ROOT/$WT_ROOT_CFG" ;; # relative — against repo root
|
|
35
|
+
esac
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
# Slashes in the slug become dashes in the directory name; the branch keeps the slash.
|
|
39
|
+
DIR_NAME=$(printf '%s' "$SLUG" | tr '/' '-')
|
|
40
|
+
WT_PATH="$WORKTREE_ROOT/$DIR_NAME"
|
|
41
|
+
BRANCH="forge/$SLUG"
|
|
42
|
+
|
|
43
|
+
if [ -e "$WT_PATH" ]; then
|
|
44
|
+
echo "✋ $WT_PATH already exists. Pick another slug or remove it first (git worktree remove)." >&2
|
|
45
|
+
exit 1
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
mkdir -p "$WORKTREE_ROOT"
|
|
49
|
+
# Best-effort refresh of origin/main so the worktree starts from current main; offline-safe.
|
|
50
|
+
git fetch origin main --quiet 2>/dev/null || true
|
|
51
|
+
git worktree add -b "$BRANCH" "$WT_PATH" "$BASE"
|
|
52
|
+
|
|
53
|
+
echo ""
|
|
54
|
+
echo "✅ Worktree ready"
|
|
55
|
+
echo " path: $WT_PATH"
|
|
56
|
+
echo " branch: $BRANCH (from $BASE)"
|
|
57
|
+
echo ""
|
|
58
|
+
echo " cd \"$WT_PATH\""
|
|
59
|
+
echo ""
|
|
60
|
+
echo "Reminders: run your install step after entering (worktrees don't share deps);"
|
|
61
|
+
echo "the primary checkout stayed on its default branch — that is intentional."
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Scratch-repo tests for .forge/bin/new-worktree.sh (issue #21, ADR-031).
|
|
3
|
+
#
|
|
4
|
+
# Builds a throwaway repo with a committed HEAD, runs the helper (base ref = HEAD so no
|
|
5
|
+
# network is needed), and asserts the worktree dir + branch it creates. Self-cleans. Run:
|
|
6
|
+
# ./.forge/bin/tests/new-worktree.test.sh
|
|
7
|
+
# Prints `ALL PASS` and exits 0 iff every case passes; non-zero on any failure.
|
|
8
|
+
|
|
9
|
+
set -uo pipefail
|
|
10
|
+
|
|
11
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
12
|
+
HELPER="$SCRIPT_DIR/../new-worktree.sh"
|
|
13
|
+
[ -x "$HELPER" ] || { printf 'FATAL: helper not executable: %s\n' "$HELPER" >&2; exit 1; }
|
|
14
|
+
|
|
15
|
+
ROOT="$(mktemp -d)"
|
|
16
|
+
trap 'rm -rf "$ROOT"' EXIT INT TERM
|
|
17
|
+
|
|
18
|
+
PASSED=0
|
|
19
|
+
FAILED=0
|
|
20
|
+
pass() { PASSED=$((PASSED + 1)); }
|
|
21
|
+
fail() { FAILED=$((FAILED + 1)); printf ' FAIL: %s\n' "$1" >&2; }
|
|
22
|
+
|
|
23
|
+
# A repo named so its default worktree_root is predictable: <base>-worktrees next to it.
|
|
24
|
+
new_repo() {
|
|
25
|
+
local d="$ROOT/$1"
|
|
26
|
+
mkdir -p "$d"
|
|
27
|
+
git -C "$d" init -q
|
|
28
|
+
git -C "$d" symbolic-ref HEAD refs/heads/main
|
|
29
|
+
git -C "$d" config user.email t@example.com
|
|
30
|
+
git -C "$d" config user.name tester
|
|
31
|
+
mkdir -p "$d/.forge"
|
|
32
|
+
: > "$d/seed"
|
|
33
|
+
git -C "$d" add -A && git -C "$d" commit -qm init
|
|
34
|
+
printf '%s\n' "$d"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# ── Case 1: default worktree_root, simple slug ───────────────────────────────────
|
|
38
|
+
R="$(new_repo proj)"
|
|
39
|
+
( cd "$R" && "$HELPER" myslug HEAD ) >/dev/null 2>&1 || true
|
|
40
|
+
WT="$ROOT/proj-worktrees/myslug"
|
|
41
|
+
if [ -d "$WT" ]; then pass; else fail "case1: worktree dir not created at $WT"; fi
|
|
42
|
+
br=$(git -C "$WT" branch --show-current 2>/dev/null || true)
|
|
43
|
+
if [ "$br" = "forge/myslug" ]; then pass; else fail "case1: branch is '$br', want forge/myslug"; fi
|
|
44
|
+
|
|
45
|
+
# ── Case 2: re-running the same slug errors (dir exists) ─────────────────────────
|
|
46
|
+
if ( cd "$R" && "$HELPER" myslug HEAD ) >/dev/null 2>&1; then
|
|
47
|
+
fail "case2: duplicate slug should have errored (exit non-zero)"
|
|
48
|
+
else
|
|
49
|
+
pass
|
|
50
|
+
fi
|
|
51
|
+
|
|
52
|
+
# ── Case 3: slug with a slash → dashed dir, slashed branch ───────────────────────
|
|
53
|
+
R2="$(new_repo proj2)"
|
|
54
|
+
( cd "$R2" && "$HELPER" feat/thing HEAD ) >/dev/null 2>&1 || true
|
|
55
|
+
WT2="$ROOT/proj2-worktrees/feat-thing"
|
|
56
|
+
if [ -d "$WT2" ]; then pass; else fail "case3: dashed dir not created at $WT2"; fi
|
|
57
|
+
br2=$(git -C "$WT2" branch --show-current 2>/dev/null || true)
|
|
58
|
+
if [ "$br2" = "forge/feat/thing" ]; then pass; else fail "case3: branch is '$br2', want forge/feat/thing"; fi
|
|
59
|
+
|
|
60
|
+
# ── Case 4: configured relative worktree_root honored ────────────────────────────
|
|
61
|
+
R3="$(new_repo proj3)"
|
|
62
|
+
printf 'orchestration:\n worktree_root: .wt\n' > "$R3/.forge/project.yml"
|
|
63
|
+
git -C "$R3" add -A && git -C "$R3" commit -qm cfg
|
|
64
|
+
( cd "$R3" && "$HELPER" wtslug HEAD ) >/dev/null 2>&1 || true
|
|
65
|
+
WT3="$R3/.wt/wtslug"
|
|
66
|
+
if [ -d "$WT3" ]; then pass; else fail "case4: configured worktree_root .wt not honored (want $WT3)"; fi
|
|
67
|
+
|
|
68
|
+
printf '\n%d passed, %d failed\n' "$PASSED" "$FAILED"
|
|
69
|
+
if [ "$FAILED" -eq 0 ]; then printf 'ALL PASS\n'; exit 0; fi
|
|
70
|
+
exit 1
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Backstop for the primary-checkout branch weld (issue #21, ADR-031).
|
|
3
|
+
#
|
|
4
|
+
# Refuses commits made in the PRIMARY checkout while HEAD is not the default branch
|
|
5
|
+
# (or is detached). The Claude PreToolUse hook (.claude/hooks/protect-primary-checkout.sh)
|
|
6
|
+
# blocks branch create/switch at the source; this git hook catches anything that slips
|
|
7
|
+
# past it — humans, non-agent tools, cd-inside-compound-command races — at the moment
|
|
8
|
+
# damage would occur: a commit landing on a branch some other concurrent session owns.
|
|
9
|
+
#
|
|
10
|
+
# Linked worktrees are unaffected (git-dir != git-common-dir there — their own HEAD is
|
|
11
|
+
# the whole point). Commits ON the default branch in the primary checkout remain allowed
|
|
12
|
+
# by design — Forge's own state-sync commits land there.
|
|
13
|
+
#
|
|
14
|
+
# Default branch is resolved dynamically (main/master + init.defaultBranch), never
|
|
15
|
+
# hardcoded. Fails OPEN on any git-probe error (a hook must not wedge commits on its own
|
|
16
|
+
# malfunction). Exit 0 = allow the commit; exit 1 = abort it.
|
|
17
|
+
#
|
|
18
|
+
# Activate once per clone (done by initializing / upgrading):
|
|
19
|
+
# git config core.hooksPath .forge/git-hooks
|
|
20
|
+
# Escape hatch (deliberate, human use): ALLOW_MAIN_BRANCHING=1 git commit ...
|
|
21
|
+
|
|
22
|
+
set -uo pipefail
|
|
23
|
+
|
|
24
|
+
[ "${ALLOW_MAIN_BRANCHING:-0}" = "1" ] && exit 0
|
|
25
|
+
|
|
26
|
+
GIT_DIR=$(git rev-parse --git-dir 2>/dev/null) || exit 0
|
|
27
|
+
COMMON_DIR=$(git rev-parse --git-common-dir 2>/dev/null) || exit 0
|
|
28
|
+
GIT_DIR_ABS=$(cd "$GIT_DIR" 2>/dev/null && pwd) || exit 0
|
|
29
|
+
COMMON_DIR_ABS=$(cd "$COMMON_DIR" 2>/dev/null && pwd) || exit 0
|
|
30
|
+
|
|
31
|
+
# Linked worktree → its own HEAD; branch commits are the whole point. Allow.
|
|
32
|
+
[ "$GIT_DIR_ABS" != "$COMMON_DIR_ABS" ] && exit 0
|
|
33
|
+
|
|
34
|
+
# Default-branch set (dynamic — mirrors protect-primary-checkout.sh).
|
|
35
|
+
DEFAULT_BRANCHES="main master"
|
|
36
|
+
GIT_DEFAULT=$(git config --get init.defaultBranch 2>/dev/null || true)
|
|
37
|
+
[ -n "$GIT_DEFAULT" ] && DEFAULT_BRANCHES="$DEFAULT_BRANCHES $GIT_DEFAULT"
|
|
38
|
+
|
|
39
|
+
BRANCH=$(git branch --show-current 2>/dev/null || true)
|
|
40
|
+
|
|
41
|
+
if [ -z "$BRANCH" ]; then
|
|
42
|
+
echo "✋ Commit blocked: detached HEAD in the primary checkout." >&2
|
|
43
|
+
echo " Return to the default branch (git checkout main) or move this work to a worktree:" >&2
|
|
44
|
+
echo " bash .forge/bin/new-worktree.sh <slug>" >&2
|
|
45
|
+
echo " Human escape hatch: ALLOW_MAIN_BRANCHING=1 git commit ..." >&2
|
|
46
|
+
exit 1
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
for b in $DEFAULT_BRANCHES; do
|
|
50
|
+
[ "$BRANCH" = "$b" ] && exit 0 # on the default branch → allowed (state-sync commits land here)
|
|
51
|
+
done
|
|
52
|
+
|
|
53
|
+
echo "✋ Commit blocked: the primary checkout must stay on the default branch (you are on '$BRANCH')." >&2
|
|
54
|
+
echo " Another concurrent session may own this branch — committing here is how work gets clobbered." >&2
|
|
55
|
+
echo " Branch work belongs in a worktree: bash .forge/bin/new-worktree.sh <slug>" >&2
|
|
56
|
+
echo " Human escape hatch: ALLOW_MAIN_BRANCHING=1 git commit ..." >&2
|
|
57
|
+
exit 1
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Scratch-repo scenario tests for .forge/git-hooks/pre-commit (issue #21 backstop).
|
|
3
|
+
#
|
|
4
|
+
# Each scenario builds a throwaway repo (and, where needed, a linked worktree), then
|
|
5
|
+
# invokes the pre-commit hook directly from the target dir and asserts its exit code
|
|
6
|
+
# (0 = commit allowed, 1 = commit aborted). No real `git commit` is needed — invoking
|
|
7
|
+
# the hook is deterministic and fast. Self-cleans on exit. Run:
|
|
8
|
+
# ./.forge/git-hooks/tests/pre-commit.test.sh
|
|
9
|
+
# Prints `ALL PASS` and exits 0 iff every scenario passes; non-zero on any failure.
|
|
10
|
+
|
|
11
|
+
set -uo pipefail
|
|
12
|
+
|
|
13
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
14
|
+
HOOK="$SCRIPT_DIR/../pre-commit"
|
|
15
|
+
[ -x "$HOOK" ] || { printf 'FATAL: pre-commit not executable: %s\n' "$HOOK" >&2; exit 1; }
|
|
16
|
+
|
|
17
|
+
ROOT="$(mktemp -d)"
|
|
18
|
+
trap 'rm -rf "$ROOT"' EXIT INT TERM
|
|
19
|
+
|
|
20
|
+
PASSED=0
|
|
21
|
+
FAILED=0
|
|
22
|
+
|
|
23
|
+
new_repo() {
|
|
24
|
+
local d="$ROOT/$1" br="${2:-main}"
|
|
25
|
+
mkdir -p "$d"
|
|
26
|
+
git -C "$d" init -q
|
|
27
|
+
git -C "$d" symbolic-ref HEAD "refs/heads/$br"
|
|
28
|
+
[ "$br" != "main" ] && git -C "$d" config init.defaultBranch "$br"
|
|
29
|
+
git -C "$d" config user.email t@example.com
|
|
30
|
+
git -C "$d" config user.name tester
|
|
31
|
+
: > "$d/seed"
|
|
32
|
+
git -C "$d" add -A && git -C "$d" commit -qm init
|
|
33
|
+
printf '%s\n' "$d"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# run_hook <dir> [env-assignment] — run the pre-commit FROM <dir>, return its exit code.
|
|
37
|
+
run_hook() {
|
|
38
|
+
local dir="$1" envset="${2:-}"
|
|
39
|
+
if [ -n "$envset" ]; then
|
|
40
|
+
( cd "$dir" && env "$envset" "$HOOK" >/dev/null 2>&1 )
|
|
41
|
+
else
|
|
42
|
+
( cd "$dir" && "$HOOK" >/dev/null 2>&1 )
|
|
43
|
+
fi
|
|
44
|
+
return $?
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# assert <desc> <expected-exit> <dir> [env]
|
|
48
|
+
assert() {
|
|
49
|
+
local desc="$1" want="$2" dir="$3" envset="${4:-}"
|
|
50
|
+
local got
|
|
51
|
+
run_hook "$dir" "$envset"; got=$?
|
|
52
|
+
if [ "$got" -eq "$want" ]; then
|
|
53
|
+
PASSED=$((PASSED + 1))
|
|
54
|
+
else
|
|
55
|
+
FAILED=$((FAILED + 1))
|
|
56
|
+
printf ' FAIL: %s (got exit %s, want %s)\n' "$desc" "$got" "$want" >&2
|
|
57
|
+
fi
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
ALLOW=0
|
|
61
|
+
BLOCK=1
|
|
62
|
+
|
|
63
|
+
# ── Scenario 1: primary checkout on the default branch → commit allowed ──────────
|
|
64
|
+
P="$(new_repo primary)"
|
|
65
|
+
assert "primary on main: commit allowed" "$ALLOW" "$P"
|
|
66
|
+
|
|
67
|
+
# ── Scenario 2: primary checkout on a feature branch → commit blocked ────────────
|
|
68
|
+
git -C "$P" checkout -q -b feature
|
|
69
|
+
assert "primary on feature branch: commit blocked" "$BLOCK" "$P"
|
|
70
|
+
|
|
71
|
+
# ── Scenario 5 (escape hatch, on the feature branch): allowed ───────────────────
|
|
72
|
+
assert "escape hatch on feature branch: commit allowed" "$ALLOW" "$P" "ALLOW_MAIN_BRANCHING=1"
|
|
73
|
+
git -C "$P" checkout -q main # back home for later worktree add
|
|
74
|
+
|
|
75
|
+
# ── Scenario 3: detached HEAD in the primary checkout → blocked ─────────────────
|
|
76
|
+
D="$(new_repo detached)"
|
|
77
|
+
git -C "$D" checkout -q --detach
|
|
78
|
+
assert "primary detached HEAD: commit blocked" "$BLOCK" "$D"
|
|
79
|
+
|
|
80
|
+
# ── Scenario 4: linked worktree on a feature branch → allowed ────────────────────
|
|
81
|
+
WT="$ROOT/primary-wt"
|
|
82
|
+
git -C "$P" worktree add -q -b wtbranch "$WT" >/dev/null 2>&1
|
|
83
|
+
assert "linked worktree on feature branch: commit allowed" "$ALLOW" "$WT"
|
|
84
|
+
|
|
85
|
+
# ── Bonus: master-default repo on master → allowed; on a branch → blocked ────────
|
|
86
|
+
M="$(new_repo master-primary master)"
|
|
87
|
+
assert "master-default on master: commit allowed" "$ALLOW" "$M"
|
|
88
|
+
git -C "$M" checkout -q -b feat
|
|
89
|
+
assert "master-default on feature branch: commit blocked" "$BLOCK" "$M"
|
|
90
|
+
|
|
91
|
+
# ── Summary ──────────────────────────────────────────────────────────────────────
|
|
92
|
+
printf '\n%d passed, %d failed\n' "$PASSED" "$FAILED"
|
|
93
|
+
if [ "$FAILED" -eq 0 ]; then
|
|
94
|
+
printf 'ALL PASS (incl. escape hatch)\n'
|
|
95
|
+
exit 0
|
|
96
|
+
fi
|
|
97
|
+
exit 1
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Migration Guide: Weld the primary checkout to the default branch (Forge 0.78.0)
|
|
2
|
+
|
|
3
|
+
Applies to projects installed before 0.78.0. This milestone (issue #21, ADR-031) welds the primary checkout to its default branch across ALL tiers: a PreToolUse guard denies branch create/switch there, a tracked git `pre-commit` backstop refuses off-default commits there, and a `new-worktree` helper teaches the right path. The `/upgrading` run that installed this guide already synced the hook bodies (`.claude/hooks/protect-primary-checkout.sh`, `.forge/git-hooks/pre-commit`, `.forge/bin/new-worktree.sh`) and merged the settings.json wiring; the one remaining per-clone step is the `core.hooksPath` wiring, which `/upgrading` applies with a detect+warn+never-clobber policy.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
1. Nothing to hand-edit — the upgrade sync + the `/upgrading` `core.hooksPath` layer ARE the migration.
|
|
8
|
+
2. If you use Husky / lefthook / a pre-commit framework (i.e. `core.hooksPath` is already set), the wiring will NOT overwrite it — you'll get a warning and can chain `.forge/git-hooks/pre-commit` into your existing hooks dir, or accept PreToolUse-only coverage.
|
|
9
|
+
|
|
10
|
+
## Detection
|
|
11
|
+
|
|
12
|
+
Prints `MIGRATE` when the primary-checkout weld is not fully wired: the PreToolUse guard is absent from `settings.json`, OR the tracked pre-commit backstop is missing, OR `core.hooksPath` is not pointing at `.forge/git-hooks`. Silent + exit 0 when fully wired.
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
migrate=""
|
|
16
|
+
# (1) PreToolUse guard wired in settings.json?
|
|
17
|
+
if [ -f .claude/settings.json ]; then
|
|
18
|
+
if ! grep -q 'protect-primary-checkout' .claude/settings.json 2>/dev/null; then
|
|
19
|
+
migrate="guard not wired in settings.json"
|
|
20
|
+
fi
|
|
21
|
+
fi
|
|
22
|
+
# (2) tracked pre-commit backstop present?
|
|
23
|
+
if [ ! -f .forge/git-hooks/pre-commit ]; then
|
|
24
|
+
migrate="${migrate:+$migrate; }pre-commit backstop missing (.forge/git-hooks/pre-commit)"
|
|
25
|
+
fi
|
|
26
|
+
# (3) core.hooksPath wired to our dir? (advisory — /upgrading applies this)
|
|
27
|
+
cur=$(git config --get core.hooksPath 2>/dev/null || true)
|
|
28
|
+
if [ "$cur" != ".forge/git-hooks" ]; then
|
|
29
|
+
migrate="${migrate:+$migrate; }core.hooksPath not '.forge/git-hooks' (currently '${cur:-unset}')"
|
|
30
|
+
fi
|
|
31
|
+
if [ -n "$migrate" ]; then
|
|
32
|
+
echo "MIGRATE — primary-checkout weld incomplete: $migrate"
|
|
33
|
+
fi
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Migration steps
|
|
37
|
+
|
|
38
|
+
### 1. Re-run the upgrade / initialize
|
|
39
|
+
|
|
40
|
+
- `/upgrading` on 0.78.0+ framework files syncs the hooks, merges the settings.json guard group, and applies the `core.hooksPath` wiring layer (detect+warn+never-clobber). The run that installed this guide already did the file sync; the `core.hooksPath` layer is what completes the weld.
|
|
41
|
+
- On a brand-new project, `initializing` Finalize wires `core.hooksPath` as part of setup.
|
|
42
|
+
|
|
43
|
+
### 2. If `core.hooksPath` was already set (Husky/lefthook)
|
|
44
|
+
|
|
45
|
+
The wiring will not overwrite it. To keep the pre-commit weld active, chain the Forge hook into your existing hooks directory — e.g. add to your `pre-commit`:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# run the Forge primary-checkout backstop, if present
|
|
49
|
+
[ -x .forge/git-hooks/pre-commit ] && .forge/git-hooks/pre-commit || true
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Or accept PreToolUse-only coverage: Claude sessions are still guarded by `protect-primary-checkout.sh`; only the human/CI backstop is inactive.
|
|
53
|
+
|
|
54
|
+
### 3. Verify
|
|
55
|
+
|
|
56
|
+
From the primary checkout on the default branch:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
# guard denies branch create (exit 2) — run it directly against a payload:
|
|
60
|
+
printf '{"tool_input":{"command":"git checkout -b probe"},"cwd":"'"$(git rev-parse --show-toplevel)"'"}' \
|
|
61
|
+
| .claude/hooks/protect-primary-checkout.sh; echo "exit=$? (expect 2 in the PRIMARY checkout)"
|
|
62
|
+
# pre-commit allows on the default branch:
|
|
63
|
+
.forge/git-hooks/pre-commit; echo "exit=$? (expect 0 on the default branch)"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
All branch work now goes through `bash .forge/bin/new-worktree.sh <slug>`. Escape hatch for deliberate human branching: `ALLOW_MAIN_BRANCHING=1`.
|
|
@@ -7,6 +7,9 @@ type: "" # recurring_friction | agent_struggle | us
|
|
|
7
7
|
date: "" # ISO 8601 date the observation was made, e.g. "2026-07-23"
|
|
8
8
|
milestone: "" # milestone id, or a short label for non-milestone work (e.g. "quick-task (...)")
|
|
9
9
|
scope: project # project | framework — absent ⇒ project (this repo only; framework ⇒ upstream-eligible per ADR-012)
|
|
10
|
+
# resolves_issue: 26 # OPTIONAL — GitHub issue number this observation's fix resolves. The
|
|
11
|
+
# # close-the-loop check (reviewing) keys off it to surface the issue as
|
|
12
|
+
# # likely-closable once the fix ships. Omit when the observation resolves no issue.
|
|
10
13
|
suggestion_target: "" # the file/skill this observation would change if acted on
|
|
11
14
|
note: >
|
|
12
15
|
What was observed — concrete enough that a future review can judge recurrence
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# Forge PreToolUse(Bash) guardrail — protect the shared main checkout's HEAD when
|
|
3
|
-
# parallel git worktrees are active.
|
|
4
|
-
#
|
|
5
|
-
# Blocks the "moved the shared HEAD under other sessions" catastrophe (canvaz, burned
|
|
6
|
-
# twice 2026-06-05): switching branches or hard-resetting in the MAIN checkout while
|
|
7
|
-
# sibling worktrees are branched off it. Orchestration-agnostic — depends only on git
|
|
8
|
-
# worktree state, so it holds whether coordination is M10, Agent Teams, or manual
|
|
9
|
-
# worktrees.
|
|
10
|
-
#
|
|
11
|
-
# Scope is deliberately narrow to avoid false positives:
|
|
12
|
-
# - fires ONLY when >1 worktree exists (single-worktree/solo work is never touched)
|
|
13
|
-
# - fires ONLY in the main checkout (linked worktrees own their own HEAD)
|
|
14
|
-
# - skips any command containing `cd` (can't reliably tell which tree it targets)
|
|
15
|
-
# - allows safe forms: file restore (`git checkout -- <path>`), branch create (`-b`)
|
|
16
|
-
#
|
|
17
|
-
# exit 0 = allow; exit 2 = deny. Override by running the command yourself outside Claude.
|
|
18
|
-
|
|
19
|
-
set -uo pipefail
|
|
20
|
-
|
|
21
|
-
PAYLOAD=$(cat)
|
|
22
|
-
CMD=$(printf '%s' "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null) || exit 0
|
|
23
|
-
[ -z "$CMD" ] && exit 0
|
|
24
|
-
|
|
25
|
-
# Only git commands are interesting; and bail on `cd` (target tree is ambiguous).
|
|
26
|
-
case "$CMD" in
|
|
27
|
-
*git*) ;;
|
|
28
|
-
*) exit 0 ;;
|
|
29
|
-
esac
|
|
30
|
-
case "$CMD" in
|
|
31
|
-
*"cd "*) exit 0 ;;
|
|
32
|
-
esac
|
|
33
|
-
|
|
34
|
-
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
|
|
35
|
-
|
|
36
|
-
# Single worktree → no shared-HEAD hazard.
|
|
37
|
-
wt_count=$(git worktree list 2>/dev/null | wc -l | tr -d ' ')
|
|
38
|
-
[ "${wt_count:-1}" -le 1 ] && exit 0
|
|
39
|
-
|
|
40
|
-
# Main checkout iff --git-dir == --git-common-dir (linked worktrees differ).
|
|
41
|
-
[ "$(git rev-parse --git-dir 2>/dev/null)" = "$(git rev-parse --git-common-dir 2>/dev/null)" ] || exit 0
|
|
42
|
-
|
|
43
|
-
deny() { echo "[forge-branch-guard] $*" >&2; exit 2; }
|
|
44
|
-
|
|
45
|
-
# Branch switch (checkout/switch to a ref), excluding file-restore (`--`) and create (`-b`).
|
|
46
|
-
switches_branch=0
|
|
47
|
-
if printf '%s' "$CMD" | grep -qE '\bgit[[:space:]]+(checkout|switch)\b'; then
|
|
48
|
-
if ! printf '%s' "$CMD" | grep -qE '([[:space:]]--([[:space:]]|$))|(\bgit[[:space:]]+(checkout|switch)[[:space:]]+-[bB]\b)'; then
|
|
49
|
-
switches_branch=1
|
|
50
|
-
fi
|
|
51
|
-
fi
|
|
52
|
-
|
|
53
|
-
# Hard/keep/merge reset rewrites HEAD + working tree.
|
|
54
|
-
hard_reset=0
|
|
55
|
-
printf '%s' "$CMD" | grep -qE '\bgit[[:space:]]+reset[[:space:]]+(--hard|--keep|--merge)\b' && hard_reset=1
|
|
56
|
-
|
|
57
|
-
if [ "$switches_branch" = "1" ] || [ "$hard_reset" = "1" ]; then
|
|
58
|
-
deny "Refusing to move the shared main HEAD — $wt_count worktrees are branched off it (the 'burned twice' failure). Switch/reset inside a worktree, or stop the other sessions first. Override: run the command yourself outside Claude."
|
|
59
|
-
fi
|
|
60
|
-
|
|
61
|
-
exit 0
|