Repository navigation
Expand file tree
/
Copy pathpull
More file actions
executable file
·228 lines (218 loc) · 12.6 KB
/
Copy pathpull
File metadata and controls
executable file
·228 lines (218 loc) · 12.6 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
#!/usr/bin/env bash
################################################################################
#
# Pulls the shared project configuration from the `config` repository and brings
# the config-managed submodules up to date.
#
# It updates `config` itself to the tip of `origin/master`, then floats every
# *config-managed* submodule — those declared with a tracked `branch` in
# `.gitmodules` (the shared `agents` submodule at `.agents/shared`, and any shared
# submodule added in the future) — to the tip of that branch, leaving each on the
# branch rather than in a detached `HEAD`. Each submodule it moves, `config`
# included, then has its own initialized submodules updated to the commits its
# new HEAD pins.
#
# Submodules the consumer owns (a Hugo theme, a vendored library, ...) declare no
# tracked branch and are deliberately left untouched: this script never advances
# or resets them, and a failure floating one shared submodule is warned about, not
# fatal, so it can never take down the whole pull.
#
# The code of the script assumes that:
#
# 1. The project uses this code as a Git sub-module installed in the `config`
# directory under the project root.
#
# 2. The script is called from the root of the project, i.e. `./config/pull`.
#
################################################################################
# Updates the submodules of the submodule at `$1`, which has just moved, to the
# commits its new HEAD pins. `git checkout`, `git pull`, and `git merge` move a
# submodule but leave its own submodules where they were, so a floated `config`
# would report its `.agents/shared` as modified to every recursive check, though
# plain `git status` hides that behind `ignore = all`. They go to the pin rather
# than to their own branch tip, for the reasons given in `init-submodules`.
#
# Unlike `init-submodules`, it passes no `--init`, so it touches only the
# submodules already initialized in `$1`: a consumer that never initialized
# `config/.agents/shared`, which it reads nothing through, does not start cloning
# it here. One that was initialized but whose clone failed, as an offline
# `init-submodules` can leave it, is cloned now; that is the retry the warnings
# there point to.
#
# First, the URLs are synced from `.gitmodules`, as step 2 does one level up. Git
# copies a URL into the active config once, at `git submodule init`, so a nested
# submodule whose URL the new HEAD changes would otherwise go on fetching from
# the old remote and miss a pin that only the new one has. `git submodule sync`
# touches only submodules that already have a URL there, so it writes nothing for
# one never initialized, and it needs no network.
#
# Next, each update strategy the active config of `$1` holds is made to match
# what `$1/.gitmodules` declares, for the reason given at step 2 below: Git
# copies `update` there once, at `git submodule init`. This is not hypothetical.
# `config` declared `update = checkout` for `.agents/shared` until 2026-06-16, so
# a submodule initialized from a pin of that time would otherwise be detached at
# the pin, and rewound if it is past it. With the declared `update = merge`, one
# already past the pin stays where it is. A strategy `.gitmodules` no longer
# declares is removed, so Git applies its default, `checkout`, rather than a
# stale `merge` that would leave the submodule past a pin rolled back. Only the
# strategies the active config holds are visited, so nothing is written for a
# submodule never initialized, for which Git reads `.gitmodules` by itself. A
# declared `!command` is not copied, as `git submodule init` refuses to copy one
# too.
#
# It goes one level at a time rather than with `--recursive`: a submodule's
# `.gitmodules` is known only once it has moved, so the URLs and strategies of
# its own submodules can be synced and converged only then. After updating the
# level of `$1`, it calls itself for each submodule there that is checked out.
#
# A failure is warned about, never fatal.
update_nested() {
[ -f "$1/.gitmodules" ] || return 0
git -C "$1" submodule sync --quiet >/dev/null 2>&1 || true
git -C "$1" config --local --get-regexp '^submodule\..*\.update$' 2>/dev/null \
| while read -r key active; do
declared=$(git config -f "$1/.gitmodules" --get "$key" 2>/dev/null)
case $declared in
"") git -C "$1" config --local --unset "$key" ;;
"$active") ;;
checkout | rebase | merge | none)
git -C "$1" config --local "$key" "$declared" ;;
esac
done
git -C "$1" submodule update --quiet \
|| echo " WARNING: could not update the submodules of '$1'; left as-is." >&2
# shellcheck disable=SC2016 # `foreach` expands `$sm_path`, not this shell.
git -C "$1" submodule foreach --quiet 'printf "%s\n" "$sm_path"' 2>/dev/null \
| while read -r sub; do
update_nested "$1/$sub"
done
}
# ---------------------------------------------------------------------------
# 1. Bring `config` itself to the tip of `origin/master`, on the `master`
# branch (not a detached `HEAD`). Pulling here also refreshes `migrate`,
# which is sourced from the updated `config` below.
# ---------------------------------------------------------------------------
echo "Updating 'config' to the latest 'master'"
( cd ./config && git checkout master && git pull --ff-only ) \
|| { echo "Error: failed to update the 'config' submodule."; exit 1; }
update_nested config
# ---------------------------------------------------------------------------
# 2. Float the config-managed submodules to the tip of their tracked branch.
# A submodule is "config-managed" when its `.gitmodules` entry declares a
# `branch` (set up by `adopt-shared-agents`); consumer-owned submodules have
# none and are skipped. `--ff-only` keeps each a clean descendant of its
# remote; any failure is warned about, never fatal.
#
# For each such submodule we also re-assert the active update strategy from
# `.gitmodules` into `.git/config`. `.gitmodules` is only a template: Git
# copies `update` into the active config once, at first `git submodule init`,
# and never again — so a clone initialized before `update = merge` was adopted
# stays on `checkout` forever, and a bare `git submodule update` would then
# detach the float back to the pinned commit. Re-syncing here makes the policy
# converge on every pull instead of only on fresh clones.
# ---------------------------------------------------------------------------
if [ -f .gitmodules ]; then
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") || 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 as-is, it makes `git fetch origin .` die with
# `fatal: invalid refspec '.'`, so the float warns about a nonexistent
# `origin/.` and the submodule stays wherever it was. Resolve it to the
# current branch name before anything is printed or touched.
if [ "$branch" = "." ]; then
# `git branch --show-current` prints the branch 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`, which fetches but then fails to
# check out, leaving the submodule stale and detached.
# `git symbolic-ref --short` shortens the same way and is no better.
branch=$(git branch --show-current 2>/dev/null)
# A detached superproject has no branch name to inherit. Unlike
# `init-submodules`, which floats only a submodule it has just
# cloned, this loop acts on submodules that are already checked out
# and in use, so guessing a branch could move one off the commit it
# is on. Leave it exactly as it is and say what would make it float.
#
# Skipping the entry outright also skips its `git submodule update
# --init` below, which is the point: with the default `checkout`
# strategy that command resets an already-floated submodule to the
# pin. One not yet checked out therefore stays that way here;
# `init-submodules`, which initializes before it resolves `.`, is
# what materializes it.
if [ -z "$branch" ]; then
echo " WARNING: '$path' tracks the superproject's branch ('.')," \
"which is detached here; left as-is." \
"Re-run from a branch to float it." >&2
continue
fi
fi
echo "Floating '$path' -> origin/$branch"
git submodule sync -- "$path" >/dev/null 2>&1 || true
git submodule update --init -- "$path" >/dev/null 2>&1 || true
# Converge the active update strategy with `.gitmodules` (see note above).
strategy=$(git config -f .gitmodules --get "submodule.$name.update" 2>/dev/null) \
&& git config "submodule.$name.update" "$strategy"
# The fetch names its destination, and the checkout is given a start
# point, for the same two reasons as in `init-submodules`.
#
# A bare `git fetch origin <branch>` leaves it to the refspec configured
# for the remote to update `refs/remotes/origin/<branch>` as well, which
# a full clone's does. 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 reaches `FETCH_HEAD` alone and `origin/<branch>` never
# appears at all. The leading `+` matches what a default clone
# configures.
#
# And a bare `git checkout <branch>` guesses the branch from a
# remote-tracking ref only when the name resolves to nothing, over just
# the refs that refspec covers. So it fails in the single-branch clone
# above; and where the submodule carries a *tag* of that name, the name
# does resolve, so Git checks out the tag and detaches `HEAD` — after
# which the `--ff-only` below fast-forwards that detached `HEAD` to the
# branch tip and reports success, leaving the submodule on the right
# commit, on no branch, with nothing warned. Asking `show-ref` which
# case this is avoids both; the branch exists locally in the ordinary
# one, where `-b` would fail with `already exists`.
#
# The merge target is spelled out as `refs/remotes/origin/<branch>` for
# the same reason: gitrevisions(7) resolves a bare name through
# `refs/tags/` BEFORE `refs/remotes/`, so a tag literally named
# `origin/<branch>` wins the shorthand, and the merge would then
# fast-forward the branch the checkout just placed exactly onto
# whatever that tag points at, reporting success.
#
# `--ff-only` still guards both paths, which matters more here than in
# `init-submodules`: these submodules are already checked out and in
# use, so a local branch that has diverged from `origin/<branch>` must
# be reported, never overwritten. Nothing here force-moves a ref but
# the remote-tracking ref the fetch owns, which a default clone
# force-updates anyway.
if ( 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" ); then
update_nested "$path"
else
echo " WARNING: could not float '$path' to origin/$branch; left as-is." >&2
fi
done
fi
# ---------------------------------------------------------------------------
# 3. Apply the shared files. `migrate` runs with `config` as the working dir.
# ---------------------------------------------------------------------------
cd ./config || { echo "Error: 'config' directory is missing."; exit 1; }
# Handle file operations related to the migration from the previous version(s).
source migrate
echo "Done."