Mercurial > hg-stable
comparison hgext/record.py @ 16324:46b991a1f428
record: allow splitting of hunks by manually editing patches
It is possible that unrelated changes in a file are on sequential lines. The
current record extension does not allow these to be committed independently.
An example use case for this is in software development for deeply embedded
real-time systems. In these environments, it is not always possible to use a
debugger (due to time-constraints) and hence inline UART-based printing is
often used. When fixing a bug in a module, it is often convenient to add a
large number of 'printf's (linked to the UART via a custom fputc) to the module
in order to work out what is going wrong. printf is a very slow function (and
also variadic so somewhat frowned upon by the MISRA standard) and hence it is
highly undesirable to commit these lines to the repository. If only a partial
fix is implemented, however, it is desirable to commit the fix without deleting
all of the printf lines. This is also simplifies removal of the printf lines
as once the final fix is committed, 'hg revert' does the rest. It is likely
that the printf lines will be very near the actual fix, so being able to split
the hunk is very useful in this case.
There were two alternatives I considered for the user interface. One was to
manually edit the patch, the other to allow a hunk to be split into individual
lines for consideration. The latter option would require a significant
refactor of the record module and is less flexible. While the former is
potentially more complicated to use, this is a feature that is likely to only
be used in certain exceptional cases (such as the use case proposed above) and
hence I felt that the complexity would not be a considerable issue.
I've also written a follow-up patch that refactors the 'prompt' code to base
everything on the choices variable. This tidies up and clarifies the code a
bit (removes constructs like 'if ret == 7' and removes the 'e' option from the
file scope options as it's not relevant there. It's not really a necessity, so
I've excluded it from this submission for now, but I can send it separately if
there's a desire and it's on bitbucket (see below) in the meantime.
Possible future improvements include:
* Tidying up the 'prompt' code to base everything on the choices variable.
This would allow entries to be removed from the prompt as currently 'e' is
offered even for entire file patches, which is currently unsupported.
* Allowing the entire file (or even multi-file) patch to be edited manually:
this would require quite a large refactor without much benefit, so I decided
to exclude it from the initial submission.
* Allow the option to retry if a patch fails to apply (this is what Git does).
This would require quite a bit of refactoring given the current 'hg record'
implementation, so it's debatable whether it's worth it.
Output is similar to existing record user interface except that an additional
option ('e') exists to allow manual editing of the patch. This opens the
user's configured editor with the patch. A comment is added to the bottom of
the patch explaining what to do (based on Git's one).
A large proportion of the changeset is test-case changes to update the options
reported by record (Ynesfdaq? instead of Ynsfdaq?). Functional changes are in
record.py and there are some new test cases in test-record.t.
author | A. S. Budden <abudden@gmail.com> |
---|---|
date | Fri, 30 Mar 2012 22:08:46 +0100 |
parents | 351a9292e430 |
children | d388c3fc8319 |
comparison
equal
deleted
inserted
replaced
16318:73f4e05287b4 | 16324:46b991a1f428 |
---|---|
259 return p.finished() | 259 return p.finished() |
260 | 260 |
261 def filterpatch(ui, headers): | 261 def filterpatch(ui, headers): |
262 """Interactively filter patch chunks into applied-only chunks""" | 262 """Interactively filter patch chunks into applied-only chunks""" |
263 | 263 |
264 def prompt(skipfile, skipall, query): | 264 def prompt(skipfile, skipall, query, chunk): |
265 """prompt query, and process base inputs | 265 """prompt query, and process base inputs |
266 | 266 |
267 - y/n for the rest of file | 267 - y/n for the rest of file |
268 - y/n for the rest | 268 - y/n for the rest |
269 - ? (help) | 269 - ? (help) |
270 - q (quit) | 270 - q (quit) |
271 | 271 |
272 Return True/False and possibly updated skipfile and skipall. | 272 Return True/False and possibly updated skipfile and skipall. |
273 """ | 273 """ |
274 newpatches = None | |
274 if skipall is not None: | 275 if skipall is not None: |
275 return skipall, skipfile, skipall | 276 return skipall, skipfile, skipall, newpatches |
276 if skipfile is not None: | 277 if skipfile is not None: |
277 return skipfile, skipfile, skipall | 278 return skipfile, skipfile, skipall, newpatches |
278 while True: | 279 while True: |
279 resps = _('[Ynsfdaq?]') | 280 resps = _('[Ynesfdaq?]') |
280 choices = (_('&Yes, record this change'), | 281 choices = (_('&Yes, record this change'), |
281 _('&No, skip this change'), | 282 _('&No, skip this change'), |
283 _('&Edit the change manually'), | |
282 _('&Skip remaining changes to this file'), | 284 _('&Skip remaining changes to this file'), |
283 _('Record remaining changes to this &file'), | 285 _('Record remaining changes to this &file'), |
284 _('&Done, skip remaining changes and files'), | 286 _('&Done, skip remaining changes and files'), |
285 _('Record &all changes to all remaining files'), | 287 _('Record &all changes to all remaining files'), |
286 _('&Quit, recording no changes'), | 288 _('&Quit, recording no changes'), |
287 _('&?')) | 289 _('&?')) |
288 r = ui.promptchoice("%s %s" % (query, resps), choices) | 290 r = ui.promptchoice("%s %s" % (query, resps), choices) |
289 ui.write("\n") | 291 ui.write("\n") |
290 if r == 7: # ? | 292 if r == 8: # ? |
291 doc = gettext(record.__doc__) | 293 doc = gettext(record.__doc__) |
292 c = doc.find('::') + 2 | 294 c = doc.find('::') + 2 |
293 for l in doc[c:].splitlines(): | 295 for l in doc[c:].splitlines(): |
294 if l.startswith(' '): | 296 if l.startswith(' '): |
295 ui.write(l.strip(), '\n') | 297 ui.write(l.strip(), '\n') |
296 continue | 298 continue |
297 elif r == 0: # yes | 299 elif r == 0: # yes |
298 ret = True | 300 ret = True |
299 elif r == 1: # no | 301 elif r == 1: # no |
300 ret = False | 302 ret = False |
301 elif r == 2: # Skip | 303 elif r == 2: # Edit patch |
304 if chunk is None: | |
305 ui.write(_('cannot edit patch for whole file')) | |
306 ui.write("\n") | |
307 continue | |
308 if chunk.header.binary(): | |
309 ui.write(_('cannot edit patch for binary file')) | |
310 ui.write("\n") | |
311 continue | |
312 # Patch comment based on the Git one (based on comment at end of | |
313 # http://mercurial.selenic.com/wiki/RecordExtension) | |
314 phelp = '---' + _(""" | |
315 To remove '-' lines, make them ' ' lines (context). | |
316 To remove '+' lines, delete them. | |
317 Lines starting with # will be removed from the patch. | |
318 | |
319 If the patch applies cleanly, the edited hunk will immediately be | |
320 added to the record list. If it does not apply cleanly, a rejects | |
321 file will be generated: you can use that when you try again. If | |
322 all lines of the hunk are removed, then the edit is aborted and | |
323 the hunk is left unchanged. | |
324 """) | |
325 (patchfd, patchfn) = tempfile.mkstemp(prefix="hg-editor-", | |
326 suffix=".diff", text=True) | |
327 try: | |
328 # Write the initial patch | |
329 f = os.fdopen(patchfd, "w") | |
330 chunk.header.write(f) | |
331 chunk.write(f) | |
332 f.write('\n'.join(['# ' + i for i in phelp.splitlines()])) | |
333 f.close() | |
334 # Start the editor and wait for it to complete | |
335 editor = ui.geteditor() | |
336 util.system("%s \"%s\"" % (editor, patchfn), | |
337 environ={'HGUSER': ui.username()}, | |
338 onerr=util.Abort, errprefix=_("edit failed"), | |
339 out=ui.fout) | |
340 # Remove comment lines | |
341 patchfp = open(patchfn) | |
342 ncpatchfp = cStringIO.StringIO() | |
343 for line in patchfp: | |
344 if not line.startswith('#'): | |
345 ncpatchfp.write(line) | |
346 patchfp.close() | |
347 ncpatchfp.seek(0) | |
348 newpatches = parsepatch(ncpatchfp) | |
349 finally: | |
350 os.unlink(patchfn) | |
351 del ncpatchfp | |
352 # Signal that the chunk shouldn't be applied as-is, but | |
353 # provide the new patch to be used instead. | |
354 ret = False | |
355 elif r == 3: # Skip | |
302 ret = skipfile = False | 356 ret = skipfile = False |
303 elif r == 3: # file (Record remaining) | 357 elif r == 4: # file (Record remaining) |
304 ret = skipfile = True | 358 ret = skipfile = True |
305 elif r == 4: # done, skip remaining | 359 elif r == 5: # done, skip remaining |
306 ret = skipall = False | 360 ret = skipall = False |
307 elif r == 5: # all | 361 elif r == 6: # all |
308 ret = skipall = True | 362 ret = skipall = True |
309 elif r == 6: # quit | 363 elif r == 7: # quit |
310 raise util.Abort(_('user quit')) | 364 raise util.Abort(_('user quit')) |
311 return ret, skipfile, skipall | 365 return ret, skipfile, skipall, newpatches |
312 | 366 |
313 seen = set() | 367 seen = set() |
314 applied = {} # 'filename' -> [] of chunks | 368 applied = {} # 'filename' -> [] of chunks |
315 skipfile, skipall = None, None | 369 skipfile, skipall = None, None |
316 pos, total = 1, sum(len(h.hunks) for h in headers) | 370 pos, total = 1, sum(len(h.hunks) for h in headers) |
324 seen.add(hdr) | 378 seen.add(hdr) |
325 if skipall is None: | 379 if skipall is None: |
326 h.pretty(ui) | 380 h.pretty(ui) |
327 msg = (_('examine changes to %s?') % | 381 msg = (_('examine changes to %s?') % |
328 _(' and ').join(map(repr, h.files()))) | 382 _(' and ').join(map(repr, h.files()))) |
329 r, skipfile, skipall = prompt(skipfile, skipall, msg) | 383 r, skipfile, skipall, np = prompt(skipfile, skipall, msg, None) |
330 if not r: | 384 if not r: |
331 continue | 385 continue |
332 applied[h.filename()] = [h] | 386 applied[h.filename()] = [h] |
333 if h.allhunks(): | 387 if h.allhunks(): |
334 applied[h.filename()] += h.hunks | 388 applied[h.filename()] += h.hunks |
340 msg = _('record this change to %r?') % chunk.filename() | 394 msg = _('record this change to %r?') % chunk.filename() |
341 else: | 395 else: |
342 idx = pos - len(h.hunks) + i | 396 idx = pos - len(h.hunks) + i |
343 msg = _('record change %d/%d to %r?') % (idx, total, | 397 msg = _('record change %d/%d to %r?') % (idx, total, |
344 chunk.filename()) | 398 chunk.filename()) |
345 r, skipfile, skipall = prompt(skipfile, skipall, msg) | 399 r, skipfile, skipall, newpatches = prompt(skipfile, |
400 skipall, msg, chunk) | |
346 if r: | 401 if r: |
347 if fixoffset: | 402 if fixoffset: |
348 chunk = copy.copy(chunk) | 403 chunk = copy.copy(chunk) |
349 chunk.toline += fixoffset | 404 chunk.toline += fixoffset |
350 applied[chunk.filename()].append(chunk) | 405 applied[chunk.filename()].append(chunk) |
406 elif newpatches is not None: | |
407 for newpatch in newpatches: | |
408 for newhunk in newpatch.hunks: | |
409 if fixoffset: | |
410 newhunk.toline += fixoffset | |
411 applied[newhunk.filename()].append(newhunk) | |
351 else: | 412 else: |
352 fixoffset += chunk.removed - chunk.added | 413 fixoffset += chunk.removed - chunk.added |
353 return sum([h for h in applied.itervalues() | 414 return sum([h for h in applied.itervalues() |
354 if h[0].special() or len(h) > 1], []) | 415 if h[0].special() or len(h) > 1], []) |
355 | 416 |
370 change to use. For each query, the following responses are | 431 change to use. For each query, the following responses are |
371 possible:: | 432 possible:: |
372 | 433 |
373 y - record this change | 434 y - record this change |
374 n - skip this change | 435 n - skip this change |
436 e - edit this change manually | |
375 | 437 |
376 s - skip remaining changes to this file | 438 s - skip remaining changes to this file |
377 f - record remaining changes to this file | 439 f - record remaining changes to this file |
378 | 440 |
379 d - done, skip remaining changes and files | 441 d - done, skip remaining changes and files |