1 # ledger/ignore: the gitignore allowlist, derived from the ledger. A 2 # dependency marked `vendor = true` has its tree committed under 3 # `.ez/lib/`, and git only keeps it when `.gitignore` says so with a 4 # `!.ez/lib/` line. That line has to follow the ledger exactly: a hash 5 # the ledger stopped naming leaves a dead tree committed, and one it names 6 # but the file does not is a tree a clone never gets. So the allowlist is not 7 # edited, it is derived: `sync` takes the ledger's dependencies and the file, 8 # and gives back the file whose allowlist lines are exactly the vendored 9 # hashes, with every line that is not ez's left as it was. `ez add`, 10 # `ez remove` and `ez lock --upgrade` all write what it returns. 11 # 12 # Which lines are ez's. A line that starts `!.ez/lib/` is ez's allowlist, as 13 # written, and whatever follows the prefix is the hash it names. Anything 14 # else is read loosely: surrounding blanks trimmed, one leading `/` and one 15 # trailing `/` dropped, so `!/.ez/lib//` and ` !.ez/lib/` are 16 # allowlist lines too. ez owns everything under `.ez/lib`, so any exception 17 # under it is ez's to keep or drop; a line spelled differently from `allow` 18 # is dropped and the hash, when the ledger still vendors it, written back in 19 # the canonical spelling. Three more lines are what makes the allowlist work 20 # at all: `.ez/*`, `!.ez/lib` and `.ez/lib/*`. Git cannot re-include a file 21 # under a directory it excluded, so a bare `.ez/` (or `.ez`, `/.ez/`) makes 22 # every allowlist line inert. That line is replaced, where it stands, by 23 # whichever of the three the file does not already have. 24 # 25 # Where the allowlist goes. The vendored hashes are written as one block, each 26 # once, in the order the ledger first names them, where the file's first 27 # allowlist line was. A file with 28 # none gets the block at its end, which is where the upgrade has always 29 # appended one, preceded by whichever of the three rules the file lacks. 30 import Base 31 import ./manifest.bend as M 32 33 # what one line of an ignore file is to ez 34 type Rule is Data: 35 Other{} 36 Dir{} 37 Star{} 38 Lib{} 39 Pkgs{} 40 Allow{hash: String} 41 42 # a line, with what it is to ez beside it 43 type Line is Data: 44 Line{kind: Rule, text: String} 45 46 # the gitignore line that keeps one committed tree 47 def allow(+hash: String) -> String: 48 "!.ez/lib/" ++ hash 49 50 # one leading `/`, which anchors a pattern to the file's own directory and 51 # so changes nothing for a `.gitignore` at the root 52 def lead(+pat: String) -> String: 53 Bool.pick(String, String.starts_with(pat, "/"), String.drop(pat, 1n), pat) 54 55 # one trailing `/`, which only narrows a pattern to directories, and every 56 # path ez names here is one 57 def trail(+pat: String) -> String: 58 Bool.pick(String, String.ends_with(pat, "/"), 59 String.take(pat, Nat.sub(String.length(pat), 1n)), pat) 60 61 # a pattern with both of those dropped 62 def bare(+pat: String) -> String: 63 trail(lead(pat)) 64 65 # an exception under `.ez/lib/` names the hash after the prefix 66 def loose.under(under: Bool, +rest: String) -> Rule: 67 match under: 68 case True{}: 69 Bool.pick(Rule, String.is_empty(rest), Other{}, Allow{rest}) 70 case False{}: 71 Other{} 72 73 # an exception: the re-included library directory, or one tree under it 74 def loose.neg(+pat: String) -> Rule: 75 Bool.pick(Rule, String.eq(pat, ".ez/lib"), Lib{}, 76 loose.under(String.starts_with(pat, ".ez/lib/"), String.drop(pat, 8n))) 77 78 # an exclusion: the whole of `.ez`, what is inside it, or what is inside 79 # its library directory 80 def loose.pos(+pat: String) -> Rule: 81 Bool.pick(Rule, String.eq(pat, ".ez"), Dir{}, 82 Bool.pick(Rule, String.eq(pat, ".ez/*"), Star{}, 83 Bool.pick(Rule, String.eq(pat, ".ez/lib/*"), Pkgs{}, Other{}))) 84 85 # a trimmed line, read by whether it is an exception 86 def loose.go(neg: Bool, +pat: String) -> Rule: 87 match neg: 88 case True{}: 89 loose.neg(bare(String.drop(pat, 1n))) 90 case False{}: 91 loose.pos(bare(pat)) 92 93 # a line that is not written the way `allow` writes one 94 def loose(+line: String) -> Rule: 95 +pat = String.trim(line) 96 loose.go(String.starts_with(pat, "!"), pat) 97 98 # `!.ez/lib/` with nothing after it is the directory, with a trailing slash 99 def canon(empty: Bool, +rest: String) -> Rule: 100 match empty: 101 case True{}: 102 Lib{} 103 case False{}: 104 Allow{rest} 105 106 # a line written the way `allow` writes one names the hash after the prefix 107 def kind.go(exact: Bool, +line: String) -> Rule: 108 match exact: 109 case True{}: 110 canon(String.is_empty(String.drop(line, 9n)), String.drop(line, 9n)) 111 case False{}: 112 loose(line) 113 114 # what a line of an ignore file is to ez 115 def kind(+line: String) -> Rule: 116 kind.go(String.starts_with(line, "!.ez/lib/"), line) 117 118 # every line, with what it is 119 def tag(ls: List<&2, String>) -> List<&2, Line>: 120 match ls: 121 case []: 122 [] 123 case +h <> t: 124 Line{kind(h), h} <> tag(t) 125 126 # the hash an allowlist line names, in front of the rest 127 def hashes.put(what: Rule, rest: List<&2, String>) -> List<&2, String>: 128 match what: 129 case Allow{h}: 130 h <> rest 131 case Other{}: 132 rest 133 case Dir{}: 134 rest 135 case Star{}: 136 rest 137 case Lib{}: 138 rest 139 case Pkgs{}: 140 rest 141 142 # every hash the allowlist lines of a file name, in order. This is what the 143 # allowlist says, and what the laws hold against the ledger. 144 def hashes(ls: List<&2, String>) -> List<&2, String>: 145 match ls: 146 case []: 147 [] 148 case +h <> t: 149 hashes.put(kind(h), hashes(t)) 150 151 # a line that is not ez's, in front of the rest 152 def others.put(what: Rule, +line: String, rest: List<&2, String>) -> List<&2, String>: 153 match what: 154 case Other{}: 155 line <> rest 156 case Allow{_h}: 157 rest 158 case Dir{}: 159 rest 160 case Star{}: 161 rest 162 case Lib{}: 163 rest 164 case Pkgs{}: 165 rest 166 167 # every line that is not ez's, in order: the lines `sync` must not touch 168 def others(ls: List<&2, String>) -> List<&2, String>: 169 match ls: 170 case []: 171 [] 172 case +h <> t: 173 others.put(kind(h), h, others(t)) 174 175 # a dependency's hash, in front of the rest, when its tree is committed 176 def vendors.put(vend: Bool, +hash: String, rest: List<&2, String>) -> List<&2, String>: 177 match vend: 178 case True{}: 179 hash <> rest 180 case False{}: 181 rest 182 183 # the hash of every dependency marked `vendor = true`, in the order the ledger 184 # wrote them. Two dependencies may name one hash. 185 def vendors(ds: List<&2, M.Dep>) -> List<&2, String>: 186 match ds: 187 case []: 188 [] 189 case +h <> t: 190 vendors.put(M.source.vendor(M.source.dep(h)), M.dep.hash(h), vendors(t)) 191 192 # whether a list of hashes already names a hash 193 def named(hs: List<&2, String>, +hash: String) -> Bool: 194 match hs: 195 case []: 196 False{} 197 case h <> t: 198 +rest = named(t, hash) 199 Bool.or(String.eq(h, hash), rest) 200 201 # a hash in front of the rest, unless it is passed over 202 def once.put(skip: Bool, +hash: String, rest: List<&2, String>) -> List<&2, String>: 203 match skip: 204 case True{}: 205 rest 206 case False{}: 207 hash <> rest 208 209 # the hashes seen so far, with this one when it was kept 210 def once.seen(skip: Bool, +hash: String, +seen: List<&2, String>) -> List<&2, String>: 211 match skip: 212 case True{}: 213 seen 214 case False{}: 215 hash <> seen 216 217 # each hash once, where it first appears, passing over any `seen` already 218 # holds. An empty hash names no tree; `M.parse` refuses a dependency with 219 # none, so that only guards a model built by hand. 220 def once.go(hs: List<&2, String>, +seen: List<&2, String>) -> List<&2, String>: 221 match hs: 222 case []: 223 [] 224 case +h <> t: 225 +skip = Bool.or(String.is_empty(h), named(seen, h)) 226 once.put(skip, h, once.go(t, once.seen(skip, h, seen))) 227 228 # the hashes the allowlist must name: every dependency marked `vendor = true`, 229 # each hash once, in the order the ledger first names it. Two dependencies 230 # that share a tree share its one line. 231 def vended(ds: List<&2, M.Dep>) -> List<&2, String>: 232 once.go(vendors(ds), []) 233 234 # one allowlist line per hash 235 def allow.each(hs: List<&2, String>) -> List<&2, String>: 236 match hs: 237 case []: 238 [] 239 case +h <> t: 240 allow(h) <> allow.each(t) 241 242 # the allowlist lines of a ledger, in its order 243 def allows(ds: List<&2, M.Dep>) -> List<&2, String>: 244 allow.each(vended(ds)) 245 246 # whether two kinds are the same rule. Two allowlist lines are never asked. 247 def same(left: Rule, right: Rule) -> Bool: 248 match left right: 249 case Dir{} Dir{}: 250 True{} 251 case Star{} Star{}: 252 True{} 253 case Lib{} Lib{}: 254 True{} 255 case Pkgs{} Pkgs{}: 256 True{} 257 case _ _: 258 False{} 259 260 # the rest of the scan only when this line was not it 261 def has.step(hit: Bool, rest: Unit -> Bool) -> Bool: 262 match hit: 263 case True{}: 264 True{} 265 case False{}: 266 rest(Unit{}) 267 268 # whether a file already has a line of this kind 269 def has(ls: List<&2, Line>, +want: Rule) -> Bool: 270 match ls: 271 case []: 272 False{} 273 case Line{+what, _text} <> t: 274 has.step(same(what, want), _u => has(t, want)) 275 276 # a rule, in front of the rest, when the file does not have it 277 def rule.put(there: Bool, +line: String, rest: List<&2, String>) -> List<&2, String>: 278 match there: 279 case True{}: 280 rest 281 case False{}: 282 line <> rest 283 284 # the three rules the allowlist needs, less the ones already there 285 def rules.go(star: Bool, lib: Bool, pkgs: Bool) -> List<&2, String>: 286 rule.put(star, ".ez/*", rule.put(lib, "!.ez/lib", rule.put(pkgs, ".ez/lib/*", []))) 287 288 # the rules a file lacks 289 def rules(+ls: List<&2, Line>) -> List<&2, String>: 290 rules.go(has(ls, Star{}), has(ls, Lib{}), has(ls, Pkgs{})) 291 292 # the rules go in front of the block only when nothing else puts them in: a 293 # `.ez/` line is replaced by them where it stands, and a ledger that vendors 294 # nothing needs none 295 def need(skip: Bool, +rs: List<&2, String>) -> List<&2, String>: 296 match skip: 297 case True{}: 298 [] 299 case False{}: 300 rs 301 302 # the allowlist block: the missing rules, then one line per vendored hash 303 def block(+ds: List<&2, M.Dep>, +ls: List<&2, Line>) -> List<&2, String>: 304 List.append(&2, String, 305 need(Bool.or(has(ls, Dir{}), List.is_empty(&2, String, vended(ds))), rules(ls)), 306 allows(ds)) 307 308 # the block, where it goes: at the first allowlist line, or at the end 309 def placed(done: Bool, +blk: List<&2, String>) -> List<&2, String>: 310 Bool.pick(List<&2, String>, done, [], blk) 311 312 # every line, walked once. `fix` replaces a `.ez/` line. `blk` replaces the 313 # first allowlist line, every later one is dropped, and `done` says the 314 # block is already down; a file with no allowlist line gets it at the end. 315 # Every other line is kept as it was. 316 def walk(ls: List<&2, Line>, +fix: List<&2, String>, +blk: List<&2, String>, done: Bool) -> List<&2, String>: 317 match ls: 318 case []: 319 placed(done, blk) 320 case Line{k, +line} <> t: 321 match k: 322 case Other{}: 323 line <> walk(t, fix, blk, done) 324 case Dir{}: 325 List.append(&2, String, fix, walk(t, fix, blk, done)) 326 case Star{}: 327 line <> walk(t, fix, blk, done) 328 case Lib{}: 329 line <> walk(t, fix, blk, done) 330 case Pkgs{}: 331 line <> walk(t, fix, blk, done) 332 case Allow{_h}: 333 List.append(&2, String, placed(done, blk), walk(t, fix, blk, True{})) 334 335 # the walk over a file's lines, each already read for what it is 336 def lines.go(+ds: List<&2, M.Dep>, +ls: List<&2, Line>) -> List<&2, String>: 337 walk(ls, rules(ls), block(ds, ls), False{}) 338 339 # the lines of an ignore file with the allowlist the ledger's dependencies 340 # call for: exactly the vendored hashes, and nothing else changed 341 def lines(ds: List<&2, M.Dep>, ls: List<&2, String>) -> List<&2, String>: 342 lines.go(ds, tag(ls)) 343 344 # The text layer. A file is its lines, each ending with a newline, and `sync` 345 # reads the file into lines, derives the allowlist over them, and writes them 346 # back. A file of one blank line is one empty line and not an empty file, so a 347 # blank line the project wrote is kept like any other line. 348 349 # the last piece of a file cut at its newlines, dropped when it is empty: it 350 # is what follows the newline the file ends with, and not a line a person 351 # wrote 352 def unend.go(rest: List<&2, String>, +last: String) -> List<&2, String>: 353 match rest: 354 case []: 355 Bool.pick(List<&2, String>, String.is_empty(last), [], [last]) 356 case +h2 <> t2: 357 last <> unend.go(t2, h2) 358 359 # the lines of a file cut at its newlines, less that last empty piece 360 def unend(ps: List<&2, String>) -> List<&2, String>: 361 match ps: 362 case []: 363 [] 364 case +h <> t: 365 unend.go(t, h) 366 367 # the lines of a file. An empty file has none, a file of one newline has one 368 # empty line, and a last line with no newline after it is a line all the same. 369 def file.lines(text: String) -> List<&2, String>: 370 unend(String.lines(text)) 371 372 # lines written out as a file, each ending with a newline, which `file.lines` 373 # reads back as those lines 374 def file.text(ls: List<&2, String>) -> String: 375 match ls: 376 case []: 377 "" 378 case h <> t: 379 String.append(h, SCon{'\n', file.text(t)}) 380 381 # a file whose lines did not change comes back byte for byte, whatever it 382 # ended with. One whose lines did is its new lines, each ending with a newline. 383 def text.out(same: Bool, +text: String, +out: String) -> String: 384 match same: 385 case True{}: 386 text 387 case False{}: 388 out 389 390 # the ignore file with the allowlist the ledger calls for. `ez add`, 391 # `ez remove` and `ez lock --upgrade` write this when it differs from what 392 # they read. 393 def sync(ds: List<&2, M.Dep>, +text: String) -> String: 394 +was = file.lines(text) 395 +out = file.text(lines(ds, was)) 396 text.out(String.eq(out, file.text(was)), text, out)