Skip to content

Commit 1074b39

Browse files
authored
Merge pull request #1256 from pre-commit/freeze
Implement `pre-commit autoupdate --freeze`
2 parents 32ce682 + 8a3c740 commit 1074b39

File tree

5 files changed

+311
-257
lines changed

5 files changed

+311
-257
lines changed

pre_commit/clientlib.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,7 @@ def apply_default(self, dct):
133133
if 'sha' in dct:
134134
dct['rev'] = dct.pop('sha')
135135

136-
def remove_default(self, dct):
137-
pass
136+
remove_default = cfgv.Required.remove_default
138137

139138

140139
def _entry(modname):

pre_commit/commands/autoupdate.py

Lines changed: 92 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
11
from __future__ import print_function
22
from __future__ import unicode_literals
33

4+
import collections
45
import os.path
56
import re
67

78
import six
89
from aspy.yaml import ordered_dump
910
from aspy.yaml import ordered_load
10-
from cfgv import remove_defaults
1111

1212
import pre_commit.constants as C
1313
from pre_commit import git
1414
from pre_commit import output
15-
from pre_commit.clientlib import CONFIG_SCHEMA
1615
from pre_commit.clientlib import InvalidManifestError
1716
from pre_commit.clientlib import load_config
1817
from pre_commit.clientlib import load_manifest
@@ -25,39 +24,44 @@
2524
from pre_commit.util import tmpdir
2625

2726

28-
class RepositoryCannotBeUpdatedError(RuntimeError):
29-
pass
30-
27+
class RevInfo(collections.namedtuple('RevInfo', ('repo', 'rev', 'frozen'))):
28+
__slots__ = ()
3129

32-
def _update_repo(repo_config, store, tags_only):
33-
"""Updates a repository to the tip of `master`. If the repository cannot
34-
be updated because a hook that is configured does not exist in `master`,
35-
this raises a RepositoryCannotBeUpdatedError
30+
@classmethod
31+
def from_config(cls, config):
32+
return cls(config['repo'], config['rev'], None)
3633

37-
Args:
38-
repo_config - A config for a repository
39-
"""
40-
with tmpdir() as repo_path:
41-
git.init_repo(repo_path, repo_config['repo'])
42-
cmd_output_b('git', 'fetch', 'origin', 'HEAD', '--tags', cwd=repo_path)
43-
44-
tag_cmd = ('git', 'describe', 'FETCH_HEAD', '--tags')
34+
def update(self, tags_only, freeze):
4535
if tags_only:
46-
tag_cmd += ('--abbrev=0',)
36+
tag_cmd = ('git', 'describe', 'FETCH_HEAD', '--tags', '--abbrev=0')
4737
else:
48-
tag_cmd += ('--exact',)
49-
try:
50-
rev = cmd_output(*tag_cmd, cwd=repo_path)[1].strip()
51-
except CalledProcessError:
52-
tag_cmd = ('git', 'rev-parse', 'FETCH_HEAD')
53-
rev = cmd_output(*tag_cmd, cwd=repo_path)[1].strip()
38+
tag_cmd = ('git', 'describe', 'FETCH_HEAD', '--tags', '--exact')
39+
40+
with tmpdir() as tmp:
41+
git.init_repo(tmp, self.repo)
42+
cmd_output_b('git', 'fetch', 'origin', 'HEAD', '--tags', cwd=tmp)
43+
44+
try:
45+
rev = cmd_output(*tag_cmd, cwd=tmp)[1].strip()
46+
except CalledProcessError:
47+
cmd = ('git', 'rev-parse', 'FETCH_HEAD')
48+
rev = cmd_output(*cmd, cwd=tmp)[1].strip()
49+
50+
frozen = None
51+
if freeze:
52+
exact = cmd_output('git', 'rev-parse', rev, cwd=tmp)[1].strip()
53+
if exact != rev:
54+
rev, frozen = exact, rev
55+
return self._replace(rev=rev, frozen=frozen)
56+
57+
58+
class RepositoryCannotBeUpdatedError(RuntimeError):
59+
pass
5460

