1
+#!/usr/bin/env python
2
+
3
+"""Functionality for interacting with Git repositories.
4
+
5
+This module provides classes for interfacing with a Git repository.
6
+"""
7
+
8
+import os
9
+import re
10
+import time
11
+from binascii import hexlify
12
+from cStringIO import StringIO
13
+import unittest
14
+
15
+from git_remote_helpers.util import debug, error, die, start_command, run_command
16
+
17
+
18
+def get_git_dir ():
19
+ """Return the path to the GIT_DIR for this repo."""
20
+ args = ("git", "rev-parse", "--git-dir")
21
+ exit_code, output, errors = run_command(args)
22
+ if exit_code:
23
+ die("Failed to retrieve git dir")
24
+ assert not errors
25
+ return output.strip()
26
+
27
+
28
+def parse_git_config ():
29
+ """Return a dict containing the parsed version of 'git config -l'."""
30
+ exit_code, output, errors = run_command(("git", "config", "-z", "-l"))
31
+ if exit_code:
32
+ die("Failed to retrieve git configuration")
33
+ assert not errors
34
+ return dict([e.split('\n', 1) for e in output.split("\0") if e])
35
+
36
+
37
+def git_config_bool (value):
38
+ """Convert the given git config string value to True or False.
39
+
40
+ Raise ValueError if the given string was not recognized as a
41
+ boolean value.
42
+
43
+ """
44
+ norm_value = str(value).strip().lower()
45
+ if norm_value in ("true", "1", "yes", "on", ""):
46
+ return True
47
+ if norm_value in ("false", "0", "no", "off", "none"):
48
+ return False
49
+ raise ValueError("Failed to parse '%s' into a boolean value" % (value))
50
+
51
+
52
+def valid_git_ref (ref_name):
53
+ """Return True iff the given ref name is a valid git ref name."""
54
+ # The following is a reimplementation of the git check-ref-format
55
+ # command. The rules were derived from the git check-ref-format(1)
56
+ # manual page. This code should be replaced by a call to
57
+ # check_ref_format() in the git library, when such is available.
58
+ if ref_name.endswith('/') or \
59
+ ref_name.startswith('.') or \
60
+ ref_name.count('/.') or \
61
+ ref_name.count('..') or \
62
+ ref_name.endswith('.lock'):
63
+ return False
64
+ for c in ref_name:
65
+ if ord(c) < 0x20 or ord(c) == 0x7f or c in " ~^:?*[":
66
+ return False
67
+ return True
68
+
69
+
70
+class GitObjectFetcher(object):
71
+
72
+ """Provide parsed access to 'git cat-file --batch'.
73
+
74
+ This provides a read-only interface to the Git object database.
75
+
76
+ """
77
+
78
+ def __init__ (self):
79
+ """Initiate a 'git cat-file --batch' session."""
80
+ self.queue = [] # List of object names to be submitted
81
+ self.in_transit = None # Object name currently in transit
82
+
83
+ # 'git cat-file --batch' produces binary output which is likely
84
+ # to be corrupted by the default "rU"-mode pipe opened by
85
+ # start_command. (Mode == "rU" does universal new-line
86
+ # conversion, which mangles carriage returns.) Therefore, we
87
+ # open an explicitly binary-safe pipe for transferring the
88
+ # output from 'git cat-file --batch'.
89
+ pipe_r_fd, pipe_w_fd = os.pipe()
90
+ pipe_r = os.fdopen(pipe_r_fd, "rb")
91
+ pipe_w = os.fdopen(pipe_w_fd, "wb")
92
+ self.proc = start_command(("git", "cat-file", "--batch"),
93
+ stdout = pipe_w)
94
+ self.f = pipe_r
95
+
96
+ def __del__ (self):
97
+ """Verify completed communication with 'git cat-file --batch'."""
98
+ assert not self.queue
99
+ assert self.in_transit is None
100
+ self.proc.stdin.close()
101
+ assert self.proc.wait() == 0 # Zero exit code
102
+ assert self.f.read() == "" # No remaining output
103
+
104
+ def _submit_next_object (self):
105
+ """Submit queue items to the 'git cat-file --batch' process.
106
+
107
+ If there are items in the queue, and there is currently no item
108
+ currently in 'transit', then pop the first item off the queue,
109
+ and submit it.
110
+
111
+ """
112
+ if self.queue and self.in_transit is None:
113
+ self.in_transit = self.queue.pop(0)
114
+ print >> self.proc.stdin, self.in_transit[0]
115
+
116
+ def push (self, obj, callback):
117
+ """Push the given object name onto the queue.
118
+
119
+ The given callback function will at some point in the future
120
+ be called exactly once with the following arguments:
121
+ - self - this GitObjectFetcher instance
122
+ - obj - the object name provided to push()
123
+ - sha1 - the SHA1 of the object, if 'None' obj is missing
124
+ - t - the type of the object (tag/commit/tree/blob)
125
+ - size - the size of the object in bytes
126
+ - data - the object contents
127
+
128
+ """
129
+ self.queue.append((obj, callback))
130
+ self._submit_next_object() # (Re)start queue processing
131
+
132
+ def process_next_entry (self):
133
+ """Read the next entry off the queue and invoke callback."""
134
+ obj, cb = self.in_transit
135
+ self.in_transit = None
136
+ header = self.f.readline()
137
+ if header == "%s missing\n" % (obj):
138
+ cb(self, obj, None, None, None, None)
139
+ return
140
+ sha1, t, size = header.split(" ")
141
+ assert len(sha1) == 40
142
+ assert t in ("tag", "commit", "tree", "blob")
143
+ assert size.endswith("\n")
144
+ size = int(size.strip())
145
+ data = self.f.read(size)
146
+ assert self.f.read(1) == "\n"
147
+ cb(self, obj, sha1, t, size, data)
148
+ self._submit_next_object()
149
+
150
+ def process (self):
151
+ """Process the current queue until empty."""
152
+ while self.in_transit is not None:
153
+ self.process_next_entry()
154
+
155
+ # High-level convenience methods:
156
+
157
+ def get_sha1 (self, objspec):
158
+ """Return the SHA1 of the object specified by 'objspec'.
159
+
160
+ Return None if 'objspec' does not specify an existing object.
161
+
162
+ """
163
+ class _ObjHandler(object):
164
+ """Helper class for getting the returned SHA1."""
165
+ def __init__ (self, parser):
166
+ self.parser = parser
167
+ self.sha1 = None
168
+
169
+ def __call__ (self, parser, obj, sha1, t, size, data):
170
+ # FIXME: Many unused arguments. Could this be cheaper?
171
+ assert parser == self.parser
172
+ self.sha1 = sha1
173
+
174
+ handler = _ObjHandler(self)
175
+ self.push(objspec, handler)
176
+ self.process()
177
+ return handler.sha1
178
+
179
+ def open_obj (self, objspec):
180
+ """Return a file object wrapping the contents of a named object.
181
+
182
+ The caller is responsible for calling .close() on the returned
183
+ file object.
184
+
185
+ Raise KeyError if 'objspec' does not exist in the repo.
186
+
187
+ """
188
+ class _ObjHandler(object):
189
+ """Helper class for parsing the returned git object."""
190
+ def __init__ (self, parser):
191
+ """Set up helper."""
192
+ self.parser = parser
193
+ self.contents = StringIO()
194
+ self.err = None
195
+
196
+ def __call__ (self, parser, obj, sha1, t, size, data):
197
+ """Git object callback (see GitObjectFetcher documentation)."""
198
+ assert parser == self.parser
199
+ if not sha1: # Missing object
200
+ self.err = "Missing object '%s'" % obj
201
+ else:
202
+ assert size == len(data)
203
+ self.contents.write(data)
204
+
205
+ handler = _ObjHandler(self)
206
+ self.push(objspec, handler)
207
+ self.process()
208
+ if handler.err:
209
+ raise KeyError(handler.err)
210
+ handler.contents.seek(0)
211
+ return handler.contents
212
+
213
+ def walk_tree (self, tree_objspec, callback, prefix = ""):
214
+ """Recursively walk the given Git tree object.
215
+
216
+ Recursively walk all subtrees of the given tree object, and
217
+ invoke the given callback passing three arguments:
218
+ (path, mode, data) with the path, permission bits, and contents
219
+ of all the blobs found in the entire tree structure.
220
+
221
+ """
222
+ class _ObjHandler(object):
223
+ """Helper class for walking a git tree structure."""
224
+ def __init__ (self, parser, cb, path, mode = None):
225
+ """Set up helper."""
226
+ self.parser = parser
227
+ self.cb = cb
228
+ self.path = path
229
+ self.mode = mode
230
+ self.err = None
231
+
232
+ def parse_tree (self, treedata):
233
+ """Parse tree object data, yield tree entries.
234
+
235
+ Each tree entry is a 3-tuple (mode, sha1, path)
236
+
237
+ self.path is prepended to all paths yielded
238
+ from this method.
239
+
240
+ """
241
+ while treedata:
242
+ mode = int(treedata[:6], 10)
243
+ # Turn 100xxx into xxx
244
+ if mode > 100000:
245
+ mode -= 100000
246
+ assert treedata[6] == " "
247
+ i = treedata.find("\0", 7)
248
+ assert i > 0
249
+ path = treedata[7:i]
250
+ sha1 = hexlify(treedata[i + 1: i + 21])
251
+ yield (mode, sha1, self.path + path)
252
+ treedata = treedata[i + 21:]
253
+
254
+ def __call__ (self, parser, obj, sha1, t, size, data):
255
+ """Git object callback (see GitObjectFetcher documentation)."""
256
+ assert parser == self.parser
257
+ if not sha1: # Missing object
258
+ self.err = "Missing object '%s'" % (obj)
259
+ return
260
+ assert size == len(data)
261
+ if t == "tree":
262
+ if self.path:
263
+ self.path += "/"
264
+ # Recurse into all blobs and subtrees
265
+ for m, s, p in self.parse_tree(data):
266
+ parser.push(s,
267
+ self.__class__(self.parser, self.cb, p, m))
268
+ elif t == "blob":
269
+ self.cb(self.path, self.mode, data)
270
+ else:
271
+ raise ValueError("Unknown object type '%s'" % (t))
272
+
273
+ self.push(tree_objspec, _ObjHandler(self, callback, prefix))
274
+ self.process()
275
+
276
+
277
+class GitRefMap(object):
278
+
279
+ """Map Git ref names to the Git object names they currently point to.
280
+
281
+ Behaves like a dictionary of Git ref names -> Git object names.
282
+
283
+ """
284
+
285
+ def __init__ (self, obj_fetcher):
286
+ """Create a new Git ref -> object map."""
287
+ self.obj_fetcher = obj_fetcher
288
+ self._cache = {} # dict: refname -> objname
289
+
290
+ def _load (self, ref):
291
+ """Retrieve the object currently bound to the given ref.
292
+
293
+ The name of the object pointed to by the given ref is stored
294
+ into this mapping, and also returned.
295
+
296
+ """
297
+ if ref not in self._cache:
298
+ self._cache[ref] = self.obj_fetcher.get_sha1(ref)
299
+ return self._cache[ref]
300
+
301
+ def __contains__ (self, refname):
302
+ """Return True if the given refname is present in this cache."""
303
+ return bool(self._load(refname))
304
+
305
+ def __getitem__ (self, refname):
306
+ """Return the git object name pointed to by the given refname."""
307
+ commit = self._load(refname)
308
+ if commit is None:
309
+ raise KeyError("Unknown ref '%s'" % (refname))
310
+ return commit
311
+
312
+ def get (self, refname, default = None):
313
+ """Return the git object name pointed to by the given refname."""
314
+ commit = self._load(refname)
315
+ if commit is None:
316
+ return default
317
+ return commit
318
+
319
+
320
+class GitFICommit(object):
321
+
322
+ """Encapsulate the data in a Git fast-import commit command."""
323
+
324
+ SHA1RE = re.compile(r'^[0-9a-f]{40}$')
325
+
326
+ @classmethod
327
+ def parse_mode (cls, mode):
328
+ """Verify the given git file mode, and return it as a string."""
329
+ assert mode in (644, 755, 100644, 100755, 120000)
330
+ return "%i" % (mode)
331
+
332
+ @classmethod
333
+ def parse_objname (cls, objname):
334
+ """Return the given object name (or mark number) as a string."""
335
+ if isinstance(objname, int): # Object name is a mark number
336
+ assert objname > 0
337
+ return ":%i" % (objname)
338
+
339
+ # No existence check is done, only checks for valid format
340
+ assert cls.SHA1RE.match(objname) # Object name is valid SHA1
341
+ return objname
342
+
343
+ @classmethod
344
+ def quote_path (cls, path):
345
+ """Return a quoted version of the given path."""
346
+ path = path.replace("\\", "\\\\")
347
+ path = path.replace("\n", "\\n")
348
+ path = path.replace('"', '\\"')
349
+ return '"%s"' % (path)
350
+
351
+ @classmethod
352
+ def parse_path (cls, path):
353
+ """Verify that the given path is valid, and quote it, if needed."""
354
+ assert not isinstance(path, int) # Cannot be a mark number
355
+
356
+ # These checks verify the rules on the fast-import man page
357
+ assert not path.count("//")
358
+ assert not path.endswith("/")
359
+ assert not path.startswith("/")
360
+ assert not path.count("/./")
361
+ assert not path.count("/../")
362
+ assert not path.endswith("/.")
363
+ assert not path.endswith("/..")
364
+ assert not path.startswith("./")
365
+ assert not path.startswith("../")
366
+
367
+ if path.count('"') + path.count('\n') + path.count('\\'):
368
+ return cls.quote_path(path)
369
+ return path
370
+
371
+ def __init__ (self, name, email, timestamp, timezone, message):
372
+ """Create a new Git fast-import commit, with the given metadata."""
373
+ self.name = name
374
+ self.email = email
375
+ self.timestamp = timestamp
376
+ self.timezone = timezone
377
+ self.message = message
378
+ self.pathops = [] # List of path operations in this commit
379
+
380
+ def modify (self, mode, blobname, path):
381
+ """Add a file modification to this Git fast-import commit."""
382
+ self.pathops.append(("M",
383
+ self.parse_mode(mode),
384
+ self.parse_objname(blobname),
385
+ self.parse_path(path)))
386
+
387
+ def delete (self, path):
388
+ """Add a file deletion to this Git fast-import commit."""
389
+ self.pathops.append(("D", self.parse_path(path)))
390
+
391
+ def copy (self, path, newpath):
392
+ """Add a file copy to this Git fast-import commit."""
393
+ self.pathops.append(("C",
394
+ self.parse_path(path),
395
+ self.parse_path(newpath)))
396
+
397
+ def rename (self, path, newpath):
398
+ """Add a file rename to this Git fast-import commit."""
399
+ self.pathops.append(("R",
400
+ self.parse_path(path),
401
+ self.parse_path(newpath)))
402
+
403
+ def note (self, blobname, commit):
404
+ """Add a note object to this Git fast-import commit."""
405
+ self.pathops.append(("N",
406
+ self.parse_objname(blobname),
407
+ self.parse_objname(commit)))
408
+
409
+ def deleteall (self):
410
+ """Delete all files in this Git fast-import commit."""
411
+ self.pathops.append("deleteall")
412
+
413
+
414
+class TestGitFICommit(unittest.TestCase):
415
+
416
+ """GitFICommit selftests."""
417
+
418
+ def test_basic (self):
419
+ """GitFICommit basic selftests."""
420
+
421
+ def expect_fail (method, data):
422
+ """Verify that the method(data) raises an AssertionError."""
423
+ try:
424
+ method(data)
425
+ except AssertionError:
426
+ return
427
+ raise AssertionError("Failed test for invalid data '%s(%s)'" %
428
+ (method.__name__, repr(data)))
429
+
430
+ def test_parse_mode (self):
431
+ """GitFICommit.parse_mode() selftests."""
432
+ self.assertEqual(GitFICommit.parse_mode(644), "644")
433
+ self.assertEqual(GitFICommit.parse_mode(755), "755")
434
+ self.assertEqual(GitFICommit.parse_mode(100644), "100644")
435
+ self.assertEqual(GitFICommit.parse_mode(100755), "100755")
436
+ self.assertEqual(GitFICommit.parse_mode(120000), "120000")
437
+ self.assertRaises(AssertionError, GitFICommit.parse_mode, 0)
438
+ self.assertRaises(AssertionError, GitFICommit.parse_mode, 123)
439
+ self.assertRaises(AssertionError, GitFICommit.parse_mode, 600)
440
+ self.assertRaises(AssertionError, GitFICommit.parse_mode, "644")
441
+ self.assertRaises(AssertionError, GitFICommit.parse_mode, "abc")
442
+
443
+ def test_parse_objname (self):
444
+ """GitFICommit.parse_objname() selftests."""
445
+ self.assertEqual(GitFICommit.parse_objname(1), ":1")
446
+ self.assertRaises(AssertionError, GitFICommit.parse_objname, 0)
447
+ self.assertRaises(AssertionError, GitFICommit.parse_objname, -1)
448
+ self.assertEqual(GitFICommit.parse_objname("0123456789" * 4),
449
+ "0123456789" * 4)
450
+ self.assertEqual(GitFICommit.parse_objname("2468abcdef" * 4),
451
+ "2468abcdef" * 4)
452
+ self.assertRaises(AssertionError, GitFICommit.parse_objname,
453
+ "abcdefghij" * 4)
454
+
455
+ def test_parse_path (self):
456
+ """GitFICommit.parse_path() selftests."""
457
+ self.assertEqual(GitFICommit.parse_path("foo/bar"), "foo/bar")
458
+ self.assertEqual(GitFICommit.parse_path("path/with\n and \" in it"),
459
+ '"path/with\\n and \\" in it"')
460
+ self.assertRaises(AssertionError, GitFICommit.parse_path, 1)
461
+ self.assertRaises(AssertionError, GitFICommit.parse_path, 0)
462
+ self.assertRaises(AssertionError, GitFICommit.parse_path, -1)
463
+ self.assertRaises(AssertionError, GitFICommit.parse_path, "foo//bar")
464
+ self.assertRaises(AssertionError, GitFICommit.parse_path, "foo/bar/")
465
+ self.assertRaises(AssertionError, GitFICommit.parse_path, "/foo/bar")
466
+ self.assertRaises(AssertionError, GitFICommit.parse_path, "foo/./bar")
467
+ self.assertRaises(AssertionError, GitFICommit.parse_path, "foo/../bar")
468
+ self.assertRaises(AssertionError, GitFICommit.parse_path, "foo/bar/.")
469
+ self.assertRaises(AssertionError, GitFICommit.parse_path, "foo/bar/..")
470
+ self.assertRaises(AssertionError, GitFICommit.parse_path, "./foo/bar")
471
+ self.assertRaises(AssertionError, GitFICommit.parse_path, "../foo/bar")
472
+
473
+
474
+class GitFastImport(object):
475
+
476
+ """Encapsulate communication with git fast-import."""
477
+
478
+ def __init__ (self, f, obj_fetcher, last_mark = 0):
479
+ """Set up self to communicate with a fast-import process through f."""
480
+ self.f = f # File object where fast-import stream is written
481
+ self.obj_fetcher = obj_fetcher # GitObjectFetcher instance
482
+ self.next_mark = last_mark + 1 # Next mark number
483
+ self.refs = set() # Keep track of the refnames we've seen
484
+
485
+ def comment (self, s):
486
+ """Write the given comment in the fast-import stream."""
487
+ assert "\n" not in s, "Malformed comment: '%s'" % (s)
488
+ self.f.write("# %s\n" % (s))
489
+
490
+ def commit (self, ref, commitdata):
491
+ """Make a commit on the given ref, with the given GitFICommit.
492
+
493
+ Return the mark number identifying this commit.
494
+
495
+ """
496
+ self.f.write("""\
497
+commit %(ref)s
498
+mark :%(mark)i
499
+committer %(name)s <%(email)s> %(timestamp)i %(timezone)s
500
+data %(msgLength)i
501
+%(msg)s
502
+""" % {
503
+ 'ref': ref,
504
+ 'mark': self.next_mark,
505
+ 'name': commitdata.name,
506
+ 'email': commitdata.email,
507
+ 'timestamp': commitdata.timestamp,
508
+ 'timezone': commitdata.timezone,
509
+ 'msgLength': len(commitdata.message),
510
+ 'msg': commitdata.message,
511
+})
512
+
513
+ if ref not in self.refs:
514
+ self.refs.add(ref)
515
+ parent = ref + "^0"
516
+ if self.obj_fetcher.get_sha1(parent):
517
+ self.f.write("from %s\n" % (parent))
518
+
519
+ for op in commitdata.pathops:
520
+ self.f.write(" ".join(op))
521
+ self.f.write("\n")
522
+ self.f.write("\n")
523
+ retval = self.next_mark
524
+ self.next_mark += 1
525
+ return retval
526
+
527
+ def blob (self, data):
528
+ """Import the given blob.
529
+
530
+ Return the mark number identifying this blob.
531
+
532
+ """
533
+ self.f.write("blob\nmark :%i\ndata %i\n%s\n" %
534
+ (self.next_mark, len(data), data))
535
+ retval = self.next_mark
536
+ self.next_mark += 1
537
+ return retval
538
+
539
+ def reset (self, ref, objname):
540
+ """Reset the given ref to point at the given Git object."""
541
+ self.f.write("reset %s\nfrom %s\n\n" %
542
+ (ref, GitFICommit.parse_objname(objname)))
543
+ if ref not in self.refs:
544
+ self.refs.add(ref)
545
+
546
+
547
+class GitNotes(object):
548
+
549
+ """Encapsulate access to Git notes.
550
+
551
+ Simulates a dictionary of object name (SHA1) -> Git note mappings.
552
+
553
+ """
554
+
555
+ def __init__ (self, notes_ref, obj_fetcher):
556
+ """Create a new Git notes interface, bound to the given notes ref."""
557
+ self.notes_ref = notes_ref
558
+ self.obj_fetcher = obj_fetcher # Used to get objects from repo
559
+ self.imports = [] # list: (objname, note data blob name) tuples
560
+
561
+ def __del__ (self):
562
+ """Verify that self.commit_notes() was called before destruction."""
563
+ if self.imports:
564
+ error("Missing call to self.commit_notes().")
565
+ error("%i notes are not committed!", len(self.imports))
566
+
567
+ def _load (self, objname):
568
+ """Return the note data associated with the given git object.
569
+
570
+ The note data is returned in string form. If no note is found
571
+ for the given object, None is returned.
572
+
573
+ """
574
+ try:
575
+ f = self.obj_fetcher.open_obj("%s:%s" % (self.notes_ref, objname))
576
+ ret = f.read()
577
+ f.close()
578
+ except KeyError:
579
+ ret = None
580
+ return ret
581
+
582
+ def __getitem__ (self, objname):
583
+ """Return the note contents associated with the given object.
584
+
585
+ Raise KeyError if given object has no associated note.
586
+
587
+ """
588
+ blobdata = self._load(objname)
589
+ if blobdata is None:
590
+ raise KeyError("Object '%s' has no note" % (objname))
591
+ return blobdata
592
+
593
+ def get (self, objname, default = None):
594
+ """Return the note contents associated with the given object.
595
+
596
+ Return given default if given object has no associated note.
597
+
598
+ """
599
+ blobdata = self._load(objname)
600
+ if blobdata is None:
601
+ return default
602
+ return blobdata
603
+
604
+ def import_note (self, objname, data, gfi):
605
+ """Tell git fast-import to store data as a note for objname.
606
+
607
+ This method uses the given GitFastImport object to create a
608
+ blob containing the given note data. Also an entry mapping the
609
+ given object name to the created blob is stored until
610
+ commit_notes() is called.
611
+
612
+ Note that this method only works if it is later followed by a
613
+ call to self.commit_notes() (which produces the note commit
614
+ that refers to the blob produced here).
615
+
616
+ """
617
+ if not data.endswith("\n"):
618
+ data += "\n"
619
+ gfi.comment("Importing note for object %s" % (objname))
620
+ mark = gfi.blob(data)
621
+ self.imports.append((objname, mark))
622
+
623
+ def commit_notes (self, gfi, author, message):
624
+ """Produce a git fast-import note commit for the imported notes.
625
+
626
+ This method uses the given GitFastImport object to create a
627
+ commit on the notes ref, introducing the notes previously
628
+ submitted to import_note().
629
+
630
+ """
631
+ if not self.imports:
632
+ return
633
+ commitdata = GitFICommit(author[0], author[1],
634
+ time.time(), "0000", message)
635
+ for objname, blobname in self.imports:
636
+ assert isinstance(objname, int) and objname > 0
637
+ assert isinstance(blobname, int) and blobname > 0
638
+ commitdata.note(blobname, objname)
639
+ gfi.commit(self.notes_ref, commitdata)
640
+ self.imports = []
641
+
642
+
643
+class GitCachedNotes(GitNotes):
644
+
645
+ """Encapsulate access to Git notes (cached version).
646
+
647
+ Only use this class if no caching is done at a higher level.
648
+
649
+ Simulates a dictionary of object name (SHA1) -> Git note mappings.
650
+
651
+ """
652
+
653
+ def __init__ (self, notes_ref, obj_fetcher):
654
+ """Set up a caching wrapper around GitNotes."""
655
+ GitNotes.__init__(self, notes_ref, obj_fetcher)
656
+ self._cache = {} # Cache: object name -> note data
657
+
658
+ def __del__ (self):
659
+ """Verify that GitNotes' destructor is called."""
660
+ GitNotes.__del__(self)
661
+
662
+ def _load (self, objname):
663
+ """Extend GitNotes._load() with a local objname -> note cache."""
664
+ if objname not in self._cache:
665
+ self._cache[objname] = GitNotes._load(self, objname)
666
+ return self._cache[objname]
667
+
668
+ def import_note (self, objname, data, gfi):
669
+ """Extend GitNotes.import_note() with a local objname -> note cache."""
670
+ if not data.endswith("\n"):
671
+ data += "\n"
672
+ assert objname not in self._cache
673
+ self._cache[objname] = data
674
+ GitNotes.import_note(self, objname, data, gfi)
675
+
676
+
677
+if __name__ == '__main__':
678
+ unittest.main()