src/git/git.bend

ezx@1.11.0.0 · 787 lines · 110 definitions

1# git/git: what ez asks of git, and how it reads the answers. The decisions are
2# pure functions over what `git ls-remote` printed and what a checkout's
3# ez.toml says: which tag `ez add` pins (`choose`), what a named ref names
4# (`exact`), the default branch (`tip.*`), whether a ref is already a commit
5# (`is_rev`), and a package's entry and name (`entry.*`, `called.*`). The
6# planners (add/plan.bend, lock/up.bend) make them; the commands that run git
7# are the interpreters', which clone, weigh and ask about ancestry, and they
8# live in git/exec.bend, since they reach foreign code a law may not import.
9#
10# Nothing here reimplements git. Bend links no TLS and Base has no readdir, so
11# the programs that already speak the protocol are run instead, through the one
12# process effect the rest of ez uses: arguments execvp'd, no shell between.
13import Base
14import ezx@1.11.0.0/src/io/file.bend as F
15import ezx@1.11.0.0/src/toml/toml.bend as T
16import ezx@1.11.0.0/src/ledger/manifest.bend as M
17import ezx@1.11.0.0/src/pkg/pkg.bend as K
18import ezx@1.11.0.0/src/pkg/path.bend as P
19
20# one `<sha>\t<name>` row of what `git ls-remote` printed
21type Row is Data:
22  Row{rev: String, name: String}
23
24# how two versions compare. A release is greater than its prerelease, and a
25# numeric identifier is less than a word, which is the semver order.
26type Ord is Data:
27  Lt{}
28  Eq{}
29  Gt{}
30
31# one prerelease identifier
32type Id is Data:
33  IdNum{n: Nat}
34  IdWord{s: String}
35
36# a semver-ish tag, or a name that is not one. Build metadata is not kept:
37# it does not change which tag is newer.
38type Ver is Data:
39  No{}
40  Yes{nums: List<&2, Nat>, pre: List<&2, Id>}
41
42# a string split once, on the first time a char occurs
43type Cut is Data:
44  Cut{left: String, right: String}
45
46# one step of a scan looking for one hit, with the head's answer in hand.
47# `Bool.or` is an ordinary function and reduces both of its sides, so the
48# alphabet was read to its end for every char of every ref. The rest of the
49# scan arrives as a thunk, which is a value, and only the arm that wants it
50# applies it.
51def among.step(here: Bool, rest: Unit -> Bool) -> Bool:
52  match here:
53    case True{}:
54      True{}
55    case False{}:
56      rest(Unit{})
57
58# whether a char is one of these. Base has `Char.is_digit` and `Char.is_alpha`
59# and nothing between, and a commit name is written with neither alphabet but
60# with sixteen characters of its own, so the sixteen are named.
61def among(+ch: Char, cs: List<&2, Char>) -> Bool:
62  match cs:
63    case []:
64      False{}
65    case +h <> t:
66      among.step(Char.is_eq(h, ch), _u => among(ch, t))
67
68# a char a commit name may hold
69def hex.at(ch: Char) -> Bool:
70  among(ch, String.to_list("0123456789abcdef"))
71
72# one step of the walk, and `among.step` turned around: `Bool.and` reduces
73# both of its sides too, so the first char that is not hex still cost a walk
74# of the rest of the ref
75def hex.all.step(here: Bool, rest: Unit -> Bool) -> Bool:
76  match here:
77    case True{}:
78      rest(Unit{})
79    case False{}:
80      False{}
81
82# whether every char of a ref is one. Base's `List.all` applies an erased
83# function parameter, which falls outside the termination check; this walk is
84# structural.
85def hex.all(cs: List<&2, Char>) -> Bool:
86  match cs:
87    case []:
88      True{}
89    case +h <> t:
90      hex.all.step(hex.at(h), _u => hex.all(t))
91
92# a ref that is already the commit it names, which is what `git rev-parse`
93# prints: forty hex characters and nothing else. Such a ref needs no remote
94# asked about it, and leaves no tag behind to re-resolve later.
95def is_rev(+ref: String) -> Bool:
96  Bool.and(Nat.is_eq(String.length(ref), 40n), hex.all(String.to_list(ref)))
97
98# a row's commit
99def row.rev(line: Row) -> String:
100  Row{rev, _name} = line
101  rev
102
103# a row's ref name
104def row.name(line: Row) -> String:
105  Row{_rev, name} = line
106  name
107
108# one row taken apart. ls-remote separates the two fields with a tab.
109def row_of(+text: String) -> Row:
110  Row{K.word(String.split(text, '\t'), 0n), K.word(String.split(text, '\t'), 1n)}
111
112# a line that says nothing is no row
113def row.put(+text: String) -> List<&2, Row>:
114  Bool.pick(List<&2, Row>, String.is_empty(String.trim(text)), [], [row_of(text)])
115
116# every row of what ls-remote printed
117def rows(ls: List<&2, String>) -> List<&2, Row>:
118  match ls:
119    case []:
120      []
121    case h <> t:
122      List.append(&2, Row, row.put(h), rows(t))
123
124# one step of the search, with the head's answer in hand. A recursive call in
125# an arm of a `Bool.pick` is not an arm at all: it is built whatever the
126# condition says, so the first row that answered still cost a walk of every
127# row after it.
128def named.step(here: Bool, rev: String, rest: Unit -> String) -> String:
129  match here:
130    case True{}:
131      rev
132    case False{}:
133      rest(Unit{})
134
135# the commit of the first row whose ref name is exactly `want`, or "" when no
136# row is. The whole name is compared: `git ls-remote` matches its patterns
137# against the tail of a ref, so it answers `refs/heads/main` with
138# `refs/heads/feature/main` as well, and only this comparison tells them apart.
139def named(rs: List<&2, Row>, +want: String) -> String:
140  match rs:
141    case []:
142      ""
143    case +h <> t:
144      named.step(String.eq(row.name(h), want), row.rev(h), _u => named(t, want))
145
146# the first of two answers that says anything
147def or_else(+primary: String, fallback: String) -> String:
148  Bool.pick(String, String.is_empty(primary), fallback, primary)
149
150# the commit a ref names, from the rows the remote answered with. A tag comes
151# first, through its peeled `^{}` row when it is annotated, since that row is
152# the commit and the other is the tag object; then a branch of that name. No
153# other row is ever picked, so a tag and a branch that share a name resolve to
154# the tag, and `main` never resolves to `feature/main`.
155def exact(+rs: List<&2, Row>, +ref: String) -> String:
156  or_else(named(rs, "refs/tags/" ++ ref ++ "^{}"),
157    or_else(named(rs, "refs/tags/" ++ ref), named(rs, "refs/heads/" ++ ref)))
158
159# that commit is the tip of
160type Tip is Data:
161  Tip{rev: String, branch: String}
162
163# the rest of the scan only when this row said nothing. A recursive call
164# written as an argument of `Bool.pick` would run for a row that already
165# answered.
166def tip.step(empty: Bool, +got: String, rest: Unit -> String) -> String:
167  match empty:
168    case True{}:
169      rest(Unit{})
170    case False{}:
171      got
172
173# a HEAD row whose first field is already a commit
174def tip.rev.hex(hex: Bool, rev: String) -> String:
175  match hex:
176    case True{}:
177      rev
178    case False{}:
179      ""
180
181# a HEAD row's commit, or "" when the row is the symref or not HEAD
182def tip.rev.pick(head: Bool, hex: Bool, rev: String) -> String:
183  match head:
184    case False{}:
185      ""
186    case True{}:
187      tip.rev.hex(hex, rev)
188
189# this row's commit when it is HEAD's commit
190def tip.rev.at(line: Row) -> String:
191  Row{+rev, name} = line
192  tip.rev.pick(String.eq(name, "HEAD"), is_rev(rev), rev)
193
194# the commit HEAD names, from the rows `ls-remote --symref` wrote
195def tip.rev(rs: List<&2, Row>) -> String:
196  match rs:
197    case []:
198      ""
199    case +h <> t:
200      tip.step(String.is_empty(tip.rev.at(h)), tip.rev.at(h), _u => tip.rev(t))
201
202# the prefix taken off when it is there, and "" when it is not, so a commit
203# line is not mistaken for a branch name
204def tip.strip(+text: String, +pre: String) -> String:
205  Bool.pick(String, String.starts_with(text, pre),
206    String.drop(text, String.length(pre)), "")
207
208# `ref: refs/heads/main` is `main`. Anything else is no branch.
209def tip.sym(+rev: String) -> String:
210  tip.strip(tip.strip(rev, "ref: "), "refs/heads/")
211
212# the symref only, so the commit row that follows it contributes nothing
213def tip.branch.pick(head: Bool, rev: String) -> String:
214  match head:
215    case False{}:
216      ""
217    case True{}:
218      tip.sym(rev)
219
220# this row's branch when it is the symref for HEAD
221def tip.branch.at(line: Row) -> String:
222  Row{rev, name} = line
223  tip.branch.pick(String.eq(name, "HEAD"), rev)
224
225# the branch HEAD is, or "" when the remote did not say
226def tip.branch(rs: List<&2, Row>) -> String:
227  match rs:
228    case []:
229      ""
230    case +h <> t:
231      tip.step(String.is_empty(tip.branch.at(h)), tip.branch.at(h), _u => tip.branch(t))
232
233# how many components a path has, which is how far down it sits
234def depth(path: String) -> Nat:
235  List.length(&2, String, String.split(P.norm(path), '/'))
236
237# the directory inside the repo a package's paths are written from. `pkg/pkg`
238# resolves that against the checkout it read; the lock records it against the
239# repo, so the same number of components is climbed back off the entry's own
240# directory here. It is "." until a module reached through `..` re-roots the
241# package above the entry.
242def root.rel(+at: String, root: String, +entry: String) -> String:
243  P.climb(Nat.sub(depth(P.dir(at)), depth(root)), P.dir(entry))
244
245# the first char of a name in upper case, which is how bend writes an alias
246def title.go(cs: List<&2, Char>) -> String:
247  match cs:
248    case []:
249      ""
250    case h <> t:
251      String.from_list(Char.to_upper(h) <> t)
252
253# a name as an import alias, as `ez add` and `ez publish` print it
254def title(text: String) -> String:
255  title.go(String.to_list(text))
256
257# the import line a consumer writes for a package: the hash, the entry's path
258# inside the package, which is where the hub serves it, and the entry's name
259# as the alias, the way bend prints it. `ez add` and `ez publish` both print
260# this line.
261def import.line(+hash: String, +at: String) -> String:
262  "import " ++ hash ++ "/" ++ at ++ " as " ++ title(P.unbend(P.base(at)))
263
264# `main.bend` when neither the entry nor the bin was named. That is the file
265# `ez init` writes when it is not told an entry.
266def entry.main(no: Bool, +binary: String) -> String:
267  match no:
268    case False{}:
269      binary
270    case True{}:
271      "main.bend"
272
273# `bin` only when the package named no entry. The bin is the file a tool
274# builds; it is not the entry unless the entry is absent.
275def entry.bin(no: Bool, +file: String, +binary: String) -> String:
276  match no:
277    case False{}:
278      file
279    case True{}:
280      entry.main(String.is_empty(binary), binary)
281
282# the file a package is taken from: its entry, else its bin, else `main.bend`
283def entry.pick(+file: String, +binary: String) -> String:
284  entry.bin(String.is_empty(file), file, binary)
285
286# a document that did not parse has no entry to take
287def entry.sects(ok: Bool, ss: List<&2, T.Sect>) -> String:
288  match ok:
289    case False{}:
290      "main.bend"
291    case True{}:
292      +ps = M.pairs_of(ss, "package")
293      entry.pick(M.value(ps, "entry"), M.value(ps, "bin"))
294
295# the entry a parsed document names
296def entry.doc(toml: T.Toml) -> String:
297  T.Toml{bad, _name, _pairs, sects} = toml
298  entry.sects(String.is_empty(bad), sects)
299
300# the entry a parsed ez.toml names
301def entry.text(+text: String) -> String:
302  entry.doc(T.parse(text))
303
304# a missing ez.toml names `main.bend`, the same stub `ez init` uses
305def entry.maybe(got: Maybe<&2, String>) -> String:
306  match got:
307    case None{}:
308      "main.bend"
309    case Some{text}:
310      entry.text(text)
311
312# the checkout's ez.toml, read for the entry it names
313def entry.at(+work: String) -> IO(String):
314  do IO<String>:
315    m : Maybe<&2, String> <- F.read(work ++ "/ez.toml")
316    return entry.maybe(m)
317
318# the package name a parsed document gives, or "" when it did not parse
319def called.sects(ok: Bool, ss: List<&2, T.Sect>) -> String:
320  match ok:
321    case False{}:
322      ""
323    case True{}:
324      M.value(M.pairs_of(ss, "package"), "name")
325
326# the package name a parsed document gives
327def called.doc(toml: T.Toml) -> String:
328  T.Toml{bad, _name, _pairs, sects} = toml
329  called.sects(String.is_empty(bad), sects)
330
331# a checkout with no ez.toml gives no package name
332def called.maybe(got: Maybe<&2, String>) -> String:
333  match got:
334    case None{}:
335      ""
336    case Some{text}:
337      called.doc(T.parse(text))
338
339# the entry a vendoring is taken from. One that was asked for is that one.
340def entry.for(given: Bool, +entry: String, +work: String) -> IO(String):
341  match given:
342    case True{}:
343      IO.pure(String, entry)
344    case False{}:
345      entry.at(work)
346
347# A tag `ez add` may pin when no ref was asked for. It is semver-ish when,
348# after an optional `v`, it is dotted numbers, then an optional prerelease
349# of dot-separated identifiers, then optional `+` build metadata. Identifiers
350# are letters, digits and hyphens. Comparison is semver: numbers beat lexical
351# order, a longer prerelease beats a shorter one that matches up to there,
352# and a release beats its prerelease. Anything else is not a candidate.
353
354def ord.lt(lt: Bool) -> Ord:
355  match lt:
356    case True{}:
357      Lt{}
358    case False{}:
359      Gt{}
360
361def ord.nat(eq: Bool, lt: Bool) -> Ord:
362  match eq:
363    case True{}:
364      Eq{}
365    case False{}:
366      ord.lt(lt)
367
368def ord.str(eq: Bool, le: Bool) -> Ord:
369  match eq:
370    case True{}:
371      Eq{}
372    case False{}:
373      ord.lt(le)
374
375def ord.flip(order: Ord) -> Ord:
376  match order:
377    case Lt{}:
378      Gt{}
379    case Gt{}:
380      Lt{}
381    case Eq{}:
382      Eq{}
383
384def ord.pad.at(zero: Bool, rest: Unit -> Ord) -> Ord:
385  match zero:
386    case False{}:
387      Lt{}
388    case True{}:
389      rest(Unit{})
390
391def ord.pad(xs: List<&2, Nat>) -> Ord:
392  match xs:
393    case Nil{}:
394      Eq{}
395    case Con{+h, t}:
396      ord.pad.at(Nat.is_eq(h, 0n), _u => ord.pad(t))
397
398def ord.nums.at(order: Ord, rest: Unit -> Ord) -> Ord:
399  match order:
400    case Eq{}:
401      rest(Unit{})
402    case Lt{}:
403      Lt{}
404    case Gt{}:
405      Gt{}
406
407def ord.nums(left: List<&2, Nat>, right: List<&2, Nat>) -> Ord:
408  match left right:
409    case Nil{} Nil{}:
410      Eq{}
411    case Nil{} Con{+h, t}:
412      ord.pad(h <> t)
413    case Con{+h, t} Nil{}:
414      ord.flip(ord.pad(h <> t))
415    case Con{+ha, ta} Con{+hb, tb}:
416      ord.nums.at(ord.nat(Nat.is_eq(ha, hb), Nat.is_lt(ha, hb)),
417        _u => ord.nums(ta, tb))
418
419def ord.id(left: Id, right: Id) -> Ord:
420  match left right:
421    case IdNum{+n} IdNum{+m}:
422      ord.nat(Nat.is_eq(n, m), Nat.is_lt(n, m))
423    case IdNum{_n} IdWord{_s}:
424      Lt{}
425    case IdWord{_s} IdNum{_n}:
426      Gt{}
427    case IdWord{+s} IdWord{+t}:
428      ord.str(String.eq(s, t), String.is_le(s, t))
429
430def ord.ids(left: List<&2, Id>, right: List<&2, Id>) -> Ord:
431  match left right:
432    case Nil{} Nil{}:
433      Eq{}
434    case Nil{} Con{_h, _t}:
435      Lt{}
436    case Con{_h, _t} Nil{}:
437      Gt{}
438    case Con{ha, ta} Con{hb, tb}:
439      ord.nums.at(ord.id(ha, hb), _u => ord.ids(ta, tb))
440
441def ord.rel.at(ae: Bool, be: Bool, left: List<&2, Id>, right: List<&2, Id>) -> Ord:
442  match ae be:
443    case True{} True{}:
444      Eq{}
445    case True{} False{}:
446      Gt{}
447    case False{} True{}:
448      Lt{}
449    case False{} False{}:
450      ord.ids(left, right)
451
452def ord.rel(+left: List<&2, Id>, +right: List<&2, Id>) -> Ord:
453  ord.rel.at(List.is_empty(&2, Id, left), List.is_empty(&2, Id, right), left, right)
454
455def ord.ver.at(order: Ord, pa: List<&2, Id>, pb: List<&2, Id>) -> Ord:
456  ord.nums.at(order, _u => ord.rel(pa, pb))
457
458def ord.ver(left: Ver, right: Ver) -> Ord:
459  match left right:
460    case Yes{na, pa} Yes{nb, pb}:
461      ord.ver.at(ord.nums(na, nb), pa, pb)
462    case No{} _:
463      Eq{}
464    case Yes{_na, _pa} No{}:
465      Eq{}
466
467# a decimal digit's value
468def dig(ch: Char) -> Nat:
469  U32.to_nat(U32.sub(Char.to_u32(ch), 48))
470
471def num.go(text: String, +acc: Nat) -> Nat:
472  match text:
473    case SNil{}:
474      acc
475    case SCon{+h, t}:
476      num.go(t, Nat.add(Nat.mul(acc, 10n), dig(h)))
477
478def flag.step(ok: Bool, rest: Unit -> Bool) -> Bool:
479  match ok:
480    case False{}:
481      False{}
482    case True{}:
483      rest(Unit{})
484
485def digits.go(text: String) -> Bool:
486  match text:
487    case SNil{}:
488      True{}
489    case SCon{+h, t}:
490      flag.step(Char.is_digit(h), _u => digits.go(t))
491
492def digits.at(empty: Bool, +text: String) -> Bool:
493  match empty:
494    case True{}:
495      False{}
496    case False{}:
497      digits.go(text)
498
499# a non-empty run of decimal digits
500def digits(+text: String) -> Bool:
501  digits.at(String.is_empty(text), text)
502
503def nums.cons(value: Nat, rest: Maybe<&2, List<&2, Nat>>) -> Maybe<&2, List<&2, Nat>>:
504  match rest:
505    case None{}:
506      None{}
507    case Some{ns}:
508      Some{value <> ns}
509
510def nums.put(ok: Bool, +part: String, rest: Maybe<&2, List<&2, Nat>>) -> Maybe<&2, List<&2, Nat>>:
511  match ok:
512    case False{}:
513      None{}
514    case True{}:
515      nums.cons(num.go(part, 0n), rest)
516
517def nums.of(ps: List<&2, String>) -> Maybe<&2, List<&2, Nat>>:
518  match ps:
519    case Nil{}:
520      Some{[]}
521    case Con{+h, t}:
522      nums.put(digits(h), h, nums.of(t))
523
524def id.char(+ch: Char) -> Bool:
525  Bool.or(Char.is_digit(ch), Bool.or(Char.is_alpha(ch), Char.is_eq(ch, '-')))
526
527def id.chars(text: String) -> Bool:
528  match text:
529    case SNil{}:
530      True{}
531    case SCon{+h, t}:
532      flag.step(id.char(h), _u => id.chars(t))
533
534def id.make(num: Bool, +text: String) -> Id:
535  match num:
536    case True{}:
537      IdNum{num.go(text, 0n)}
538    case False{}:
539      IdWord{text}
540
541def ids.cons(ident: Id, rest: Maybe<&2, List<&2, Id>>) -> Maybe<&2, List<&2, Id>>:
542  match rest:
543    case None{}:
544      None{}
545    case Some{xs}:
546      Some{ident <> xs}
547
548def ids.ok(ok: Bool, num: Bool, +part: String, rest: Maybe<&2, List<&2, Id>>) -> Maybe<&2, List<&2, Id>>:
549  match ok:
550    case False{}:
551      None{}
552    case True{}:
553      ids.cons(id.make(num, part), rest)
554
555def ids.put(empty: Bool, ok: Bool, num: Bool, +part: String, rest: Maybe<&2, List<&2, Id>>) -> Maybe<&2, List<&2, Id>>:
556  match empty:
557    case True{}:
558      None{}
559    case False{}:
560      ids.ok(ok, num, part, rest)
561
562def ids.of(ps: List<&2, String>) -> Maybe<&2, List<&2, Id>>:
563  match ps:
564    case Nil{}:
565      Some{[]}
566    case Con{+h, t}:
567      ids.put(String.is_empty(h), id.chars(h), digits(h), h, ids.of(t))
568
569def pre.parts(empty: Bool, +raw: String) -> Maybe<&2, List<&2, Id>>:
570  match empty:
571    case True{}:
572      None{}
573    case False{}:
574      ids.of(String.split(raw, '.'))
575
576def pre.of(had: Bool, +raw: String) -> Maybe<&2, List<&2, Id>>:
577  match had:
578    case False{}:
579      Some{[]}
580    case True{}:
581      pre.parts(String.is_empty(raw), raw)
582
583def split1.put(ch: Char, cut: Cut) -> Cut:
584  Cut{left, right} = cut
585  Cut{SCon{ch, left}, right}
586
587def split1.step(hit: Bool, ch: Char, +tail: String, rest: Unit -> Cut) -> Cut:
588  match hit:
589    case True{}:
590      Cut{"", tail}
591    case False{}:
592      split1.put(ch, rest(Unit{}))
593
594# a string cut at the first of a character, the character dropped; no hit leaves
595# the whole string on the left
596def split1(+sep: Char, text: String) -> Cut:
597  match text:
598    case SNil{}:
599      Cut{"", ""}
600    case SCon{+h, +t}:
601      split1.step(Char.is_eq(h, sep), h, t, _u => split1(sep, t))
602
603def unv.step(drop: Bool, ch: Char, tail: String) -> String:
604  match drop:
605    case True{}:
606      tail
607    case False{}:
608      SCon{ch, tail}
609
610# a tag with one leading `v` or `V` dropped
611def unv(text: String) -> String:
612  match text:
613    case SNil{}:
614      ""
615    case SCon{+h, t}:
616      unv.step(Bool.or(Char.is_eq(h, 'v'), Char.is_eq(h, 'V')), h, t)
617
618def ver.yes(empty: Bool, xs: List<&2, Nat>, ys: List<&2, Id>) -> Ver:
619  match empty:
620    case True{}:
621      No{}
622    case False{}:
623      Yes{xs, ys}
624
625def ver.pre(+xs: List<&2, Nat>, ps: Maybe<&2, List<&2, Id>>) -> Ver:
626  match ps:
627    case None{}:
628      No{}
629    case Some{ys}:
630      ver.yes(List.is_empty(&2, Nat, xs), xs, ys)
631
632def ver.nums(had: Bool, ns: Maybe<&2, List<&2, Nat>>, +pre: String) -> Ver:
633  match ns:
634    case None{}:
635      No{}
636    case Some{xs}:
637      ver.pre(xs, pre.of(had, pre))
638
639def ver.from(empty: Bool, had: Bool, +core: String, +pre: String) -> Ver:
640  match empty:
641    case True{}:
642      No{}
643    case False{}:
644      ver.nums(had, nums.of(String.split(core, '.')), pre)
645
646def ver.cut(had: Bool, cut: Cut) -> Ver:
647  Cut{+core, +pre} = cut
648  ver.from(String.is_empty(core), had, core, pre)
649
650def ver.main(+main: String) -> Ver:
651  ver.cut(String.contains(main, "-"), split1('-', main))
652
653def ver.core(cut: Cut) -> Ver:
654  Cut{+main, _build} = cut
655  ver.main(main)
656
657# a tag read as a version. A leading `v` is optional, and `+` metadata is dropped.
658def ver.of(+tag: String) -> Ver:
659  ver.core(split1('+', unv(tag)))
660
661# whether the first version is strictly newer
662def newer(order: Ord) -> Bool:
663  match order:
664    case Gt{}:
665      True{}
666    case Lt{}:
667      False{}
668    case Eq{}:
669      False{}
670
671def latest.cmp(take: Bool, +name: String, +rest: String) -> String:
672  match take:
673    case True{}:
674      name
675    case False{}:
676      rest
677
678def latest.have(empty: Bool, +name: String, +rest: String, version: Ver) -> String:
679  match empty:
680    case True{}:
681      name
682    case False{}:
683      latest.cmp(newer(ord.ver(version, ver.of(rest))), name, rest)
684
685def latest.step(version: Ver, +name: String, +rest: String) -> String:
686  match version:
687    case No{}:
688      rest
689    case Yes{ns, ps}:
690      latest.have(String.is_empty(rest), name, rest, Yes{ns, ps})
691
692# the newest semver-ish name, or "" when none of them is one
693def latest(names: List<&2, String>) -> String:
694  match names:
695    case Nil{}:
696      ""
697    case Con{+h, t}:
698      latest.step(ver.of(h), h, latest(t))
699
700# a version with no pre-release, which is a release
701def release.ver(version: Ver) -> Bool:
702  match version:
703    case No{}:
704      False{}
705    case Yes{_ns, ps}:
706      List.is_empty(&2, Id, ps)
707
708# a semver-ish tag that is a release: `v1.9.0`, not `v2.0.0-rc1`
709def release(+name: String) -> Bool:
710  release.ver(ver.of(name))
711
712# a name that is a release, weighed against the greatest release after it the
713# way `latest` weighs any two versions; a name that is not is passed over
714def latest.rel.step(keep: Bool, +name: String, +rest: String) -> String:
715  match keep:
716    case False{}:
717      rest
718    case True{}:
719      latest.have(String.is_empty(rest), name, rest, ver.of(name))
720
721# the greatest release, or "" when no name is one
722def latest.rel(names: List<&2, String>) -> String:
723  match names:
724    case Nil{}:
725      ""
726    case Con{+h, t}:
727      latest.rel.step(release(h), h, latest.rel(t))
728
729# one name found to be a release, or the rest still to be asked about
730def has.release.step(here: Bool, rest: Unit -> Bool) -> Bool:
731  match here:
732    case True{}:
733      True{}
734    case False{}:
735      rest(Unit{})
736
737# whether any name is a release
738def has.release(names: List<&2, String>) -> Bool:
739  match names:
740    case Nil{}:
741      False{}
742    case Con{+h, t}:
743      has.release.step(release(h), _u => has.release(t))
744
745# the greatest release when there is one, and otherwise the greatest of what
746# is left, every one of which is a pre-release
747def choose.go(none: Bool, +rel: String, +any: String) -> String:
748  match none:
749    case True{}:
750      any
751    case False{}:
752      rel
753
754# the tag `ez add` pins when no ref was asked for: a release beats every
755# pre-release, however much greater the pre-release's numbers are, and
756# within each class the greatest wins by `ord.ver`, the later of two equal
757# ones winning as `latest` has it. "" when no name is semver-ish.
758def choose(+names: List<&2, String>) -> String:
759  choose.go(String.is_empty(latest.rel(names)), latest.rel(names), latest(names))
760
761
762# one row's tag name, or nothing when the row is peeled or not a tag
763def tag.put(hit: Bool, +name: String) -> List<&2, String>:
764  match hit:
765    case False{}:
766      []
767    case True{}:
768      [String.drop(name, String.length("refs/tags/"))]
769
770def tag.keep(peeled: Bool, +name: String) -> List<&2, String>:
771  match peeled:
772    case True{}:
773      []
774    case False{}:
775      tag.put(String.starts_with(name, "refs/tags/"), name)
776
777def tag.one(line: Row) -> List<&2, String>:
778  Row{_rev, +name} = line
779  tag.keep(String.ends_with(name, "^{}"), name)
780
781# every tag name `ls-remote --tags` wrote, peeled rows left out
782def tag.names(rs: List<&2, Row>) -> List<&2, String>:
783  match rs:
784    case Nil{}:
785      []
786    case Con{+h, t}:
787      List.append(&2, String, tag.one(h), tag.names(t))