blob: d0532c5ab85ed68c0dcab70e9a501d996c80f2ee [file] [log] [blame]
Brad Bishopd7bf8c12018-02-25 22:55:05 -05001#!/usr/bin/env python3
Brad Bishopc342db32019-05-15 21:57:59 -04002#
Patrick Williams92b42cb2022-09-03 06:53:57 -05003# Copyright BitBake Contributors
4#
Brad Bishopc342db32019-05-15 21:57:59 -04005# SPDX-License-Identifier: GPL-2.0-only
6#
7
Brad Bishopd7bf8c12018-02-25 22:55:05 -05008"""git-make-shallow: make the current git repository shallow
9
10Remove the history of the specified revisions, then optionally filter the
11available refs to those specified.
12"""
13
14import argparse
15import collections
16import errno
17import itertools
18import os
19import subprocess
20import sys
Andrew Geissler5199d832021-09-24 16:47:35 -050021import warnings
22warnings.simplefilter("default")
Brad Bishopd7bf8c12018-02-25 22:55:05 -050023
24version = 1.0
25
26
27def main():
28 if sys.version_info < (3, 4, 0):
29 sys.exit('Python 3.4 or greater is required')
30
31 git_dir = check_output(['git', 'rev-parse', '--git-dir']).rstrip()
32 shallow_file = os.path.join(git_dir, 'shallow')
33 if os.path.exists(shallow_file):
34 try:
35 check_output(['git', 'fetch', '--unshallow'])
36 except subprocess.CalledProcessError:
37 try:
38 os.unlink(shallow_file)
39 except OSError as exc:
40 if exc.errno != errno.ENOENT:
41 raise
42
43 args = process_args()
44 revs = check_output(['git', 'rev-list'] + args.revisions).splitlines()
45
46 make_shallow(shallow_file, args.revisions, args.refs)
47
48 ref_revs = check_output(['git', 'rev-list'] + args.refs).splitlines()
49 remaining_history = set(revs) & set(ref_revs)
50 for rev in remaining_history:
51 if check_output(['git', 'rev-parse', '{}^@'.format(rev)]):
52 sys.exit('Error: %s was not made shallow' % rev)
53
54 filter_refs(args.refs)
55
56 if args.shrink:
57 shrink_repo(git_dir)
58 subprocess.check_call(['git', 'fsck', '--unreachable'])
59
60
61def process_args():
62 # TODO: add argument to automatically keep local-only refs, since they
63 # can't be easily restored with a git fetch.
64 parser = argparse.ArgumentParser(description='Remove the history of the specified revisions, then optionally filter the available refs to those specified.')
65 parser.add_argument('--ref', '-r', metavar='REF', action='append', dest='refs', help='remove all but the specified refs (cumulative)')
66 parser.add_argument('--shrink', '-s', action='store_true', help='shrink the git repository by repacking and pruning')
67 parser.add_argument('revisions', metavar='REVISION', nargs='+', help='a git revision/commit')
68 if len(sys.argv) < 2:
69 parser.print_help()
70 sys.exit(2)
71
72 args = parser.parse_args()
73
74 if args.refs:
75 args.refs = check_output(['git', 'rev-parse', '--symbolic-full-name'] + args.refs).splitlines()
76 else:
77 args.refs = get_all_refs(lambda r, t, tt: t == 'commit' or tt == 'commit')
78
79 args.refs = list(filter(lambda r: not r.endswith('/HEAD'), args.refs))
80 args.revisions = check_output(['git', 'rev-parse'] + ['%s^{}' % i for i in args.revisions]).splitlines()
81 return args
82
83
84def check_output(cmd, input=None):
85 return subprocess.check_output(cmd, universal_newlines=True, input=input)
86
87
88def make_shallow(shallow_file, revisions, refs):
89 """Remove the history of the specified revisions."""
90 for rev in follow_history_intersections(revisions, refs):
91 print("Processing %s" % rev)
92 with open(shallow_file, 'a') as f:
93 f.write(rev + '\n')
94
95
96def get_all_refs(ref_filter=None):
97 """Return all the existing refs in this repository, optionally filtering the refs."""
98 ref_output = check_output(['git', 'for-each-ref', '--format=%(refname)\t%(objecttype)\t%(*objecttype)'])
99 ref_split = [tuple(iter_extend(l.rsplit('\t'), 3)) for l in ref_output.splitlines()]
100 if ref_filter:
101 ref_split = (e for e in ref_split if ref_filter(*e))
102 refs = [r[0] for r in ref_split]
103 return refs
104
105
106def iter_extend(iterable, length, obj=None):
107 """Ensure that iterable is the specified length by extending with obj."""
108 return itertools.islice(itertools.chain(iterable, itertools.repeat(obj)), length)
109
110
111def filter_refs(refs):
112 """Remove all but the specified refs from the git repository."""
113 all_refs = get_all_refs()
114 to_remove = set(all_refs) - set(refs)
115 if to_remove:
116 check_output(['xargs', '-0', '-n', '1', 'git', 'update-ref', '-d', '--no-deref'],
117 input=''.join(l + '\0' for l in to_remove))
118
119
120def follow_history_intersections(revisions, refs):
121 """Determine all the points where the history of the specified revisions intersects the specified refs."""
122 queue = collections.deque(revisions)
123 seen = set()
124
125 for rev in iter_except(queue.popleft, IndexError):
126 if rev in seen:
127 continue
128
129 parents = check_output(['git', 'rev-parse', '%s^@' % rev]).splitlines()
130
131 yield rev
132 seen.add(rev)
133
134 if not parents:
135 continue
136
137 check_refs = check_output(['git', 'merge-base', '--independent'] + sorted(refs)).splitlines()
138 for parent in parents:
139 for ref in check_refs:
140 print("Checking %s vs %s" % (parent, ref))
141 try:
142 merge_base = check_output(['git', 'merge-base', parent, ref]).rstrip()
143 except subprocess.CalledProcessError:
144 continue
145 else:
146 queue.append(merge_base)
147
148
149def iter_except(func, exception, start=None):
150 """Yield a function repeatedly until it raises an exception."""
151 try:
152 if start is not None:
153 yield start()
154 while True:
155 yield func()
156 except exception:
157 pass
158
159
160def shrink_repo(git_dir):
161 """Shrink the newly shallow repository, removing the unreachable objects."""
162 subprocess.check_call(['git', 'reflog', 'expire', '--expire-unreachable=now', '--all'])
163 subprocess.check_call(['git', 'repack', '-ad'])
164 try:
165 os.unlink(os.path.join(git_dir, 'objects', 'info', 'alternates'))
166 except OSError as exc:
167 if exc.errno != errno.ENOENT:
168 raise
169 subprocess.check_call(['git', 'prune', '--expire', 'now'])
170
171
172if __name__ == '__main__':
173 main()