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