src/lock/up.bend

ezx@1.5.0.0 · 1,278 lines · 0 definitions

1# lock/up: what `ez lock --upgrade` decides, as a pure function of what the
2# remote said. It is the upgrade half of the planner (lock/plan.bend): it reads
3# the ledger and the answers to its own questions, and returns either the
4# questions it still has, a refusal, or the ledger the lock is made from and
5# everything the upgrade writes around it. The interpreter (lock/run.bend)
6# answers each question by IO, and the planner turns the result into effects.
7#
8# A git dependency with a tag is re-resolved to that tag; one with only a
9# commit fast-forwards to the default branch tip when the pin is an ancestor
10# of it. A hub dependency is left, because the hash is the pin. `--package`
11# selects one dependency or tool, and no other entry is asked about. A tool
12# pin with no commit or no NAR hash is filled here and nowhere else. A moved
13# dependency marked `vendor = true` has its new tree committed under
14# `.ez/lib` and its old one removed, the gitignore allowlist is derived again
15# from the ledger the upgrade leaves (`I.sync`), and an import that names a
16# moved hash is rewritten. Nothing is written when the upgrade refuses: that
17# is the planner's, since a refusal is a plan with no effects.
18#
19# A same commit is checked out and weighed, and `U.judge` is given whether
20# that checkout agrees with the pin, so a pinned commit whose tree no longer
21# hashes to the pin is `Drift` and refused.
22import Base
23import ezx@1.5.0.0/src/lock/lock.bend as L
24import ezx@1.5.0.0/src/ledger/manifest.bend as M
25import ezx@1.5.0.0/src/ledger/render.bend as Rend
26import ezx@1.5.0.0/src/ledger/upgrade.bend as U
27import ezx@1.5.0.0/src/ledger/ignore.bend as I
28import ezx@1.5.0.0/src/git/git.bend as Git
29import ezx@1.5.0.0/src/pkg/pkg.bend as K
30import ezx@1.5.0.0/src/pkg/path.bend as Path
31import ezx@1.5.0.0/src/hub/hub.bend as Web
32import ezx@1.5.0.0/src/share/sha.bend as Sha
33import ezx@1.5.0.0/src/share/say.bend as Say
34
35# ---------------------------------------------------------------------------
36# questions and answers
37
38# a question the upgrade asks, answered by one IO action. `ez add`
39# (add/plan.bend) asks four of them too: `Tags`, `Head` and `Refs` to resolve
40# a ref, and `Clone` for the checkout it walks. The upgrade asks each with
41# the source its ledger records, and looks the answer up by that; git is asked
42# the same question with that source anchored at the project (`anchor`),
43# which the planner computes (lock/plan.bend, `P.Query`). `ez add` and
44# `ez fetch` ask with the source anchored already.
45type Ask is Data:
46  Refs{url: String, ref: String}                 # `git ls-remote url refs/tags/<ref> ... refs/heads/<ref>`
47  Head{url: String}                              # `git ls-remote --symref url HEAD`
48  Tags{url: String}                              # `git ls-remote --tags url`, for a tool with no tag
49  Above{url: String, pin: String, tip: String}   # whether `tip` descends from `pin`
50  Tree{url: String, rev: String, entry: String}  # a checkout at rev: narHash, root, the package its entry reaches
51  Weigh{url: String, rev: String}                # a checkout at rev, narHash only, for a tool
52  HubHas{hub: String, hash: String}              # the hub's manifest for a hash a dependency moved to
53  Ignore{}                                       # `.gitignore`, "" when missing
54  Sources{}                                      # every `.bend` file outside `.ez` and `.git`
55  Clone{url: String, rev: String}                # a checkout at rev: narHash and every file's text, for `ez add`
56
57# the source a question names, "" for one that names none
58def ask.url(ask: Ask) -> String:
59  match ask:
60    case Refs{url, _ref}:
61      url
62    case Head{url}:
63      url
64    case Tags{url}:
65      url
66    case Above{url, _pin, _tip}:
67      url
68    case Tree{url, _rev, _entry}:
69      url
70    case Weigh{url, _rev}:
71      url
72    case HubHas{_hub, _hash}:
73      ""
74    case Ignore{}:
75      ""
76    case Sources{}:
77      ""
78    case Clone{url, _rev}:
79      url
80
81# a question as git is to be asked it: the source it names anchored at the
82# directory `here` (`Path.anchor`), so a relative path, which the ledger keeps
83# relative to the project, is read from the project wherever git runs. A URL
84# and an absolute path are as they are, and a question that names no source
85# is as it is.
86def anchor(+here: String, ask: Ask) -> Ask:
87  match ask:
88    case Refs{url, ref}:
89      Refs{Path.anchor(here, url), ref}
90    case Head{url}:
91      Head{Path.anchor(here, url)}
92    case Tags{url}:
93      Tags{Path.anchor(here, url)}
94    case Above{url, pin, tip}:
95      Above{Path.anchor(here, url), pin, tip}
96    case Tree{url, rev, entry}:
97      Tree{Path.anchor(here, url), rev, entry}
98    case Weigh{url, rev}:
99      Weigh{Path.anchor(here, url), rev}
100    case HubHas{hub, hash}:
101      HubHas{hub, hash}
102    case Ignore{}:
103      Ignore{}
104    case Sources{}:
105      Sources{}
106    case Clone{url, rev}:
107      Clone{Path.anchor(here, url), rev}
108
109# one source file of the project, with its text
110type Found is Data:
111  Found{at: String, text: String}
112
113# the answer to one question. Git's output is kept as it was printed, with
114# whether the command succeeded, so reading it is the planner's decision.
115type Answer is Data:
116  Printed{ok: Bool, text: String}                                              # Refs, Head, Tags
117  Onward{yes: Bool}                                                            # Above
118  Checkout{nar: String, root: String, manifest: String, srcs: List<&2, String>}  # Tree
119  Weighed{nar: String}                                                         # Weigh
120  HubSaid{got: Web.Got}                                                        # HubHas
121  Text{text: String}                                                           # Ignore
122  Files{files: List<&2, Found>}                                                # Sources
123  Cloned{nar: String, files: List<&2, K.Source>}                               # Clone
124  Miss{why: String}                                                            # any question IO could not answer
125
126# one question, answered
127type Reply is Data:
128  Reply{ask: Ask, answer: Answer}
129
130# a tree checked out at a rev a dependency moved to: the package the lock
131# reads for its new hash, so it is not cloned a second time
132type Laid is Data:
133  Laid{hash: String, nar: String, manifest: String, srcs: List<&2, String>}
134
135# a file the upgrade writes: ez.toml, `.gitignore`, or a source
136type Edit is Data:
137  Edit{at: String, text: String}
138
139# what the upgrade decided. `model` is the ledger model the upgrade renders
140# into ez.toml, and ez.toml as it is when the upgrade refuses, still asks, or
141# moves nothing; the upgrade's decisions are stated over it. `read` is the
142# ledger the lock is made from: the one the upgrade writes, read back, or
143# ez.toml as it is when nothing moved.
144# `stop` is why the upgrade refuses, "" when it does not; a World with
145# questions still open refuses too, and names them in `asks`. The rest is what
146# the upgrade writes around the lock: the trees it checked out, the hashes
147# committed under `.ez/lib`, the lines a person is told, ez.toml and
148# `.gitignore` when they change, the project's sources, which it writes where
149# a moved hash is rewritten (`edit.sources`), the committed trees it removes,
150# and the hashes import lines stop naming, each with the hash that replaces
151# it, which the lock reads the committed sources through. The sources are
152# rewritten once, by the plan, and not on every round of questions.
153type Next is Data:
154  Next{model: M.Read, read: M.Read, stop: String, asks: List<&2, Ask>, trees: List<&2, Laid>,
155    vends: List<&2, String>, says: List<&2, String>, edits: List<&2, Edit>,
156    found: List<&2, Found>, drops: List<&2, String>, swaps: List<&2, U.Swap>}
157
158def next.model(nx: Next) -> M.Read:
159  Next{model, _read, _stop, _asks, _trees, _vends, _says, _edits, _found, _drops, _swaps} = nx
160  model
161
162def next.read(nx: Next) -> M.Read:
163  Next{_model, read, _stop, _asks, _trees, _vends, _says, _edits, _found, _drops, _swaps} = nx
164  read
165
166def next.stop(nx: Next) -> String:
167  Next{_model, _read, stop, _asks, _trees, _vends, _says, _edits, _found, _drops, _swaps} = nx
168  stop
169
170def next.asks(nx: Next) -> List<&2, Ask>:
171  Next{_model, _read, _stop, asks, _trees, _vends, _says, _edits, _found, _drops, _swaps} = nx
172  asks
173
174def next.trees(nx: Next) -> List<&2, Laid>:
175  Next{_model, _read, _stop, _asks, trees, _vends, _says, _edits, _found, _drops, _swaps} = nx
176  trees
177
178def next.vends(nx: Next) -> List<&2, String>:
179  Next{_model, _read, _stop, _asks, _trees, vends, _says, _edits, _found, _drops, _swaps} = nx
180  vends
181
182def next.says(nx: Next) -> List<&2, String>:
183  Next{_model, _read, _stop, _asks, _trees, _vends, says, _edits, _found, _drops, _swaps} = nx
184  says
185
186def next.edits(nx: Next) -> List<&2, Edit>:
187  Next{_model, _read, _stop, _asks, _trees, _vends, _says, edits, _found, _drops, _swaps} = nx
188  edits
189
190def next.found(nx: Next) -> List<&2, Found>:
191  Next{_model, _read, _stop, _asks, _trees, _vends, _says, _edits, found, _drops, _swaps} = nx
192  found
193
194def next.drops(nx: Next) -> List<&2, String>:
195  Next{_model, _read, _stop, _asks, _trees, _vends, _says, _edits, _found, drops, _swaps} = nx
196  drops
197
198def next.swaps(nx: Next) -> List<&2, U.Swap>:
199  Next{_model, _read, _stop, _asks, _trees, _vends, _says, _edits, _found, _drops, swaps} = nx
200  swaps
201
202# a plain lock: ez.toml as it is, and nothing around it
203def plain(+read: M.Read) -> Next:
204  Next{read, read, "", [], [], [], [], [], [], [], []}
205
206# a refusal's reason, never empty. An empty reason is no refusal at all, so
207# an upgrade that stopped with one would otherwise go on to lock the ledger
208# as it was, and a pin that halted with one would drop out of the ledger the
209# upgrade writes. The interpreter never answers with an empty reason.
210def halt.why(+why: String) -> String:
211  Bool.pick(String, String.is_empty(why), "ez: the upgrade stopped without a reason", why)
212
213# an upgrade that refuses, or still asks: the lock is not made, and nothing is
214# written
215def stopped(+read: M.Read, +why: String, asks: List<&2, Ask>) -> Next:
216  Next{read, read, halt.why(why), asks, [], [], [], [], [], [], []}
217
218# ---------------------------------------------------------------------------
219# looking an answer up
220
221# a question as a key. A ledger value never holds a newline, so the fields
222# are kept apart by one.
223def key(ask: Ask) -> String:
224  match ask:
225    case Refs{url, ref}:
226      "refs\n" ++ url ++ "\n" ++ ref
227    case Head{url}:
228      "head\n" ++ url
229    case Tags{url}:
230      "tags\n" ++ url
231    case Above{url, pin, tip}:
232      "above\n" ++ url ++ "\n" ++ pin ++ "\n" ++ tip
233    case Tree{url, rev, entry}:
234      "tree\n" ++ url ++ "\n" ++ rev ++ "\n" ++ entry
235    case Weigh{url, rev}:
236      "weigh\n" ++ url ++ "\n" ++ rev
237    case HubHas{hub, hash}:
238      "hub\n" ++ hub ++ "\n" ++ hash
239    case Ignore{}:
240      "ignore"
241    case Sources{}:
242      "sources"
243    case Clone{url, rev}:
244      "clone\n" ++ url ++ "\n" ++ rev
245
246# an answer looked up, or none yet
247type Heard is Data:
248  Heard{answer: Answer}
249  Open{}
250
251# this answer when it is the question's, otherwise the rest's. The rest
252# arrives as a thunk, so the first hit ends the scan.
253def look.step(hit: Bool, answer: Answer, rest: Unit -> Heard) -> Heard:
254  match hit:
255    case True{}:
256      Heard{answer}
257    case False{}:
258      rest(Unit{})
259
260# the first answer given to a question
261def look(rs: List<&2, Reply>, +want: String) -> Heard:
262  match rs:
263    case []:
264      Open{}
265    case Reply{ask, answer} <> t:
266      look.step(String.eq(key(ask), want), answer, _u => look(t, want))
267
268# the answer to a question, or none yet
269def heard(rs: List<&2, Reply>, ask: Ask) -> Heard:
270  look(rs, key(ask))
271
272# what git printed, once it is known: the text, or why there is none, or the
273# question still open
274type Said is Data:
275  Said{text: String}
276  Unsaid{why: String}
277  Unasked{ask: Ask}
278
279def printed.ok(ok: Bool, +what: String, text: String) -> Said:
280  match ok:
281    case True{}:
282      Said{text}
283    case False{}:
284      Unsaid{"ez: git " ++ what ++ ": " ++ String.trim(text)}
285
286def printed.of(answer: Answer, +what: String) -> Said:
287  match answer:
288    case Printed{ok, text}:
289      printed.ok(ok, what, text)
290    case Miss{why}:
291      Unsaid{why}
292    case _:
293      Unsaid{"ez: git " ++ what ++ " was not answered with what git printed"}
294
295# what git printed for `what`
296def printed.heard(got: Heard, ask: Ask, +what: String) -> Said:
297  match got:
298    case Open{}:
299      Unasked{ask}
300    case Heard{answer}:
301      printed.of(answer, what)
302
303# the same, looked up among the answers
304def printed(rs: List<&2, Reply>, +ask: Ask, +what: String) -> Said:
305  printed.heard(heard(rs, ask), ask, what)
306
307# a file's text, "" when it is missing
308def text.of(answer: Answer) -> Said:
309  match answer:
310    case Text{text}:
311      Said{text}
312    case Miss{why}:
313      Unsaid{why}
314    case _:
315      Unsaid{"ez: a file was not answered with its text"}
316
317def text.said.heard(got: Heard, ask: Ask) -> Said:
318  match got:
319    case Open{}:
320      Unasked{ask}
321    case Heard{answer}:
322      text.of(answer)
323
324# the same, looked up among the answers
325def text.said(rs: List<&2, Reply>, +ask: Ask) -> Said:
326  text.said.heard(heard(rs, ask), ask)
327
328# a checkout, once it is known
329type Co is Data:
330  Co{nar: String, root: String, manifest: String, srcs: List<&2, String>}
331  CoWait{ask: Ask}
332  CoNo{why: String}
333
334def checkout.of(answer: Answer) -> Co:
335  match answer:
336    case Checkout{nar, root, manifest, srcs}:
337      Co{nar, root, manifest, srcs}
338    case Miss{why}:
339      CoNo{why}
340    case _:
341      CoNo{"ez: a checkout was not answered with one"}
342
343# the checkout a question was answered with
344def checkout.heard(got: Heard, ask: Ask) -> Co:
345  match got:
346    case Open{}:
347      CoWait{ask}
348    case Heard{answer}:
349      checkout.of(answer)
350
351# the same, looked up among the answers
352def checkout(rs: List<&2, Reply>, +ask: Ask) -> Co:
353  checkout.heard(heard(rs, ask), ask)
354
355# ancestry, once it is known
356type Asc is Data:
357  Asc{yes: Bool}
358  AscWait{ask: Ask}
359  AscNo{why: String}
360
361def onward.of(answer: Answer) -> Asc:
362  match answer:
363    case Onward{yes}:
364      Asc{yes}
365    case Miss{why}:
366      AscNo{why}
367    case _:
368      AscNo{"ez: ancestry was not answered with whether it holds"}
369
370# the ancestry a question was answered with
371def onward.heard(got: Heard, ask: Ask) -> Asc:
372  match got:
373    case Open{}:
374      AscWait{ask}
375    case Heard{answer}:
376      onward.of(answer)
377
378# the same, looked up among the answers
379def onward(rs: List<&2, Reply>, +ask: Ask) -> Asc:
380  onward.heard(heard(rs, ask), ask)
381
382# a NAR hash, once it is known
383type Wd is Data:
384  Wd{nar: String}
385  WdWait{ask: Ask}
386  WdNo{why: String}
387
388def weighed.of(answer: Answer) -> Wd:
389  match answer:
390    case Weighed{nar}:
391      Wd{nar}
392    case Miss{why}:
393      WdNo{why}
394    case _:
395      WdNo{"ez: a weighing was not answered with a NAR hash"}
396
397# the NAR hash a question was answered with
398def weighed.heard(got: Heard, ask: Ask) -> Wd:
399  match got:
400    case Open{}:
401      WdWait{ask}
402    case Heard{answer}:
403      weighed.of(answer)
404
405# the same, looked up among the answers
406def weighed(rs: List<&2, Reply>, +ask: Ask) -> Wd:
407  weighed.heard(heard(rs, ask), ask)
408
409# the hub's word on a hash, once it is known. It stops the upgrade only when a
410# manifest arrived under the hash and did not hash to it.
411type Hubbed is Data:
412  Clear{}
413  Clash{}
414  HubWait{ask: Ask}
415  HubNo{why: String}
416
417def hub.got(got: Web.Got) -> Hubbed:
418  match got:
419    case Web.Have{_body}:
420      Clear{}
421    case Web.Miss{why}:
422      Bool.pick(Hubbed, U.hub.bad(why), Clash{}, Clear{})
423
424def hub.of(answer: Answer) -> Hubbed:
425  match answer:
426    case HubSaid{got}:
427      hub.got(got)
428    case Miss{why}:
429      HubNo{why}
430    case _:
431      HubNo{"ez: the hub was not answered with what it served"}
432
433# the hub's word a question was answered with
434def hubbed.heard(got: Heard, ask: Ask) -> Hubbed:
435  match got:
436    case Open{}:
437      HubWait{ask}
438    case Heard{answer}:
439      hub.of(answer)
440
441# the same, looked up among the answers
442def hubbed(rs: List<&2, Reply>, +ask: Ask) -> Hubbed:
443  hubbed.heard(heard(rs, ask), ask)
444
445# the project's sources, once they are known
446type Srcs is Data:
447  Srcs{files: List<&2, Found>}
448  SrcsWait{ask: Ask}
449  SrcsNo{why: String}
450
451def sources.of(answer: Answer) -> Srcs:
452  match answer:
453    case Files{fs}:
454      Srcs{fs}
455    case Miss{why}:
456      SrcsNo{why}
457    case _:
458      SrcsNo{"ez: the sources were not answered with their texts"}
459
460# the sources a question was answered with
461def sources.heard(got: Heard, ask: Ask) -> Srcs:
462  match got:
463    case Open{}:
464      SrcsWait{ask}
465    case Heard{answer}:
466      sources.of(answer)
467
468# the same, looked up among the answers
469def sources(rs: List<&2, Reply>, +ask: Ask) -> Srcs:
470  sources.heard(heard(rs, ask), ask)
471
472# ---------------------------------------------------------------------------
473# what a ref names
474
475# the commit a pin is asked to move to, the tag it came through ("" for
476# none), and the default branch it tips ("" for a tag); or the question still
477# open, or why there is no answer
478type Tip is Data:
479  At{rev: String, tag: String, branch: String}
480  TipWait{ask: Ask}
481  TipNo{why: String}
482
483# the commit the rows name for a ref. A ref the remote does not have is a
484# mistake worth naming.
485def follow.rev(+rev: String, +url: String, +ref: String) -> Tip:
486  Bool.pick(Tip, String.is_empty(rev), TipNo{"ez: " ++ url ++ " has no ref named " ++ ref},
487    At{rev, ref, ""})
488
489def follow.said(said: Said, +url: String, +ref: String) -> Tip:
490  match said:
491    case Said{text}:
492      follow.rev(Git.exact(Git.rows(String.lines(text)), ref), url, ref)
493    case Unsaid{why}:
494      TipNo{why}
495    case Unasked{ask}:
496      TipWait{ask}
497
498def follow.ask(pinned: Bool, +rs: List<&2, Reply>, +url: String, +ref: String) -> Tip:
499  match pinned:
500    case True{}:
501      At{ref, "", ""}
502    case False{}:
503      follow.said(printed(rs, Refs{url, ref}, "ls-remote " ++ url), url, ref)
504
505# the commit a ref names, as `Git.resolve` reads it: a 40-hex ref is a commit
506# already, and asks the remote nothing
507def follow(+rs: List<&2, Reply>, +url: String, +ref: String) -> Tip:
508  follow.ask(Git.is_rev(ref), rs, url, ref)
509
510# a remote with no HEAD is not one an upgrade can move
511def forward.rev(+rev: String, branch: String, +url: String) -> Tip:
512  Bool.pick(Tip, String.is_empty(rev), TipNo{"ez: " ++ url ++ " has no HEAD"},
513    At{rev, "", branch})
514
515def forward.said(said: Said, +url: String) -> Tip:
516  match said:
517    case Said{text}:
518      +rows = Git.rows(String.lines(text))
519      forward.rev(Git.tip.rev(rows), Git.tip.branch(rows), url)
520    case Unsaid{why}:
521      TipNo{why}
522    case Unasked{ask}:
523      TipWait{ask}
524
525# the commit the default branch tips, and its name
526def forward(+rs: List<&2, Reply>, +url: String) -> Tip:
527  forward.said(printed(rs, Head{url}, "ls-remote " ++ url), url)
528
529# the word Off quotes: a branch name when there is one, otherwise the commit
530def tip.of(+branch: String, +tip: String) -> String:
531  Bool.pick(String, String.is_empty(branch), tip, branch)
532
533# a tag that moved off the pin's history, or a branch tip that does not
534# descend from a rev-only pin
535def off.why(+name: String, +tag: String, +pin: String, +tip: String) -> String:
536  Bool.pick(String, String.is_empty(tag),
537    "ez: " ++ name ++ ": " ++ pin ++ " is not an ancestor of " ++ tip,
538    "ez: " ++ name ++ ": tag " ++ tag ++ " moved off " ++ pin)
539
540# the branch a rev-only pin followed, said when there is one
541def branch.of(+label: String) -> String:
542  Bool.pick(String, String.is_empty(label), "", " (" ++ label ++ ")")
543
544# a line saying a pin moved
545def say.move(+name: String, +old: String, +new: String, +branch: String) -> String:
546  name ++ ": " ++ old ++ " -> " ++ new ++ branch.of(branch)
547
548# the `0x` name a manifest is the digest of
549def hash.of(+manifest: String) -> String:
550  "0x" ++ String.take(Sha.hex(manifest), 32n)
551
552# the bare checkout's hash when it is the pin's, and the walk's otherwise
553def hash.kept.or(same: Bool, +bare: String, +full: String) -> String:
554  match same:
555    case True{}:
556      bare
557    case False{}:
558      full
559
560def hash.kept.pick(same: Bool, +pin: String, +full: String, +manifest: String) -> String:
561  match same:
562    case True{}:
563      full
564    case False{}:
565      +bare = hash.of(K.manifest.lines(K.bare(L.manifest.files(String.lines(manifest)))))
566      hash.kept.or(String.eq(bare, pin), bare, full)
567
568# the hash a pinned commit's checkout has, by the rule the pin was taken by:
569# the walk's, or, for a pin written before bend 2.0.27 took the LICENSE files
570# along, the same checkout's without them. A pin that is neither is a drift,
571# and names the walk's hash.
572def hash.kept(+pin: String, +manifest: String) -> String:
573  +full = hash.of(manifest)
574  hash.kept.pick(String.eq(full, pin), pin, full, manifest)
575
576# ---------------------------------------------------------------------------
577# one dependency
578
579# what one dependency became: its entry in the ledger the upgrade leaves, the
580# committed tree to remove ("" for none), its new hash, the hash an import
581# must stop naming ("" for none), whether it moved, what a person is told, and
582# the tree checked out at its new rev. Or the questions still open, or why the
583# upgrade refuses.
584type Fate is Data:
585  Became{dep: M.Dep, old: String, new: String, prev: String, moved: Bool,
586    says: List<&2, String>, trees: List<&2, Laid>}
587  Waits{asks: List<&2, Ask>}
588  Halts{why: String}
589
590# a dependency left as it was
591def stay(dep: M.Dep, says: List<&2, String>) -> Fate:
592  Became{dep, "", "", "", False{}, says, []}
593
594# the old hash, or "" when it did not change
595def swap.hash(same: Bool, +old: String) -> String:
596  match same:
597    case True{}:
598      ""
599    case False{}:
600      old
601
602# a committed tree whose hash changed is a tree to remove
603def swap.of(vend: Bool, +old: String, +new: String) -> String:
604  match vend:
605    case False{}:
606      ""
607    case True{}:
608      swap.hash(String.eq(old, new), old)
609
610# a line, only when there is something to say
611def line.if(say: Bool, +text: String) -> List<&2, String>:
612  Bool.pick(List<&2, String>, say, [text], [])
613
614# a moved pin, once the hub has not objected to its new hash: the ledger's
615# entry retargeted, the committed tree it replaces, the hash imports stop
616# naming, and what a person is told
617def dep.moved(
618  +dep: M.Dep,
619  +hash: String,
620  +rev: String,
621  +root: String,
622  +nar: String,
623  manifest: String,
624  srcs: List<&2, String>,
625  +branch: String
626) -> Fate:
627  +src = M.source.dep(dep)
628  +name = M.dep.name(dep)
629  +old = M.dep.hash(dep)
630  +drop = swap.of(M.source.vendor(src), old, hash)
631  +prev = swap.hash(String.eq(old, hash), old)
632  Became{U.retarget(dep, rev, hash, root, nar), drop, hash, prev, True{},
633    say.move(name, M.source.rev(src), rev, branch) <>
634      List.append(&2, String,
635        line.if(Bool.not(String.is_empty(prev)), name ++ ": imports " ++ old ++ " -> " ++ hash),
636        line.if(Bool.not(String.is_empty(drop)), name ++ ": vendored .ez/lib/" ++ hash)),
637    [Laid{hash, nar, manifest, srcs}]}
638
639# the hub's word on the new hash
640def dep.hub(
641  hubbed: Hubbed,
642  +dep: M.Dep,
643  +hash: String,
644  +rev: String,
645  +root: String,
646  +nar: String,
647  manifest: String,
648  srcs: List<&2, String>,
649  +branch: String
650) -> Fate:
651  match hubbed:
652    case Clear{}:
653      dep.moved(dep, hash, rev, root, nar, manifest, srcs, branch)
654    case Clash{}:
655      Halts{"ez: " ++ M.dep.name(dep) ++ ": hub digest does not match " ++ hash}
656    case HubWait{ask}:
657      Waits{[ask]}
658    case HubNo{why}:
659      Halts{why}
660
661# the checkout at the rev the pin advances to, then the hub asked about the
662# hash it has. The hub a moved hash is checked against is the ledger's.
663def dep.advance(co: Co, +rs: List<&2, Reply>, +hub: String, +dep: M.Dep, +rev: String, +branch: String) -> Fate:
664  match co:
665    case Co{nar, root, +manifest, srcs}:
666      +hash = hash.of(manifest)
667      dep.hub(hubbed(rs, HubHas{hub, hash}), dep, hash, rev, root, nar, manifest, srcs,
668        branch)
669    case CoWait{ask}:
670      Waits{[ask]}
671    case CoNo{why}:
672      Halts{why}
673
674# the verdict. `seen` is the hash the pinned commit's checkout has, which a
675# drift names; "" when the commit moved and the pin was not checked out.
676def dep.verdict(
677  verdict: U.Verdict,
678  +rs: List<&2, Reply>,
679  +hub: String,
680  +dep: M.Dep,
681  +tip: String,
682  +branch: String,
683  +seen: String
684) -> Fate:
685  match verdict:
686    case U.Keep{}:
687      stay(dep, [M.dep.name(dep) ++ ": unchanged " ++ M.source.rev(M.source.dep(dep))])
688    case U.Drift{}:
689      +src = M.source.dep(dep)
690      Halts{Say.drift(M.dep.name(dep), M.source.tag(src), M.source.rev(src),
691        M.dep.hash(dep), seen)}
692    case U.Off{}:
693      +src = M.source.dep(dep)
694      Halts{off.why(M.dep.name(dep), M.source.tag(src), M.source.rev(src),
695        tip.of(branch, tip))}
696    case U.Advance{+rev, _tag}:
697      dep.advance(checkout(rs, Tree{M.source.url(M.source.dep(dep)), rev, M.dep.entry(dep)}), rs, hub, dep, rev, branch)
698
699# the pinned commit's checkout, judged with whether it agrees with the pin:
700# the same hash, NAR hash and root. One that does not is a drift.
701def dep.kept(co: Co, +rs: List<&2, Reply>, +hub: String, +dep: M.Dep, +tip: String, +tag: String) -> Fate:
702  match co:
703    case Co{nar, root, +manifest, _srcs}:
704      +src = M.source.dep(dep)
705      +hash = hash.kept(M.dep.hash(dep), manifest)
706      dep.verdict(U.judge(True{}, True{},
707        U.agree(M.dep.hash(dep), hash, M.source.nar(src), nar, M.source.root(src), root),
708        tip, tag), rs, hub, dep, tip, "", hash)
709    case CoWait{ask}:
710      Waits{[ask]}
711    case CoNo{why}:
712      Halts{why}
713
714# a different commit, once ancestry is known. The commit moved, so no
715# checkout at the pin was weighed and none agrees with it.
716def dep.onward(
717  asc: Asc,
718  +rs: List<&2, Reply>,
719  +hub: String,
720  +dep: M.Dep,
721  +tip: String,
722  +tag: String,
723  +branch: String
724) -> Fate:
725  match asc:
726    case Asc{yes}:
727      dep.verdict(U.judge(False{}, yes, False{}, tip, tag), rs, hub, dep, tip,
728        branch, "")
729    case AscWait{ask}:
730      Waits{[ask]}
731    case AscNo{why}:
732      Halts{why}
733
734# the same commit is checked out to be weighed; a different one is asked
735# whether it descends from the pin
736def dep.same(
737  same: Bool,
738  +rs: List<&2, Reply>,
739  +hub: String,
740  +dep: M.Dep,
741  +tip: String,
742  +tag: String,
743  +branch: String
744) -> Fate:
745  match same:
746    case True{}:
747      +src = M.source.dep(dep)
748      dep.kept(checkout(rs, Tree{M.source.url(src), M.source.rev(src), M.dep.entry(dep)}), rs, hub, dep, tip, tag)
749    case False{}:
750      +src = M.source.dep(dep)
751      dep.onward(onward(rs, Above{M.source.url(src), M.source.rev(src), tip}), rs, hub, dep, tip, tag, branch)
752
753# the commit the remote names, against the pin
754def dep.tip(tip: Tip, +rs: List<&2, Reply>, +hub: String, +dep: M.Dep) -> Fate:
755  match tip:
756    case At{+rev, tag, branch}:
757      dep.same(String.eq(M.source.rev(M.source.dep(dep)), rev), rs, hub, dep, rev,
758        tag, branch)
759    case TipWait{ask}:
760      Waits{[ask]}
761    case TipNo{why}:
762      Halts{why}
763
764# a hub pin named with `--package` is reported and left. An unselected
765# dependency is left quietly.
766def dep.hold(sel: Bool, +dep: M.Dep) -> Fate:
767  stay(dep, line.if(sel, M.dep.name(dep) ++ ": pinned by its hash"))
768
769# one dependency, by what it is asked
770def dep.one(aim: U.Aim, +rs: List<&2, Reply>, +only: String, +hub: String, +dep: M.Dep) -> Fate:
771  match aim:
772    case U.Hold{}:
773      dep.hold(U.chosen(only, M.dep.name(dep)), dep)
774    case U.Follow{ref}:
775      dep.tip(follow(rs, M.source.url(M.source.dep(dep)), ref), rs, hub, dep)
776    case U.Forward{}:
777      dep.tip(forward(rs, M.source.url(M.source.dep(dep))), rs, hub, dep)
778
779# every dependency after the upgrade: the ledger's entries in its order, the
780# committed trees that moved (`swaps`), the hashes import lines stop naming
781# (`edits`), whether any pin moved, what a person is told, the trees checked
782# out, the questions still open, and the first refusal, "" for none
783type Deps is Data:
784  Deps{deps: List<&2, M.Dep>, swaps: List<&2, U.Swap>, edits: List<&2, U.Swap>,
785    moved: Bool, says: List<&2, String>, trees: List<&2, Laid>,
786    asks: List<&2, Ask>, bad: String}
787
788# a moved hash, kept only when there is one
789def swaps.put(empty: Bool, +old: String, +new: String, rest: List<&2, U.Swap>) -> List<&2, U.Swap>:
790  match empty:
791    case True{}:
792      rest
793    case False{}:
794      U.Swap{old, new} <> rest
795
796# one dependency in front of the rest. A refusal ahead of the rest wins, so
797# the first in ledger order is the one given.
798def deps.join(fate: Fate, rest: Deps) -> Deps:
799  match fate:
800    case Became{d, +old, +new, +prev, m, says, trees}:
801      Deps{ds, ss, es, any, sy, tr, asks, bad} = rest
802      Deps{d <> ds, swaps.put(String.is_empty(old), old, new, ss),
803        swaps.put(String.is_empty(prev), prev, new, es), Bool.or(m, any),
804        List.append(&2, String, says, sy), List.append(&2, Laid, trees, tr), asks, bad}
805    case Waits{more}:
806      Deps{ds, ss, es, any, sy, tr, asks, bad} = rest
807      Deps{ds, ss, es, any, sy, tr, List.append(&2, Ask, more, asks), bad}
808    case Halts{why}:
809      Deps{ds, ss, es, any, sy, tr, asks, _bad} = rest
810      Deps{ds, ss, es, any, sy, tr, asks, halt.why(why)}
811
812# every dependency, in the order the ledger wrote them
813def deps.walk(+rs: List<&2, Reply>, +only: String, +hub: String, ds: List<&2, M.Dep>) -> Deps:
814  match ds:
815    case []:
816      Deps{[], [], [], False{}, [], [], [], ""}
817    case +h <> t:
818      deps.join(dep.one(U.aim(U.chosen(only, M.dep.name(h)), M.source.dep(h)), rs,
819        only, hub, h), deps.walk(rs, only, hub, t))
820
821# ---------------------------------------------------------------------------
822# one tool
823
824# one tool after it was asked about, or the questions still open, or why the
825# upgrade refuses
826type Pinned is Data:
827  Pinned{tool: M.Tool, moved: Bool, says: List<&2, String>}
828  PinWait{asks: List<&2, Ask>}
829  PinHalt{why: String}
830
831# the pin with a commit and a NAR hash written in. An empty tag leaves the one
832# the ledger already had. The vendor bit is off: a tool is not committed under
833# `.ez/lib`.
834def set.go(
835  +name: String,
836  +entry: String,
837  +bin: String,
838  source: M.Source,
839  +rev: String,
840  +tag: String,
841  +nar: String
842) -> M.Tool:
843  match source:
844    case M.Hub{named}:
845      M.Tool{name, entry, bin, M.Hub{named}}
846    case M.Git{url, _rev, old, root, _nar, _vend}:
847      M.Tool{name, entry, bin, M.Git{url, rev,
848        Bool.pick(String, String.is_empty(tag), old, tag), M.root.or(root),
849        nar, False{}}}
850
851# a tool's pin with a commit, a tag and a NAR hash written in
852def set(tool: M.Tool, +rev: String, +tag: String, +nar: String) -> M.Tool:
853  M.Tool{n, e, b, s} = tool
854  set.go(n, e, b, s, rev, tag, nar)
855
856# a commit and its NAR hash written into the pin. A pin with either one empty
857# is short, and an upgrade that wrote one would fill it again on the next run,
858# so a weighing that names no commit or no NAR hash pins nothing.
859def tool.pin(short: Bool, +tool: M.Tool, +rev: String, +tag: String, +nar: String, says: List<&2, String>) -> Pinned:
860  match short:
861    case True{}:
862      PinHalt{"ez: tools." ++ M.tool.name(tool) ++ ": weighing " ++ rev ++ " gave no narHash"}
863    case False{}:
864      Pinned{set(tool, rev, tag, nar), True{}, says}
865
866# a commit weighed, then written into the pin
867def tool.weighed(wd: Wd, +tool: M.Tool, +rev: String, +tag: String, says: List<&2, String>) -> Pinned:
868  match wd:
869    case Wd{+nar}:
870      tool.pin(Bool.or(String.is_empty(rev), String.is_empty(nar)), tool, rev, tag, nar, says)
871    case WdWait{ask}:
872      PinWait{[ask]}
873    case WdNo{why}:
874      PinHalt{why}
875
876# the commit a ref named, weighed. The pin records the ref as its tag, even
877# one that names a commit, so the next upgrade asks about the same ref.
878def tool.resolved(tip: Tip, +rs: List<&2, Reply>, +tool: M.Tool, +ref: String) -> Pinned:
879  match tip:
880    case At{+rev, _tag, _branch}:
881      tool.weighed(weighed(rs, Weigh{M.source.url(M.tool.source(tool)), rev}), tool, rev, ref, [])
882    case TipWait{ask}:
883      PinWait{[ask]}
884    case TipNo{why}:
885      PinHalt{why}
886
887# the ref a pin with no commit is pinned from, once it is known
888type Named is Data:
889  Named{ref: String}
890  NamedWait{ask: Ask}
891  NamedNo{why: String}
892
893# a remote whose HEAD names no branch leaves nothing to pin
894def heads.branch(+name: String, +url: String) -> Named:
895  Bool.pick(Named, String.is_empty(name),
896    NamedNo{"ez: " ++ url ++
897      " has no semver-ish tag and its HEAD names no branch; name a ref to pin"},
898    Named{name})
899
900def heads.said(said: Said, +url: String) -> Named:
901  match said:
902    case Said{text}:
903      heads.branch(Git.tip.branch(Git.rows(String.lines(text))), url)
904    case Unsaid{why}:
905      NamedNo{why}
906    case Unasked{ask}:
907      NamedWait{ask}
908
909# the greatest semver-ish tag, else the default branch
910def tags.best(none: Bool, +best: String, +rs: List<&2, Reply>, +url: String) -> Named:
911  match none:
912    case False{}:
913      Named{best}
914    case True{}:
915      heads.said(printed(rs, Head{url}, "ls-remote " ++ url), url)
916
917def tags.said(said: Said, +rs: List<&2, Reply>, +url: String) -> Named:
918  match said:
919    case Said{text}:
920      +best = Git.choose(Git.tag.names(Git.rows(String.lines(text))))
921      tags.best(String.is_empty(best), best, rs, url)
922    case Unsaid{why}:
923      NamedNo{why}
924    case Unasked{ask}:
925      NamedWait{ask}
926
927# the ref `ez add` pins when none was asked for: the greatest semver-ish
928# release tag, else the greatest pre-release, else the default branch
929def tool.default(+rs: List<&2, Reply>, +url: String) -> Named:
930  tags.said(printed(rs, Tags{url}, "ls-remote " ++ url), rs, url)
931
932# the ref to resolve: the tag the ledger named, or the default ref
933def tool.ref(no_tag: Bool, +rs: List<&2, Reply>, +url: String, +tag: String) -> Named:
934  match no_tag:
935    case False{}:
936      Named{tag}
937    case True{}:
938      tool.default(rs, url)
939
940def tool.named(named: Named, +rs: List<&2, Reply>, +tool: M.Tool, +url: String) -> Pinned:
941  match named:
942    case Named{+ref}:
943      tool.resolved(follow(rs, url, ref), rs, tool, ref)
944    case NamedWait{ask}:
945      PinWait{[ask]}
946    case NamedNo{why}:
947      PinHalt{why}
948
949# the verdict on a full pin. A pin that moves keeps its own tag, as a
950# dependency does: the tip was named by that tag, or by the default branch
951# for a pin with none.
952def tool.verdict(verdict: U.Verdict, +rs: List<&2, Reply>, +tool: M.Tool, +tip: String, +branch: String) -> Pinned:
953  match verdict:
954    case U.Keep{}:
955      Pinned{tool, False{}, [M.tool.name(tool) ++ ": unchanged " ++
956        M.source.rev(M.tool.source(tool))]}
957    case U.Drift{}:
958      PinHalt{"ez: " ++ M.tool.name(tool) ++ ": narHash drifted at " ++
959        M.source.rev(M.tool.source(tool))}
960    case U.Off{}:
961      +src = M.tool.source(tool)
962      PinHalt{off.why(M.tool.name(tool), M.source.tag(src), M.source.rev(src),
963        tip.of(branch, tip))}
964    case U.Advance{+rev, _tag}:
965      +src = M.tool.source(tool)
966      tool.weighed(weighed(rs, Weigh{M.source.url(src), rev}), tool, rev, "",
967        [say.move(M.tool.name(tool), M.source.rev(src), rev, branch)])
968
969# the pinned commit weighed: its NAR hash written in when the pin has none,
970# else judged with whether it is the one the pin records
971def tool.kept.nar(fill: Bool, +rs: List<&2, Reply>, +tool: M.Tool, +tip: String, +tag: String, +nar: String) -> Pinned:
972  match fill:
973    case True{}:
974      tool.pin(Bool.or(String.is_empty(tip), String.is_empty(nar)), tool, tip, "", nar, [])
975    case False{}:
976      tool.verdict(U.judge(True{}, True{},
977        String.eq(M.source.nar(M.tool.source(tool)), nar), tip, tag), rs, tool, tip, "")
978
979# the pinned commit weighed again
980def tool.kept(wd: Wd, +rs: List<&2, Reply>, +tool: M.Tool, +tip: String, +tag: String) -> Pinned:
981  match wd:
982    case Wd{+nar}:
983      tool.kept.nar(String.is_empty(M.source.nar(M.tool.source(tool))), rs, tool, tip, tag, nar)
984    case WdWait{ask}:
985      PinWait{[ask]}
986    case WdNo{why}:
987      PinHalt{why}
988
989def tool.onward(asc: Asc, +rs: List<&2, Reply>, +tool: M.Tool, +tip: String, +tag: String, +branch: String) -> Pinned:
990  match asc:
991    case Asc{yes}:
992      tool.verdict(U.judge(False{}, yes, False{}, tip, tag), rs, tool, tip, branch)
993    case AscWait{ask}:
994      PinWait{[ask]}
995    case AscNo{why}:
996      PinHalt{why}
997
998def tool.same(same: Bool, +rs: List<&2, Reply>, +tool: M.Tool, +tip: String, +tag: String, +branch: String) -> Pinned:
999  match same:
1000    case True{}:
1001      +src = M.tool.source(tool)
1002      tool.kept(weighed(rs, Weigh{M.source.url(src), M.source.rev(src)}), rs, tool, tip, tag)
1003    case False{}:
1004      +src = M.tool.source(tool)
1005      tool.onward(onward(rs, Above{M.source.url(src), M.source.rev(src), tip}), rs, tool, tip, tag, branch)
1006
1007def tool.tip(tip: Tip, +rs: List<&2, Reply>, +tool: M.Tool) -> Pinned:
1008  match tip:
1009    case At{+rev, tag, branch}:
1010      tool.same(String.eq(M.source.rev(M.tool.source(tool)), rev), rs, tool, rev,
1011        tag, branch)
1012    case TipWait{ask}:
1013      PinWait{[ask]}
1014    case TipNo{why}:
1015      PinHalt{why}
1016
1017# a full pin, re-asked: its tag re-resolved, or the default branch followed
1018def tool.aim(aim: U.Aim, +rs: List<&2, Reply>, +tool: M.Tool) -> Pinned:
1019  match aim:
1020    case U.Hold{}:
1021      Pinned{tool, False{}, []}
1022    case U.Follow{ref}:
1023      tool.tip(follow(rs, M.source.url(M.tool.source(tool)), ref), rs, tool)
1024    case U.Forward{}:
1025      tool.tip(forward(rs, M.source.url(M.tool.source(tool))), rs, tool)
1026
1027# a pin with no commit is resolved from its tag, or the default ref, then
1028# weighed. One with a commit and no NAR hash is upgraded as a full pin is,
1029# and its NAR hash is written in wherever it lands: filled at its own commit
1030# only, it would move on the next upgrade, which follows its tag or the
1031# default branch.
1032def tool.fill(no_rev: Bool, +rs: List<&2, Reply>, +tool: M.Tool) -> Pinned:
1033  match no_rev:
1034    case True{}:
1035      +src = M.tool.source(tool)
1036      +url = M.source.url(src)
1037      tool.named(tool.ref(String.is_empty(M.source.tag(src)), rs, url,
1038        M.source.tag(src)), rs, tool, url)
1039    case False{}:
1040      tool.aim(U.aim(True{}, M.tool.source(tool)), rs, tool)
1041
1042# a selected pin with no commit or no NAR hash is filled; a full one is asked
1043# whether it moved
1044def tool.short(short: Bool, +rs: List<&2, Reply>, +tool: M.Tool) -> Pinned:
1045  match short:
1046    case True{}:
1047      tool.fill(String.is_empty(M.source.rev(M.tool.source(tool))), rs, tool)
1048    case False{}:
1049      tool.aim(U.aim(True{}, M.tool.source(tool)), rs, tool)
1050
1051# a selected tool names a repo, or it cannot be pinned
1052def tool.git(git: Bool, +rs: List<&2, Reply>, +tool: M.Tool) -> Pinned:
1053  match git:
1054    case False{}:
1055      PinHalt{"ez: tools." ++ M.tool.name(tool) ++ " has no git"}
1056    case True{}:
1057      +src = M.tool.source(tool)
1058      tool.short(Bool.or(String.is_empty(M.source.rev(src)),
1059        String.is_empty(M.source.nar(src))), rs, tool)
1060
1061# a selected tool is asked; the others are left
1062def tool.sel(sel: Bool, +rs: List<&2, Reply>, +tool: M.Tool) -> Pinned:
1063  match sel:
1064    case False{}:
1065      Pinned{tool, False{}, []}
1066    case True{}:
1067      tool.git(M.source.is_git(M.tool.source(tool)), rs, tool)
1068
1069# every tool after the upgrade, as `Deps` is for dependencies
1070type Tools is Data:
1071  Tools{tools: List<&2, M.Tool>, moved: Bool, says: List<&2, String>,
1072    asks: List<&2, Ask>, bad: String}
1073
1074def tools.join(pinned: Pinned, rest: Tools) -> Tools:
1075  match pinned:
1076    case Pinned{t, m, says}:
1077      Tools{ts, any, sy, asks, bad} = rest
1078      Tools{t <> ts, Bool.or(m, any), List.append(&2, String, says, sy), asks, bad}
1079    case PinWait{more}:
1080      Tools{ts, any, sy, asks, bad} = rest
1081      Tools{ts, any, sy, List.append(&2, Ask, more, asks), bad}
1082    case PinHalt{why}:
1083      Tools{ts, any, sy, asks, _bad} = rest
1084      Tools{ts, any, sy, asks, halt.why(why)}
1085
1086# every tool, in the order the ledger wrote them
1087def tools.walk(+rs: List<&2, Reply>, +only: String, ts: List<&2, M.Tool>) -> Tools:
1088  match ts:
1089    case []:
1090      Tools{[], False{}, [], [], ""}
1091    case +h <> t:
1092      tools.join(tool.sel(U.chosen(only, M.tool.name(h)), rs, h),
1093        tools.walk(rs, only, t))
1094
1095# ---------------------------------------------------------------------------
1096# what the upgrade writes around the lock
1097
1098# ez.toml, only when a pin moved, so an upgrade that changed nothing does not
1099# reformat the file
1100def edit.toml(moved: Bool, +text: String, rest: List<&2, Edit>) -> List<&2, Edit>:
1101  match moved:
1102    case True{}:
1103      Edit{"ez.toml", text} <> rest
1104    case False{}:
1105      rest
1106
1107# a file, only when its text changes
1108def edit.if(same: Bool, +at: String, +text: String, rest: List<&2, Edit>) -> List<&2, Edit>:
1109  match same:
1110    case True{}:
1111      rest
1112    case False{}:
1113      Edit{at, text} <> rest
1114
1115# every source with each moved hash rewritten in its imports, written only
1116# when that changes it
1117def edit.sources(fs: List<&2, Found>, +es: List<&2, U.Swap>) -> List<&2, Edit>:
1118  match fs:
1119    case []:
1120      []
1121    case Found{+at, +text} <> t:
1122      +next = U.reimport.many(es, text)
1123      edit.if(String.eq(next, text), at, next, edit.sources(t, es))
1124
1125# every committed tree that moved, removed
1126def drops.of(ss: List<&2, U.Swap>) -> List<&2, String>:
1127  match ss:
1128    case []:
1129      []
1130    case U.Swap{old, _new} <> t:
1131      old <> drops.of(t)
1132
1133# the ledger the lock is made from: the one the upgrade writes, read back, or
1134# ez.toml as it was when nothing moved
1135def read.next(moved: Bool, +text: String, old: M.Manifest) -> M.Read:
1136  match moved:
1137    case True{}:
1138      M.parse(text)
1139    case False{}:
1140      M.Good{old}
1141
1142# the ledger model the upgrade renders, or ez.toml's when nothing moved
1143def model.next(moved: Bool, next: M.Manifest, old: M.Manifest) -> M.Read:
1144  match moved:
1145    case True{}:
1146      M.Good{next}
1147    case False{}:
1148      M.Good{old}
1149
1150# why an upgrade that moved a pin stops, "" when it does not: the ledger it
1151# would write cannot be written so that it reads back as the model it renders
1152# (EZ-LED-4), so it is refused, with no effect, rather than written as
1153# something else
1154def fit.why(moved: Bool, next: M.Manifest) -> String:
1155  match moved:
1156    case True{}:
1157      Bool.pick(String, Rend.renderable(next), "", Rend.unrenderable())
1158    case False{}:
1159      ""
1160
1161# every answer in: the ledger, the allowlist and the imports, and the trees
1162def done(
1163  +old: M.Manifest,
1164  dd: Deps,
1165  tt: Tools,
1166  +ignore: String,
1167  fs: List<&2, Found>
1168) -> Next:
1169  M.Manifest{+name, +entry, +bin, +hub, +pubas, +pubver, _ds, _ts} = old
1170  Deps{+ds, swaps, +edits, dmoved, dsays, trees, _dasks, _dbad} = dd
1171  Tools{+ts, tmoved, tsays, _tasks, _tbad} = tt
1172  +moved = Bool.or(dmoved, tmoved)
1173  +text = Rend.show(M.Manifest{name, entry, bin, hub, pubas, pubver, ds, ts})
1174  +allow = I.sync(ds, ignore)
1175  Next{model.next(moved, M.Manifest{name, entry, bin, hub, pubas, pubver, ds, ts}, old),
1176    read.next(moved, text, old), fit.why(moved, M.Manifest{name, entry, bin, hub, pubas, pubver, ds, ts}), [],
1177    trees, I.vended(ds),
1178    List.append(&2, String, dsays, tsays),
1179    edit.toml(moved, text, edit.if(String.eq(allow, ignore), ".gitignore", allow, [])),
1180    fs, drops.of(swaps), edits}
1181
1182# the question still open, when one is
1183def open.srcs(srcs: Srcs) -> List<&2, Ask>:
1184  match srcs:
1185    case SrcsWait{ask}:
1186      [ask]
1187    case _:
1188      []
1189
1190# the sources, once the ignore file is known
1191def got.srcs(srcs: Srcs, +old: M.Manifest, dd: Deps, tt: Tools, +ignore: String) -> Next:
1192  match srcs:
1193    case Srcs{fs}:
1194      done(old, dd, tt, ignore, fs)
1195    case SrcsWait{ask}:
1196      stopped(M.Good{old}, "ez: the upgrade was not given the project's sources", [ask])
1197    case SrcsNo{why}:
1198      stopped(M.Good{old}, why, [])
1199
1200# the ignore file, then the sources
1201def got.ignore(said: Said, +srcs: Srcs, +old: M.Manifest, dd: Deps, tt: Tools) -> Next:
1202  match said:
1203    case Said{ignore}:
1204      got.srcs(srcs, old, dd, tt, ignore)
1205    case Unasked{ask}:
1206      stopped(M.Good{old}, "ez: the upgrade was not given .gitignore",
1207        ask <> open.srcs(srcs))
1208    case Unsaid{why}:
1209      stopped(M.Good{old}, why, [])
1210
1211# the sources are read only when an import has a hash to stop naming
1212def ask.srcs(none: Bool, +rs: List<&2, Reply>) -> Srcs:
1213  match none:
1214    case True{}:
1215      Srcs{[]}
1216    case False{}:
1217      sources(rs, Sources{})
1218
1219def deps.edits(dd: Deps) -> List<&2, U.Swap>:
1220  Deps{_ds, _ss, es, _m, _sy, _tr, _asks, _bad} = dd
1221  es
1222
1223# every pin answered: what is written around the lock
1224def settled(+rs: List<&2, Reply>, +old: M.Manifest, +dd: Deps, tt: Tools) -> Next:
1225  got.ignore(text.said(rs, Ignore{}),
1226    ask.srcs(List.is_empty(&2, U.Swap, deps.edits(dd)), rs), old, dd, tt)
1227
1228# the pins' questions still open: the lock waits for them
1229def pins.asks(asks: List<&2, Ask>, +rs: List<&2, Reply>, +old: M.Manifest, dd: Deps, tt: Tools) -> Next:
1230  match asks:
1231    case []:
1232      settled(rs, old, dd, tt)
1233    case h <> t:
1234      stopped(M.Good{old}, "ez: the upgrade still has questions for the remote", h <> t)
1235
1236# a refusal wins over every question still open
1237def pins.bad(
1238  clean: Bool,
1239  +bad: String,
1240  asks: List<&2, Ask>,
1241  +rs: List<&2, Reply>,
1242  +old: M.Manifest,
1243  dd: Deps,
1244  tt: Tools
1245) -> Next:
1246  match clean:
1247    case True{}:
1248      pins.asks(asks, rs, old, dd, tt)
1249    case False{}:
1250      stopped(M.Good{old}, bad, [])
1251
1252# the dependencies' and the tools' answers together
1253def pins(dd: Deps, tt: Tools, +rs: List<&2, Reply>, +old: M.Manifest) -> Next:
1254  Deps{ds, ss, es, dm, dsy, dtr, dasks, +dbad} = dd
1255  Tools{ts, tm, tsy, tasks, +tbad} = tt
1256  pins.bad(Bool.and(String.is_empty(dbad), String.is_empty(tbad)),
1257    Bool.pick(String, String.is_empty(dbad), tbad, dbad),
1258    List.append(&2, Ask, dasks, tasks), rs, old,
1259    Deps{ds, ss, es, dm, dsy, dtr, [], ""}, Tools{ts, tm, tsy, [], ""})
1260
1261# `--package` has to name a dependency or a pinned CLI
1262def known(ok: Bool, +only: String, +rs: List<&2, Reply>, +old: M.Manifest) -> Next:
1263  match ok:
1264    case False{}:
1265      stopped(M.Good{old}, "ez: no dependency or tool named " ++ only, [])
1266    case True{}:
1267      M.Manifest{_name, _entry, _bin, +hub, +pubas, +pubver, +ds, +ts} = old
1268      pins(deps.walk(rs, only, M.hub.or(hub), ds), tools.walk(rs, only, ts), rs, old)
1269
1270# `ez lock --upgrade [--package only]` over the ledger and the answers so far.
1271# A ledger that does not parse is the plain lock's refusal.
1272def next(+only: String, read: M.Read, +rs: List<&2, Reply>) -> Next:
1273  match read:
1274    case M.Bad{why}:
1275      plain(M.Bad{why})
1276    case M.Good{+m}:
1277      M.Manifest{_name, _entry, _bin, _hub, _pubas, _pubver, +ds, +ts} = m
1278      known(Bool.or(U.known(only, ds), M.tool.known(only, ts)), only, rs, m)