Repository navigation
Expand file tree
/
Copy pathinit-submodules
More file actions
executable file
·177 lines (164 loc) · 8.04 KB
/
Copy pathinit-submodules
File metadata and controls
executable file
·177 lines (164 loc) · 8.04 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
#!/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 initializes ONLY submodules that are BOTH:
#
# * not yet checked out — those `git submodule status` marks with a leading `-`,
# at the commit the branch pins; 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.
#
# 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, so a tree that floated
# `config` / `.agents/shared` to a branch tip via `./config/pull` is never
# silently rewound to the pin. That makes the script idempotent and safe to run on
# every session start.
#
# It does NOT float submodules to their branch tips — run `./config/pull`
# afterwards for that. Unlike `pull`, it 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 set of config-managed submodule paths: `config` itself (handled specially —
# it carries no tracked branch, exactly as in `./config/pull`), plus every
# submodule declaring a tracked `branch` in `.gitmodules`. Mirrors `pull`'s rule.
config_managed_paths() {
printf '%s\n' 'config'
git config -f .gitmodules --get-regexp '^submodule\..*\.branch$' 2>/dev/null \
| while read -r key _branch; do
name=${key#submodule.}; name=${name%.branch}
git config -f .gitmodules --get "submodule.$name.path" 2>/dev/null
done
}
managed=$(config_managed_paths | sort -u)
# `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
if printf '%s\n' "$managed" | grep -qxF -- "$path"; then
echo "init-submodules: initializing '$path'"
git submodule update --init --recursive -- "$path" \
|| echo "init-submodules: WARNING — could not initialize '$path' (offline?)." >&2
else
echo "init-submodules: skipping consumer-owned '$path' (not config-managed)." >&2
fi
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