##// END OF EJS Templates
fix: compute changed lines lazily to make whole-file fixer tools faster...
Danny Hooper -
r38896:257c9846 default
parent child Browse files
Show More
@@ -1,601 +1,602 b''
1 # fix - rewrite file content in changesets and working copy
1 # fix - rewrite file content in changesets and working copy
2 #
2 #
3 # Copyright 2018 Google LLC.
3 # Copyright 2018 Google LLC.
4 #
4 #
5 # This software may be used and distributed according to the terms of the
5 # This software may be used and distributed according to the terms of the
6 # GNU General Public License version 2 or any later version.
6 # GNU General Public License version 2 or any later version.
7 """rewrite file content in changesets or working copy (EXPERIMENTAL)
7 """rewrite file content in changesets or working copy (EXPERIMENTAL)
8
8
9 Provides a command that runs configured tools on the contents of modified files,
9 Provides a command that runs configured tools on the contents of modified files,
10 writing back any fixes to the working copy or replacing changesets.
10 writing back any fixes to the working copy or replacing changesets.
11
11
12 Here is an example configuration that causes :hg:`fix` to apply automatic
12 Here is an example configuration that causes :hg:`fix` to apply automatic
13 formatting fixes to modified lines in C++ code::
13 formatting fixes to modified lines in C++ code::
14
14
15 [fix]
15 [fix]
16 clang-format:command=clang-format --assume-filename={rootpath}
16 clang-format:command=clang-format --assume-filename={rootpath}
17 clang-format:linerange=--lines={first}:{last}
17 clang-format:linerange=--lines={first}:{last}
18 clang-format:fileset=set:**.cpp or **.hpp
18 clang-format:fileset=set:**.cpp or **.hpp
19
19
20 The :command suboption forms the first part of the shell command that will be
20 The :command suboption forms the first part of the shell command that will be
21 used to fix a file. The content of the file is passed on standard input, and the
21 used to fix a file. The content of the file is passed on standard input, and the
22 fixed file content is expected on standard output. If there is any output on
22 fixed file content is expected on standard output. If there is any output on
23 standard error, the file will not be affected. Some values may be substituted
23 standard error, the file will not be affected. Some values may be substituted
24 into the command::
24 into the command::
25
25
26 {rootpath} The path of the file being fixed, relative to the repo root
26 {rootpath} The path of the file being fixed, relative to the repo root
27 {basename} The name of the file being fixed, without the directory path
27 {basename} The name of the file being fixed, without the directory path
28
28
29 If the :linerange suboption is set, the tool will only be run if there are
29 If the :linerange suboption is set, the tool will only be run if there are
30 changed lines in a file. The value of this suboption is appended to the shell
30 changed lines in a file. The value of this suboption is appended to the shell
31 command once for every range of changed lines in the file. Some values may be
31 command once for every range of changed lines in the file. Some values may be
32 substituted into the command::
32 substituted into the command::
33
33
34 {first} The 1-based line number of the first line in the modified range
34 {first} The 1-based line number of the first line in the modified range
35 {last} The 1-based line number of the last line in the modified range
35 {last} The 1-based line number of the last line in the modified range
36
36
37 The :fileset suboption determines which files will be passed through each
37 The :fileset suboption determines which files will be passed through each
38 configured tool. See :hg:`help fileset` for possible values. If there are file
38 configured tool. See :hg:`help fileset` for possible values. If there are file
39 arguments to :hg:`fix`, the intersection of these filesets is used.
39 arguments to :hg:`fix`, the intersection of these filesets is used.
40
40
41 There is also a configurable limit for the maximum size of file that will be
41 There is also a configurable limit for the maximum size of file that will be
42 processed by :hg:`fix`::
42 processed by :hg:`fix`::
43
43
44 [fix]
44 [fix]
45 maxfilesize=2MB
45 maxfilesize=2MB
46
46
47 """
47 """
48
48
49 from __future__ import absolute_import
49 from __future__ import absolute_import
50
50
51 import collections
51 import collections
52 import itertools
52 import itertools
53 import os
53 import os
54 import re
54 import re
55 import subprocess
55 import subprocess
56
56
57 from mercurial.i18n import _
57 from mercurial.i18n import _
58 from mercurial.node import nullrev
58 from mercurial.node import nullrev
59 from mercurial.node import wdirrev
59 from mercurial.node import wdirrev
60
60
61 from mercurial import (
61 from mercurial import (
62 cmdutil,
62 cmdutil,
63 context,
63 context,
64 copies,
64 copies,
65 error,
65 error,
66 mdiff,
66 mdiff,
67 merge,
67 merge,
68 obsolete,
68 obsolete,
69 pycompat,
69 pycompat,
70 registrar,
70 registrar,
71 scmutil,
71 scmutil,
72 util,
72 util,
73 worker,
73 worker,
74 )
74 )
75
75
76 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
76 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
77 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
77 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
78 # be specifying the version(s) of Mercurial they are tested with, or
78 # be specifying the version(s) of Mercurial they are tested with, or
79 # leave the attribute unspecified.
79 # leave the attribute unspecified.
80 testedwith = 'ships-with-hg-core'
80 testedwith = 'ships-with-hg-core'
81
81
82 cmdtable = {}
82 cmdtable = {}
83 command = registrar.command(cmdtable)
83 command = registrar.command(cmdtable)
84
84
85 configtable = {}
85 configtable = {}
86 configitem = registrar.configitem(configtable)
86 configitem = registrar.configitem(configtable)
87
87
88 # Register the suboptions allowed for each configured fixer.
88 # Register the suboptions allowed for each configured fixer.
89 FIXER_ATTRS = ('command', 'linerange', 'fileset')
89 FIXER_ATTRS = ('command', 'linerange', 'fileset')
90
90
91 for key in FIXER_ATTRS:
91 for key in FIXER_ATTRS:
92 configitem('fix', '.*(:%s)?' % key, default=None, generic=True)
92 configitem('fix', '.*(:%s)?' % key, default=None, generic=True)
93
93
94 # A good default size allows most source code files to be fixed, but avoids
94 # A good default size allows most source code files to be fixed, but avoids
95 # letting fixer tools choke on huge inputs, which could be surprising to the
95 # letting fixer tools choke on huge inputs, which could be surprising to the
96 # user.
96 # user.
97 configitem('fix', 'maxfilesize', default='2MB')
97 configitem('fix', 'maxfilesize', default='2MB')
98
98
99 @command('fix',
99 @command('fix',
100 [('', 'all', False, _('fix all non-public non-obsolete revisions')),
100 [('', 'all', False, _('fix all non-public non-obsolete revisions')),
101 ('', 'base', [], _('revisions to diff against (overrides automatic '
101 ('', 'base', [], _('revisions to diff against (overrides automatic '
102 'selection, and applies to every revision being '
102 'selection, and applies to every revision being '
103 'fixed)'), _('REV')),
103 'fixed)'), _('REV')),
104 ('r', 'rev', [], _('revisions to fix'), _('REV')),
104 ('r', 'rev', [], _('revisions to fix'), _('REV')),
105 ('w', 'working-dir', False, _('fix the working directory')),
105 ('w', 'working-dir', False, _('fix the working directory')),
106 ('', 'whole', False, _('always fix every line of a file'))],
106 ('', 'whole', False, _('always fix every line of a file'))],
107 _('[OPTION]... [FILE]...'))
107 _('[OPTION]... [FILE]...'))
108 def fix(ui, repo, *pats, **opts):
108 def fix(ui, repo, *pats, **opts):
109 """rewrite file content in changesets or working directory
109 """rewrite file content in changesets or working directory
110
110
111 Runs any configured tools to fix the content of files. Only affects files
111 Runs any configured tools to fix the content of files. Only affects files
112 with changes, unless file arguments are provided. Only affects changed lines
112 with changes, unless file arguments are provided. Only affects changed lines
113 of files, unless the --whole flag is used. Some tools may always affect the
113 of files, unless the --whole flag is used. Some tools may always affect the
114 whole file regardless of --whole.
114 whole file regardless of --whole.
115
115
116 If revisions are specified with --rev, those revisions will be checked, and
116 If revisions are specified with --rev, those revisions will be checked, and
117 they may be replaced with new revisions that have fixed file content. It is
117 they may be replaced with new revisions that have fixed file content. It is
118 desirable to specify all descendants of each specified revision, so that the
118 desirable to specify all descendants of each specified revision, so that the
119 fixes propagate to the descendants. If all descendants are fixed at the same
119 fixes propagate to the descendants. If all descendants are fixed at the same
120 time, no merging, rebasing, or evolution will be required.
120 time, no merging, rebasing, or evolution will be required.
121
121
122 If --working-dir is used, files with uncommitted changes in the working copy
122 If --working-dir is used, files with uncommitted changes in the working copy
123 will be fixed. If the checked-out revision is also fixed, the working
123 will be fixed. If the checked-out revision is also fixed, the working
124 directory will update to the replacement revision.
124 directory will update to the replacement revision.
125
125
126 When determining what lines of each file to fix at each revision, the whole
126 When determining what lines of each file to fix at each revision, the whole
127 set of revisions being fixed is considered, so that fixes to earlier
127 set of revisions being fixed is considered, so that fixes to earlier
128 revisions are not forgotten in later ones. The --base flag can be used to
128 revisions are not forgotten in later ones. The --base flag can be used to
129 override this default behavior, though it is not usually desirable to do so.
129 override this default behavior, though it is not usually desirable to do so.
130 """
130 """
131 opts = pycompat.byteskwargs(opts)
131 opts = pycompat.byteskwargs(opts)
132 if opts['all']:
132 if opts['all']:
133 if opts['rev']:
133 if opts['rev']:
134 raise error.Abort(_('cannot specify both "--rev" and "--all"'))
134 raise error.Abort(_('cannot specify both "--rev" and "--all"'))
135 opts['rev'] = ['not public() and not obsolete()']
135 opts['rev'] = ['not public() and not obsolete()']
136 opts['working_dir'] = True
136 opts['working_dir'] = True
137 with repo.wlock(), repo.lock(), repo.transaction('fix'):
137 with repo.wlock(), repo.lock(), repo.transaction('fix'):
138 revstofix = getrevstofix(ui, repo, opts)
138 revstofix = getrevstofix(ui, repo, opts)
139 basectxs = getbasectxs(repo, opts, revstofix)
139 basectxs = getbasectxs(repo, opts, revstofix)
140 workqueue, numitems = getworkqueue(ui, repo, pats, opts, revstofix,
140 workqueue, numitems = getworkqueue(ui, repo, pats, opts, revstofix,
141 basectxs)
141 basectxs)
142 fixers = getfixers(ui)
142 fixers = getfixers(ui)
143
143
144 # There are no data dependencies between the workers fixing each file
144 # There are no data dependencies between the workers fixing each file
145 # revision, so we can use all available parallelism.
145 # revision, so we can use all available parallelism.
146 def getfixes(items):
146 def getfixes(items):
147 for rev, path in items:
147 for rev, path in items:
148 ctx = repo[rev]
148 ctx = repo[rev]
149 olddata = ctx[path].data()
149 olddata = ctx[path].data()
150 newdata = fixfile(ui, opts, fixers, ctx, path, basectxs[rev])
150 newdata = fixfile(ui, opts, fixers, ctx, path, basectxs[rev])
151 # Don't waste memory/time passing unchanged content back, but
151 # Don't waste memory/time passing unchanged content back, but
152 # produce one result per item either way.
152 # produce one result per item either way.
153 yield (rev, path, newdata if newdata != olddata else None)
153 yield (rev, path, newdata if newdata != olddata else None)
154 results = worker.worker(ui, 1.0, getfixes, tuple(), workqueue)
154 results = worker.worker(ui, 1.0, getfixes, tuple(), workqueue)
155
155
156 # We have to hold on to the data for each successor revision in memory
156 # We have to hold on to the data for each successor revision in memory
157 # until all its parents are committed. We ensure this by committing and
157 # until all its parents are committed. We ensure this by committing and
158 # freeing memory for the revisions in some topological order. This
158 # freeing memory for the revisions in some topological order. This
159 # leaves a little bit of memory efficiency on the table, but also makes
159 # leaves a little bit of memory efficiency on the table, but also makes
160 # the tests deterministic. It might also be considered a feature since
160 # the tests deterministic. It might also be considered a feature since
161 # it makes the results more easily reproducible.
161 # it makes the results more easily reproducible.
162 filedata = collections.defaultdict(dict)
162 filedata = collections.defaultdict(dict)
163 replacements = {}
163 replacements = {}
164 commitorder = sorted(revstofix, reverse=True)
164 commitorder = sorted(revstofix, reverse=True)
165 with ui.makeprogress(topic=_('fixing'), unit=_('files'),
165 with ui.makeprogress(topic=_('fixing'), unit=_('files'),
166 total=sum(numitems.values())) as progress:
166 total=sum(numitems.values())) as progress:
167 for rev, path, newdata in results:
167 for rev, path, newdata in results:
168 progress.increment(item=path)
168 progress.increment(item=path)
169 if newdata is not None:
169 if newdata is not None:
170 filedata[rev][path] = newdata
170 filedata[rev][path] = newdata
171 numitems[rev] -= 1
171 numitems[rev] -= 1
172 # Apply the fixes for this and any other revisions that are
172 # Apply the fixes for this and any other revisions that are
173 # ready and sitting at the front of the queue. Using a loop here
173 # ready and sitting at the front of the queue. Using a loop here
174 # prevents the queue from being blocked by the first revision to
174 # prevents the queue from being blocked by the first revision to
175 # be ready out of order.
175 # be ready out of order.
176 while commitorder and not numitems[commitorder[-1]]:
176 while commitorder and not numitems[commitorder[-1]]:
177 rev = commitorder.pop()
177 rev = commitorder.pop()
178 ctx = repo[rev]
178 ctx = repo[rev]
179 if rev == wdirrev:
179 if rev == wdirrev:
180 writeworkingdir(repo, ctx, filedata[rev], replacements)
180 writeworkingdir(repo, ctx, filedata[rev], replacements)
181 else:
181 else:
182 replacerev(ui, repo, ctx, filedata[rev], replacements)
182 replacerev(ui, repo, ctx, filedata[rev], replacements)
183 del filedata[rev]
183 del filedata[rev]
184
184
185 cleanup(repo, replacements, bool(filedata[wdirrev]))
185 cleanup(repo, replacements, bool(filedata[wdirrev]))
186
186
187 def cleanup(repo, replacements, wdirwritten):
187 def cleanup(repo, replacements, wdirwritten):
188 """Calls scmutil.cleanupnodes() with the given replacements.
188 """Calls scmutil.cleanupnodes() with the given replacements.
189
189
190 "replacements" is a dict from nodeid to nodeid, with one key and one value
190 "replacements" is a dict from nodeid to nodeid, with one key and one value
191 for every revision that was affected by fixing. This is slightly different
191 for every revision that was affected by fixing. This is slightly different
192 from cleanupnodes().
192 from cleanupnodes().
193
193
194 "wdirwritten" is a bool which tells whether the working copy was affected by
194 "wdirwritten" is a bool which tells whether the working copy was affected by
195 fixing, since it has no entry in "replacements".
195 fixing, since it has no entry in "replacements".
196
196
197 Useful as a hook point for extending "hg fix" with output summarizing the
197 Useful as a hook point for extending "hg fix" with output summarizing the
198 effects of the command, though we choose not to output anything here.
198 effects of the command, though we choose not to output anything here.
199 """
199 """
200 replacements = {prec: [succ] for prec, succ in replacements.iteritems()}
200 replacements = {prec: [succ] for prec, succ in replacements.iteritems()}
201 scmutil.cleanupnodes(repo, replacements, 'fix', fixphase=True)
201 scmutil.cleanupnodes(repo, replacements, 'fix', fixphase=True)
202
202
203 def getworkqueue(ui, repo, pats, opts, revstofix, basectxs):
203 def getworkqueue(ui, repo, pats, opts, revstofix, basectxs):
204 """"Constructs the list of files to be fixed at specific revisions
204 """"Constructs the list of files to be fixed at specific revisions
205
205
206 It is up to the caller how to consume the work items, and the only
206 It is up to the caller how to consume the work items, and the only
207 dependence between them is that replacement revisions must be committed in
207 dependence between them is that replacement revisions must be committed in
208 topological order. Each work item represents a file in the working copy or
208 topological order. Each work item represents a file in the working copy or
209 in some revision that should be fixed and written back to the working copy
209 in some revision that should be fixed and written back to the working copy
210 or into a replacement revision.
210 or into a replacement revision.
211
211
212 Work items for the same revision are grouped together, so that a worker
212 Work items for the same revision are grouped together, so that a worker
213 pool starting with the first N items in parallel is likely to finish the
213 pool starting with the first N items in parallel is likely to finish the
214 first revision's work before other revisions. This can allow us to write
214 first revision's work before other revisions. This can allow us to write
215 the result to disk and reduce memory footprint. At time of writing, the
215 the result to disk and reduce memory footprint. At time of writing, the
216 partition strategy in worker.py seems favorable to this. We also sort the
216 partition strategy in worker.py seems favorable to this. We also sort the
217 items by ascending revision number to match the order in which we commit
217 items by ascending revision number to match the order in which we commit
218 the fixes later.
218 the fixes later.
219 """
219 """
220 workqueue = []
220 workqueue = []
221 numitems = collections.defaultdict(int)
221 numitems = collections.defaultdict(int)
222 maxfilesize = ui.configbytes('fix', 'maxfilesize')
222 maxfilesize = ui.configbytes('fix', 'maxfilesize')
223 for rev in sorted(revstofix):
223 for rev in sorted(revstofix):
224 fixctx = repo[rev]
224 fixctx = repo[rev]
225 match = scmutil.match(fixctx, pats, opts)
225 match = scmutil.match(fixctx, pats, opts)
226 for path in pathstofix(ui, repo, pats, opts, match, basectxs[rev],
226 for path in pathstofix(ui, repo, pats, opts, match, basectxs[rev],
227 fixctx):
227 fixctx):
228 if path not in fixctx:
228 if path not in fixctx:
229 continue
229 continue
230 fctx = fixctx[path]
230 fctx = fixctx[path]
231 if fctx.islink():
231 if fctx.islink():
232 continue
232 continue
233 if fctx.size() > maxfilesize:
233 if fctx.size() > maxfilesize:
234 ui.warn(_('ignoring file larger than %s: %s\n') %
234 ui.warn(_('ignoring file larger than %s: %s\n') %
235 (util.bytecount(maxfilesize), path))
235 (util.bytecount(maxfilesize), path))
236 continue
236 continue
237 workqueue.append((rev, path))
237 workqueue.append((rev, path))
238 numitems[rev] += 1
238 numitems[rev] += 1
239 return workqueue, numitems
239 return workqueue, numitems
240
240
241 def getrevstofix(ui, repo, opts):
241 def getrevstofix(ui, repo, opts):
242 """Returns the set of revision numbers that should be fixed"""
242 """Returns the set of revision numbers that should be fixed"""
243 revs = set(scmutil.revrange(repo, opts['rev']))
243 revs = set(scmutil.revrange(repo, opts['rev']))
244 for rev in revs:
244 for rev in revs:
245 checkfixablectx(ui, repo, repo[rev])
245 checkfixablectx(ui, repo, repo[rev])
246 if revs:
246 if revs:
247 cmdutil.checkunfinished(repo)
247 cmdutil.checkunfinished(repo)
248 checknodescendants(repo, revs)
248 checknodescendants(repo, revs)
249 if opts.get('working_dir'):
249 if opts.get('working_dir'):
250 revs.add(wdirrev)
250 revs.add(wdirrev)
251 if list(merge.mergestate.read(repo).unresolved()):
251 if list(merge.mergestate.read(repo).unresolved()):
252 raise error.Abort('unresolved conflicts', hint="use 'hg resolve'")
252 raise error.Abort('unresolved conflicts', hint="use 'hg resolve'")
253 if not revs:
253 if not revs:
254 raise error.Abort(
254 raise error.Abort(
255 'no changesets specified', hint='use --rev or --working-dir')
255 'no changesets specified', hint='use --rev or --working-dir')
256 return revs
256 return revs
257
257
258 def checknodescendants(repo, revs):
258 def checknodescendants(repo, revs):
259 if (not obsolete.isenabled(repo, obsolete.allowunstableopt) and
259 if (not obsolete.isenabled(repo, obsolete.allowunstableopt) and
260 repo.revs('(%ld::) - (%ld)', revs, revs)):
260 repo.revs('(%ld::) - (%ld)', revs, revs)):
261 raise error.Abort(_('can only fix a changeset together '
261 raise error.Abort(_('can only fix a changeset together '
262 'with all its descendants'))
262 'with all its descendants'))
263
263
264 def checkfixablectx(ui, repo, ctx):
264 def checkfixablectx(ui, repo, ctx):
265 """Aborts if the revision shouldn't be replaced with a fixed one."""
265 """Aborts if the revision shouldn't be replaced with a fixed one."""
266 if not ctx.mutable():
266 if not ctx.mutable():
267 raise error.Abort('can\'t fix immutable changeset %s' %
267 raise error.Abort('can\'t fix immutable changeset %s' %
268 (scmutil.formatchangeid(ctx),))
268 (scmutil.formatchangeid(ctx),))
269 if ctx.obsolete():
269 if ctx.obsolete():
270 # It would be better to actually check if the revision has a successor.
270 # It would be better to actually check if the revision has a successor.
271 allowdivergence = ui.configbool('experimental',
271 allowdivergence = ui.configbool('experimental',
272 'evolution.allowdivergence')
272 'evolution.allowdivergence')
273 if not allowdivergence:
273 if not allowdivergence:
274 raise error.Abort('fixing obsolete revision could cause divergence')
274 raise error.Abort('fixing obsolete revision could cause divergence')
275
275
276 def pathstofix(ui, repo, pats, opts, match, basectxs, fixctx):
276 def pathstofix(ui, repo, pats, opts, match, basectxs, fixctx):
277 """Returns the set of files that should be fixed in a context
277 """Returns the set of files that should be fixed in a context
278
278
279 The result depends on the base contexts; we include any file that has
279 The result depends on the base contexts; we include any file that has
280 changed relative to any of the base contexts. Base contexts should be
280 changed relative to any of the base contexts. Base contexts should be
281 ancestors of the context being fixed.
281 ancestors of the context being fixed.
282 """
282 """
283 files = set()
283 files = set()
284 for basectx in basectxs:
284 for basectx in basectxs:
285 stat = basectx.status(fixctx, match=match, listclean=bool(pats),
285 stat = basectx.status(fixctx, match=match, listclean=bool(pats),
286 listunknown=bool(pats))
286 listunknown=bool(pats))
287 files.update(
287 files.update(
288 set(itertools.chain(stat.added, stat.modified, stat.clean,
288 set(itertools.chain(stat.added, stat.modified, stat.clean,
289 stat.unknown)))
289 stat.unknown)))
290 return files
290 return files
291
291
292 def lineranges(opts, path, basectxs, fixctx, content2):
292 def lineranges(opts, path, basectxs, fixctx, content2):
293 """Returns the set of line ranges that should be fixed in a file
293 """Returns the set of line ranges that should be fixed in a file
294
294
295 Of the form [(10, 20), (30, 40)].
295 Of the form [(10, 20), (30, 40)].
296
296
297 This depends on the given base contexts; we must consider lines that have
297 This depends on the given base contexts; we must consider lines that have
298 changed versus any of the base contexts, and whether the file has been
298 changed versus any of the base contexts, and whether the file has been
299 renamed versus any of them.
299 renamed versus any of them.
300
300
301 Another way to understand this is that we exclude line ranges that are
301 Another way to understand this is that we exclude line ranges that are
302 common to the file in all base contexts.
302 common to the file in all base contexts.
303 """
303 """
304 if opts.get('whole'):
304 if opts.get('whole'):
305 # Return a range containing all lines. Rely on the diff implementation's
305 # Return a range containing all lines. Rely on the diff implementation's
306 # idea of how many lines are in the file, instead of reimplementing it.
306 # idea of how many lines are in the file, instead of reimplementing it.
307 return difflineranges('', content2)
307 return difflineranges('', content2)
308
308
309 rangeslist = []
309 rangeslist = []
310 for basectx in basectxs:
310 for basectx in basectxs:
311 basepath = copies.pathcopies(basectx, fixctx).get(path, path)
311 basepath = copies.pathcopies(basectx, fixctx).get(path, path)
312 if basepath in basectx:
312 if basepath in basectx:
313 content1 = basectx[basepath].data()
313 content1 = basectx[basepath].data()
314 else:
314 else:
315 content1 = ''
315 content1 = ''
316 rangeslist.extend(difflineranges(content1, content2))
316 rangeslist.extend(difflineranges(content1, content2))
317 return unionranges(rangeslist)
317 return unionranges(rangeslist)
318
318
319 def unionranges(rangeslist):
319 def unionranges(rangeslist):
320 """Return the union of some closed intervals
320 """Return the union of some closed intervals
321
321
322 >>> unionranges([])
322 >>> unionranges([])
323 []
323 []
324 >>> unionranges([(1, 100)])
324 >>> unionranges([(1, 100)])
325 [(1, 100)]
325 [(1, 100)]
326 >>> unionranges([(1, 100), (1, 100)])
326 >>> unionranges([(1, 100), (1, 100)])
327 [(1, 100)]
327 [(1, 100)]
328 >>> unionranges([(1, 100), (2, 100)])
328 >>> unionranges([(1, 100), (2, 100)])
329 [(1, 100)]
329 [(1, 100)]
330 >>> unionranges([(1, 99), (1, 100)])
330 >>> unionranges([(1, 99), (1, 100)])
331 [(1, 100)]
331 [(1, 100)]
332 >>> unionranges([(1, 100), (40, 60)])
332 >>> unionranges([(1, 100), (40, 60)])
333 [(1, 100)]
333 [(1, 100)]
334 >>> unionranges([(1, 49), (50, 100)])
334 >>> unionranges([(1, 49), (50, 100)])
335 [(1, 100)]
335 [(1, 100)]
336 >>> unionranges([(1, 48), (50, 100)])
336 >>> unionranges([(1, 48), (50, 100)])
337 [(1, 48), (50, 100)]
337 [(1, 48), (50, 100)]
338 >>> unionranges([(1, 2), (3, 4), (5, 6)])
338 >>> unionranges([(1, 2), (3, 4), (5, 6)])
339 [(1, 6)]
339 [(1, 6)]
340 """
340 """
341 rangeslist = sorted(set(rangeslist))
341 rangeslist = sorted(set(rangeslist))
342 unioned = []
342 unioned = []
343 if rangeslist:
343 if rangeslist:
344 unioned, rangeslist = [rangeslist[0]], rangeslist[1:]
344 unioned, rangeslist = [rangeslist[0]], rangeslist[1:]
345 for a, b in rangeslist:
345 for a, b in rangeslist:
346 c, d = unioned[-1]
346 c, d = unioned[-1]
347 if a > d + 1:
347 if a > d + 1:
348 unioned.append((a, b))
348 unioned.append((a, b))
349 else:
349 else:
350 unioned[-1] = (c, max(b, d))
350 unioned[-1] = (c, max(b, d))
351 return unioned
351 return unioned
352
352
353 def difflineranges(content1, content2):
353 def difflineranges(content1, content2):
354 """Return list of line number ranges in content2 that differ from content1.
354 """Return list of line number ranges in content2 that differ from content1.
355
355
356 Line numbers are 1-based. The numbers are the first and last line contained
356 Line numbers are 1-based. The numbers are the first and last line contained
357 in the range. Single-line ranges have the same line number for the first and
357 in the range. Single-line ranges have the same line number for the first and
358 last line. Excludes any empty ranges that result from lines that are only
358 last line. Excludes any empty ranges that result from lines that are only
359 present in content1. Relies on mdiff's idea of where the line endings are in
359 present in content1. Relies on mdiff's idea of where the line endings are in
360 the string.
360 the string.
361
361
362 >>> from mercurial import pycompat
362 >>> from mercurial import pycompat
363 >>> lines = lambda s: b'\\n'.join([c for c in pycompat.iterbytestr(s)])
363 >>> lines = lambda s: b'\\n'.join([c for c in pycompat.iterbytestr(s)])
364 >>> difflineranges2 = lambda a, b: difflineranges(lines(a), lines(b))
364 >>> difflineranges2 = lambda a, b: difflineranges(lines(a), lines(b))
365 >>> difflineranges2(b'', b'')
365 >>> difflineranges2(b'', b'')
366 []
366 []
367 >>> difflineranges2(b'a', b'')
367 >>> difflineranges2(b'a', b'')
368 []
368 []
369 >>> difflineranges2(b'', b'A')
369 >>> difflineranges2(b'', b'A')
370 [(1, 1)]
370 [(1, 1)]
371 >>> difflineranges2(b'a', b'a')
371 >>> difflineranges2(b'a', b'a')
372 []
372 []
373 >>> difflineranges2(b'a', b'A')
373 >>> difflineranges2(b'a', b'A')
374 [(1, 1)]
374 [(1, 1)]
375 >>> difflineranges2(b'ab', b'')
375 >>> difflineranges2(b'ab', b'')
376 []
376 []
377 >>> difflineranges2(b'', b'AB')
377 >>> difflineranges2(b'', b'AB')
378 [(1, 2)]
378 [(1, 2)]
379 >>> difflineranges2(b'abc', b'ac')
379 >>> difflineranges2(b'abc', b'ac')
380 []
380 []
381 >>> difflineranges2(b'ab', b'aCb')
381 >>> difflineranges2(b'ab', b'aCb')
382 [(2, 2)]
382 [(2, 2)]
383 >>> difflineranges2(b'abc', b'aBc')
383 >>> difflineranges2(b'abc', b'aBc')
384 [(2, 2)]
384 [(2, 2)]
385 >>> difflineranges2(b'ab', b'AB')
385 >>> difflineranges2(b'ab', b'AB')
386 [(1, 2)]
386 [(1, 2)]
387 >>> difflineranges2(b'abcde', b'aBcDe')
387 >>> difflineranges2(b'abcde', b'aBcDe')
388 [(2, 2), (4, 4)]
388 [(2, 2), (4, 4)]
389 >>> difflineranges2(b'abcde', b'aBCDe')
389 >>> difflineranges2(b'abcde', b'aBCDe')
390 [(2, 4)]
390 [(2, 4)]
391 """
391 """
392 ranges = []
392 ranges = []
393 for lines, kind in mdiff.allblocks(content1, content2):
393 for lines, kind in mdiff.allblocks(content1, content2):
394 firstline, lastline = lines[2:4]
394 firstline, lastline = lines[2:4]
395 if kind == '!' and firstline != lastline:
395 if kind == '!' and firstline != lastline:
396 ranges.append((firstline + 1, lastline))
396 ranges.append((firstline + 1, lastline))
397 return ranges
397 return ranges
398
398
399 def getbasectxs(repo, opts, revstofix):
399 def getbasectxs(repo, opts, revstofix):
400 """Returns a map of the base contexts for each revision
400 """Returns a map of the base contexts for each revision
401
401
402 The base contexts determine which lines are considered modified when we
402 The base contexts determine which lines are considered modified when we
403 attempt to fix just the modified lines in a file. It also determines which
403 attempt to fix just the modified lines in a file. It also determines which
404 files we attempt to fix, so it is important to compute this even when
404 files we attempt to fix, so it is important to compute this even when
405 --whole is used.
405 --whole is used.
406 """
406 """
407 # The --base flag overrides the usual logic, and we give every revision
407 # The --base flag overrides the usual logic, and we give every revision
408 # exactly the set of baserevs that the user specified.
408 # exactly the set of baserevs that the user specified.
409 if opts.get('base'):
409 if opts.get('base'):
410 baserevs = set(scmutil.revrange(repo, opts.get('base')))
410 baserevs = set(scmutil.revrange(repo, opts.get('base')))
411 if not baserevs:
411 if not baserevs:
412 baserevs = {nullrev}
412 baserevs = {nullrev}
413 basectxs = {repo[rev] for rev in baserevs}
413 basectxs = {repo[rev] for rev in baserevs}
414 return {rev: basectxs for rev in revstofix}
414 return {rev: basectxs for rev in revstofix}
415
415
416 # Proceed in topological order so that we can easily determine each
416 # Proceed in topological order so that we can easily determine each
417 # revision's baserevs by looking at its parents and their baserevs.
417 # revision's baserevs by looking at its parents and their baserevs.
418 basectxs = collections.defaultdict(set)
418 basectxs = collections.defaultdict(set)
419 for rev in sorted(revstofix):
419 for rev in sorted(revstofix):
420 ctx = repo[rev]
420 ctx = repo[rev]
421 for pctx in ctx.parents():
421 for pctx in ctx.parents():
422 if pctx.rev() in basectxs:
422 if pctx.rev() in basectxs:
423 basectxs[rev].update(basectxs[pctx.rev()])
423 basectxs[rev].update(basectxs[pctx.rev()])
424 else:
424 else:
425 basectxs[rev].add(pctx)
425 basectxs[rev].add(pctx)
426 return basectxs
426 return basectxs
427
427
428 def fixfile(ui, opts, fixers, fixctx, path, basectxs):
428 def fixfile(ui, opts, fixers, fixctx, path, basectxs):
429 """Run any configured fixers that should affect the file in this context
429 """Run any configured fixers that should affect the file in this context
430
430
431 Returns the file content that results from applying the fixers in some order
431 Returns the file content that results from applying the fixers in some order
432 starting with the file's content in the fixctx. Fixers that support line
432 starting with the file's content in the fixctx. Fixers that support line
433 ranges will affect lines that have changed relative to any of the basectxs
433 ranges will affect lines that have changed relative to any of the basectxs
434 (i.e. they will only avoid lines that are common to all basectxs).
434 (i.e. they will only avoid lines that are common to all basectxs).
435 """
435 """
436 newdata = fixctx[path].data()
436 newdata = fixctx[path].data()
437 for fixername, fixer in fixers.iteritems():
437 for fixername, fixer in fixers.iteritems():
438 if fixer.affects(opts, fixctx, path):
438 if fixer.affects(opts, fixctx, path):
439 ranges = lineranges(opts, path, basectxs, fixctx, newdata)
439 rangesfn = lambda: lineranges(opts, path, basectxs, fixctx, newdata)
440 command = fixer.command(ui, path, ranges)
440 command = fixer.command(ui, path, rangesfn)
441 if command is None:
441 if command is None:
442 continue
442 continue
443 ui.debug('subprocess: %s\n' % (command,))
443 ui.debug('subprocess: %s\n' % (command,))
444 proc = subprocess.Popen(
444 proc = subprocess.Popen(
445 command,
445 command,
446 shell=True,
446 shell=True,
447 cwd='/',
447 cwd='/',
448 stdin=subprocess.PIPE,
448 stdin=subprocess.PIPE,
449 stdout=subprocess.PIPE,
449 stdout=subprocess.PIPE,
450 stderr=subprocess.PIPE)
450 stderr=subprocess.PIPE)
451 newerdata, stderr = proc.communicate(newdata)
451 newerdata, stderr = proc.communicate(newdata)
452 if stderr:
452 if stderr:
453 showstderr(ui, fixctx.rev(), fixername, stderr)
453 showstderr(ui, fixctx.rev(), fixername, stderr)
454 else:
454 else:
455 newdata = newerdata
455 newdata = newerdata
456 return newdata
456 return newdata
457
457
458 def showstderr(ui, rev, fixername, stderr):
458 def showstderr(ui, rev, fixername, stderr):
459 """Writes the lines of the stderr string as warnings on the ui
459 """Writes the lines of the stderr string as warnings on the ui
460
460
461 Uses the revision number and fixername to give more context to each line of
461 Uses the revision number and fixername to give more context to each line of
462 the error message. Doesn't include file names, since those take up a lot of
462 the error message. Doesn't include file names, since those take up a lot of
463 space and would tend to be included in the error message if they were
463 space and would tend to be included in the error message if they were
464 relevant.
464 relevant.
465 """
465 """
466 for line in re.split('[\r\n]+', stderr):
466 for line in re.split('[\r\n]+', stderr):
467 if line:
467 if line:
468 ui.warn(('['))
468 ui.warn(('['))
469 if rev is None:
469 if rev is None:
470 ui.warn(_('wdir'), label='evolve.rev')
470 ui.warn(_('wdir'), label='evolve.rev')
471 else:
471 else:
472 ui.warn((str(rev)), label='evolve.rev')
472 ui.warn((str(rev)), label='evolve.rev')
473 ui.warn(('] %s: %s\n') % (fixername, line))
473 ui.warn(('] %s: %s\n') % (fixername, line))
474
474
475 def writeworkingdir(repo, ctx, filedata, replacements):
475 def writeworkingdir(repo, ctx, filedata, replacements):
476 """Write new content to the working copy and check out the new p1 if any
476 """Write new content to the working copy and check out the new p1 if any
477
477
478 We check out a new revision if and only if we fixed something in both the
478 We check out a new revision if and only if we fixed something in both the
479 working directory and its parent revision. This avoids the need for a full
479 working directory and its parent revision. This avoids the need for a full
480 update/merge, and means that the working directory simply isn't affected
480 update/merge, and means that the working directory simply isn't affected
481 unless the --working-dir flag is given.
481 unless the --working-dir flag is given.
482
482
483 Directly updates the dirstate for the affected files.
483 Directly updates the dirstate for the affected files.
484 """
484 """
485 for path, data in filedata.iteritems():
485 for path, data in filedata.iteritems():
486 fctx = ctx[path]
486 fctx = ctx[path]
487 fctx.write(data, fctx.flags())
487 fctx.write(data, fctx.flags())
488 if repo.dirstate[path] == 'n':
488 if repo.dirstate[path] == 'n':
489 repo.dirstate.normallookup(path)
489 repo.dirstate.normallookup(path)
490
490
491 oldparentnodes = repo.dirstate.parents()
491 oldparentnodes = repo.dirstate.parents()
492 newparentnodes = [replacements.get(n, n) for n in oldparentnodes]
492 newparentnodes = [replacements.get(n, n) for n in oldparentnodes]
493 if newparentnodes != oldparentnodes:
493 if newparentnodes != oldparentnodes:
494 repo.setparents(*newparentnodes)
494 repo.setparents(*newparentnodes)
495
495
496 def replacerev(ui, repo, ctx, filedata, replacements):
496 def replacerev(ui, repo, ctx, filedata, replacements):
497 """Commit a new revision like the given one, but with file content changes
497 """Commit a new revision like the given one, but with file content changes
498
498
499 "ctx" is the original revision to be replaced by a modified one.
499 "ctx" is the original revision to be replaced by a modified one.
500
500
501 "filedata" is a dict that maps paths to their new file content. All other
501 "filedata" is a dict that maps paths to their new file content. All other
502 paths will be recreated from the original revision without changes.
502 paths will be recreated from the original revision without changes.
503 "filedata" may contain paths that didn't exist in the original revision;
503 "filedata" may contain paths that didn't exist in the original revision;
504 they will be added.
504 they will be added.
505
505
506 "replacements" is a dict that maps a single node to a single node, and it is
506 "replacements" is a dict that maps a single node to a single node, and it is
507 updated to indicate the original revision is replaced by the newly created
507 updated to indicate the original revision is replaced by the newly created
508 one. No entry is added if the replacement's node already exists.
508 one. No entry is added if the replacement's node already exists.
509
509
510 The new revision has the same parents as the old one, unless those parents
510 The new revision has the same parents as the old one, unless those parents
511 have already been replaced, in which case those replacements are the parents
511 have already been replaced, in which case those replacements are the parents
512 of this new revision. Thus, if revisions are replaced in topological order,
512 of this new revision. Thus, if revisions are replaced in topological order,
513 there is no need to rebase them into the original topology later.
513 there is no need to rebase them into the original topology later.
514 """
514 """
515
515
516 p1rev, p2rev = repo.changelog.parentrevs(ctx.rev())
516 p1rev, p2rev = repo.changelog.parentrevs(ctx.rev())
517 p1ctx, p2ctx = repo[p1rev], repo[p2rev]
517 p1ctx, p2ctx = repo[p1rev], repo[p2rev]
518 newp1node = replacements.get(p1ctx.node(), p1ctx.node())
518 newp1node = replacements.get(p1ctx.node(), p1ctx.node())
519 newp2node = replacements.get(p2ctx.node(), p2ctx.node())
519 newp2node = replacements.get(p2ctx.node(), p2ctx.node())
520
520
521 def filectxfn(repo, memctx, path):
521 def filectxfn(repo, memctx, path):
522 if path not in ctx:
522 if path not in ctx:
523 return None
523 return None
524 fctx = ctx[path]
524 fctx = ctx[path]
525 copied = fctx.renamed()
525 copied = fctx.renamed()
526 if copied:
526 if copied:
527 copied = copied[0]
527 copied = copied[0]
528 return context.memfilectx(
528 return context.memfilectx(
529 repo,
529 repo,
530 memctx,
530 memctx,
531 path=fctx.path(),
531 path=fctx.path(),
532 data=filedata.get(path, fctx.data()),
532 data=filedata.get(path, fctx.data()),
533 islink=fctx.islink(),
533 islink=fctx.islink(),
534 isexec=fctx.isexec(),
534 isexec=fctx.isexec(),
535 copied=copied)
535 copied=copied)
536
536
537 memctx = context.memctx(
537 memctx = context.memctx(
538 repo,
538 repo,
539 parents=(newp1node, newp2node),
539 parents=(newp1node, newp2node),
540 text=ctx.description(),
540 text=ctx.description(),
541 files=set(ctx.files()) | set(filedata.keys()),
541 files=set(ctx.files()) | set(filedata.keys()),
542 filectxfn=filectxfn,
542 filectxfn=filectxfn,
543 user=ctx.user(),
543 user=ctx.user(),
544 date=ctx.date(),
544 date=ctx.date(),
545 extra=ctx.extra(),
545 extra=ctx.extra(),
546 branch=ctx.branch(),
546 branch=ctx.branch(),
547 editor=None)
547 editor=None)
548 sucnode = memctx.commit()
548 sucnode = memctx.commit()
549 prenode = ctx.node()
549 prenode = ctx.node()
550 if prenode == sucnode:
550 if prenode == sucnode:
551 ui.debug('node %s already existed\n' % (ctx.hex()))
551 ui.debug('node %s already existed\n' % (ctx.hex()))
552 else:
552 else:
553 replacements[ctx.node()] = sucnode
553 replacements[ctx.node()] = sucnode
554
554
555 def getfixers(ui):
555 def getfixers(ui):
556 """Returns a map of configured fixer tools indexed by their names
556 """Returns a map of configured fixer tools indexed by their names
557
557
558 Each value is a Fixer object with methods that implement the behavior of the
558 Each value is a Fixer object with methods that implement the behavior of the
559 fixer's config suboptions. Does not validate the config values.
559 fixer's config suboptions. Does not validate the config values.
560 """
560 """
561 result = {}
561 result = {}
562 for name in fixernames(ui):
562 for name in fixernames(ui):
563 result[name] = Fixer()
563 result[name] = Fixer()
564 attrs = ui.configsuboptions('fix', name)[1]
564 attrs = ui.configsuboptions('fix', name)[1]
565 for key in FIXER_ATTRS:
565 for key in FIXER_ATTRS:
566 setattr(result[name], pycompat.sysstr('_' + key),
566 setattr(result[name], pycompat.sysstr('_' + key),
567 attrs.get(key, ''))
567 attrs.get(key, ''))
568 return result
568 return result
569
569
570 def fixernames(ui):
570 def fixernames(ui):
571 """Returns the names of [fix] config options that have suboptions"""
571 """Returns the names of [fix] config options that have suboptions"""
572 names = set()
572 names = set()
573 for k, v in ui.configitems('fix'):
573 for k, v in ui.configitems('fix'):
574 if ':' in k:
574 if ':' in k:
575 names.add(k.split(':', 1)[0])
575 names.add(k.split(':', 1)[0])
576 return names
576 return names
577
577
578 class Fixer(object):
578 class Fixer(object):
579 """Wraps the raw config values for a fixer with methods"""
579 """Wraps the raw config values for a fixer with methods"""
580
580
581 def affects(self, opts, fixctx, path):
581 def affects(self, opts, fixctx, path):
582 """Should this fixer run on the file at the given path and context?"""
582 """Should this fixer run on the file at the given path and context?"""
583 return scmutil.match(fixctx, [self._fileset], opts)(path)
583 return scmutil.match(fixctx, [self._fileset], opts)(path)
584
584
585 def command(self, ui, path, ranges):
585 def command(self, ui, path, rangesfn):
586 """A shell command to use to invoke this fixer on the given file/lines
586 """A shell command to use to invoke this fixer on the given file/lines
587
587
588 May return None if there is no appropriate command to run for the given
588 May return None if there is no appropriate command to run for the given
589 parameters.
589 parameters.
590 """
590 """
591 expand = cmdutil.rendercommandtemplate
591 expand = cmdutil.rendercommandtemplate
592 parts = [expand(ui, self._command,
592 parts = [expand(ui, self._command,
593 {'rootpath': path, 'basename': os.path.basename(path)})]
593 {'rootpath': path, 'basename': os.path.basename(path)})]
594 if self._linerange:
594 if self._linerange:
595 ranges = rangesfn()
595 if not ranges:
596 if not ranges:
596 # No line ranges to fix, so don't run the fixer.
597 # No line ranges to fix, so don't run the fixer.
597 return None
598 return None
598 for first, last in ranges:
599 for first, last in ranges:
599 parts.append(expand(ui, self._linerange,
600 parts.append(expand(ui, self._linerange,
600 {'first': first, 'last': last}))
601 {'first': first, 'last': last}))
601 return ' '.join(parts)
602 return ' '.join(parts)
General Comments 0
You need to be logged in to leave comments. Login now