55-
# Don't bother trying to update if our rev is the same
56-
if rev == repo_config['rev']:
57-
return repo_config
5861

62+
def _check_hooks_still_exist_at_rev(repo_config, info, store):
5963
try:
60-
path = store.clone(repo_config['repo'], rev)
64+
path = store.clone(repo_config['repo'], info.rev)
6165
manifest = load_manifest(os.path.join(path, C.MANIFEST_FILE))
6266
except InvalidManifestError as e:
6367
raise RepositoryCannotBeUpdatedError(six.text_type(e))
@@ -71,94 +75,91 @@ def _update_repo(repo_config, store, tags_only):
7175
'{}'.format(', '.join(sorted(hooks_missing))),
7276
)
7377

74-
# Construct a new config with the head rev
75-
new_config = repo_config.copy()
76-
new_config['rev'] = rev
77-
return new_config
78-
7978

80-
REV_LINE_RE = re.compile(r'^(\s+)rev:(\s*)([^\s#]+)(.*)$', re.DOTALL)
81-
REV_LINE_FMT = '{}rev:{}{}{}'
79+
REV_LINE_RE = re.compile(r'^(\s+)rev:(\s*)([^\s#]+)(.*)(\r?\n)$', re.DOTALL)
80+
REV_LINE_FMT = '{}rev:{}{}{}{}'
8281

8382

84-
def _write_new_config_file(path, output):
83+
def _original_lines(path, rev_infos, retry=False):
84+
"""detect `rev:` lines or reformat the file"""
8585
with open(path) as f:
86-
original_contents = f.read()
87-
output = remove_defaults(output, CONFIG_SCHEMA)
88-
new_contents = ordered_dump(output, **C.YAML_DUMP_KWARGS)
89-
90-
lines = original_contents.splitlines(True)
91-
rev_line_indices_reversed = list(
92-
reversed([
93-
i for i, line in enumerate(lines) if REV_LINE_RE.match(line)
94-
]),
95-
)
96-
97-
for line in new_contents.splitlines(True):
98-
if REV_LINE_RE.match(line):
99-
# It's possible we didn't identify the rev lines in the original
100-
if not rev_line_indices_reversed:
101-
break
102-
line_index = rev_line_indices_reversed.pop()
103-
original_line = lines[line_index]
104-
orig_match = REV_LINE_RE.match(original_line)
105-
new_match = REV_LINE_RE.match(line)
106-
lines[line_index] = REV_LINE_FMT.format(
107-
orig_match.group(1), orig_match.group(2),
108-
new_match.group(3), orig_match.group(4),
109-
)
110-
111-
# If we failed to intelligently rewrite the rev lines, fall back to the
112-
# pretty-formatted yaml output
113-
to_write = ''.join(lines)
114-
if remove_defaults(ordered_load(to_write), CONFIG_SCHEMA) != output:
115-
to_write = new_contents
86+
original = f.read()
87+
88+
lines = original.splitlines(True)
89+
idxs = [i for i, line in enumerate(lines) if REV_LINE_RE.match(line)]
90+
if len(idxs) == len(rev_infos):
91+
return lines, idxs
92+
elif retry:
93+
raise AssertionError('could not find rev lines')
94+
else:
95+
with open(path, 'w') as f:
96+
f.write(ordered_dump(ordered_load(original), **C.YAML_DUMP_KWARGS))
97+
return _original_lines(path, rev_infos, retry=True)
98+
99+
100+
def _write_new_config(path, rev_infos):
101+
lines, idxs = _original_lines(path, rev_infos)
102+
103+
for idx, rev_info in zip(idxs, rev_infos):
104+
if rev_info is None:
105+
continue
106+
match = REV_LINE_RE.match(lines[idx])
107+
assert match is not None
108+
new_rev_s = ordered_dump({'rev': rev_info.rev}, **C.YAML_DUMP_KWARGS)
109+
new_rev = new_rev_s.split(':', 1)[1].strip()
110+
if rev_info.frozen is not None:
111+
comment = ' # {}'.format(rev_info.frozen)
112+
else:
113+
comment = match.group(4)
114+
lines[idx] = REV_LINE_FMT.format(
115+
match.group(1), match.group(2), new_rev, comment, match.group(5),
116+
)
116117

