Repository navigation
Expand file tree
/
Copy pathopenRepoTools
More file actions
executable file
·5244 lines (5062 loc) · 267 KB
/
Copy pathopenRepoTools
File metadata and controls
executable file
·5244 lines (5062 loc) · 267 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
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env bash
# SPDX-License-Identifier: Apache-2.0
#
# openRepoTools — the short way to put the estate commands on your PATH.
#
# Install them once, from anywhere, with no checkout and no clone:
#
# gh api repos/opensoft/openRepoTools/contents/openRepoTools \
# -H 'Accept: application/vnd.github.raw' | bash -s -- --install
#
# (or the raw-URL twin, `curl -fsSL …/main/openRepoTools | bash -s -- --install`).
#
# IT INSTALLS THE ESTATE'S COMMANDS, AND IT OWNS THE ONE ACT THAT CREATES THE
# WORKSPACE THOSE COMMANDS READ. `openRepoShape` is the standard's front door
# and stays there: this scaffolds no product repository, and that refusal is
# not ours to touch. The two verbs are `park` and `resume`, which add no
# mechanics of their own — they find the estate under your projects directory
# and run its own `make park` / `make resume` — and `status` is the read-only
# view of the same estate.
#
# THIS FILE USED TO SAY "There is no verb here and no scaffold", and that line
# is gone on purpose rather than by accident. Brett Heap RULED the command —
# lane-collision-protocol Amendment 9, ruling 4, verbatim "openRepoTools wip
# init" — so the line, the `*)` arm and the test that pinned them are what
# change (Amendment 9(b), and R-A9-1). The identity that survives is narrower
# and truer.
#
# `--install` PLACES SEVENTEEN FILES, not four: this file, `park`, `resume`,
# `status` (openRepoShape #82; the fourth under Brett Heap's RULING of
# 2026-09-10 in this repository, "lets go with a fourth file"), `lane` and
# `lanes` (lane-collision-protocol Amendment 18 Addendum 1 and Amendment 11's
# ratified decision 6 — `lane` is where `restart` was, Addendum 2 having retired
# that word from the PATH), `lane-handoff` (Amendment 17(a), ratified
# 2026-09-14: the swap IS the handoff, and this is its shell form),
# `lane-rename` (Amendment 16, ratified 2026-09-14: a lane is renamed by ONE
# WORD, in ONE COMMIT, and its old name resolves for ever), and the four
# lane helpers `lanes-edit.sh`, `lane-start`, `lane-end` and `link-estates` with
# the alias table `repos.tsv` they read — which came here from
# `opensoft/brett-wip` with their history under Amendment 9(b), because a
# person's workspace repository holds their DATA and the code belongs where code
# goes — `claude-current` and `claude-restart-check`
# (opensoft/workBenches#119, Brett Heap's home ruling of 2026-09-29: the Claude
# Code a launch starts, and the RESTART NEEDED line a running session shows) —
# `lanes-index` (opensoft/openRepoTools#160, lane-collision-protocol
# Amendment 14, ratified by Brett Heap 2026-10-05: the DERIVED INDEX of the lane
# register, which nothing that acts reads) — and `lane-worktrees`
# (opensoft/openRepoTools#162, on Brett Heap's word of 2026-10-05, "build 162":
# the sweep that retires a lane's abandoned worktrees, rescue first). It also
# places THREE skills,
# `/handoff`, `/lane-swap` and `/restart`, at two paths each, THREE command
# files, `/handoff`, `/ctx` and `/swap`, at that same pair of paths (A11
# Addendum 4 ruling 9; Amendment 17(a) and (f)), and merges TWO hook entries into
# `~/.claude/settings.json` — the `SessionStart` one (Amendment 9(b), A8
# Addendum 2 R-A8-5) and, since lane-collision-protocol Amendment 12 adoption
# act 3, the `UserPromptSubmit` NAME GUARD beside it — both of them only once
# `~/projects/xFactory/lanes-edit.sh`, the path they call, answers `guard`
# (opensoft/openRepoTools#237): TWENTY-NINE artifacts always, plus the two hook
# entries once that path answers, and the count is the invariant.
#
# One install line for the whole toolset, so a second engineer gets the estate
# commands from the README's one line and nothing depends on anyone's dotfiles.
#
# openRepoShape #82 wrote the two verbs and these install semantics; its #92
# carved them out into this repository, which added `status`; lane protocol
# Amendment 9 brought the lane tooling home and added `wip init`.
set -euo pipefail
# $OPENREPOTOOLS_REPO is an owner/name slug: it is an API path and a raw-URL
# path, and nothing here clones. Name a fork or a mirror once and both fetch
# forms follow it.
REPO="${OPENREPOTOOLS_REPO:-opensoft/openRepoTools}"
REF="${OPENREPOTOOLS_REF:-main}"
SELF="${BASH_SOURCE[0]:-$0}"
MODE=""
WORKDIR=""
STAGED=""
# THE PREFIX OF THE TEMPORARY A PLACEMENT IS MAKING (#167), named here for the
# `EXIT` trap `workdir` sets, which removes `"$PLACING"*`: `place_by_rename`
# removes its temporary on every failure it sees, and this is what removes it
# on the ones it cannot see — a signal, or an `errexit`. A PREFIX, set before
# `mktemp` runs, so no instant exists in which the file is there and the trap
# does not know its name (Copilot round 2 on #172).
PLACING=""
say() { printf '%s\n' "$*"; }
die() { printf '\nREFUSED: %s\n' "$1" >&2; exit "${2:-2}"; }
usage() {
cat <<'USAGE'
openRepoTools --install install (or update) the seventeen estate and
lane commands into ~/.local/bin, plus the
/handoff, /lane-swap and /restart skills, the
/handoff, /ctx and /swap command files, the
SessionStart hook entry and the
UserPromptSubmit name guard, once
~/projects/xFactory/lanes-edit.sh
answers `guard`
openRepoTools wip init create your workspace repository, clone it,
seed it, and point this workstation at it
openRepoTools --help | --version
`openRepoShape` is the standard's front door and scaffolds product
repositories; this command scaffolds none. `--install` places seventeen files
beside each other — this command, the two verbs, the read-only view, the four
lane words, the four lane helpers, the alias table they read, the two Claude
Code launch commands, the derived index of the lane register and the worktree
sweep:
park [<Name>] commit, push and RECORD an estate's open
features, so another workstation can take
the work up
resume [<Name>] clone or fast-forward that estate here and
recreate those features
status [<Name>] what is in and out of sync across that
estate, as of the last fetch; changes nothing
lane [<name> [<profile>]] the lanes, NUMBERED, and one question: the
one you pick is bound — a parked lane
through the launcher in its own directory
and profile, one live here by attaching to
its window, one bound elsewhere by naming
where. With no terminal it asks nothing
lanes [--all] [--fetch] inside a checkout: that repository's lanes
and the next free position, filled in;
outside one, or with --all, every lane.
Reads locally, changes nothing, asks nothing
lane-start <repo> <n> open a lane: name the window, register the
row, launch or resume the session — with
--agent <name>, from any agent that can read
the lane (Amendment 17(c))
lane-handoff [--restart|--exit] hand this lane off: the identity triple, the
handoff refreshed, the writers polled, the
PAUSED record written, the restart line
printed. --restart is /ctx; --exit ends the
session a second place asked for the lane
lane-end <lane> close it, and refuse while anything is in
flight or any tree, branch, scratch or cache
of it is still on disk (--inventory-only
ends it anyway, and says so)
lane-rename <old> <new> rename it: the row, the object log, the
handoff and lanes/aliases.tsv in ONE commit,
one comment per object it holds, and the old
name resolving for ever (Amendment 16)
lanes-edit.sh <verb> … the register's and the object logs' only
writer
link-estates place the symlinks every lane path already
uses, into your workspace repository
repos.tsv the organisation's alias table, which
`lanes-edit.sh` reads
claude-current [--porcelain] the Claude Code a launch starts: npm's
published version, updated when behind and
verified with --version, by absolute path
and never through PATH (lane-start uses it)
claude-restart-check one RESTART NEEDED line for a running
session whose binary moved on disk, for the
shared status line to print
lanes-index sync|reconcile|status
the DERIVED INDEX of the lane register
(Amendment 14): written behind the sources,
read only by lanes --index and
history --index
lane-worktrees sweep <lane> retire a lane's abandoned worktrees: a dry
run by default; --yes pushes or rescues
first and removes only what is on origin
or in a bundle, never a live writer's
lane-worktrees add <lane> <slice>
make a lane's worktree and record it in the
lane's inventory, in one act
`--install` also places THREE SKILLS at two paths each —
`~/.claude-profiles/shared/skills/<name>/SKILL.md` and
`~/.claude/skills/<name>/SKILL.md`, for `handoff`, `lane-swap` and `restart` —
and THREE COMMAND FILES at the matching pair,
`~/.claude-profiles/shared/commands/<name>.md` and `~/.claude/commands/<name>.md`,
for `handoff`, `ctx` and `swap`. `handoff` is the act itself (Amendment 17(a));
`/ctx` is `/handoff --restart` (clause (f)); `/swap` and `/lane-swap` are the
aliases the amendment keeps so that nothing written about them stops working,
and each adds nothing to the skill it names. It merges TWO entries into
`~/.claude/settings.json` — the `SessionStart` one and, since
lane-collision-protocol Amendment 12, the `UserPromptSubmit` NAME GUARD, which
refuses a prompt whose window, session name and register row are not one lane
(needs `jq`, refuses rather than rewriting a file it cannot parse, and leaves
every `UserPromptSubmit` hook of your own exactly where it is). It merges the
two only once `~/projects/xFactory/lanes-edit.sh`, the path both call, exists,
is executable and answers `guard` — the launcher's own rule (#237). Until then
it says it DEFERRED them, and takes its own guard entry back out of a file that
already carries it; `wip init` merges them after `link-estates` links that
path. It never writes a profile's own settings.json: the launcher owns that one.
It then writes a RECEIPT of the files it placed — one row of
`<name> <destination> <sha256> <UTC>` each — at
`${OPENREPOTOOLS_DATA_DIR:-${XDG_DATA_HOME:-~/.local/share}/openRepoTools}/installed.tsv`,
mode 0600. That is how a later `--install` knows that a command it is about to
RETIRE is a copy it wrote rather than a file of yours that happens to carry the
same header: a digest that still matches is removed, one that has moved — or a
row it cannot check, for want of a digest tool — is named and left for you. A
receipt it cannot write is one line on this terminal and never a refused
install.
`/restart` is the INSIDE half and has to be a skill: a running session cannot
`exec` a launcher over itself, so it binds the window, stamps the record
through `lane-start --no-launch` and prints — never a picker (Amendment 11
clause (f), ratified decisions 3 and 7). `lane` is the OUTSIDE half, and it is
the whole of what a person is given: `restart` left the PATH under Amendment 18
Addendum 2 (ratified 2026-09-14T16:50:32Z, verbatim "ratify").
`wip init` — the workspace repository, which holds your lane register, your
object logs, your handoffs and your workspace manifests, and no code at all:
openRepoTools wip init [--org <org>] [--login <name>] [--team <team>]
[--dry-run]
It asks you at most one question, and only where an administrator has to create
the repository for you — then it prints the exact commands, already filled in.
Everything else is derived: your login from `gh api user`, the organisation
from this toolset's own default, the team from `gh api /user/teams` where
exactly one matches. Idempotent: a workstation that already has a workspace
does nothing to it, and only completes the hook pair — running `link-estates`
again first where that path does not answer and ~/projects/xFactory exists.
Exits 0 (done, or already done) or 2 (refused); a hook reconcile it cannot
make is printed, and the exit stays 0.
`park --help`, `resume --help`, `status --help`, `lane-start --help` and
`lane-end --help` say the rest, and each command's exit codes are its own. The
two verbs implement none of the mechanics: they find the estate under your
projects directory and run its own `make park` / `make resume`, which run the
Speckit git extension's scripts. `status` only reads.
$OPENREPOTOOLS_REPO owner/name to fetch from (default opensoft/openRepoTools)
$OPENREPOTOOLS_REF the ref to fetch it at (default main)
$OPENREPOTOOLS_BIN_DIR where --install puts them (default ~/.local/bin)
$OPENREPOTOOLS_DATA_DIR where the receipt goes (default
$XDG_DATA_HOME/openRepoTools)
$XDG_DATA_HOME the data root it falls back to (default ~/.local/share)
$AGENT_PROTOCOL_ROOT where workspace.yaml lives (default ~/.agents)
$CLAUDE_PROFILES_HOME the profiles root (default ~/.claude-profiles)
$PROJECTS_DIR your projects directory (default ~/projects)
USAGE
}
# THE TRAP BELONGS TO THE SHELL THAT DOES THE WRITING, so this makes the
# directory and takes no output. A `$(workdir)` would be a COMMAND
# SUBSTITUTION — a subshell, whose `trap … EXIT` fires the instant the
# substitution closes, so `rm -rf` would run before a single fetched byte had
# landed and $WORKDIR out here would still be empty (openRepoShape #74). A trap
# only ever fires in the shell that set it. Callers read $WORKDIR.
workdir() {
if [ -z "$WORKDIR" ]; then
WORKDIR="$(mktemp -d)"
trap 'rm -rf -- "$WORKDIR"; [ -z "$PLACING" ] || rm -f -- "$PLACING"*' EXIT
fi
}
# THE API FIRST, the raw URL second. `gh api` is authenticated, so it still
# works inside an organisation whose policy blocks raw.githubusercontent.com;
# curl is the fallback for a machine with no `gh`. Each attempt writes to a
# file and is checked before its bytes are used, so a half-written first
# attempt is never mistaken for a fetch.
#
# THESE FORTY LINES ARE A DELIBERATE COPY of openRepoShape's own shim, and the
# duplication is the cheaper of the two mistakes: a shared `orp-fetch.sh` would
# be a FIFTH FILE for `--install` to place, and a broken installer the first
# time somebody copied only some of them. It is the same argument the estate
# resolver inside `park`, `resume` and `status` already carries, for the same
# reason — each of these files is one file a person has on PATH. Two installers that
# fetch the same way is a claim `tests/test_openrepotools_command.py` and
# openRepoShape's own suite each assert against their own script's text.
#
# WHAT IT FETCHES FROM IS THE ONE THING THAT MOVED (#186, T007): the CURRENT
# SOURCE, `$PAYLOAD_REPO` at `$PAYLOAD_REV`, which is `$REPO` itself until
# `payload_resolve_remote` has read and checked `$REPO`'s own code pin, and the
# repository that pin names after that. For a single repository it never
# moves: `$REPO` at `$REF`, exactly as it always was.
fetch_from_repo() {
local path="$1" dest="$2"
if command -v gh >/dev/null 2>&1 &&
gh api "repos/${PAYLOAD_REPO:-$REPO}/contents/$path?ref=${PAYLOAD_REV:-$REF}" \
-H 'Accept: application/vnd.github.raw' >"$dest" 2>/dev/null &&
[ -s "$dest" ]; then
return 0
fi
if command -v curl >/dev/null 2>&1 &&
curl -fsSL "https://raw.githubusercontent.com/${PAYLOAD_REPO:-$REPO}/${PAYLOAD_REV:-$REF}/$path" \
>"$dest" 2>/dev/null && [ -s "$dest" ]; then
return 0
fi
# RETURNS rather than dying, so a caller fetching SEVERAL files can report
# the whole set instead of stopping mid-way through an install
# (openRepoShape #82, F10 of the review on its #83).
return 1
}
# ------------------------------------------------ WHERE THE PAYLOAD COMES FROM
#
# opensoft/openRepoTools#186 — T007 of `specs/004-migrate-to-triad/tasks.md`,
# on `specs/004-migrate-to-triad/installer-design.md`. There are TWO LAYOUTS,
# and this asks which one it is looking at rather than guessing:
#
# * A SINGLE REPOSITORY — `opensoft/openRepoTools` today — carries every
# payload file at its root. NOTHING ABOUT IT CHANGES: a directory beside
# this file that holds every payload file (a checkout, workBenches'
# vendored tree) is copied with no network at all, byte for byte and line
# for line what it always was; one that holds only some of them copies
# those and fetches the rest; and a run from stdin fetches them all.
# * AN ADOPTED ASSEMBLY — the same slug after the triad split — carries
# `contracts/code-pin.yaml`, which names the CODE repository and commit,
# and a gitlink at the pinned path that must agree with it. The payload is
# that repository's, at that commit, and nothing else's.
#
# A SINGLE REPOSITORY IS READ EXACTLY AS IT ALWAYS WAS: `$OPENREPOTOOLS_REF`
# as given, file by file, by `gh api …/contents` and then the raw URL — no
# other host, no `jq`, no fallback that was not there before. The one request
# it makes that it did not make before is the question below, by those same
# two transports, and it prints nothing. Only an ADOPTED ASSEMBLY resolves the
# ref:
# `$OPENREPOTOOLS_REF` becomes a commit on the first API round trip and every
# request after it names that commit, so a branch that moves while the files
# come down cannot hand one run two versions. That guarantee needs
# api.github.com (and `jq` on the `curl` path), which a single repository's
# install never needed and still does not: today's layout keeps today's
# network needs, byte for byte (#191's review). `$OPENREPOTOOLS_REPO` and
# `$OPENREPOTOOLS_REF` mean what they always meant: what to install from, and
# at which ref.
#
# WHICH LAYOUT IS DECIDED BY AN ANSWER, NEVER BY A FAILURE. One file is asked
# for by name at `$REF`, through `fetch_from_repo`'s own two transports
# (`probe_path`): `contracts/code-pin.yaml`. A pin that is there is the adopted
# layout. GitHub's 404 for it is a single repository, read as above. An
# authentication failure, a rate limit, a transport failure or any other status
# from both transports is a REFUSAL naming what answered — never evidence of
# the older layout, because reading it that way would install whatever the
# assembly root happened to carry. NOTHING ELSE IS ASKED on that 404: a second
# question (the adoption manifest, `project.yaml`) would be a second request on
# today's layout for a state the adopter never publishes — it writes the pin
# and the manifest in one commit — and that `make validate` refuses in the
# assembly itself (#191's review).
#
# A PIN THAT IS PRESENT AND WRONG REFUSES. Its grammar is fixed, so it is read
# here with `read` and `case` rather than a YAML runtime (`pin_parse`), and
# every field is checked. The payload repository is the pin's own
# `source_repository`, never a name made by appending `-code` to a fork's: a
# fork follows the pin it actually carries.
#
# WHAT A REMOTE INSTALL DOES NOT CLAIM: the pin's `tree_sha256`. That digest is
# over the code leg's WHOLE tree and is the composed validator's to recompute
# (`scripts/validate-pins.py` in the assembly). What is verified here is the
# identity this run consumes — the commit the ref names, the gitlink at the
# pinned path, and that the pinned commit is one the pinned repository has.
#
# LOCALLY — this file run from the code leg MOUNTED in an assembly checkout,
# `<root>/<submodule_path>`, whose `contracts/code-pin.yaml` names this
# directory — the pin and the
# gitlink must agree and every payload file must be the pinned commit's bytes;
# then they are copied, offline. A code checkout under an assembly that is NOT
# its mounted leg (a paired feature worktree, `<root>/worktrees/<NNN>/code`) is
# somebody installing that checkout ON PURPOSE: its bytes are copied as they
# are, and the run names that checkout and its commit and says it is not the
# pinned code. Neither is ever completed from a network — local siblings and
# remote bytes do not make one install between them. THOSE TWO LAYOUTS AND NO
# OTHER: a `contracts/code-pin.yaml` anywhere else above this file — another
# project's assembly that happens to hold a vendored tree deep inside it — is
# not this tree's, is not read, and changes nothing (`assembly_above`).
#
# ALL OF IT HAPPENS BEFORE ANYTHING IS PLACED: `install_commands` asks
# `payload_select` straight after the targets, before the first byte is
# collected, and every refusal here places nothing.
#
# ONE CURRENT SOURCE, AND IT IS `$REPO`'S. Every request this file makes reads
# `${PAYLOAD_REPO:-$REPO}`: `$REPO` while its ref, its tree and its pin are
# read, and — only once that pin has passed every check below — the repository
# `$REPO`'s own pin names. No other repository is ever reached for the payload,
# and `tests/test_repo_hygiene.py` holds every fetching line to naming `$REPO`.
PAYLOAD_MODE="" # "" until chosen: local | siblings | remote
PAYLOAD_DIR="" # the directory payload files are copied from, if any
PAYLOAD_REPO="" # owner/name every fetch reads, once resolved
PAYLOAD_REV="" # the 40-hex commit every fetch reads, once resolved
SOURCE_SHA="" # $REF, resolved once
SOURCE_LAYOUT="" # single | adopted | feature, once known
API_SHA=""
API_WHY=""
PROBE_LAYOUT="" # single | adopted | unknown, once contracts/code-pin.yaml is asked for
PROBE_WHY=""
RESOLVE_FOR=install # whose refusal a failed resolution is: install, or wip (`wip init` step 7)
#: THE CHARACTERS GITHUB SPELLS A NAME WITH, WRITTEN OUT. A bracket RANGE such
#: as `[a-z]` follows the locale's collation in bash 3.2 and can admit capitals,
#: and `case` is how this file matches.
ORT_ALNUM='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
ORT_TAB="$(printf '\t')"
ORT_CR="$(printf '\r')"
#: One listing row per entry: `path<TAB>type<TAB>sha`. `gh` evaluates it itself
#: (`--jq`); the curl path hands the same filter to `jq`.
ORT_TREE_JQ='.tree[] | [.path, .type, .sha] | @tsv'
# EVERY FILE `--install` READS, derived from the three lists and from nothing
# else: the commands at the root, then each skill's and each command file's path.
payload_names() {
local name
for name in "${INSTALLABLES[@]}"; do printf '%s\n' "$name"; done
for name in "${SKILLS[@]}"; do skill_source_path "$name"; done
for name in "${COMMANDS[@]}"; do command_source_path "$name"; done
}
is_lower_hex() { # <length> <value>
case "$2" in '' | *[!0123456789abcdef]*) return 1 ;; esac
[ "${#2}" -eq "$1" ]
}
is_slug() { # <owner/name> — a repository as GitHub spells one, and nothing else
local owner="${1%%/*}" name="${1#*/}"
[ "$owner" != "$1" ] || return 1
case "$owner" in '' | -* | *- | *[!${ORT_ALNUM}-]*) return 1 ;; esac
case "$name" in '' | . | .. | */* | *[!${ORT_ALNUM}._-]*) return 1 ;; esac
return 0
}
is_segment() { # <path> — exactly one path segment
case "$1" in '' | . | .. | *[!${ORT_ALNUM}._-]*) return 1 ;; esac
return 0
}
# --- the code pin's fixed grammar ---------------------------------------------
#
# `contracts/code-pin.yaml` as openRepoShape's adopter writes it: top-level
# `key: value` lines, comments, blank lines, and one mapping, `digests:`, whose
# indented `tree_sha256:` is read. A scalar may be double- or single-quoted.
# A key this reader does not use (a `tag:` label beside the commit, say) is
# allowed once; a key it does use must appear exactly once; and every value
# it uses must have the one form the contract gives it. LF only.
PIN_SCALAR=""
pin_scalar() { # <text after the colon> — PIN_SCALAR, or 1 for a quote that does not close
local v="$1" q rest
while :; do case "$v" in ' '* | "$ORT_TAB"*) v="${v#?}" ;; *) break ;; esac; done
case "$v" in
'"'* | "'"*)
q="${v:0:1}"
rest="${v#?}"
case "$rest" in *"$q"*) ;; *) return 1 ;; esac
PIN_SCALAR="${rest%%"$q"*}"
rest="${rest#*"$q"}"
while :; do case "$rest" in ' '* | "$ORT_TAB"*) rest="${rest#?}" ;; *) break ;; esac; done
case "$rest" in '' | '#'*) ;; *) return 1 ;; esac
# No escapes in this grammar: a backslash is a value nobody wrote.
case "$PIN_SCALAR" in *\\*) return 1 ;; esac
;;
'#'*) PIN_SCALAR="" ;;
*)
PIN_SCALAR="${v%% #*}"
while :; do
case "$PIN_SCALAR" in *' ' | *"$ORT_TAB") PIN_SCALAR="${PIN_SCALAR%?}" ;; *) break ;; esac
done
;;
esac
return 0
}
pin_bad() { # <field> <value> <what it must be> — PIN_WHY, and 1
if [ -z "$2" ]; then
PIN_WHY="\`$1\` is missing"
else
PIN_WHY="\`$1\` is '$2', $3"
fi
return 1
}
pin_parse() { # <file> — the PIN_* fields and 0, or PIN_WHY and 1
local line body key n=0 seen=" " block="" tree_seen=0
PIN_SCHEMA="" PIN_KIND="" PIN_LEG_ROLE="" PIN_SOURCE_REPOSITORY=""
PIN_SUBMODULE_PATH="" PIN_COMMIT="" PIN_REVISION_KIND=""
PIN_DIGEST_ALGORITHM="" PIN_DIGEST_DEFINITION="" PIN_TREE_SHA256=""
PIN_VERIFY="" PIN_RUNBOOK="" PIN_WHY=""
[ -s "$1" ] || { PIN_WHY="it is empty"; return 1; }
while IFS= read -r line || [ -n "$line" ]; do
n=$((n + 1))
case "$line" in
*"$ORT_CR"*) PIN_WHY="line $n ends in a carriage return, and the grammar is LF-only"; return 1 ;;
'' | '#'*) continue ;;
' '* | "$ORT_TAB"*)
body="$line"
while :; do case "$body" in ' '* | "$ORT_TAB"*) body="${body#?}" ;; *) break ;; esac; done
case "$body" in '' | '#'*) continue ;; esac
# AN INDENTED LINE BELONGS TO THE KEY ABOVE IT, and the only one
# this reads into is `digests:`.
[ "$block" = digests ] || continue
key="${body%%:*}"
[ "$key" != "$body" ] || { PIN_WHY="line $n is not \`key: value\`"; return 1; }
pin_scalar "${body#*:}" || { PIN_WHY="line $n has a quoted value that does not close cleanly"; return 1; }
if [ "$key" = tree_sha256 ]; then
[ "$tree_seen" -eq 0 ] || { PIN_WHY="\`digests.tree_sha256\` appears twice"; return 1; }
tree_seen=1
PIN_TREE_SHA256="$PIN_SCALAR"
fi
continue ;;
esac
block=""
key="${line%%:*}"
[ "$key" != "$line" ] || { PIN_WHY="line $n is not \`key: value\`"; return 1; }
case "$key" in
'' | *[!abcdefghijklmnopqrstuvwxyz0123456789_]*)
PIN_WHY="line $n's key '$key' is not a key this grammar has"; return 1 ;;
esac
case "$seen" in *" $key "*) PIN_WHY="\`$key\` appears twice"; return 1 ;; esac
seen="$seen$key "
pin_scalar "${line#*:}" || { PIN_WHY="line $n has a quoted value that does not close cleanly"; return 1; }
case "$key" in
schema_version) PIN_SCHEMA="$PIN_SCALAR" ;;
kind) PIN_KIND="$PIN_SCALAR" ;;
leg_role) PIN_LEG_ROLE="$PIN_SCALAR" ;;
source_repository) PIN_SOURCE_REPOSITORY="$PIN_SCALAR" ;;
submodule_path) PIN_SUBMODULE_PATH="$PIN_SCALAR" ;;
commit) PIN_COMMIT="$PIN_SCALAR" ;;
revision_kind) PIN_REVISION_KIND="$PIN_SCALAR" ;;
digest_algorithm) PIN_DIGEST_ALGORITHM="$PIN_SCALAR" ;;
digest_definition) PIN_DIGEST_DEFINITION="$PIN_SCALAR" ;;
verify_pin) PIN_VERIFY="$PIN_SCALAR" ;;
resync_runbook) PIN_RUNBOOK="$PIN_SCALAR" ;;
digests)
[ -z "$PIN_SCALAR" ] || { PIN_WHY="\`digests\` is '$PIN_SCALAR', not a mapping"; return 1; }
block=digests ;;
esac
done <"$1"
[ "$PIN_SCHEMA" = 1 ] || { pin_bad schema_version "$PIN_SCHEMA" "not 1"; return 1; }
[ "$PIN_KIND" = pinned_contract_manifest ] ||
{ pin_bad kind "$PIN_KIND" "not pinned_contract_manifest"; return 1; }
[ "$PIN_LEG_ROLE" = code ] || { pin_bad leg_role "$PIN_LEG_ROLE" "not code"; return 1; }
is_slug "$PIN_SOURCE_REPOSITORY" ||
{ pin_bad source_repository "$PIN_SOURCE_REPOSITORY" "not an owner/name repository"; return 1; }
is_segment "$PIN_SUBMODULE_PATH" ||
{ pin_bad submodule_path "$PIN_SUBMODULE_PATH" "not one path segment"; return 1; }
is_lower_hex 40 "$PIN_COMMIT" ||
{ pin_bad commit "$PIN_COMMIT" "not 40 lowercase hex — a branch, a tag or an abbreviated commit is a moving reference"; return 1; }
[ "$PIN_REVISION_KIND" = commit ] || { pin_bad revision_kind "$PIN_REVISION_KIND" "not commit"; return 1; }
[ "$PIN_DIGEST_ALGORITHM" = sha256 ] ||
{ pin_bad digest_algorithm "$PIN_DIGEST_ALGORITHM" "not sha256"; return 1; }
[ "$PIN_DIGEST_DEFINITION" = sorted-ls-tree-r-v1 ] ||
{ pin_bad digest_definition "$PIN_DIGEST_DEFINITION" "not sorted-ls-tree-r-v1"; return 1; }
is_lower_hex 64 "$PIN_TREE_SHA256" ||
{ pin_bad digests.tree_sha256 "$PIN_TREE_SHA256" "not 64 lowercase hex"; return 1; }
[ -n "$PIN_VERIFY" ] || { pin_bad verify_pin "" ""; return 1; }
[ -n "$PIN_RUNBOOK" ] || { pin_bad resync_runbook "" ""; return 1; }
return 0
}
# --- the release stamp's fixed grammar ----------------------------------------
#
# `openrepotools/_release.py`, beside a package's payload (opensoft/openRepoTools
# #228; `specs/006-release-pypi/contracts/stamp.md` is the grammar and the
# refusal table, design.md Decision 5 the reason). BASH IS ITS ONE READER AT RUN
# TIME: the entry module reads nothing, so Python and bash can never disagree
# about a stamp. It is read by the rules below and NEVER SOURCED, IMPORTED OR
# RUN, because its docstring is free text and a `$(…)` in it is only text.
#
# EACH REASON IS ONE LINE THAT BEGINS WITH ITS CAUSE, `cannot be read (…)`,
# `is partial: …` or `is malformed: …`, so that `package_init`'s refusal reads
# as a sentence around it. Nothing here prints, on either stream: that refusal
# is the one line a broken stamp gets. And a refused stamp sets none of the
# five values, because a stamp is all or nothing (FR-010) and is never read as
# the part it holds. The five, the state and the reason are set here, so that
# nothing in the environment can set them.
STAMP_STATE="" # unstamped | stamped, once `stamp_read` has accepted a stamp
STAMP_VERSION="" STAMP_TAG="" STAMP_COMMIT="" STAMP_DIGEST="" STAMP_REPOSITORY=""
STAMP_WHY="" # why `stamp_read` refused, one line
#: THE THREE LETTERS HATCH'S `(?i)` FOLDS ONTO ONE OF `version`'s, beyond ASCII's
#: own capitals: U+017F onto `s`, and U+0130 and U+0131 onto `i`. They are every
#: such letter, counted with Python's `re` over the whole of Unicode. Written as
#: their UTF-8 bytes, so the guard folds them in any locale.
STAMP_FOLD_S="$(printf '\305\277')"
STAMP_FOLD_I_DOT="$(printf '\304\260')"
STAMP_FOLD_I_DOTLESS="$(printf '\304\261')"
stamp_number() { # <text> — 0 for a decimal number with no leading zero
case "$1" in '' | *[!0123456789]* | 0?*) return 1 ;; esac
return 0
}
#: `X.Y.Z` or `X.Y.ZrcN`, each number with no leading zero: contracts/stamp.md's
#: pattern, by `case` and parameter expansion. Not `=~`, whose quoting bash 3.2
#: reads differently from bash 4, and whose `[0-9]` is a range.
stamp_version_ok() { # <version>
local v="$1" rest="" z=""
case "$v" in
*rc*)
stamp_number "${v##*rc}" || return 1
v="${v%rc*}"
;;
esac
rest="${v#*.}"
[ "$rest" != "$v" ] && stamp_number "${v%%.*}" || return 1
z="${rest#*.}"
[ "$z" != "$rest" ] && stamp_number "${rest%%.*}" && stamp_number "$z"
}
#: THE DOCSTRING GUARD (rule 3): a line that begins, in any case, with `version`
#: or `__version__`, then optional spaces and `=`. Hatch's version source takes
#: the first line ANYWHERE in the file that its pattern matches, docstring or
#: not, so such a line would give the wheel one version and `--version`
#: another. "Any case" is hatch's own, so the three letters it folds are folded
#: here first; the guard is wider than hatch's pattern, which also wants a quote.
stamp_hatch_reads() { # <docstring line> — 0 when hatch could read it as the version
local s="$1"
s=${s//$STAMP_FOLD_S/s}
s=${s//$STAMP_FOLD_I_DOT/i}
s=${s//$STAMP_FOLD_I_DOTLESS/i}
case "$s" in
[Vv][Ee][Rr][Ss][Ii][Oo][Nn]*) s="${s#???????}" ;;
__[Vv][Ee][Rr][Ss][Ii][Oo][Nn]__*) s="${s#???????????}" ;;
*) return 1 ;;
esac
while :; do case "$s" in ' '*) s="${s#?}" ;; *) break ;; esac; done
case "$s" in '='*) return 0 ;; esac
return 1
}
stamp_names() { # <name>… — them in prose with their verb: `A is`, `A and B are`, `A, B and C are`
local out="${1-}" verb=is
shift || :
while [ $# -gt 0 ]; do
verb=are
if [ $# -eq 1 ]; then out="$out and $1"; else out="$out, $1"; fi
shift
done
printf '%s %s\n' "$out" "$verb"
}
#: contracts/stamp.md's rules 1 to 5, line by line, then the checks after the
#: read. `pin_parse`'s shape: each refusal returns at once with its reason, and
#: the five values are copied out only once the whole file has passed.
stamp_read() { # <file> — STAMP_STATE, the five STAMP_* values and 0, or STAMP_WHY and 1
local line="" rest="" name="" value="" n=0 doc=none doc_line=0 guarded=0
local seen=" " field="" ref="" missing="" set_names="" partial=""
local f_VERSION="" f_TAG="" f_COMMIT="" f_DIGEST="" f_REPOSITORY=""
local tq='"""' sep=' = "' dq='"'
local shape='is not NAME = "VALUE" in column 0, with one space either side of ='
STAMP_STATE="" STAMP_VERSION="" STAMP_TAG="" STAMP_COMMIT="" STAMP_DIGEST=""
STAMP_REPOSITORY="" STAMP_WHY=""
# A FILE THAT CANNOT BE READ is said before any read is tried. A directory
# opens on Linux and fails at its first `read`, with bash's own line on
# stderr; a FIFO would wait for ever.
if [ -d "$1" ]; then
STAMP_WHY="cannot be read (it is a directory)"; return 1
elif [ ! -e "$1" ]; then
STAMP_WHY="cannot be read (there is no such file)"; return 1
elif [ ! -f "$1" ]; then
STAMP_WHY="cannot be read (it is not a regular file)"; return 1
elif [ ! -r "$1" ]; then
STAMP_WHY="cannot be read (it is not readable by this user)"; return 1
fi
while IFS= read -r line || [ -n "$line" ]; do
n=$((n + 1))
# RULE 1, BEFORE EVERY OTHER: on a `#` line and in the docstring too.
case "$line" in *"$ORT_CR"*) STAMP_WHY="is malformed: line $n has a carriage return"; return 1 ;; esac
# RULE 3, INSIDE THE DOCSTRING: every line is skipped. The first that
# hatch could read as the version is held until the docstring closes,
# so that one which never closes is refused as that, and not as one of
# the assignments it swallowed.
if [ "$doc" = open ]; then
if [ "$guarded" -eq 0 ] && stamp_hatch_reads "$line"; then guarded=$n; fi
case "$line" in *"$tq"*) doc=closed ;; esac
if [ "$doc" = closed ] && [ "$guarded" -ne 0 ]; then
STAMP_WHY="is malformed: line $guarded, in the docstring, would be read by hatch as the version"
return 1
fi
continue
fi
# RULE 2: a blank line, or a `#` in column 0, before the docstring as
# well as after it.
case "$line" in '#'*) continue ;; esac
rest="$line"
while :; do case "$rest" in ' '* | "$ORT_TAB"*) rest="${rest#?}" ;; *) break ;; esac; done
[ -n "$rest" ] || continue
# RULE 3, ITS OPENING: the one line beginning `"""` before any
# assignment. It closes on that line when a second `"""` follows.
case "$line" in
"$tq"*)
if [ "$doc" = none ] && [ "$seen" = " " ]; then
doc=open doc_line=$n
case "${line#"$tq"}" in *"$tq"*) doc=closed ;; esac
continue
fi
;;
esac
# RULE 4: `NAME = "VALUE"` and nothing else, NAME one of the five and
# VALUE free of `"`, `\` and `$`.
case "$line" in
*"$sep"*"$dq") ;;
*) STAMP_WHY="is malformed: line $n $shape"; return 1 ;;
esac
name="${line%%"$sep"*}"
value="${line#*"$sep"}"
value="${value%"$dq"}"
case "$name" in
VERSION | TAG | COMMIT | DIGEST | REPOSITORY) ;;
'' | [0123456789]* | *[!${ORT_ALNUM}_]*) STAMP_WHY="is malformed: line $n $shape"; return 1 ;;
*) STAMP_WHY="is malformed: line $n assigns $name, which is not one of the stamp's five names"; return 1 ;;
esac
case "$value" in
*"$dq"* | *\\* | *'$'*)
STAMP_WHY="is malformed: line $n has a quote, a backslash or a dollar sign in its value"; return 1 ;;
esac
# RULE 5: each of the five exactly once.
case "$seen" in *" $name "*) STAMP_WHY="is malformed: $name is assigned twice, the second time on line $n"; return 1 ;; esac
seen="$seen$name "
case "$name" in
VERSION) f_VERSION="$value" ;;
TAG) f_TAG="$value" ;;
COMMIT) f_COMMIT="$value" ;;
DIGEST) f_DIGEST="$value" ;;
REPOSITORY) f_REPOSITORY="$value" ;;
esac
done 2>/dev/null <"$1" || { STAMP_WHY="cannot be read (it could not be opened)"; return 1; }
if [ "$doc" = open ]; then
STAMP_WHY="is malformed: the docstring opened on line $doc_line never closes"; return 1
fi
for field in VERSION TAG COMMIT DIGEST REPOSITORY; do
case "$seen" in *" $field "*) ;; *) missing="$missing $field" ;; esac
done
if [ -n "$missing" ]; then
STAMP_WHY="is malformed: $(stamp_names $missing) missing"; return 1
fi
# UNSTAMPED is TAG, COMMIT and DIGEST all empty, and then VERSION is 0.0.0
# and REPOSITORY is empty: a REPOSITORY with no commit names nothing.
if [ -z "$f_TAG$f_COMMIT$f_DIGEST" ]; then
[ "$f_VERSION" = 0.0.0 ] || partial="VERSION is ${f_VERSION:-empty} rather than 0.0.0"
[ -z "$f_REPOSITORY" ] || partial="${partial:+$partial and }REPOSITORY is $f_REPOSITORY"
if [ -n "$partial" ]; then
STAMP_WHY="is partial: $partial, but TAG, COMMIT and DIGEST are empty"; return 1
fi
STAMP_STATE=unstamped
else
# STAMPED is everything else, and then every field is set and has the
# one shape its rule gives it.
for field in VERSION TAG COMMIT DIGEST REPOSITORY; do
ref="f_$field"
if [ -n "${!ref}" ]; then set_names="$set_names $field"; else missing="$missing $field"; fi
done
if [ -n "$missing" ]; then
STAMP_WHY="is partial: $(stamp_names $set_names) set, but $(stamp_names $missing) empty"; return 1
fi
if [ "$f_VERSION" = 0.0.0 ]; then
STAMP_WHY="is malformed: VERSION is 0.0.0, the unstamped version, and TAG, COMMIT and DIGEST are set"; return 1
fi
stamp_version_ok "$f_VERSION" ||
{ STAMP_WHY="is malformed: VERSION is $f_VERSION, not X.Y.Z or X.Y.ZrcN with no leading zeros"; return 1; }
[ "$f_TAG" = "v$f_VERSION" ] || { STAMP_WHY="is malformed: TAG is $f_TAG, not v$f_VERSION"; return 1; }
is_lower_hex 40 "$f_COMMIT" ||
{ STAMP_WHY="is malformed: COMMIT is $f_COMMIT, not 40 lowercase hex digits"; return 1; }
is_lower_hex 64 "$f_DIGEST" ||
{ STAMP_WHY="is malformed: DIGEST is $f_DIGEST, not 64 lowercase hex digits"; return 1; }
is_slug "$f_REPOSITORY" ||
{ STAMP_WHY="is malformed: REPOSITORY is $f_REPOSITORY, not an owner/name repository"; return 1; }
STAMP_STATE=stamped
fi
STAMP_VERSION="$f_VERSION" STAMP_TAG="$f_TAG" STAMP_COMMIT="$f_COMMIT"
STAMP_DIGEST="$f_DIGEST" STAMP_REPOSITORY="$f_REPOSITORY"
return 0
}
# --- one round trip, `gh` first and the API by `curl` second ------------------
#
# The same order as `fetch_from_repo`, for the same reason, and for the same
# identities. Each sets API_WHY to what BOTH answered when neither did, which
# is what a refusal prints.
api_said() { # <tool> <its stderr> — its first line, as one clause
local first=""
IFS= read -r first <"$2" 2>/dev/null || :
case "$first" in
'') printf '%s failed and said nothing\n' "$1" ;;
"$1:"*) printf '%s\n' "$first" ;;
*) printf '%s: %s\n' "$1" "$first" ;;
esac
}
#: A COMMIT OF THE CURRENT SOURCE, BY REF: API_SHA and 0, or API_WHY and 1. An
#: answer that is not 40 lowercase hex is no answer — `gh` prints a failed
#: request's JSON on stdout.
api_sha() { # <ref, URL-escaped>
local out="$WORKDIR/api-sha" why="" sha=""
API_SHA="" API_WHY=""
if command -v gh >/dev/null 2>&1; then
if gh api "repos/${PAYLOAD_REPO:-$REPO}/commits/$1" -H 'Accept: application/vnd.github.sha' \
>"$out" 2>"$out.err"; then
IFS= read -r sha <"$out" || :
if is_lower_hex 40 "$sha"; then API_SHA="$sha"; return 0; fi
why="gh: an answer that is not a commit"
else
why="$(api_said gh "$out.err")"
fi
else
why="gh: not installed"
fi
if command -v curl >/dev/null 2>&1; then
if curl -fsSL -H 'Accept: application/vnd.github.sha' \
"https://api.github.com/repos/${PAYLOAD_REPO:-$REPO}/commits/$1" >"$out" 2>"$out.err"; then
sha=""
IFS= read -r sha <"$out" || :
if is_lower_hex 40 "$sha"; then API_SHA="$sha"; return 0; fi
why="$why; curl: an answer that is not a commit"
else
why="$why; $(api_said curl "$out.err")"
fi
else
why="$why; curl: not installed"
fi
API_WHY="$why"
return 1
}
tree_listing_ok() { # <file> — one or more `path<TAB>blob|tree|commit<TAB>40-hex` rows
[ -s "$1" ] || return 1
awk -F'\t' 'NF != 3 || ($2 != "blob" && $2 != "tree" && $2 != "commit") ||
length($3) != 40 || $3 ~ /[^0123456789abcdef]/ { bad = 1 }
END { exit bad ? 1 : 0 }' "$1"
}
#: A TREE OF THE CURRENT SOURCE, ONE LEVEL: the listing in <dest> and 0, or
#: API_WHY and 1.
api_tree() { # <commit or tree sha> <dest>
local out="$2" why=""
API_WHY=""
if command -v gh >/dev/null 2>&1; then
if gh api "repos/${PAYLOAD_REPO:-$REPO}/git/trees/$1" --jq "$ORT_TREE_JQ" >"$out" 2>"$out.err"; then
tree_listing_ok "$out" && return 0
why="gh: an answer that is not a tree listing"
else
why="$(api_said gh "$out.err")"
fi
else
why="gh: not installed"
fi
if ! command -v curl >/dev/null 2>&1; then
why="$why; curl: not installed"
elif ! command -v jq >/dev/null 2>&1; then
why="$why; curl: not asked, because reading api.github.com's JSON needs \`jq\` and there is none"
elif ! curl -fsSL "https://api.github.com/repos/${PAYLOAD_REPO:-$REPO}/git/trees/$1" >"$out.json" 2>"$out.err"; then
why="$why; $(api_said curl "$out.err")"
elif jq -r "$ORT_TREE_JQ" "$out.json" >"$out" 2>/dev/null && tree_listing_ok "$out"; then
return 0
else
why="$why; curl: an answer that is not a tree listing"
fi
API_WHY="$why"
return 1
}
tree_entry() { # <listing> <name> — "<type> <sha>" of that entry, or 1
awk -F'\t' -v name="$2" '$1 == name { print $2 " " $3; found = 1; exit }
END { exit found ? 0 : 1 }' "$1"
}
url_ref() { # <ref> — with the two characters a URL path reads otherwise escaped
local r="$1" out=""
while [ -n "$r" ]; do
case "$r" in
'%'*) out="$out%25" ;;
'#'*) out="$out%23" ;;
*) out="$out${r:0:1}" ;;
esac
r="${r#?}"
done
printf '%s' "$out"
}
# --- the two questions every remote run asks first ----------------------------
#: IS <path> IN THE CURRENT SOURCE? Asked exactly the way `fetch_from_repo`
#: asks for a payload file — `gh api …/contents` first, the raw URL second — and
#: of the same source, which is `$REPO` at `$REF` until an adopted assembly's
#: own commit is known, so the question today's layout is asked needs no host
#: and no tool its install did not already need. 0: it is there, its bytes in
#: <dest>. 3: it is not, which is GitHub's own 404 (`gh` names the status in
#: what it prints; the raw URL's is read with `-w`). 1: no answer either way.
#: PROBE_WHY says what each transport said, for 3 and for 1.
probe_path() { # <path> <dest>
local path="$1" dest="$2" status="" why=""
PROBE_WHY=""
if command -v gh >/dev/null 2>&1; then
if gh api "repos/${PAYLOAD_REPO:-$REPO}/contents/$path?ref=${PAYLOAD_REV:-$REF}" \
-H 'Accept: application/vnd.github.raw' >"$dest" 2>"$dest.err"; then
return 0
fi
why="$(api_said gh "$dest.err")"
case "$why" in *'HTTP 404'*) PROBE_WHY="$why"; return 3 ;; esac
else
why="gh: not installed"
fi
if command -v curl >/dev/null 2>&1; then
if status="$(curl -sSL -o "$dest" -w '%{http_code}' "https://raw.githubusercontent.com/${PAYLOAD_REPO:-$REPO}/${PAYLOAD_REV:-$REF}/$path" 2>"$dest.err")"; then
case "$status" in
200) return 0 ;;
404) PROBE_WHY="$why; curl: HTTP 404"; return 3 ;;
esac
why="$why; curl: HTTP ${status:-with no status}"
else
why="$why; $(api_said curl "$dest.err")"
fi
else
why="$why; curl: not installed"
fi
PROBE_WHY="$why"
return 1
}
#: WHICH LAYOUT `$REPO` HAS AT `$REF`: PROBE_LAYOUT single, adopted or unknown,
#: with PROBE_WHY for unknown. One request, and it refuses nothing; what each
#: answer means is its callers' (`payload_resolve_remote`, `wip_shape_ref`).
payload_probe() {
[ -z "$PROBE_LAYOUT" ] || return 0
local rc=0
workdir
probe_path contracts/code-pin.yaml "$WORKDIR/probe-code-pin" || rc=$?
case "$rc" in
0) PROBE_LAYOUT=adopted ;;
3) PROBE_LAYOUT=single ;;
*) PROBE_LAYOUT=unknown ;;
esac
}
#: A RESOLUTION THAT FAILED, refused in the words of the run it failed: an
#: `--install`, which has placed nothing yet, or `wip init`'s step 7, which
#: comes after its repository was made and cloned, so "nothing was written"
#: would not be true there.
resolve_refuse() { # <what happened>
case "$RESOLVE_FOR" in
wip) die "$1
Step 7 seeded nothing: no template file was written into the checkout and
nothing was staged. What the steps before it made is kept as it is, and a
re-run picks up from here." ;;
*) die "$1
NOTHING was installed and nothing already installed was replaced." ;;
esac
}
# --- the remote source, resolved once ---------------------------------------
payload_resolve_remote() {
[ -z "$SOURCE_LAYOUT" ] || return 0
local listing contracts pin entry link
workdir
payload_probe
case "$PROBE_LAYOUT" in
single)
# TODAY'S LAYOUT, READ AS TODAY: `$REPO` at `$REF` as given, file by
# file, by the transports that always fetched it.
PAYLOAD_REPO="$REPO" PAYLOAD_REV="" SOURCE_LAYOUT=single
return 0 ;;
unknown)
resolve_refuse "could not fetch contracts/code-pin.yaml from $REPO at $REF, the file that
says whether it is a single repository or an adopted assembly, and a failed
answer is a refusal, never a reading of the layout. What answered:
$PROBE_WHY
Both ways were tried:
gh api repos/$REPO/contents/contracts/code-pin.yaml?ref=$REF
curl -sSL https://raw.githubusercontent.com/$REPO/$REF/contracts/code-pin.yaml" ;;
esac
# AN ADOPTED ASSEMBLY: everything again, at ONE commit.
listing="$WORKDIR/source-root.tsv"
contracts="$WORKDIR/source-contracts.tsv"
pin="$WORKDIR/code-pin.yaml"
is_slug "$REPO" || resolve_refuse "\$OPENREPOTOOLS_REPO is '$REPO', which is not an owner/name repository,
so the code its contracts/code-pin.yaml names cannot be asked for."
# THE CURRENT SOURCE IS $REPO until its own pin says otherwise.
PAYLOAD_REPO="$REPO" PAYLOAD_REV=""
api_sha "$(url_ref "$REF")" || resolve_refuse "could not resolve $REPO at $REF to a commit. What answered:
$API_WHY
Both were asked:
gh api repos/$REPO/commits/$REF -H 'Accept: application/vnd.github.sha'
curl -fsSL -H 'Accept: application/vnd.github.sha' https://api.github.com/repos/$REPO/commits/$REF
An adopted assembly is installed from ONE commit, and a failed answer is a
refusal, never a reading of the repository's layout."
SOURCE_SHA="$API_SHA" PAYLOAD_REV="$API_SHA"
api_tree "$SOURCE_SHA" "$listing" || resolve_refuse "could not list $REPO at $SOURCE_SHA ($REF), so the gitlink its code pin
must agree with cannot be read. What answered:
$API_WHY"
entry=""
if entry="$(tree_entry "$listing" contracts)" && [ "${entry%% *}" = tree ]; then
api_tree "${entry#* }" "$contracts" || resolve_refuse "could not list contracts/ in $REPO at $SOURCE_SHA ($REF). What answered:
$API_WHY"
entry="$(tree_entry "$contracts" code-pin.yaml)" || entry=""
else
entry=""
fi
[ -n "$entry" ] || resolve_refuse "$REPO carried contracts/code-pin.yaml at $REF when it was first asked,
and $SOURCE_SHA, the commit $REF named a moment later, does not: the ref moved
between the two reads, so this run would not be installing one answer.
Re-run once it has settled."
[ "${entry%% *}" = blob ] || resolve_refuse "contracts/code-pin.yaml in $REPO at $SOURCE_SHA is a ${entry%% *}, not a
file."
# ASKED BY `probe_path`, not `fetch_from_repo`, so that a refusal can say
# what answered and not only what was asked (#191's review, lane 2's (d)).
probe_path contracts/code-pin.yaml "$pin" ||
resolve_refuse "could not fetch contracts/code-pin.yaml from $REPO at $SOURCE_SHA ($REF), which
the listing shows is there. What answered:
$PROBE_WHY
Both were asked:
gh api repos/$REPO/contents/contracts/code-pin.yaml?ref=$SOURCE_SHA