Repository navigation
Expand file tree
/
Copy pathresume
More file actions
executable file
·1758 lines (1657 loc) · 77.4 KB
/
Copy pathresume
File metadata and controls
executable file
·1758 lines (1657 loc) · 77.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
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
#
# resume — arrive at a workstation and get the estate back: `resume InkRouter`.
#
# Installed with the other three by `openRepoTools --install`.
#
# IT ADDS NO MECHANICS, exactly as the `openRepoShape` command adds none. It
# REBUILDS OR REFRESHES the estate with the tools that already exist — `git
# clone`, the root's own `make bootstrap`, the holder's own `make siblings` —
# and then runs the estate's own `make resume`, which runs the SPECKIT GIT
# EXTENSION's `resume.sh`: the one implementation of `git worktree add`, the
# `.specify/feature.json` rewrite and the soft reset (#77, RULING 2026-09-09
# ruling 1). Every worktree, branch and refusal is that script's.
#
# openRepoShape #82, under Brett Heap's RULING of 2026-09-09: this is the ONLY
# command that clones, and it clones from the manifest (ruling 1); it
# fast-forwards each sibling's tracking branch with `--ff-only` and REFUSES BY
# NAME a sibling that is dirty or on a feature branch, never resetting one
# (ruling 2); the command is `resume` (ruling 4).
#
# BARE `resume`, WITH NO <Name> AND NO ESTATE AROUND THE CWD, STILL REFUSES —
# deliberately NOT changed by Brett Heap's RULING of 2026-09-10 (#91), which
# supersedes ruling 3 of #82 for `park`'s own bare form only, nor by his later
# RULING of the same day in openRepoTools, under which `park` ASKS there and
# sweeps on a yes or a `--all`. `resume` has no `--all` and asks nothing:
# rebuilding every estate this machine has a record of, by accident, from one
# bare `resume` in the wrong folder, is the opposite risk from `park` failing
# to park the one estate you meant — a clone or a fast-forward per estate is
# real network and real disk, and undoing an accidental one is not as simple
# as re-running the command. Both rulings name `park` only; this refusal
# stands.
# openRepoShape #82 wrote this, its #93 last changed it, its #92 moved it here.
#
# THE ONLY FILE THIS STANDARD EVER WRITES OUTSIDE A REPOSITORY is the two-line
# user config, and only when `--workspace <owner/name>` asks for it by name on
# a machine that has none. A person names their own private repository.
set -euo pipefail
SELF_NAME="resume"
NAME=""
REPO_SLUG=""
REPO_ARG=""
WORKSPACE_SLUG=""
ORG_WANTED=""
DRY_RUN=0
PASSTHROUGH=()
#: `flag_value`'s three answers, declared with the other globals so that a
#: `die` reaching `$FLAG_HINT` before any arm has asked is an empty hint under
#: `set -u`, never "unbound variable" at exit 1 — the one exit these arms must
#: not produce (the independent review of this branch, 2026-09-11).
FLAG_VALUE=""
FLAG_SHIFT=2
FLAG_HINT=""
say() { printf '%s\n' "$*"; }
die() { printf '\nREFUSED: %s\n' "$1" >&2; exit "${2:-2}"; }
usage() {
cat <<'USAGE'
resume [<Name>] [--repo <owner/name>] [--workspace <owner/name>] [--org <org>]
[--dry-run] [-- ARGS...]
resume --help
Bring one estate back on this workstation: clone or fast-forward it, bootstrap
it, place the family's members beside the holder, and then recreate every
feature `park` recorded.
<Name> is the family or the project as your workspace record names it - the
manifest file `workspaces/<org>/<Name>.yaml` in the private repository you own.
With no <Name> the estate around the current directory is refreshed.
--repo <owner/name> the estate of a clone you already have, by its
origin - in any url spelling of that repository
--workspace <owner/name>
the FIRST time on a fresh machine: clone that private
repository and write ~/.agents/workspace.yaml. This is
the only thing here that writes that file, and only
when you ask for it by name
--project <Name> the positional under a flag (also --name)
--org <org> which org's manifest, when two of them record a <Name>
--dry-run say what would happen; clone nothing, pull nothing
--flag=<value> every flag that takes a value takes it after an `=`
too (`--org=<org>`): the one way to give a value that
begins with `-`, which the spaced spelling refuses
-- ARGS... passed on to the extension through `make resume ARGS=`
$PROJECTS_DIR your projects directory (default: ~/projects, then
~/Projects - people spell it both ways)
$AGENT_PROTOCOL_ROOT where workspace.yaml lives (default: ~/.agents)
$OPENREPOTOOLS_REMOTE_BASE
TEST PATH: clone from bare repositories in this
directory (<name>.git) instead of from GitHub, so
the whole flow runs offline
WHAT RESUME NEVER DOES: it resets nothing and it moves nobody's HEAD. A clone
that is dirty, or on a branch that is not its tracking branch, is REFUSED BY
NAME and skipped - it is neither fast-forwarded, nor bootstrapped, nor given
the verb - and so is a root one of whose LEGS is off the branch that root's own
`make bootstrap` places it on (its row's `branch:`, else the root's
`tracking_branch`), because bootstrap would move the leg. The rest of the
estate still comes back.
USAGE
}
# --- BEGIN shared estate resolver (park, resume, status: byte for byte) -----
#
# THIS BLOCK IS DUPLICATED IN THE OTHER COMMANDS, DELIBERATELY, and
# `tests/test_park_resume_commands.py` asserts the three copies are IDENTICAL.
# Each of these is one file a person has on PATH: a shared `orp-estate.sh`
# would be a fifth file for `--install` to place and a broken command the
# first time somebody copied only one of them. The rules themselves are NOT
# new: `<dir>/<Name>/family.yaml` carrying `kind: family-manifest` and naming
# `<Name>` is `setup-project.py`'s `family_folder_here`, doubled-name layout
# and all (#76, RULING ruling 3). What "naming" accepts is widened by #236,
# at `is_family_folder` below: the display `name:` or the repository's name.
# `$PROJECTS_DIR` names ONE directory when it is set. Otherwise both
# spellings, in that order, because people really do use both - and DEDUPED by
# inode, because on a case-insensitive filesystem (a stock Mac) `~/projects`
# and `~/Projects` are the same directory, and listing every estate twice
# would read as two estates.
PROJECTS_DIRS=()
if [ -n "${PROJECTS_DIR:-}" ]; then
PROJECTS_DIRS=("$PROJECTS_DIR")
else
PROJECTS_DIRS=("$HOME/projects")
if [ -d "$HOME/Projects" ]; then
if [ -d "$HOME/projects" ] && [ "$HOME/Projects" -ef "$HOME/projects" ]; then
: # one directory under two spellings; name it once
else
PROJECTS_DIRS+=("$HOME/Projects")
fi
fi
fi
# Where a NEW clone would go: the first spelling that already exists, else the
# first named.
#
# shellcheck disable=SC2329 # `park` and `status` create nothing and never
# call this. The block is byte-identical in all three commands BY DESIGN, so
# each carries a couple of functions only another one asks.
projects_dir_for_new() {
local dir
for dir in "${PROJECTS_DIRS[@]}"; do
if [ -d "$dir" ]; then
printf '%s\n' "$dir"
return 0
fi
done
printf '%s\n' "${PROJECTS_DIRS[0]}"
}
# ONE SCALAR'S TEXT, read the way openRepoShape v1.1.0's `parse_yaml` reads
# it (`scripts/repo_shape.py`: `_strip_comment`, `_quote_end`,
# `_quoted_scalar`), so a file both of them read gets one answer (#243). A
# value that OPENS WITH A QUOTE is the text up to the quote that closes it -
# inside `"..."` a `\` takes the character after it along, and inside `'...'`
# a doubled `''` is one quote, so neither closes it - and whatever follows
# the close, a `# comment` included, is not the value: a ` #` inside the
# quotes is text. Any other value is cut at the first `#` that opens it or
# follows a space or tab, which is a comment, and trimmed; `a#b` is the text
# `a#b`. The escapes are honoured to find the close and never unescaped, as
# this reader never unescaped them: no branch, path, folder or repository
# name it is asked for carries one. A quote nothing closes is not YAML at
# all, and the text is kept whole, as before. What is NULL is not said here:
# `null` and `"null"` are both the text `null`, and `yaml_scalar` says which
# of the two it was.
trim_unquote() {
local value="$1" quote="" ch="" i=1
value="${value%$'\r'}"
value="${value#"${value%%[![:space:]]*}"}"
case "$value" in
'"'* | "'"*)
quote="${value:0:1}"
while [ "$i" -lt "${#value}" ]; do
ch="${value:$i:1}"
if [ "$quote" = '"' ] && [ "$ch" = "\\" ]; then
i=$((i + 2))
elif [ "$ch" != "$quote" ]; then
i=$((i + 1))
elif [ "$quote" = "'" ] && [ "${value:$((i + 1)):1}" = "'" ]; then
i=$((i + 2))
else
printf '%s' "${value:1:$((i - 1))}"
return 0
fi
done
;;
*)
value=" $value"
value="${value%%[[:space:]]#*}"
value="${value#"${value%%[![:space:]]*}"}"
;;
esac
printf '%s' "${value%"${value##*[![:space:]]}"}"
}
# ONE FOLDER NAME, and never a path. A `<Name>` off the command line and a
# `root:` out of somebody's workspace manifest both end up joined to the
# projects directory, so both are held to this: no empty string, no `/`, and
# no leading `.` — which is what stops `resume ../../elsewhere/X` from cloning
# outside the projects directory at all, rather than noticing afterwards.
is_safe_folder_name() {
local name="$1"
case "$name" in
"" | .* | */*) return 1 ;;
esac
case "$name" in
*[!A-Za-z0-9_.+@-]*) return 1 ;;
esac
return 0
}
# One indent-zero scalar out of a small YAML file, with parameter expansion
# only: no YAML library, no python per line, no forks per line. The line is
# found the way the Speckit extension's `workspace_read_scalar` finds one, at
# indent zero by its key; the VALUE is `trim_unquote`'s, above, which is
# openRepoShape v1.1.0's reading of it and differs from the extension's where
# a `#` sits inside quotes or with no space in front of it, and on a line that
# is not YAML at all (#243). The keys the three commands ask for are
# `family.yaml`'s and `project.yaml`'s, a record's `family:`, the two of
# `workspace.yaml`, and the shape pin's.
# `YAML_QUOTED` is 1 when the value was QUOTED: YAML's null is an unquoted
# `null` or `~`, and a quoted `"null"` is the text, which is a difference
# `trim_unquote`'s answer alone cannot carry (`named_for_repository`).
YAML_VALUE=""
YAML_QUOTED=0
yaml_scalar() {
local file="$1" key="$2" line value
YAML_VALUE=""
YAML_QUOTED=0
[ -f "$file" ] || return 1
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
"$key":*) ;;
*) continue ;;
esac
value="${line#"$key":}"
case "${value#"${value%%[![:space:]]*}"}" in
'"'* | "'"*) YAML_QUOTED=1 ;;
esac
YAML_VALUE="$(trim_unquote "$value")"
return 0
done <"$file"
return 1
}
# A LINKED WORKTREE, not a checkout of its own: `.git` is a FILE holding a
# `gitdir:` line, and `--git-dir` and `--git-common-dir` disagree. A
# single-repository project's FEATURE worktree is a whole `project.yaml` root
# by construction, so without this the walk-up would answer `<worktree_root>/
# 001-my-feature` as an estate named after the branch — and `park` would run
# the write verb inside the very worktree the extension is about to park.
# GIT'S OWN ANSWER, and not the `.git`-is-a-file shortcut, because that shape
# is shared with a SUBMODULE and a leg must not be mistaken for a worktree:
# in a linked worktree `--git-dir` is `<common>/worktrees/<name>` and the two
# differ; in a submodule both are `<super>/.git/modules/<name>`; in an
# ordinary checkout both are `.git`. Verified against all three.
is_linked_worktree() {
local dir="$1" gitdir common
[ -e "$dir/.git" ] || return 1
gitdir="$(git -C "$dir" rev-parse --git-dir 2>/dev/null || true)"
common="$(git -C "$dir" rev-parse --git-common-dir 2>/dev/null || true)"
[ -n "$gitdir" ] && [ -n "$common" ] && [ "$gitdir" != "$common" ]
}
# The PINNED copy inside a family holder: `<Family>/<Family>/members/<X>`, so
# the grandparent carries the `family.yaml`. It is detached, it is what
# `bootstrap` and `validate` read, and nobody's in-flight work is in it — a
# walk-up that answered it would run a WRITE verb on a detached HEAD. The
# working clone BESIDE the holder is the one a person works in, which is the
# same rule #80's holder dispatch follows.
is_pinned_member() {
local dir="$1" parent grandparent
parent="${dir%/*}"
[ -n "$parent" ] && [ "$parent" != "$dir" ] || return 1
grandparent="${parent%/*}"
[ -n "$grandparent" ] && [ "$grandparent" != "$parent" ] || return 1
yaml_scalar "$grandparent/family.yaml" kind || return 1
[ "$YAML_VALUE" = "family-manifest" ]
}
# Is `<dir>` a FAMILY FOLDER? All three facts have to agree: the holder clone
# sits at `<dir>/<basename>`, its `family.yaml` says `kind: family-manifest`,
# and the folder is named for the family. A family has TWO names and either
# one is the folder's (#236): its display `name:`, which is the rule this
# block began with and what an estate laid out before openRepoShape v1.1.0
# has, or the LAST PART of its `repository:`, which is v1.1.0's rule - the
# holder, and the folder around it, are named for the REPOSITORY, `name:`
# being only a title (opensoft/openRepoShape#629), and the holder's `make
# siblings` stops before cloning when the folder is not that name
# (opensoft/openRepoShape#543). A family whose two names differ has no layout
# that works for both unless both are accepted. A directory called Northwind
# is a directory called Northwind; what makes it a family folder is read,
# never guessed from a name. The repository arm is `named_for_repository`,
# below, which `status` and `resume` also ask when one family is on disk in
# both layouts (#243).
is_family_folder() {
local dir="$1" name manifest
name="${dir##*/}"
[ -n "$name" ] || return 1
manifest="$dir/$name/family.yaml"
yaml_scalar "$manifest" kind || return 1
[ "$YAML_VALUE" = "family-manifest" ] || return 1
if yaml_scalar "$manifest" name && [ "$YAML_VALUE" = "$name" ]; then
return 0
fi
named_for_repository "$dir"
}
# Is `<dir>` named for its holder's REPOSITORY: the LAST PART of
# `<dir>/<basename>/family.yaml`'s `repository:`, as v1.1.0's `repo_basename`
# takes it (`rsplit("/", 1)[-1]`)? That is the folder v1.1.0 prescribes, and
# where `make siblings` moves a holder that is not in it. AN UNQUOTED `null`
# OR `~` IS NO REPOSITORY (#243) - nor `Null` or `NULL`, the spellings
# v1.1.0's `_scalar` reads as null too - so a folder called `null` is not
# named for one, and `is_family_folder` has only the display `name:` left to
# go on, which is v1.1.0's own fallback. A QUOTED `"null"` is the text.
named_for_repository() {
local dir="$1" name
name="${dir##*/}"
[ -n "$name" ] || return 1
yaml_scalar "$dir/$name/family.yaml" repository || return 1
if [ "$YAML_QUOTED" -eq 0 ]; then
case "$YAML_VALUE" in null | Null | NULL | '~') return 1 ;; esac
fi
[ "${YAML_VALUE##*/}" = "$name" ]
}
is_project_root() {
local dir="$1"
yaml_scalar "$dir/project.yaml" kind || return 1
[ "$YAML_VALUE" = "project-manifest" ] || return 1
is_pinned_member "$dir" && return 1
is_linked_worktree "$dir" && return 1
return 0
}
ESTATE_KIND=""
ESTATE_DIR=""
ESTATE_ROOT=""
ESTATE_NAME=""
# Is THIS directory an estate? A FAMILY FOLDER WINS over a standalone root of
# the same name: the holder is what drives the members, so a family answered as
# one of its own members would act on one project and report the estate done.
estate_here() {
local dir="$1"
if is_family_folder "$dir"; then
ESTATE_KIND="family"
ESTATE_DIR="$dir"
ESTATE_NAME="${dir##*/}"
ESTATE_ROOT="$dir/$ESTATE_NAME"
return 0
fi
if is_project_root "$dir"; then
ESTATE_KIND="standalone"
ESTATE_DIR="$dir"
ESTATE_NAME="${dir##*/}"
ESTATE_ROOT="$dir"
return 0
fi
return 1
}
# The NEAREST estate at or above `<dir>`: the holder beside you, or the project
# root you are standing in. NEAREST WINS, so standing inside a member of a
# family acts on that member - the estate the person is standing in. A pinned
# copy and a linked worktree are WALKED PAST rather than answered, so
# `<Family>/<Family>/members/<X>` reaches the family folder and a feature
# worktree reaches the root that owns it.
estate_walk_up() {
local dir="$1"
while [ -n "$dir" ] && [ "$dir" != "/" ]; do
estate_here "$dir" && return 0
dir="${dir%/*}"
done
return 1
}
estate_by_name() {
local want="$1" dir
is_safe_folder_name "$want" || return 1
for dir in "${PROJECTS_DIRS[@]}"; do
estate_here "$dir/$want" && return 0
done
return 1
}
# Every estate under the projects directory, one row each. What ruling 3 asks
# a refusal to print: a person who typed the command in the wrong folder needs
# the names, not an instruction to go and look for them.
list_estates() {
local dir child
for dir in "${PROJECTS_DIRS[@]}"; do
[ -d "$dir" ] || continue
for child in "$dir"/*; do
[ -d "$child" ] || continue
if is_family_folder "$child"; then
printf ' family %-20s %s\n' "${child##*/}" "$child"
elif is_project_root "$child"; then
printf ' project %-20s %s\n' "${child##*/}" "$child"
fi
done
done
}
estates_or_none() {
local rows
rows="$(list_estates)"
if [ -n "$rows" ]; then
printf '%s\n' "$rows"
else
printf ' (none)\n'
fi
}
# Every FAMILY FOLDER under the projects directory whose holder's
# `family.yaml` says `id: <id>`, one per line, in `list_estates`' order, and
# nothing for an empty <id>. The id is what `park.sh` files a family's record
# under (`workspace_family_name` reads it), and it is the SAME in every layout
# of one family: v1.1.0's folder named for the repository and the
# display-name folder before it are two directories, two estates and ONE
# record (#243). So this is how `resume` finds a holder its record's folder
# no longer names, and how `status` sees one family laid out twice.
#
# ONE FOLDER IS LISTED ONCE, however many spellings reach it. `PROJECTS_DIRS`
# is deduped by inode, but the folders inside two distinct directories are
# not: a `~/Projects/Quill` that is a LINK to `~/projects/Quill` is one
# holder, and listing it twice made `resume` refuse "2 family folders" and
# `status` report one family "laid out 2 times" (the review of #251, finding
# 1). So a folder that is `-ef` one already listed is skipped, and the first
# spelling, in `PROJECTS_DIRS`' order, is the one named.
#
# shellcheck disable=SC2329 # `park` never asks: it files the record, and
# never looks a holder up by it. The block is byte-identical by design.
family_folders_with_id() {
local want="$1" dir child prior dup
local -a found=()
[ -n "$want" ] || return 0
for dir in "${PROJECTS_DIRS[@]}"; do
[ -d "$dir" ] || continue
for child in "$dir"/*; do
[ -d "$child" ] || continue
is_family_folder "$child" || continue
yaml_scalar "$child/${child##*/}/family.yaml" id || continue
[ "$YAML_VALUE" = "$want" ] || continue
dup=0
for prior in ${found[@]+"${found[@]}"}; do
if [ "$child" -ef "$prior" ]; then dup=1; fi
done
[ "$dup" -eq 0 ] || continue
found+=("$child")
printf '%s\n' "$child"
done
done
}
lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
# `owner/name` — CASE PRESERVED, so a refusal prints back what a person can
# type — out of any spelling git accepts: owner/name, owner/name.git,
# https://host/owner/name(.git), git@host:owner/name.git,
# ssh://git@host/owner/name.git, and a token-bearing https url. Callers
# compare with `lower` on both sides, because GitHub owners and names are
# case-insensitive.
#
# A FILESYSTEM PATH IS REFUSED rather than parsed, for the reason the
# extension's own `workspace_org_from_url` refuses one: `/srv/remotes/Name.git`
# has a name but no owner, and inventing `remotes` as the owner would let
# `--repo remotes/Name` match a bare repository on disk. Such a clone answers
# `<Name>` and the walk-up; it does not answer `--repo`.
repo_slug_of() {
local url="$1" rest owner name
rest="$url"
case "$rest" in
/* | ./* | ../* | [A-Za-z]:[/\\]* | file://*) return 1 ;;
esac
rest="${rest%/}"
rest="${rest%.git}"
case "$rest" in
*://*)
rest="${rest#*://}"
rest="${rest#*@}"
rest="${rest#*/}"
;;
*@*:*)
rest="${rest#*@}"
rest="${rest#*:}"
;;
esac
case "$rest" in
*/*) ;;
*) return 1 ;;
esac
name="${rest##*/}"
owner="${rest%/*}"
owner="${owner##*/}"
[ -n "$owner" ] && [ -n "$name" ] || return 1
case "$owner$name" in
*[!A-Za-z0-9_.-]*) return 1 ;;
esac
printf '%s/%s\n' "$owner" "$name"
}
# The clone under the projects directory whose `origin` IS that repository -
# ruling 1. Two levels deep, because the estates this standard places are
# `<projects>/<Project>` and `<projects>/<Family>/<Project>`.
clone_with_origin() {
local want dir candidate origin found
want="$(lower "$1")"
for dir in "${PROJECTS_DIRS[@]}"; do
[ -d "$dir" ] || continue
for candidate in "$dir"/*/ "$dir"/*/*/; do
candidate="${candidate%/}"
[ -e "$candidate/.git" ] || continue
origin="$(git -C "$candidate" remote get-url origin 2>/dev/null || true)"
[ -n "$origin" ] || continue
found="$(repo_slug_of "$origin" || true)"
[ -n "$found" ] || continue
if [ "$(lower "$found")" = "$want" ]; then
printf '%s\n' "$candidate"
return 0
fi
done
done
return 1
}
# The family's members, by the same ` - project: <Project>` row `family.py`
# writes and `siblings.py` reads. The working clone is `<family folder>/
# <Project>`, beside the holder.
family_members() {
local manifest="$1" line
[ -f "$manifest" ] || return 0
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
" - project: "*) printf '%s\n' "$(trim_unquote "${line# - project: }")" ;;
esac
done <"$manifest"
}
# The branch a checkout belongs on: its OWN manifest's `tracking_branch:` —
# the field `scripts/bootstrap.py` already reads to place the legs — falling
# back to `main`. The workspace manifest records a `tracking_branch` too, but
# that is PROVENANCE from the machine that parked; the local declaration is
# what this machine's clone actually tracks, and the extension takes the same
# line about `worktree_root`.
tracking_branch_of() {
local root="$1"
if yaml_scalar "$root/family.yaml" tracking_branch && [ -n "$YAML_VALUE" ]; then
printf '%s\n' "$YAML_VALUE"
return 0
fi
if yaml_scalar "$root/project.yaml" tracking_branch && [ -n "$YAML_VALUE" ]; then
printf '%s\n' "$YAML_VALUE"
return 0
fi
printf 'main\n'
}
# The legs a root declares, by path, out of `project.yaml`'s `legs:` block —
# the same rows `scripts/bootstrap.py` reads, and in the order it reads them,
# `role:` before `path:`. The assembly leg is `path: "."` and is the root
# itself, so it is not one of these.
#
# shellcheck disable=SC2329 # only `resume` bootstraps a root, so only
# `resume` has to ask where its legs are; see the note above.
legs_of() {
local root="$1" line role="" path=""
[ -f "$root/project.yaml" ] || return 0
while IFS= read -r line || [ -n "$line" ]; do
line="${line%$'\r'}"
case "$line" in
' - role:'*)
role="$(trim_unquote "${line#*:}")"
path=""
;;
' path:'*)
path="$(trim_unquote "${line#*:}")"
[ -n "$role" ] || continue
[ "$role" != "assembly" ] || continue
[ "$path" != "." ] || continue
[ -n "$path" ] && printf '%s\n' "$path"
;;
[![:space:]]*) role=""; path="" ;;
esac
done <"$root/project.yaml"
}
# The branch a leg's OWN row declares for it: `leg_branch_of <root> <leg path>`
# prints the `branch:` of the `legs:` row whose `path:` is <leg path>, and
# NOTHING when that row declares none, or no row has the path. It is the
# value `scripts/bootstrap.py` puts first — `str(leg.get("branch") or branch)`
# — so a leg is placed on its own `branch:` and on the root's
# `tracking_branch:` only when its row has no `branch:` of its own; a caller
# falls back to `tracking_branch_of` on an empty answer, which is that `or`.
#
# Read the way `legs_of` reads the same rows — `role:` before `path:`, the
# assembly row and a row with no role skipped — except that `branch:` may sit
# on either side of `path:` in its row, so the answer comes the moment the
# row has shown both, and a row that does not open with `- role:` clears what
# the row before it left. A leg is found by its path SPELLED EITHER WAY: the
# caller may hold `.gitmodules`' `spec` (`status`) or the manifest's own text
# (`resume`), and `./spec` and `spec/` are one leg, so a leading `./` and a
# trailing `/` come off both sides before they are compared.
#
# WHAT IS NO BRANCH: an empty value, `null`, `Null`, `NULL` or `~`, once a
# trailing ` # comment` is cut off the value (a branch name holds no space, so
# ` #` cannot be inside one). The loader is not matched to the letter: it keeps
# a QUOTED `"null"` and `'~'` as branches and reads `false` and `0` as no
# branch, while this does the opposite for both. No real branch is named any
# of those, so nothing here chases the difference. (Since #243 `trim_unquote`
# cuts that comment as well; the cut here runs first and is kept.)
#
# WHO ASKS IT: `resume`'s leg guard (`root_legs_ok`), `status`'s
# `estate_repos` and `read_leg`, and `park`'s not-carried report
# (`report_repos`, #242, PR #249) - each to know the branch a leg belongs on.
#
# shellcheck disable=SC2329 # a `park` from before #249 never calls it;
# the disable can go once #249 has landed.
leg_branch_of() {
local root="$1" want="$2" line role="" path="" branch=""
[ -n "$want" ] || return 0
[ -f "$root/project.yaml" ] || return 0
want="${want#./}"
want="${want%/}"
while IFS= read -r line || [ -n "$line" ]; do
line="${line%$'\r'}"
case "$line" in
' - role:'*)
role="$(trim_unquote "${line#*:}")"
path=""
branch=""
;;
' - '*) role=""; path=""; branch="" ;;
' path:'*)
path="$(trim_unquote "${line#*:}")"
path="${path#./}"
path="${path%/}"
[ -n "$role" ] || path=""
[ "$role" != "assembly" ] || path=""
;;
' branch:'*)
branch="${line#*:}"
branch="$(trim_unquote "${branch%%[[:space:]]#*}")"
case "$branch" in null | Null | NULL | '~') branch="" ;; esac
;;
[![:space:]]*) role=""; path=""; branch="" ;;
esac
if [ -n "$path" ] && [ "$path" = "$want" ] && [ -n "$branch" ]; then
printf '%s\n' "$branch"
return 0
fi
done <"$root/project.yaml"
}
# --- END shared estate resolver ---------------------------------------------
# --- the user config and the workspace repository ---------------------------
CONFIG_FILE="${AGENT_PROTOCOL_ROOT:-$HOME/.agents}/workspace.yaml"
# shellcheck disable=SC2088 # a literal leading ~ is what is matched here
expand_home() {
local value="$1"
case "$value" in
'~') printf '%s\n' "$HOME" ;;
'~/'*) printf '%s\n' "$HOME/${value#\~/}" ;;
*) printf '%s\n' "$value" ;;
esac
}
# The optional per-org override, as `org<TAB>path` rows. The config's shape is
# the plan's own (#77 S1, §2.3):
#
# repository: opensoft/brett-wip
# path: ~/projects/brett-wip
# orgs:
# MedxSoft:
# repository: MedxSoft/brett-wip
# path: ~/projects/MedxSoft-wip
#
# READ ONLY, AND NEVER A REFUSAL HERE. The extension refuses a malformed
# `orgs:` block because it is about to WRITE a manifest and filing a
# confidential org's work in the wrong repository is the one thing that
# override exists to prevent. Nothing in this command writes a manifest: it
# looks for one, so an entry it cannot read is an entry it does not find, and
# `park.sh`'s own refusal is still the authority on the block being wrong.
config_org_paths() {
local file="$1" line org="" path="" in_orgs=0
[ -f "$file" ] || return 0
while IFS= read -r line || [ -n "$line" ]; do
line="${line%$'\r'}"
case "$line" in
'#'*) continue ;;
orgs:*)
in_orgs=1; org=""; path=""
continue
;;
[![:space:]]*)
# ANY other indent-zero key ends the block, so a two-space key
# under something else is never read as an org override.
[ -z "$org" ] || [ -z "$path" ] || printf '%s\t%s\n' "$org" "$path"
in_orgs=0; org=""; path=""
continue
;;
esac
[ "$in_orgs" -eq 1 ] || continue
case "$line" in
' '[![:space:]]*)
[ -z "$org" ] || [ -z "$path" ] || printf '%s\t%s\n' "$org" "$path"
org="$(trim_unquote "${line%:}")"
path=""
;;
' path:'*) path="$(trim_unquote "${line#*path:}")" ;;
esac
done <"$file"
[ -z "$org" ] || [ -z "$path" ] || printf '%s\t%s\n' "$org" "$path"
}
config_path_for_org() {
local want org path
want="$(lower "$1")"
while IFS=$'\t' read -r org path; do
[ -n "${path:-}" ] || continue
if [ "$(lower "$org")" = "$want" ]; then
expand_home "$path"
return 0
fi
done < <(config_org_paths "$CONFIG_FILE")
return 1
}
is_checkout() {
local path="$1"
[ -n "$path" ] || return 1
[ -e "$path/.git" ] || return 1
git -C "$path" rev-parse --git-dir >/dev/null 2>&1
}
# `https://github.com/<owner>/<name>.git` by default.
#
# `$OPENREPOTOOLS_REMOTE_BASE` IS THE OFFLINE TEST PATH, and it is the same
# concession `setup-project.py --local-remote-dir` and `family.py
# --local-remote-dir` make: a directory of BARE repositories named
# `<name>.git`, used as origins, so this whole flow — clone the workspace,
# clone the holder, place the siblings — is provable with no network and no
# real repository. It is documented here for the same reason the shim
# documents `$OPENREPOSHAPE_SETUP_SH`, and it grants no file-protocol
# permission of its own: a test that needs `protocol.file.allow` sets it in
# git's own environment, which is how it stays a test's decision.
clone_url_for() {
local slug="$1" base="${OPENREPOTOOLS_REMOTE_BASE:-}"
if [ -n "$base" ]; then
printf '%s/%s.git\n' "${base%/}" "${slug##*/}"
return 0
fi
printf 'https://github.com/%s.git\n' "$slug"
}
# --- the manifest -----------------------------------------------------------
# `<org>\t<file>\t<workspace>` for every `workspaces/<org>/<Name>.yaml` that
# records this name, in every workspace checkout this config knows about. The
# basename is compared CASE-INSENSITIVELY: a standalone project's manifest is
# named for its `project.yaml` id (`atlas`), and the folder a person types is
# the repository name (`Atlas`).
manifest_candidates() {
local want workspace dir file base
want="$(lower "$1")"
shift
for workspace in "$@"; do
[ -d "$workspace/workspaces" ] || continue
for dir in "$workspace"/workspaces/*/; do
dir="${dir%/}"
[ -d "$dir" ] || continue
for file in "$dir"/*.yaml; do
[ -f "$file" ] || continue
base="${file##*/}"
base="${base%.yaml}"
[ "$(lower "$base")" = "$want" ] || continue
printf '%s\t%s\t%s\n' "${dir##*/}" "$file" "$workspace"
done
done
done
}
# One field of the FIRST `projects[]` block, read the way the extension's own
# reader does: on the indent width and the leading `- ` of a list item, which
# is safe because this file is written by that extension with a fixed layout.
manifest_project_field() {
local file="$1" field="$2" line seen=0
[ -f "$file" ] || return 1
while IFS= read -r line || [ -n "$line" ]; do
line="${line%$'\r'}"
case "$line" in
' - id:'*)
[ "$seen" -eq 0 ] || return 1
seen=1
continue
;;
esac
[ "$seen" -eq 1 ] || continue
case "$line" in
" $field:"*)
printf '%s\n' "$(trim_unquote "${line#*:}")"
return 0
;;
esac
done <"$file"
return 1
}
# --- the refresh ------------------------------------------------------------
REFUSED=0
# RULING 2, and its whole point: a clone that is dirty or on a feature branch
# is REFUSED BY NAME AND SKIPPED, never reset. `pull --ff-only origin <branch>`
# rather than a bare `pull`, so a clone with no upstream configured is
# fast-forwarded rather than told to set one - and `--ff-only` is what makes a
# divergent branch a refusal from git itself instead of a merge commit nobody
# asked for.
pull_ff_only() {
local label="$1" repo="$2" branch="${3:-}" head dirty
# THE BRANCH IS AN ARGUMENT so the WORKSPACE repository can be checked
# too: it is a private repository with no `project.yaml` and no declared
# tracking branch, and refusing it for being on `master` rather than
# `main` would be a refusal about nothing. Its caller passes the branch it
# is already on, which keeps the dirty guard and the `--ff-only` guard and
# drops only the off-branch one.
[ -n "$branch" ] || branch="$(tracking_branch_of "$repo")"
head="$(git -C "$repo" rev-parse --abbrev-ref HEAD 2>/dev/null || printf '?')"
if [ "$head" != "$branch" ]; then
REFUSED=$((REFUSED + 1))
say " REFUSED $label: HEAD is '$head', not its tracking branch '$branch'."
say " Nothing was fast-forwarded and nothing was reset. Your work is where you left it."
say " look: git -C $repo status --short --branch"
say " then: git -C $repo checkout $branch && $SELF_NAME $ESTATE_NAME"
return 1
fi
dirty="$(git -C "$repo" status --porcelain 2>/dev/null || true)"
if [ -n "$dirty" ]; then
REFUSED=$((REFUSED + 1))
say " REFUSED $label: uncommitted changes in $repo."
say " Nothing was fast-forwarded and nothing was reset."
say " look: git -C $repo status --short"
say " then: commit or stash them yourself, and re-run \`$SELF_NAME $ESTATE_NAME\`."
return 1
fi
if [ "$DRY_RUN" -eq 1 ]; then
say " would fast-forward $label: git -C $repo pull --ff-only origin $branch"
return 0
fi
if ! git -C "$repo" pull --ff-only origin "$branch"; then
REFUSED=$((REFUSED + 1))
say " REFUSED $label: \`git pull --ff-only origin $branch\` did not fast-forward."
say " Local commits are on that branch, or the remote has none. Nothing was reset."
return 1
fi
return 0
}
# THE LEGS, BEFORE A ROOT IS BOOTSTRAPPED (F3 of the review on #83). A root's
# own `make bootstrap` — `templates/assembly-root/scripts/bootstrap.py`,
# `checkout_tracking_branch` — MOVES A LEG'S HEAD onto the branch it places the
# leg on: `git checkout <branch>` in a leg whose local <branch> tip equals the
# pin, `checkout -b <branch> <pin>` in a leg that has no local one and, since
# openRepoShape v1.1.0, a fast-forward of a <branch> BEHIND the pin to the pin
# and a checkout of it — from that branch, from a HEAD detached at the pin, or
# from ANOTHER branch (opensoft/openRepoShape#475, and opensoft/openRepoShape#603
# → opensoft/openRepoShape#615) — and a move of a <branch> AHEAD of the pin,
# with no commit of its own, back to the pin from a HEAD detached at it. A leg
# sitting on a clean feature branch at the pin makes the superproject clean,
# so the dirty and off-branch guards above see nothing and say nothing — and
# `resume` used to walk that leg off `001-my-feature` onto `main` and exit 0.
#
# WHICH <branch> IS THE LEG'S OWN (#235). `bootstrap.py` places a leg on
# `str(leg.get("branch") or branch)`: the `branch:` its row in the root's
# `project.yaml` declares for that leg when it declares one, and the root's
# `tracking_branch` ONLY when the row has none (`leg_branch_of`, then
# `tracking_branch_of`, above). Every leg held to the ROOT's branch lets a leg
# declaring `branch: develop` be moved onto `develop` while `resume` exits 0,
# and refuses a leg that is already on `develop`.
#
# So the legs are asked first, and a root with one off the branch bootstrap
# would put it on is REFUSED BY NAME and neither pulled nor bootstrapped,
# whichever of the two that branch comes from, and the refusal says which.
# DETACHED AT THE PIN is not a refusal: that is the state a fresh clone arrives
# in and precisely what bootstrap exists to fix.
root_legs_ok() {
local label="$1" root="$2" branch leg want kind from head pin at
branch="$(tracking_branch_of "$root")"
while IFS= read -r leg; do
[ -n "$leg" ] || continue
[ -e "$root/$leg/.git" ] || continue
want="$(leg_branch_of "$root" "$leg")"
if [ -n "$want" ]; then
kind="its own branch"
from="the \`branch:\` of its row in project.yaml"
else
want="$branch"
kind="its tracking branch"
from="the root's \`tracking_branch\`"
fi
head="$(git -C "$root/$leg" rev-parse --abbrev-ref HEAD 2>/dev/null || printf '?')"
[ "$head" != "$want" ] || continue
if [ "$head" = "HEAD" ]; then
pin="$(git -C "$root" rev-parse "HEAD:$leg" 2>/dev/null || true)"
at="$(git -C "$root/$leg" rev-parse HEAD 2>/dev/null || true)"
if [ -n "$pin" ] && [ "$pin" = "$at" ]; then
continue
fi
head="detached at ${at:-?}, and the pin is ${pin:-?}"
fi
REFUSED=$((REFUSED + 1))
say " REFUSED $label: leg $leg is on '$head', not $kind '$want' ($from);"
say " \`make bootstrap\` would move it - nothing was touched."
say " look: git -C $root/$leg status --short --branch"
say " then: finish or park that leg's work, put it back on $want, and re-run."
return 1
done < <(legs_of "$root")
return 0
}
# One root: the legs, then the fast-forward, then its own bootstrap - and a
# REFUSAL AT ANY STEP SKIPS THE ONES AFTER IT (F1 of the review on #83). The
# holder's bootstrap is `git submodule update --init --recursive`, which snaps
# every `members/<X>` back to the recorded gitlink: running it after saying
# "nothing was reset" would have reset exactly the pin bump the refusal was
# protecting. Returns 1 when the root was refused.
refresh_root() {
local label="$1" root="$2"
root_legs_ok "$label" "$root" || {
say " [$label] bootstrap skipped: refused above. Nothing here was fetched, moved or reset."
return 1
}
pull_ff_only "$label" "$root" || {
say " [$label] bootstrap skipped: refused above. Nothing here was fetched, moved or reset."
return 1
}
run_make "$label" "$root" bootstrap || {
REFUSED=$((REFUSED + 1))
say " FINDING: [$label] \`make bootstrap\` did not exit 0; its legs may not be placed."
}
return 0
}
run_make() {
local label="$1" root="$2" target="$3" args="${4:-}" status
if [ ! -f "$root/Makefile" ]; then
say " [$label] no Makefile; \`make $target\` skipped. Re-sync the root with update-shape.py."
return 0
fi
if [ "$DRY_RUN" -eq 1 ]; then
# shellcheck disable=SC2016 # the single quotes are LITERAL: this is
# the `make` line a person can copy, and `ARGS='...'` is how it is typed.
say " would run: make $target${args:+ ARGS='$args'} ($root)"
return 0
fi
say " --- $label: make $target ---"
# AN ARRAY, so `ARGS=--feature 001-a --dry-run` reaches make as ONE
# assignment. `${var:+"..."}` unquoted is one word in bash today and a
# thing a reader has to squint at; an array is neither.
local call=(make "$target")
[ -z "$args" ] || call+=("ARGS=$args")
set +e
(cd "$root" && exec "${call[@]}")
status=$?
set -e
return "$status"
}
# --- arguments -------------------------------------------------------------
# THE VALUE OF A VALUE-TAKING FLAG, in either spelling, read in ONE place so
# that the ways of not giving one are ONE refusal. `flag_value "$@"` from an
# arm sets `FLAG_VALUE` and `FLAG_SHIFT` — the arguments that arm then
# consumes, 2 for `--flag value` and 1 for `--flag=value` — or it returns 1
# with `FLAG_HINT` carrying whatever that refusal has to add. THE WORDS STAY
# AT THE ARM: each flag says what its own value is, and this says only whether
# one was given.
# A VALUE BEGINNING WITH `-` IS NOT ONE in the spaced spelling — the hole #17
# named and left open. `park Atlas --lane --dry-run` ran a REAL park with the
# lane set to `--dry-run`: the flag a person typed was swallowed as a value,
# `--dry-run` itself never took effect, and the only sign was a WIP commit
# subject labelled with it. Nothing here swallows the next word whatever it
# is, so that is a refusal.
# `--flag=value` IS THE OTHER HALF, and the escape that refusing `-*` would
# otherwise foreclose: a value that really does begin with `-` is spelled
# `--lane=--weird`, which takes whatever follows the FIRST `=` and asks
# nothing about it. An `=` with nothing after it is an empty value and is
# refused as #17 refuses one.
# BYTE-IDENTICAL IN `park`, `resume` AND `status`, held by
# `tests/test_repo_hygiene.py` the way the estate resolver and `expand_home`
# are: three files a person has on PATH, and one answer to what a flag's value
# is.