117118
with open(path, 'w') as f:
118-
f.write(to_write)
119+
f.write(''.join(lines))
119120

120121

121-
def autoupdate(config_file, store, tags_only, repos=()):
122+
def autoupdate(config_file, store, tags_only, freeze, repos=()):
122123
"""Auto-update the pre-commit config to the latest versions of repos."""
123124
migrate_config(config_file, quiet=True)
124125
retv = 0
125-
output_repos = []
126+
rev_infos = []
126127
changed = False
127128

128-
input_config = load_config(config_file)
129+
config = load_config(config_file)
130+
for repo_config in config['repos']:
131+
if repo_config['repo'] in {LOCAL, META}:
132+
continue
129133

130-
for repo_config in input_config['repos']:
131-
if (
132-
repo_config['repo'] in {LOCAL, META} or
133-
# Skip updating any repo_configs that aren't for the specified repo
134-
repos and repo_config['repo'] not in repos
135-
):
136-
output_repos.append(repo_config)
134+
info = RevInfo.from_config(repo_config)
135+
if repos and info.repo not in repos:
136+
rev_infos.append(None)
137137
continue
138-
output.write('Updating {}...'.format(repo_config['repo']))
138+
139+
output.write('Updating {}...'.format(info.repo))
140+
new_info = info.update(tags_only=tags_only, freeze=freeze)
139141
try:
140-
new_repo_config = _update_repo(repo_config, store, tags_only)
142+
_check_hooks_still_exist_at_rev(repo_config, new_info, store)
141143
except RepositoryCannotBeUpdatedError as error:
142144
output.write_line(error.args[0])
143-
output_repos.append(repo_config)
145+
rev_infos.append(None)
144146
retv = 1
145147
continue
146148

147-
if new_repo_config['rev'] != repo_config['rev']:
149+
if new_info.rev != info.rev:
148150
changed = True
149-
output.write_line(
150-
'updating {} -> {}.'.format(
151-
repo_config['rev'], new_repo_config['rev'],
152-
),
153-
)
154-
output_repos.append(new_repo_config)
151+
if new_info.frozen:
152+
updated_to = '{} (frozen)'.format(new_info.frozen)
153+
else:
154+
updated_to = new_info.rev
155+
msg = 'updating {} -> {}.'.format(info.rev, updated_to)
156+
output.write_line(msg)
157+
rev_infos.append(new_info)
155158
else:
156159
output.write_line('already up to date.')
157-
output_repos.append(repo_config)
160+
rev_infos.append(None)
158161

159162
if changed:
160-
output_config = input_config.copy()
161-
output_config['repos'] = output_repos
162-
_write_new_config_file(config_file, output_config)
163+
_write_new_config(config_file, rev_infos)
163164

164165
return retv

pre_commit/main.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,10 @@ def main(argv=None):
175175
'tagged version (the default behavior).'
176176
),
177177
)
178+
autoupdate_parser.add_argument(
179+
'--freeze', action='store_true',
180+
help='Store "frozen" hashes in `rev` instead of tag names',
181+
)
178182
autoupdate_parser.add_argument(
179183
'--repo', dest='repos', action='append', metavar='REPO',
180184
help='Only update this repository -- may be specified multiple times.',
@@ -313,6 +317,7 @@ def main(argv=None):
313317
return autoupdate(
314318
args.config, store,
315319
tags_only=not args.bleeding_edge,
320+
freeze=args.freeze,
316321
repos=args.repos,
317322
)
318323
elif args.command == 'clean':

0 commit comments

Comments
 (0)