Repository navigation
Expand file tree
/
Copy pathinit-submodules
More file actions
executable file
·297 lines (283 loc) · 15.4 KB
/
Copy pathinit-submodules
File metadata and controls
executable file
·297 lines (283 loc) · 15.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
#!/usr/bin/env bash
################################################################################
#
# Materialize the *config-managed* submodules a fresh working tree is missing, so
# agent assets resolve.
#
# `git worktree add` — and some shallow CI / cloud checkouts — populate only the
# superproject's own tracked files; registered submodules are left UNinitialized.
# In a Spine repo that means the `config` and `.agents/shared` submodules are
# empty, the `.agents/skills` -> `.agents/shared/skills` symlink dangles, and no
# agent skills, scripts, or guidelines can be found.
#
# This script is the bootstrap that has to run BEFORE `./config/pull`: `pull`
# lives inside the `config` submodule, so on a fresh worktree it does not yet
# exist. `init-submodules`, by contrast, is a plain tracked file at the repo root
# (distributed by `config`), so `git worktree add` always checks it out — it can
# therefore bring `config` itself into existence.
#
# It acts ONLY on submodules that are BOTH:
#
# * not yet checked out — those `git submodule status` marks with a leading
# `-`; and
#
# * config-managed — `config` itself (the bootstrap target `pull` lives inside,
# which carries no tracked `branch` in a consumer's `.gitmodules`), plus every
# submodule that declares a tracked `branch` in `.gitmodules`. This is exactly
# the rule `./config/pull` uses to decide what it floats, so the two scripts
# can never disagree about what is shared.
#
# Such a submodule is initialized and then advanced to the tip of its tracked
# branch, the way `./config/pull` floats it — NOT left at the commit the
# superproject pins. That pin is not a version anyone maintains: `.gitmodules`
# declares `ignore = all` for `.agents/shared`, which hides the submodule from
# `git status` and `git diff`, and `./config/pull` floats the on-disk copy without
# ever committing the moved gitlink. The pin therefore still holds the value it
# had on the day the submodule was adopted; an audit of all 19 Spine consumer
# repositories on 2026-10-16 found every one of them months stale. Materializing
# it hands a fresh worktree, CI checkout, or cloud session hook scripts, skills,
# and guidelines that are months out of date.
#
# A submodule may declare `branch = .`, which gitmodules(5) defines as the branch
# the superproject is on rather than a branch name. It is resolved before it
# reaches a refspec, because `git fetch origin .` is a `fatal: invalid refspec`.
# Nothing in Spine declares it today — `.agents/shared` tracks `agents`' `master`
# whatever branch the consumer is on — but it is valid in any `.gitmodules`.
#
# Neither the clone nor the float can fail the session start. With no network at
# all the clone is what fails: the submodule is left uninitialized and the
# symlinks keep dangling until a run that has a network, exactly as before this
# script floated anything. A float that fails after a successful clone — a branch
# the remote does not have, a fetch that dies midway, a local branch that is not
# a fast-forward — warns and leaves the submodule wherever the steps that did
# succeed put it: at worst the pin, a usable, if older, tree. The submodule is
# checked out by then, so later runs no longer see a `-` line for it and never
# retry the float — which is why the warning points at `./config/pull`.
#
# Consumer-owned submodules (a Hugo theme, a vendored library, documentation
# examples, ...) declare no tracked branch and are deliberately left untouched.
# Because a `SessionStart` hook runs this script automatically on every session,
# initializing them would mean trying to clone — or failing on credentials for —
# a submodule this project does not manage, on every single start. They are
# skipped (noted on stderr).
#
# Submodules already present are left exactly as they are, neither rewound to the
# pin nor advanced. That keeps the script idempotent, keeps the runs that find
# nothing missing free of network access, and never disturbs a tree whose
# submodules someone deliberately parked on a particular commit. Run
# `./config/pull` to float those.
#
# Unlike `pull`, this script depends on no pre-existing `config` submodule, so it
# can bootstrap a bare worktree where `./config/pull` does not yet exist.
#
################################################################################
set -u
# Ignore an inherited `CDPATH`: it would redirect a relative `cd`, such as into
# the `.git` that `git rev-parse --git-common-dir` prints in a main checkout,
# and make `cd` print the directory it enters.
unset CDPATH
# Decode the JSON string escapes that a path can hold: `\\` (each backslash of a
# Windows path), `\"`, and `\/`. Fail on any other escape, such as `\n`.
json_unescape() {
local rest=$1 out="" next
while :; do
case $rest in
*\\*) ;;
*) printf '%s' "$out$rest"; return 0 ;;
esac
out=$out${rest%%\\*}
rest=${rest#*\\}
next=${rest%"${rest#?}"}
case $next in
\\ | \" | /) out=$out$next ;;
*) return 1 ;;
esac
rest=${rest#?}
done
}
# Act on the work tree the session is in. Claude Code runs this script as a
# `SessionStart` hook and passes the hook input JSON on stdin. The `cwd` field
# of that JSON is the session's working directory. In a worktree session, that
# directory is inside the worktree, while this process and `$CLAUDE_PROJECT_DIR`
# stay in the main checkout. Read stdin only when Claude Code started the script
# (it exports `CLAUDE_PROJECT_DIR` to hooks) and stdin is not a terminal, so that
# a manual run never waits for input.
#
# Parse the input with `jq` when it is installed. The bootstrap cannot require
# `jq`, so without it a pattern match reads the first `cwd` member, and
# `json_unescape` decodes it; a value it cannot decode falls back as described
# below. Unlike `jq`, the pattern does not validate the rest of the input, which
# Claude Code writes. Either way, the path must belong to the project, as checked
# below.
session_dir=""
if [ -n "${CLAUDE_PROJECT_DIR:-}" ] && [ ! -t 0 ]; then
if command -v jq >/dev/null 2>&1; then
session_dir=$(jq -r '.cwd // empty' 2>/dev/null) || session_dir=""
else
input=$(cat)
cwd_pattern='"cwd"[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)"'
if [[ $input =~ $cwd_pattern ]]; then
session_dir=$(json_unescape "${BASH_REMATCH[1]}") || session_dir=""
fi
fi
fi
# The physical path of the Git directory that a repository's main checkout and
# linked worktrees share; empty outside a work tree. Each `git` result is checked
# before use, because `cd ""` succeeds.
common_git_dir() {
(cd "$1" && top=$(git rev-parse --show-toplevel) && cd "$top" \
&& common=$(git rev-parse --git-common-dir) && cd "$common" && pwd -P) 2>/dev/null
}
# From the session directory, climb out of any submodule (`config`,
# `.agents/shared`) to the first work tree of the project's own repository — its
# main checkout or a linked worktree. Anything else (no `cwd`, one that does not
# resolve, an unrelated repository) falls back to the current directory, as
# before: this script changes repository configuration, so it must not act on a
# repository the project does not own.
root=""
if [ -n "$session_dir" ]; then
project=$(common_git_dir "$CLAUDE_PROJECT_DIR")
tree=$(git -C "$session_dir" rev-parse --show-toplevel 2>/dev/null)
while [ -n "$tree" ] && [ -n "$project" ]; do
if [ "$(common_git_dir "$tree")" = "$project" ]; then
root=$tree
break
fi
tree=$(git -C "$tree" rev-parse --show-superproject-working-tree 2>/dev/null)
done
fi
[ -n "$root" ] || root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
cd "$root" || exit 0
# Nothing to do in a repo without submodules.
[ -f .gitmodules ] || exit 0
# The config-managed submodules, as `path<TAB>branch` lines: every submodule that
# declares a tracked `branch` in `.gitmodules`, plus `config` itself — which
# carries no tracked branch in a consumer's `.gitmodules`, and which
# `./config/pull` floats to `master` in its step 1. Mirrors `pull`'s rule. The
# declared entries come first, so a `.gitmodules` that does give `config` a
# tracked branch wins over that default.
config_managed() {
git config -f .gitmodules --get-regexp '^submodule\..*\.branch$' 2>/dev/null \
| while read -r key branch; do
name=${key#submodule.}; name=${name%.branch}
path=$(git config -f .gitmodules --get "submodule.$name.path" 2>/dev/null) || continue
printf '%s\t%s\n' "$path" "$branch"
done
printf '%s\t%s\n' 'config' 'master'
}
managed=$(config_managed)
# The branch the submodule at `$1` tracks; empty when that path is not
# config-managed at all. The first match wins, as noted above.
tracked_branch() {
printf '%s\n' "$managed" | awk -F'\t' -v path="$1" '$1 == path { print $2; exit }'
}
# `git submodule status` prefixes each uninitialized submodule with `-`; an
# initialized one starts with a space (at the pinned commit) or `+` (ahead of it).
# Act only on the `-` lines, taking the path from the second field, and only when
# that path is config-managed.
git submodule status 2>/dev/null | awk '$1 ~ /^-/ { print $2 }' | while read -r path; do
[ -n "$path" ] || continue
branch=$(tracked_branch "$path")
if [ -z "$branch" ]; then
echo "init-submodules: skipping consumer-owned '$path' (not config-managed)." >&2
continue
fi
echo "init-submodules: initializing '$path'"
git submodule update --init --recursive -- "$path" </dev/null || {
echo "init-submodules: WARNING — could not initialize '$path' (offline?)." >&2
continue
}
# `.gitmodules` may declare `branch = .`, which gitmodules(5) defines as the
# branch the superproject itself is on. That is a sentinel, not a ref: passed
# through, it dies with `fatal: invalid refspec '.'`, and because the clone
# has already succeeded by then the submodule would keep the pin and never be
# retried. Resolve it to the current branch name. A detached superproject has
# no name to inherit, so the float is skipped and the pin kept — the one case
# where `./config/pull` is the only way forward, once on a branch.
if [ "$branch" = "." ]; then
# `git branch --show-current` prints the name exactly, and prints nothing
# when `HEAD` is detached. `git rev-parse --abbrev-ref HEAD` cannot stand
# in: it shortens to a *non-ambiguous* name, so a superproject carrying
# both a branch and a tag called `foo` yields `heads/foo`; the fetch
# then succeeds but the checkout fails, leaving this freshly
# initialized submodule stale and never retried. `./config/pull`
# resolves it the same way, for the same reason.
branch=$(git branch --show-current 2>/dev/null)
if [ -z "$branch" ]; then
echo "init-submodules: WARNING — '$path' tracks the superproject's" \
"branch ('.'), which is detached here; left at the pin. Run" \
"'./config/pull' from a branch to float it." >&2
continue
fi
fi
# What that just checked out is the superproject's pin, which nobody
# maintains (see the header). Advance the fresh checkout to the tip of its
# tracked branch, leaving it ON the branch rather than in a detached `HEAD` —
# the same three steps `./config/pull` takes in its step 2, both scripts
# naming the fetch destination and the checkout start point, for the two
# reasons below. A failure leaves the submodule wherever the steps that did
# succeed put it, so the warning promises no particular commit, as `pull`'s
# does not.
#
# The fetch names its destination. A bare `git fetch origin <branch>` relies
# on the refspec configured for the remote to update
# `refs/remotes/origin/<branch>` opportunistically as well, which a full
# clone's does. But a submodule declaring `shallow = true` in `.gitmodules`
# is cloned single-branch (a shallow clone implies `--single-branch`), as
# `+refs/heads/<default>:refs/remotes/origin/<default>`, so any other tracked
# branch lands in `FETCH_HEAD` alone, `refs/remotes/origin/<branch>` never
# appears, and the checkout below fails. Spelling the refspec out makes the
# float work whatever refspec the clone carries, and the leading `+` matches
# what a default clone configures.
#
# The checkout asks whether the branch exists locally rather than letting Git
# work it out, because both answers are otherwise wrong in a reachable case.
# A fresh clone has a local branch only for the default branch, so `-b` alone
# would fail with `already exists` on the common path. And plain
# `git checkout <branch>` is not a reliable substitute: with no local branch
# of that name it guesses one from a remote-tracking ref, considering only
# refs the remote's fetch refspec covers — so in the single-branch clone
# above it fails even though the explicit fetch did create
# `refs/remotes/origin/<branch>` — and if the submodule carries a *tag* of
# that name it quietly resolves the tag and detaches `HEAD` instead, which
# the `--ff-only` below then fast-forwards to the right commit on no branch
# at all. Naming the start point avoids both.
#
# `merge --ff-only` is kept after either path: a local branch that has
# diverged from `origin/<branch>` is reported rather than overwritten.
# Nothing here force-moves a ref except the remote-tracking ref the fetch
# owns, which a default clone force-updates anyway.
#
# It merges `refs/remotes/origin/<branch>`, not the `origin/<branch>`
# shorthand, for the reason the checkout above spells a ref out too.
# gitrevisions(7) resolves a bare name through `refs/tags/` BEFORE
# `refs/remotes/`, so a tag literally named `origin/<branch>` wins the
# shorthand — and then this merge would fast-forward the branch the
# checkout just placed exactly onto whatever that tag points at, reporting
# success. Every ref in the chain is therefore fully qualified.
echo "init-submodules: floating '$path' -> origin/$branch"
( cd "$path" \
&& git fetch --quiet origin \
"+refs/heads/$branch:refs/remotes/origin/$branch" \
&& if git show-ref --verify --quiet "refs/heads/$branch"; then \
git checkout --quiet "$branch"; \
else \
git checkout --quiet -b "$branch" \
"refs/remotes/origin/$branch"; \
fi \
&& git merge --quiet --ff-only "refs/remotes/origin/$branch" ) </dev/null \
|| echo "init-submodules: WARNING — could not float '$path' to" \
"origin/$branch; run './config/pull' to retry." >&2
done
# Route Git hooks to the shared hooks directory so the secret-scan `pre-commit`
# hook is active even in a brand-new worktree, before `./config/pull` runs. The
# path floats with the `.agents/shared` submodule; until that submodule is
# initialized the hook simply does not fire (Git skips a missing hook). Set only
# when unset or already ours — never override a repo's own `core.hooksPath`.
desired_hooks=".agents/scripts/git-hooks"
current_hooks=$(git config --local --get core.hooksPath 2>/dev/null || true)
if [ -z "$current_hooks" ] || [ "$current_hooks" = "$desired_hooks" ]; then
git config --local core.hooksPath "$desired_hooks" \
&& echo "init-submodules: Git hooks routed to '$desired_hooks' (secret-scan pre-commit active)."
fi
exit 0