]> git.scottworley.com Git - pinch/blame - pinch.py
Spell "log" in "git log" correctly
[pinch] / pinch.py
CommitLineData
0e5e611d 1import argparse
f15e458d 2import configparser
2f96f32a
SW
3import filecmp
4import functools
736c25eb 5import getpass
2f96f32a
SW
6import hashlib
7import operator
8import os
9import os.path
9a78329e 10import shlex
2f96f32a 11import shutil
73bec7e8 12import subprocess
9d7844bb 13import sys
2f96f32a 14import tempfile
89e79125 15import types
2f96f32a
SW
16import urllib.parse
17import urllib.request
18import xml.dom.minidom
19
20from typing import (
2f96f32a
SW
21 Dict,
22 Iterable,
23 List,
73bec7e8 24 NewType,
2f96f32a
SW
25 Tuple,
26)
27
3603dde2
SW
28# Use xdg module when it's less painful to have as a dependency
29
30
31class XDG(types.SimpleNamespace):
32 XDG_CACHE_HOME: str
33
34
35xdg = XDG(
36 XDG_CACHE_HOME=os.getenv(
37 'XDG_CACHE_HOME',
38 os.path.expanduser('~/.cache')))
26125a28
SW
39
40
73bec7e8
SW
41Digest16 = NewType('Digest16', str)
42Digest32 = NewType('Digest32', str)
43
2f96f32a 44
72d3478a 45class ChannelTableEntry(types.SimpleNamespace):
e434d96d 46 absolute_url: str
73bec7e8 47 digest: Digest16
89e79125
SW
48 file: str
49 size: int
50 url: str
51
52
72d3478a 53class Channel(types.SimpleNamespace):
17906b27 54 alias_of: str
89e79125 55 channel_html: bytes
b17def3f 56 channel_url: str
89e79125 57 forwarded_url: str
dc038df0
SW
58 git_ref: str
59 git_repo: str
89e79125 60 git_revision: str
8fca6c28 61 old_git_revision: str
3e6421c4 62 release_name: str
72d3478a 63 table: Dict[str, ChannelTableEntry]
89e79125
SW
64
65
2f96f32a
SW
66class VerificationError(Exception):
67 pass
68
69
70class Verification:
71
72 def __init__(self) -> None:
73 self.line_length = 0
74
75 def status(self, s: str) -> None:
9d7844bb 76 print(s, end=' ', file=sys.stderr, flush=True)
2f96f32a
SW
77 self.line_length += 1 + len(s) # Unicode??
78
79 @staticmethod
80 def _color(s: str, c: int) -> str:
81 return '\033[%2dm%s\033[00m' % (c, s)
82
83 def result(self, r: bool) -> None:
84 message, color = {True: ('OK ', 92), False: ('FAIL', 91)}[r]
85 length = len(message)
86 cols = shutil.get_terminal_size().columns
87 pad = (cols - (self.line_length + length)) % cols
9d7844bb 88 print(' ' * pad + self._color(message, color), file=sys.stderr)
2f96f32a
SW
89 self.line_length = 0
90 if not r:
91 raise VerificationError()
92
93 def check(self, s: str, r: bool) -> None:
94 self.status(s)
95 self.result(r)
96
97 def ok(self) -> None:
98 self.result(True)
99
100
dc038df0 101def compare(a: str, b: str) -> Tuple[List[str], List[str], List[str]]:
2f96f32a
SW
102
103 def throw(error: OSError) -> None:
104 raise error
105
106 def join(x: str, y: str) -> str:
107 return y if x == '.' else os.path.join(x, y)
108
109 def recursive_files(d: str) -> Iterable[str]:
110 all_files: List[str] = []
111 for path, dirs, files in os.walk(d, onerror=throw):
112 rel = os.path.relpath(path, start=d)
113 all_files.extend(join(rel, f) for f in files)
114 for dir_or_link in dirs:
115 if os.path.islink(join(path, dir_or_link)):
116 all_files.append(join(rel, dir_or_link))
117 return all_files
118
119 def exclude_dot_git(files: Iterable[str]) -> Iterable[str]:
120 return (f for f in files if not f.startswith('.git/'))
121
122 files = functools.reduce(
123 operator.or_, (set(
124 exclude_dot_git(
125 recursive_files(x))) for x in [a, b]))
126 return filecmp.cmpfiles(a, b, files, shallow=False)
127
128
ca2c3edd 129def fetch(v: Verification, channel: Channel) -> None:
2f96f32a 130 v.status('Fetching channel')
b17def3f 131 request = urllib.request.urlopen(channel.channel_url, timeout=10)
72d3478a
SW
132 channel.channel_html = request.read()
133 channel.forwarded_url = request.geturl()
2f96f32a 134 v.result(request.status == 200)
b17def3f 135 v.check('Got forwarded', channel.channel_url != channel.forwarded_url)
2f96f32a
SW
136
137
72d3478a 138def parse_channel(v: Verification, channel: Channel) -> None:
2f96f32a 139 v.status('Parsing channel description as XML')
72d3478a 140 d = xml.dom.minidom.parseString(channel.channel_html)
2f96f32a
SW
141 v.ok()
142
3e6421c4
SW
143 v.status('Extracting release name:')
144 title_name = d.getElementsByTagName(
145 'title')[0].firstChild.nodeValue.split()[2]
146 h1_name = d.getElementsByTagName('h1')[0].firstChild.nodeValue.split()[2]
147 v.status(title_name)
148 v.result(title_name == h1_name)
72d3478a 149 channel.release_name = title_name
3e6421c4
SW
150
151 v.status('Extracting git commit:')
2f96f32a 152 git_commit_node = d.getElementsByTagName('tt')[0]
61aaf799
SW
153 channel.git_revision = git_commit_node.firstChild.nodeValue
154 v.status(channel.git_revision)
2f96f32a
SW
155 v.ok()
156 v.status('Verifying git commit label')
157 v.result(git_commit_node.previousSibling.nodeValue == 'Git commit ')
158
159 v.status('Parsing table')
72d3478a 160 channel.table = {}
2f96f32a
SW
161 for row in d.getElementsByTagName('tr')[1:]:
162 name = row.childNodes[0].firstChild.firstChild.nodeValue
163 url = row.childNodes[0].firstChild.getAttribute('href')
164 size = int(row.childNodes[1].firstChild.nodeValue)
73bec7e8 165 digest = Digest16(row.childNodes[2].firstChild.firstChild.nodeValue)
dc038df0
SW
166 channel.table[name] = ChannelTableEntry(
167 url=url, digest=digest, size=size)
2f96f32a
SW
168 v.ok()
169
170
dc038df0
SW
171def digest_string(s: bytes) -> Digest16:
172 return Digest16(hashlib.sha256(s).hexdigest())
173
174
73bec7e8
SW
175def digest_file(filename: str) -> Digest16:
176 hasher = hashlib.sha256()
177 with open(filename, 'rb') as f:
178 # pylint: disable=cell-var-from-loop
179 for block in iter(lambda: f.read(4096), b''):
180 hasher.update(block)
181 return Digest16(hasher.hexdigest())
182
183
184def to_Digest16(v: Verification, digest32: Digest32) -> Digest16:
185 v.status('Converting digest to base16')
186 process = subprocess.run(
187 ['nix', 'to-base16', '--type', 'sha256', digest32], capture_output=True)
188 v.result(process.returncode == 0)
189 return Digest16(process.stdout.decode().strip())
190
191
192def to_Digest32(v: Verification, digest16: Digest16) -> Digest32:
193 v.status('Converting digest to base32')
194 process = subprocess.run(
195 ['nix', 'to-base32', '--type', 'sha256', digest16], capture_output=True)
196 v.result(process.returncode == 0)
197 return Digest32(process.stdout.decode().strip())
198
199
200def fetch_with_nix_prefetch_url(
201 v: Verification,
202 url: str,
203 digest: Digest16) -> str:
204 v.status('Fetching %s' % url)
205 process = subprocess.run(
206 ['nix-prefetch-url', '--print-path', url, digest], capture_output=True)
207 v.result(process.returncode == 0)
208 prefetch_digest, path, empty = process.stdout.decode().split('\n')
209 assert empty == ''
210 v.check("Verifying nix-prefetch-url's digest",
211 to_Digest16(v, Digest32(prefetch_digest)) == digest)
212 v.status("Verifying file digest")
213 file_digest = digest_file(path)
214 v.result(file_digest == digest)
215 return path
2f96f32a 216
73bec7e8 217
72d3478a 218def fetch_resources(v: Verification, channel: Channel) -> None:
2f96f32a 219 for resource in ['git-revision', 'nixexprs.tar.xz']:
72d3478a 220 fields = channel.table[resource]
e434d96d
SW
221 fields.absolute_url = urllib.parse.urljoin(
222 channel.forwarded_url, fields.url)
223 fields.file = fetch_with_nix_prefetch_url(
224 v, fields.absolute_url, fields.digest)
73bec7e8
SW
225 v.status('Verifying git commit on main page matches git commit in table')
226 v.result(
227 open(
61aaf799 228 channel.table['git-revision'].file).read(999) == channel.git_revision)
2f96f32a 229
971d3659 230
9836141c 231def git_cachedir(git_repo: str) -> str:
26125a28
SW
232 return os.path.join(
233 xdg.XDG_CACHE_HOME,
234 'pinch/git',
eb0c6f1b
SW
235 digest_string(git_repo.encode()))
236
237
238def tarball_cache_file(channel: Channel) -> str:
239 return os.path.join(
240 xdg.XDG_CACHE_HOME,
241 'pinch/git-tarball',
242 '%s-%s-%s' %
243 (digest_string(channel.git_repo.encode()),
244 channel.git_revision,
245 channel.release_name))
971d3659
SW
246
247
248def verify_git_ancestry(v: Verification, channel: Channel) -> None:
249 cachedir = git_cachedir(channel.git_repo)
250 v.status('Verifying rev is an ancestor of ref')
251 process = subprocess.run(['git',
252 '-C',
253 cachedir,
254 'merge-base',
255 '--is-ancestor',
256 channel.git_revision,
257 channel.git_ref])
258 v.result(process.returncode == 0)
259
260 if hasattr(channel, 'old_git_revision'):
261 v.status(
262 'Verifying rev is an ancestor of previous rev %s' %
263 channel.old_git_revision)
264 process = subprocess.run(['git',
265 '-C',
266 cachedir,
267 'merge-base',
268 '--is-ancestor',
269 channel.old_git_revision,
270 channel.git_revision])
271 v.result(process.returncode == 0)
9836141c 272
2f96f32a 273
dc038df0
SW
274def git_fetch(v: Verification, channel: Channel) -> None:
275 # It would be nice if we could share the nix git cache, but as of the time
276 # of writing it is transitioning from gitv2 (deprecated) to gitv3 (not ready
277 # yet), and trying to straddle them both is too far into nix implementation
278 # details for my comfort. So we re-implement here half of nix.fetchGit.
279 # :(
280
9836141c
SW
281 cachedir = git_cachedir(channel.git_repo)
282 if not os.path.exists(cachedir):
dc038df0
SW
283 v.status("Initializing git repo")
284 process = subprocess.run(
9836141c 285 ['git', 'init', '--bare', cachedir])
dc038df0
SW
286 v.result(process.returncode == 0)
287
971d3659
SW
288 v.status('Fetching ref "%s" from %s' % (channel.git_ref, channel.git_repo))
289 # We don't use --force here because we want to abort and freak out if forced
290 # updates are happening.
291 process = subprocess.run(['git',
292 '-C',
293 cachedir,
294 'fetch',
295 channel.git_repo,
296 '%s:%s' % (channel.git_ref,
297 channel.git_ref)])
298 v.result(process.returncode == 0)
299
8fca6c28 300 if hasattr(channel, 'git_revision'):
971d3659 301 v.status('Verifying that fetch retrieved this rev')
8fca6c28 302 process = subprocess.run(
9836141c 303 ['git', '-C', cachedir, 'cat-file', '-e', channel.git_revision])
dc038df0 304 v.result(process.returncode == 0)
971d3659 305 else:
8fca6c28
SW
306 channel.git_revision = open(
307 os.path.join(
9836141c 308 cachedir,
8fca6c28
SW
309 'refs',
310 'heads',
311 channel.git_ref)).read(999).strip()
dc038df0 312
971d3659 313 verify_git_ancestry(v, channel)
dc038df0 314
971d3659
SW
315
316def ensure_git_rev_available(v: Verification, channel: Channel) -> None:
317 cachedir = git_cachedir(channel.git_repo)
318 if os.path.exists(cachedir):
319 v.status('Checking if we already have this rev:')
320 process = subprocess.run(
321 ['git', '-C', cachedir, 'cat-file', '-e', channel.git_revision])
322 if process.returncode == 0:
323 v.status('yes')
324 if process.returncode == 1:
325 v.status('no')
326 v.result(process.returncode == 0 or process.returncode == 1)
327 if process.returncode == 0:
328 verify_git_ancestry(v, channel)
329 return
330 git_fetch(v, channel)
7d889b12 331
dc038df0 332
925c801b
SW
333def compare_tarball_and_git(
334 v: Verification,
335 channel: Channel,
336 channel_contents: str,
337 git_contents: str) -> None:
338 v.status('Comparing channel tarball with git checkout')
339 match, mismatch, errors = compare(os.path.join(
340 channel_contents, channel.release_name), git_contents)
341 v.ok()
342 v.check('%d files match' % len(match), len(match) > 0)
343 v.check('%d files differ' % len(mismatch), len(mismatch) == 0)
344 expected_errors = [
345 '.git-revision',
346 '.version-suffix',
347 'nixpkgs',
348 'programs.sqlite',
349 'svn-revision']
350 benign_errors = []
351 for ee in expected_errors:
352 if ee in errors:
353 errors.remove(ee)
354 benign_errors.append(ee)
355 v.check(
356 '%d unexpected incomparable files' %
357 len(errors),
358 len(errors) == 0)
359 v.check(
360 '(%d of %d expected incomparable files)' %
361 (len(benign_errors),
362 len(expected_errors)),
363 len(benign_errors) == len(expected_errors))
364
365
366def extract_tarball(v: Verification, channel: Channel, dest: str) -> None:
367 v.status('Extracting tarball %s' %
368 channel.table['nixexprs.tar.xz'].file)
369 shutil.unpack_archive(
370 channel.table['nixexprs.tar.xz'].file,
371 dest)
372 v.ok()
373
374
375def git_checkout(v: Verification, channel: Channel, dest: str) -> None:
376 v.status('Checking out corresponding git revision')
377 git = subprocess.Popen(['git',
378 '-C',
9836141c 379 git_cachedir(channel.git_repo),
925c801b 380 'archive',
61aaf799 381 channel.git_revision],
925c801b
SW
382 stdout=subprocess.PIPE)
383 tar = subprocess.Popen(
384 ['tar', 'x', '-C', dest, '-f', '-'], stdin=git.stdout)
de68382a
SW
385 if git.stdout:
386 git.stdout.close()
925c801b
SW
387 tar.wait()
388 git.wait()
389 v.result(git.returncode == 0 and tar.returncode == 0)
390
391
736c25eb 392def git_get_tarball(v: Verification, channel: Channel) -> str:
eb0c6f1b
SW
393 cache_file = tarball_cache_file(channel)
394 if os.path.exists(cache_file):
395 cached_tarball = open(cache_file).read(9999)
396 if os.path.exists(cached_tarball):
397 return cached_tarball
398
736c25eb
SW
399 with tempfile.TemporaryDirectory() as output_dir:
400 output_filename = os.path.join(
401 output_dir, channel.release_name + '.tar.xz')
402 with open(output_filename, 'w') as output_file:
403 v.status(
404 'Generating tarball for git revision %s' %
405 channel.git_revision)
406 git = subprocess.Popen(['git',
407 '-C',
408 git_cachedir(channel.git_repo),
409 'archive',
410 '--prefix=%s/' % channel.release_name,
411 channel.git_revision],
412 stdout=subprocess.PIPE)
413 xz = subprocess.Popen(['xz'], stdin=git.stdout, stdout=output_file)
414 xz.wait()
415 git.wait()
416 v.result(git.returncode == 0 and xz.returncode == 0)
417
418 v.status('Putting tarball in Nix store')
419 process = subprocess.run(
420 ['nix-store', '--add', output_filename], capture_output=True)
421 v.result(process.returncode == 0)
eb0c6f1b
SW
422 store_tarball = process.stdout.decode().strip()
423
424 os.makedirs(os.path.dirname(cache_file), exist_ok=True)
425 open(cache_file, 'w').write(store_tarball)
426 return store_tarball
736c25eb
SW
427
428
f9cd7bdc
SW
429def check_channel_metadata(
430 v: Verification,
431 channel: Channel,
432 channel_contents: str) -> None:
433 v.status('Verifying git commit in channel tarball')
434 v.result(
435 open(
436 os.path.join(
437 channel_contents,
438 channel.release_name,
61aaf799 439 '.git-revision')).read(999) == channel.git_revision)
f9cd7bdc
SW
440
441 v.status(
442 'Verifying version-suffix is a suffix of release name %s:' %
443 channel.release_name)
444 version_suffix = open(
445 os.path.join(
446 channel_contents,
447 channel.release_name,
448 '.version-suffix')).read(999)
449 v.status(version_suffix)
450 v.result(channel.release_name.endswith(version_suffix))
451
452
72d3478a 453def check_channel_contents(v: Verification, channel: Channel) -> None:
dc038df0
SW
454 with tempfile.TemporaryDirectory() as channel_contents, \
455 tempfile.TemporaryDirectory() as git_contents:
925c801b
SW
456
457 extract_tarball(v, channel, channel_contents)
f9cd7bdc
SW
458 check_channel_metadata(v, channel, channel_contents)
459
925c801b
SW
460 git_checkout(v, channel, git_contents)
461
462 compare_tarball_and_git(v, channel, channel_contents, git_contents)
463
dc038df0 464 v.status('Removing temporary directories')
2f96f32a
SW
465 v.ok()
466
467
8fca6c28
SW
468def pin_channel(v: Verification, channel: Channel) -> None:
469 fetch(v, channel)
470 parse_channel(v, channel)
471 fetch_resources(v, channel)
971d3659 472 ensure_git_rev_available(v, channel)
8fca6c28
SW
473 check_channel_contents(v, channel)
474
475
e3cae769
SW
476def git_revision_name(v: Verification, channel: Channel) -> str:
477 v.status('Getting commit date')
478 process = subprocess.run(['git',
479 '-C',
9836141c 480 git_cachedir(channel.git_repo),
bed32182 481 'log',
e3cae769
SW
482 '-n1',
483 '--format=%ct-%h',
484 '--abbrev=11',
485 channel.git_revision],
486 capture_output=True)
de68382a 487 v.result(process.returncode == 0 and process.stdout != b'')
e3cae769
SW
488 return '%s-%s' % (os.path.basename(channel.git_repo),
489 process.stdout.decode().strip())
490
491
01ba0eb2
SW
492def read_config(filename: str) -> configparser.ConfigParser:
493 config = configparser.ConfigParser()
494 config.read_file(open(filename), filename)
495 return config
496
497
0e5e611d 498def pin(args: argparse.Namespace) -> None:
2f96f32a 499 v = Verification()
01ba0eb2 500 config = read_config(args.channels_file)
5cfa8e11 501 for section in config.sections():
98853153
SW
502 if args.channels and section not in args.channels:
503 continue
736c25eb
SW
504
505 channel = Channel(**dict(config[section].items()))
17906b27
SW
506
507 if hasattr(channel, 'alias_of'):
508 assert not hasattr(channel, 'git_repo')
509 continue
510
736c25eb
SW
511 if hasattr(channel, 'git_revision'):
512 channel.old_git_revision = channel.git_revision
513 del channel.git_revision
514
8fca6c28
SW
515 if 'channel_url' in config[section]:
516 pin_channel(v, channel)
736c25eb 517 config[section]['release_name'] = channel.release_name
8fca6c28
SW
518 config[section]['tarball_url'] = channel.table['nixexprs.tar.xz'].absolute_url
519 config[section]['tarball_sha256'] = channel.table['nixexprs.tar.xz'].digest
520 else:
521 git_fetch(v, channel)
736c25eb 522 config[section]['release_name'] = git_revision_name(v, channel)
8fca6c28
SW
523 config[section]['git_revision'] = channel.git_revision
524
0e5e611d 525 with open(args.channels_file, 'w') as configfile:
e434d96d 526 config.write(configfile)
2f96f32a
SW
527
528
10ddbaff
SW
529def fetch_channel(
530 v: Verification,
531 section: str,
532 conf: configparser.SectionProxy) -> str:
533 if 'git_repo' not in conf or 'release_name' not in conf:
534 raise Exception(
535 'Cannot update unpinned channel "%s" (Run "pin" before "update")' %
536 section)
537
538 if 'channel_url' in conf:
539 return fetch_with_nix_prefetch_url(
540 v, conf['tarball_url'], Digest16(
541 conf['tarball_sha256']))
542
543 channel = Channel(**dict(conf.items()))
544 ensure_git_rev_available(v, channel)
545 return git_get_tarball(v, channel)
546
547
736c25eb
SW
548def update(args: argparse.Namespace) -> None:
549 v = Verification()
550 config = configparser.ConfigParser()
da135b07 551 exprs: Dict[str, str] = {}
01ba0eb2
SW
552 configs = [read_config(filename) for filename in args.channels_file]
553 for config in configs:
554 for section in config.sections():
01ba0eb2
SW
555 if 'alias_of' in config[section]:
556 assert 'git_repo' not in config[section]
557 continue
10ddbaff 558 tarball = fetch_channel(v, section, config[section])
ab7ebb2f
SW
559 if section in exprs:
560 raise Exception('Duplicate channel "%s"' % section)
01ba0eb2
SW
561 exprs[section] = (
562 'f: f { name = "%s"; channelName = "%%s"; src = builtins.storePath "%s"; }' %
563 (config[section]['release_name'], tarball))
564
565 for config in configs:
566 for section in config.sections():
567 if 'alias_of' in config[section]:
ab7ebb2f
SW
568 if section in exprs:
569 raise Exception('Duplicate channel "%s"' % section)
01ba0eb2 570 exprs[section] = exprs[str(config[section]['alias_of'])]
17906b27 571
9a78329e
SW
572 command = [
573 'nix-env',
574 '--profile',
575 '/nix/var/nix/profiles/per-user/%s/channels' %
576 getpass.getuser(),
577 '--show-trace',
578 '--file',
579 '<nix/unpack-channel.nix>',
580 '--install',
17906b27 581 '--from-expression'] + [exprs[name] % name for name in sorted(exprs.keys())]
9a78329e
SW
582 if args.dry_run:
583 print(' '.join(map(shlex.quote, command)))
584 else:
585 v.status('Installing channels with nix-env')
586 process = subprocess.run(command)
587 v.result(process.returncode == 0)
736c25eb
SW
588
589
0e5e611d
SW
590def main() -> None:
591 parser = argparse.ArgumentParser(prog='pinch')
592 subparsers = parser.add_subparsers(dest='mode', required=True)
593 parser_pin = subparsers.add_parser('pin')
594 parser_pin.add_argument('channels_file', type=str)
98853153 595 parser_pin.add_argument('channels', type=str, nargs='*')
0e5e611d 596 parser_pin.set_defaults(func=pin)
736c25eb 597 parser_update = subparsers.add_parser('update')
9a78329e 598 parser_update.add_argument('--dry-run', action='store_true')
01ba0eb2 599 parser_update.add_argument('channels_file', type=str, nargs='+')
736c25eb 600 parser_update.set_defaults(func=update)
0e5e611d
SW
601 args = parser.parse_args()
602 args.func(args)
603
604
605main()