-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathupdate
More file actions
executable file
·414 lines (375 loc) · 19.1 KB
/
Copy pathupdate
File metadata and controls
executable file
·414 lines (375 loc) · 19.1 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
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
#!/bin/bash
#
# Fold commits made to a source repo AFTER the monorepo was built back into the
# monorepo, preserving history -- the maintenance companion to `add`.
#
# monorepoize keeps each source repo as a <name>/<branch> branch (its original
# history and SHAs) and lays that branch's content under a <name>/ subdirectory
# on the monorepo's default branch. There are two ways to fold new commits in:
#
# replay (default): replay just the new commits onto the <name>/ subdir with
# `git format-patch | git am --directory=<name>`, keeping each commit's
# message, author, and date. This is for the archive case where the monorepo
# never edits <name>/ itself; it can only apply a linear range (no merges)
# and conflicts if the subdir has diverged.
#
# merge (opt in with --merge, or build/add --sync=merge): subtree-merge the
# upstream tip into the <name>/ subdir with `git merge -X subtree=<name>`.
# This is for the live case where BOTH the upstream repo and the monorepo's
# <name>/ keep changing: it does a real 3-way merge (conflicts resolved once
# and recorded), and folds in upstream merge commits too. Each repo's mode is
# recorded in .monorepoize/modes; how far upstream it has been integrated is
# recorded in .monorepoize/synced (the integration base, decoupled from the
# mirror ref so a failed sync is never mistaken for "up to date").
#
# The upstream URL for each repo is read from the committed .monorepoize/sources
# file that build/add records. Override a single repo with --upstream, point at a
# .repos file with --repos, or, when every repo is eponymous and lives on the
# same origin as the monorepo, derive URLs from the monorepo's own origin with
# --same-origin (the monorepo's origin URL with its last path segment replaced by
# <name>.git). A monorepo built before sources were recorded gets them
# backfilled: running with --repos records (and commits) the resolved URLs in
# .monorepoize/sources, so later runs need no --repos.
#
# Run it against a local clone of the monorepo (it mutates that clone); push the
# result back with --push, or yourself afterward.
source "$(dirname "$0")/monorepo.sh"
set +x # monorepo.sh enables command tracing; this command stays quiet.
set -euo pipefail
usage() {
cat <<EOF
Usage: $(basename "$0") [options] MONOREPO [NAME]
Pull new upstream commits into the monorepo at MONOREPO (a local clone): one
repo NAME, or every repo with --all. Updates each repo's <name>/<branch> branch
and its <name>/ subdir on the default branch.
Arguments:
MONOREPO Local clone of the monorepo (a writable git repo).
NAME Repo name / subdirectory to update (omit with --all).
Options:
--all Update every repo in the monorepo. Raw git output is
suppressed; you get one summary line per repo.
--merge Use (and switch the repo to) merge mode: subtree-merge new
upstream commits into <name>/ instead of replaying them.
Handles a monorepo side that also edits <name>/ and folds
in upstream merge commits. Recorded, so later runs need no
flag. On first use for a diverged/replay repo it establishes
a merge baseline.
-u, --upstream URL Source repo to pull from (single repo only). Overrides the
URL recorded in .monorepoize/sources.
--repos FILE A .repos file (one source URL per line) to resolve upstream
URLs from, matching each repo by URL basename. If the
monorepo has no .monorepoize/sources yet (built before
sources were recorded), the URLs are recorded and
committed so future runs need no --repos.
--same-origin Derive each upstream from the monorepo's own origin: the
origin URL with its last path segment replaced by <name>.git
(only valid when the repos are eponymous and on that origin).
Used as a fallback when no URL is otherwise known.
-b, --branch BR Upstream branch to track (default: auto-detect main/master).
-n, --dry-run Show what would be applied; change nothing.
--push Push the updated default branch and per-repo refs to origin.
-h, --help Show this help.
EOF
}
UPSTREAM=""
REPOS_FILE=""
SAME_ORIGIN=0
BRANCH=""
DRYRUN=0
PUSH=0
ALL=0
WANT_MERGE=0
MONOREPO=""
NAME=""
while [ $# -gt 0 ]; do
case "$1" in
-u|--upstream) UPSTREAM="$2"; shift 2 ;;
--repos) REPOS_FILE="$2"; shift 2 ;;
--same-origin) SAME_ORIGIN=1; shift ;;
--merge) WANT_MERGE=1; shift ;;
-b|--branch) BRANCH="$2"; shift 2 ;;
-n|--dry-run) DRYRUN=1; shift ;;
--push) PUSH=1; shift ;;
--all) ALL=1; shift ;;
-h|--help) usage; exit 0 ;;
-*) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;;
*)
if [ -z "$MONOREPO" ]; then MONOREPO="$1"
elif [ -z "$NAME" ]; then NAME="$1"
else echo "Error: unexpected argument: $1" >&2; usage >&2; exit 1
fi
shift ;;
esac
done
die() { echo "Error: $*" >&2; exit 1; }
[ -n "$MONOREPO" ] || { echo "Error: no MONOREPO given" >&2; usage >&2; exit 1; }
[ -d "$MONOREPO/.git" ] || die "$MONOREPO is not a git repo (point at a local clone of the monorepo)"
if [ "$ALL" -eq 1 ]; then
[ -z "$NAME" ] || die "don't pass NAME with --all"
[ -z "$UPSTREAM" ] || die "--upstream can't be combined with --all (use --repos or .monorepoize/sources)"
else
[ -n "$NAME" ] || { echo "Error: no NAME given (or use --all)" >&2; usage >&2; exit 1; }
fi
if [ -n "$REPOS_FILE" ]; then
[ -f "$REPOS_FILE" ] || die "--repos file not found: $REPOS_FILE"
REPOS_FILE=$(cd "$(dirname "$REPOS_FILE")" && pwd)/$(basename "$REPOS_FILE")
fi
cd "$MONOREPO"
git diff --quiet && git diff --cached --quiet || die "working tree has uncommitted changes; commit or stash first"
# Suppress raw git output in --all mode; let it through for a single repo.
QUIET="$ALL"
gq() { if [ "$QUIET" -eq 1 ]; then git "$@" >/dev/null 2>&1; else git "$@"; fi; }
# Prints the first of its argument refs that exists (full refname); else fails.
resolve_ref() {
local r
for r in "$@"; do
if git rev-parse --verify --quiet "$r" >/dev/null; then printf '%s' "$r"; return 0; fi
done
return 1
}
# Monorepo's default branch.
mono_main_ref=$(resolve_ref refs/heads/main refs/heads/master) \
|| die "monorepo has no 'main' or 'master' branch"
MONO_MAIN=${mono_main_ref#refs/heads/}
# Source URLs recorded by build/add, read from the committed default-branch file.
SOURCES=$(git show "$MONO_MAIN:.monorepoize/sources" 2>/dev/null || true)
# Per-repo sync markers, read live from the committed default branch each call so
# they reflect commits this run makes (baseline/marker updates as we go).
# synced_for: last integrated upstream SHA (empty if never recorded).
# mode_for: "merge" or "replay" (default "replay" when unrecorded).
# The `|| true` keeps a missing marker file (git show exits 128) from tripping
# `set -o pipefail` + `set -e` -- an absent marker just yields the empty string.
marker_value() { { git show "$MONO_MAIN:.monorepoize/$1" 2>/dev/null || true; } | awk -v n="$2" '$1 == n { print $2; exit }'; }
synced_for() { marker_value synced "$1"; }
mode_for() { local m; m=$(marker_value modes "$1"); printf '%s' "${m:-replay}"; }
# Monorepo's own origin, used by --same-origin to derive eponymous upstreams.
ORIGIN=$(git remote get-url origin 2>/dev/null || true)
if [ "$SAME_ORIGIN" -eq 1 ] && [ -z "$ORIGIN" ]; then
die "--same-origin needs an 'origin' remote on the monorepo to derive upstream URLs"
fi
# Look a repo's URL up in the --repos file by URL basename. Prints the URL and
# returns 0, or returns 1 if the file wasn't given or has no match.
url_from_repos_file() {
local name="$1" line b
[ -n "$REPOS_FILE" ] || return 1
while IFS= read -r line; do
[ -n "$line" ] || continue
b=${line##*/}; b=${b%.git}
if [ "$b" = "$name" ]; then printf '%s' "$line"; return 0; fi
done < "$REPOS_FILE"
return 1
}
# Resolve the upstream URL for a repo, most authoritative source first: explicit
# --upstream, then a --repos file (matched by URL basename), then the recorded
# .monorepoize/sources, then the --same-origin heuristic (origin URL with its
# last path segment replaced by <name>.git). Prints the URL and returns 0, or
# returns 1 if none is known.
upstream_for() {
local name="$1" url=""
if [ -n "$UPSTREAM" ]; then printf '%s' "$UPSTREAM"; return 0; fi
if url=$(url_from_repos_file "$name"); then printf '%s' "$url"; return 0; fi
if [ -n "$SOURCES" ]; then
url=$(printf '%s\n' "$SOURCES" | awk -v n="$name" '$1 == n { print $2; exit }')
[ -n "$url" ] && { printf '%s' "$url"; return 0; }
fi
if [ "$SAME_ORIGIN" -eq 1 ]; then printf '%s' "${ORIGIN%/*}/$name.git"; return 0; fi
return 1
}
# Backfill for monorepos built before source URLs were recorded: when there is
# no .monorepoize/sources but a --repos file was given, record (and commit) the
# URL of every constituent repo the file can resolve, so future runs need no
# --repos. Only the --repos file is consulted (not -u, which in single-repo
# mode would misattribute one URL to every name).
if [ -z "$SOURCES" ] && [ -n "$REPOS_FILE" ] && [ "$DRYRUN" -eq 0 ]; then
echo "No .monorepoize/sources in this monorepo; recording sources from $(basename "$REPOS_FILE") ..." >&2
git checkout --quiet "$MONO_MAIN"
recorded=0
while IFS= read -r name; do
[ -n "$name" ] || continue
url=$(url_from_repos_file "$name") || continue
stage_source "$name" "$url"
recorded=$((recorded + 1))
done <<< "$(constituent_names .)"
if [ "$recorded" -gt 0 ]; then
git commit -q -m "Record source URLs."
SOURCES=$(git show "$MONO_MAIN:.monorepoize/sources")
echo "Recorded $recorded source URL(s) in .monorepoize/sources." >&2
else
echo "No constituent repos matched a URL in $(basename "$REPOS_FILE"); nothing recorded." >&2
fi
fi
# Subtree-merge the upstream tip THEIRS into <name>/ on the current branch,
# leaving the result staged but uncommitted (so the caller can fold the sync
# marker into the same commit, or leave a conflict in progress). Returns 0 on a
# clean merge, non-zero on conflict.
run_merge() {
local name="$1" theirs="$2"
if [ "$QUIET" -eq 1 ]; then
git merge --no-commit --no-edit -X subtree="$name" "$theirs" >/dev/null 2>&1
else
git merge --no-commit --no-edit -X subtree="$name" "$theirs"
fi
}
# Replay the linear range base..newtip onto the <name>/ subdir. Returns 0 on
# success, non-zero on conflict (caller aborts).
run_replay() {
local name="$1" base="$2" newtip="$3"
if [ "$QUIET" -eq 1 ]; then
git format-patch --stdout "$base..$newtip" 2>/dev/null | git am --3way --directory="$name" >/dev/null 2>&1
else
git format-patch --stdout "$base..$newtip" | git am --3way --directory="$name"
fi
}
# Update one repo. Sets _status (nobranch|nourl|missing|uptodate|merges|pending|
# updated|conflict), _count, and _mode (the effective mode); prints nothing;
# never exits.
update_one() {
local name="$1" upstream br src_ref base newtip merges mode want have_marker
_status=""; _count=0; _mode=""
# Locate the pre-fetch mirror branch and its branch name.
br=""; src_ref=""
local b
for b in ${BRANCH:-main master}; do
if src_ref=$(resolve_ref "refs/heads/$name/$b" "refs/remotes/origin/$name/$b"); then br="$b"; break; fi
done
if [ -z "$src_ref" ]; then _status="nobranch"; return 0; fi
if ! upstream=$(upstream_for "$name"); then _status="nourl"; return 0; fi
# Effective mode: the recorded mode, unless --merge forces a switch to merge.
mode=$(mode_for "$name")
if [ "$WANT_MERGE" -eq 1 ]; then want="merge"; else want="$mode"; fi
_mode="$want"
# Integration base: the recorded synced position, else the current mirror tip
# (first-time / legacy). Deliberately NOT re-derived from the just-fetched
# mirror, so a failed integration can never be mistaken for "up to date".
base=$(synced_for "$name"); have_marker=1
if [ -z "$base" ]; then base=$(git rev-parse "$src_ref"); have_marker=0; fi
##################### dry run: report only, no writes ####################
if [ "$DRYRUN" -eq 1 ]; then
if ! gq fetch --quiet "$upstream" "refs/heads/$br:refs/__update_peek"; then _status="missing"; return 0; fi
newtip=$(git rev-parse refs/__update_peek)
git update-ref -d refs/__update_peek >/dev/null 2>&1 || true
if [ "$base" = "$newtip" ]; then _status="uptodate"; return 0; fi
_count=$(git rev-list --count "$base..$newtip")
merges=$(git rev-list --merges --count "$base..$newtip")
if [ "$want" = "replay" ] && [ "$merges" -ne 0 ]; then _status="merges"; return 0; fi
_status="pending"; return 0
fi
############## establish/record baseline & mode as needed ###############
# Ensure the base is an ancestor of the default branch (needed when switching a
# diverged/replay-built repo to merge mode) and that the mode/synced markers
# exist, committing whatever changed as a one-time baseline.
gq checkout --quiet "$MONO_MAIN"
if [ "$want" = "merge" ] && ! git merge-base --is-ancestor "$base" "$MONO_MAIN"; then
gq merge -s ours --no-edit -m "Establish merge baseline for $name at $(git rev-parse --short "$base")." "$base"
fi
local baseline_changed=0
if [ "$want" != "$mode" ]; then stage_mode "$name" "$want"; baseline_changed=1; fi
if [ "$have_marker" -eq 0 ]; then stage_synced "$name" "$base"; baseline_changed=1; fi
if [ "$baseline_changed" -eq 1 ]; then gq commit -q -m "Record sync mode/position for $name."; fi
mode="$want"
########################### advance the mirror ##########################
if ! gq fetch --quiet --no-tags "$upstream" "+refs/heads/*:refs/heads/$name/*"; then _status="missing"; return 0; fi
gq fetch --quiet "$upstream" "+refs/tags/*:refs/tags/$name/*" || true
newtip=$(git rev-parse "refs/heads/$name/$br")
if [ "$base" = "$newtip" ]; then _status="uptodate"; return 0; fi
_count=$(git rev-list --count "$base..$newtip")
merges=$(git rev-list --merges --count "$base..$newtip")
############################### integrate ###############################
if [ "$mode" = "merge" ]; then
gq checkout --quiet "$MONO_MAIN"
if run_merge "$name" "refs/heads/$name/$br"; then
stage_synced "$name" "$newtip"
gq commit -q -m "Merge $_count upstream commit(s) for $name under $name/."
_status="updated"
elif [ "$QUIET" -eq 1 ]; then
git merge --abort >/dev/null 2>&1 || true; _status="conflict"
else
# Single-repo: leave the merge in progress and stage the marker so the
# user's resolving commit records the new sync position.
stage_synced "$name" "$newtip"; _status="conflict"
fi
else
if [ "$merges" -ne 0 ]; then _status="merges"; return 0; fi
gq checkout --quiet "$MONO_MAIN"
if run_replay "$name" "$base" "$newtip"; then
stage_synced "$name" "$newtip"
gq commit -q -m "Record sync position for $name."
_status="updated"
else
# Marker stays at base (unchanged), so re-running re-detects the pending
# commits rather than reporting "up to date".
git am --abort >/dev/null 2>&1 || true; _status="conflict"
fi
fi
}
########################################################################
# Single repo
########################################################################
if [ "$ALL" -eq 0 ]; then
echo "Updating '$NAME' in $MONOREPO ..." >&2
update_one "$NAME"
case "$_status" in
nobranch) die "no '$NAME/${BRANCH:-main|master}' branch in this monorepo; was it built/cloned with the per-repo branches?" ;;
nourl) die "no upstream URL known for '$NAME'; pass -u URL, --repos FILE, or --same-origin, or rebuild so .monorepoize/sources is recorded" ;;
missing) die "could not fetch the upstream for '$NAME' (deleted, or unreachable); pass -u to point at it" ;;
merges) die "the upstream range for '$NAME' has merge commit(s); replay mode only handles linear history — use --merge to subtree-merge instead" ;;
uptodate) echo "Already up to date." >&2; exit 0 ;;
pending) echo "$_count new commit(s) for '$NAME' (dry run — nothing changed)." >&2; exit 0 ;;
conflict)
if [ "$_mode" = "merge" ]; then
die "merge conflict integrating '$NAME' under $NAME/. The merge is in progress on $MONO_MAIN: resolve the conflicts, 'git add' them, and 'git commit' to finish (the sync marker is already staged), or 'git merge --abort' to back out."
else
die "could not apply '$NAME' cleanly under $NAME/ (conflicts). $MONO_MAIN left unchanged and the sync position was not advanced, so re-running will retry these commits. Resolve by hand, or switch to --merge."
fi ;;
updated) echo "Integrated $_count commit(s) under $NAME/ on $MONO_MAIN." >&2 ;;
esac
if [ "$PUSH" -eq 1 ]; then
echo "Pushing $MONO_MAIN and $NAME/* to origin ..." >&2
git push origin "$MONO_MAIN"
git push origin "refs/heads/$NAME/*:refs/heads/$NAME/*"
if git for-each-ref --format='x' "refs/tags/$NAME/*" | grep -q x; then
git push origin "refs/tags/$NAME/*:refs/tags/$NAME/*"
fi
echo "Pushed." >&2
fi
exit 0
fi
########################################################################
# --all
########################################################################
repos=$(constituent_names .)
[ -n "$repos" ] || die "no per-repo branches found in this monorepo"
total=$(printf '%s\n' "$repos" | grep -c .)
if [ "$DRYRUN" -eq 1 ]; then
echo "Checking $total repo(s) in $MONOREPO for upstream changes ..." >&2
else
echo "Updating $total repo(s) in $MONOREPO ..." >&2
fi
echo >&2
n_changed=0 n_commits=0 n_uptodate=0 n_missing=0 n_skipped=0
while IFS= read -r name; do
[ -n "$name" ] || continue
update_one "$name"
case "$_status" in
missing) printf ' %-30s %s\n' "$name" "UPSTREAM MISSING" >&2; n_missing=$((n_missing + 1)) ;;
nourl) printf ' %-30s %s\n' "$name" "no upstream URL — skipped" >&2; n_skipped=$((n_skipped + 1)) ;;
uptodate) printf ' %-30s %s\n' "$name" "up to date" >&2; n_uptodate=$((n_uptodate + 1)) ;;
merges) printf ' %-30s %s\n' "$name" "$_count new, has merges — skipped" >&2; n_skipped=$((n_skipped + 1)) ;;
conflict) printf ' %-30s %s\n' "$name" "CONFLICT — skipped" >&2; n_skipped=$((n_skipped + 1)) ;;
nobranch) printf ' %-30s %s\n' "$name" "no per-repo branch — skipped" >&2; n_skipped=$((n_skipped + 1)) ;;
pending) printf ' %-30s %s\n' "$name" "$_count new commit(s)" >&2; n_changed=$((n_changed + 1)); n_commits=$((n_commits + _count)) ;;
updated) printf ' %-30s %s\n' "$name" "updated ($_count commit(s))" >&2; n_changed=$((n_changed + 1)); n_commits=$((n_commits + _count)) ;;
esac
done <<< "$repos"
echo >&2
if [ "$DRYRUN" -eq 1 ]; then
echo "$total repo(s): $n_changed with updates ($n_commits commit(s)), $n_uptodate up to date, $n_missing upstream missing, $n_skipped skipped." >&2
exit 0
fi
echo "$total repo(s): updated $n_changed ($n_commits commit(s)), $n_uptodate up to date, $n_missing upstream missing, $n_skipped skipped." >&2
if [ "$PUSH" -eq 1 ] && [ "$n_changed" -gt 0 ]; then
echo "Pushing all branches and tags to origin ..." >&2
git push --quiet origin --all
git push --quiet origin --tags
echo "Pushed." >&2
fi