Mercurial > public > mercurial-scm > hg
comparison mercurial/cmdutil.py @ 50203:ee7a7155de10
record: extract a closure to the module level
This clean up is almost as gratuituous as this closure was.
author | Pierre-Yves David <pierre-yves.david@octobus.net> |
---|---|
date | Tue, 07 Feb 2023 10:27:21 +0100 |
parents | fef5bca96513 |
children | 798e4314ddd9 |
comparison
equal
deleted
inserted
replaced
50202:fef5bca96513 | 50203:ee7a7155de10 |
---|---|
6 # GNU General Public License version 2 or any later version. | 6 # GNU General Public License version 2 or any later version. |
7 | 7 |
8 | 8 |
9 import copy as copymod | 9 import copy as copymod |
10 import errno | 10 import errno |
11 import functools | |
11 import os | 12 import os |
12 import re | 13 import re |
13 | 14 |
14 from typing import ( | 15 from typing import ( |
15 Any, | 16 Any, |
438 finally: | 439 finally: |
439 ui.write = oldwrite | 440 ui.write = oldwrite |
440 return newchunks, newopts | 441 return newchunks, newopts |
441 | 442 |
442 | 443 |
444 def _record( | |
445 ui, | |
446 repo, | |
447 message, | |
448 match, | |
449 opts, | |
450 commitfunc, | |
451 backupall, | |
452 filterfn, | |
453 pats, | |
454 ): | |
455 """This is generic record driver. | |
456 | |
457 Its job is to interactively filter local changes, and | |
458 accordingly prepare working directory into a state in which the | |
459 job can be delegated to a non-interactive commit command such as | |
460 'commit' or 'qrefresh'. | |
461 | |
462 After the actual job is done by non-interactive command, the | |
463 working directory is restored to its original state. | |
464 | |
465 In the end we'll record interesting changes, and everything else | |
466 will be left in place, so the user can continue working. | |
467 """ | |
468 assert repo.currentwlock() is not None | |
469 if not opts.get(b'interactive-unshelve'): | |
470 checkunfinished(repo, commit=True) | |
471 wctx = repo[None] | |
472 merge = len(wctx.parents()) > 1 | |
473 if merge: | |
474 raise error.InputError( | |
475 _(b'cannot partially commit a merge ' b'(use "hg commit" instead)') | |
476 ) | |
477 | |
478 def fail(f, msg): | |
479 raise error.InputError(b'%s: %s' % (f, msg)) | |
480 | |
481 force = opts.get(b'force') | |
482 if not force: | |
483 match = matchmod.badmatch(match, fail) | |
484 | |
485 status = repo.status(match=match) | |
486 | |
487 overrides = {(b'ui', b'commitsubrepos'): True} | |
488 | |
489 with repo.ui.configoverride(overrides, b'record'): | |
490 # subrepoutil.precommit() modifies the status | |
491 tmpstatus = scmutil.status( | |
492 copymod.copy(status.modified), | |
493 copymod.copy(status.added), | |
494 copymod.copy(status.removed), | |
495 copymod.copy(status.deleted), | |
496 copymod.copy(status.unknown), | |
497 copymod.copy(status.ignored), | |
498 copymod.copy(status.clean), # pytype: disable=wrong-arg-count | |
499 ) | |
500 | |
501 # Force allows -X subrepo to skip the subrepo. | |
502 subs, commitsubs, newstate = subrepoutil.precommit( | |
503 repo.ui, wctx, tmpstatus, match, force=True | |
504 ) | |
505 for s in subs: | |
506 if s in commitsubs: | |
507 dirtyreason = wctx.sub(s).dirtyreason(True) | |
508 raise error.Abort(dirtyreason) | |
509 | |
510 if not force: | |
511 repo.checkcommitpatterns(wctx, match, status, fail) | |
512 diffopts = patch.difffeatureopts( | |
513 ui, | |
514 opts=opts, | |
515 whitespace=True, | |
516 section=b'commands', | |
517 configprefix=b'commit.interactive.', | |
518 ) | |
519 diffopts.nodates = True | |
520 diffopts.git = True | |
521 diffopts.showfunc = True | |
522 originaldiff = patch.diff(repo, changes=status, opts=diffopts) | |
523 original_headers = patch.parsepatch(originaldiff) | |
524 match = scmutil.match(repo[None], pats) | |
525 | |
526 # 1. filter patch, since we are intending to apply subset of it | |
527 try: | |
528 chunks, newopts = filterfn(ui, original_headers, match) | |
529 except error.PatchParseError as err: | |
530 raise error.InputError(_(b'error parsing patch: %s') % err) | |
531 except error.PatchApplicationError as err: | |
532 raise error.StateError(_(b'error applying patch: %s') % err) | |
533 opts.update(newopts) | |
534 | |
535 # We need to keep a backup of files that have been newly added and | |
536 # modified during the recording process because there is a previous | |
537 # version without the edit in the workdir. We also will need to restore | |
538 # files that were the sources of renames so that the patch application | |
539 # works. | |
540 newlyaddedandmodifiedfiles, alsorestore = newandmodified(chunks) | |
541 contenders = set() | |
542 for h in chunks: | |
543 if isheader(h): | |
544 contenders.update(set(h.files())) | |
545 | |
546 changed = status.modified + status.added + status.removed | |
547 newfiles = [f for f in changed if f in contenders] | |
548 if not newfiles: | |
549 ui.status(_(b'no changes to record\n')) | |
550 return 0 | |
551 | |
552 modified = set(status.modified) | |
553 | |
554 # 2. backup changed files, so we can restore them in the end | |
555 | |
556 if backupall: | |
557 tobackup = changed | |
558 else: | |
559 tobackup = [ | |
560 f | |
561 for f in newfiles | |
562 if f in modified or f in newlyaddedandmodifiedfiles | |
563 ] | |
564 backups = {} | |
565 if tobackup: | |
566 backupdir = repo.vfs.join(b'record-backups') | |
567 try: | |
568 os.mkdir(backupdir) | |
569 except FileExistsError: | |
570 pass | |
571 try: | |
572 # backup continues | |
573 for f in tobackup: | |
574 fd, tmpname = pycompat.mkstemp( | |
575 prefix=os.path.basename(f) + b'.', dir=backupdir | |
576 ) | |
577 os.close(fd) | |
578 ui.debug(b'backup %r as %r\n' % (f, tmpname)) | |
579 util.copyfile(repo.wjoin(f), tmpname, copystat=True) | |
580 backups[f] = tmpname | |
581 | |
582 fp = stringio() | |
583 for c in chunks: | |
584 fname = c.filename() | |
585 if fname in backups: | |
586 c.write(fp) | |
587 dopatch = fp.tell() | |
588 fp.seek(0) | |
589 | |
590 # 2.5 optionally review / modify patch in text editor | |
591 if opts.get(b'review', False): | |
592 patchtext = ( | |
593 crecordmod.diffhelptext + crecordmod.patchhelptext + fp.read() | |
594 ) | |
595 reviewedpatch = ui.edit( | |
596 patchtext, b"", action=b"diff", repopath=repo.path | |
597 ) | |
598 fp.truncate(0) | |
599 fp.write(reviewedpatch) | |
600 fp.seek(0) | |
601 | |
602 [os.unlink(repo.wjoin(c)) for c in newlyaddedandmodifiedfiles] | |
603 # 3a. apply filtered patch to clean repo (clean) | |
604 if backups: | |
605 m = scmutil.matchfiles(repo, set(backups.keys()) | alsorestore) | |
606 mergemod.revert_to(repo[b'.'], matcher=m) | |
607 | |
608 # 3b. (apply) | |
609 if dopatch: | |
610 try: | |
611 ui.debug(b'applying patch\n') | |
612 ui.debug(fp.getvalue()) | |
613 patch.internalpatch(ui, repo, fp, 1, eolmode=None) | |
614 except error.PatchParseError as err: | |
615 raise error.InputError(pycompat.bytestr(err)) | |
616 except error.PatchApplicationError as err: | |
617 raise error.StateError(pycompat.bytestr(err)) | |
618 del fp | |
619 | |
620 # 4. We prepared working directory according to filtered | |
621 # patch. Now is the time to delegate the job to | |
622 # commit/qrefresh or the like! | |
623 | |
624 # Make all of the pathnames absolute. | |
625 newfiles = [repo.wjoin(nf) for nf in newfiles] | |
626 return commitfunc(ui, repo, *newfiles, **pycompat.strkwargs(opts)) | |
627 finally: | |
628 # 5. finally restore backed-up files | |
629 try: | |
630 dirstate = repo.dirstate | |
631 for realname, tmpname in backups.items(): | |
632 ui.debug(b'restoring %r to %r\n' % (tmpname, realname)) | |
633 | |
634 if dirstate.get_entry(realname).maybe_clean: | |
635 # without normallookup, restoring timestamp | |
636 # may cause partially committed files | |
637 # to be treated as unmodified | |
638 | |
639 # XXX-PENDINGCHANGE: We should clarify the context in | |
640 # which this function is called to make sure it | |
641 # already called within a `pendingchange`, However we | |
642 # are taking a shortcut here in order to be able to | |
643 # quickly deprecated the older API. | |
644 with dirstate.changing_parents(repo): | |
645 dirstate.update_file( | |
646 realname, | |
647 p1_tracked=True, | |
648 wc_tracked=True, | |
649 possibly_dirty=True, | |
650 ) | |
651 | |
652 # copystat=True here and above are a hack to trick any | |
653 # editors that have f open that we haven't modified them. | |
654 # | |
655 # Also note that this racy as an editor could notice the | |
656 # file's mtime before we've finished writing it. | |
657 util.copyfile(tmpname, repo.wjoin(realname), copystat=True) | |
658 os.unlink(tmpname) | |
659 if tobackup: | |
660 os.rmdir(backupdir) | |
661 except OSError: | |
662 pass | |
663 | |
664 | |
443 def dorecord( | 665 def dorecord( |
444 ui, repo, commitfunc, cmdsuggest, backupall, filterfn, *pats, **opts | 666 ui, repo, commitfunc, cmdsuggest, backupall, filterfn, *pats, **opts |
445 ): | 667 ): |
446 opts = pycompat.byteskwargs(opts) | 668 opts = pycompat.byteskwargs(opts) |
447 if not ui.interactive(): | 669 if not ui.interactive(): |
453 | 675 |
454 # make sure username is set before going interactive | 676 # make sure username is set before going interactive |
455 if not opts.get(b'user'): | 677 if not opts.get(b'user'): |
456 ui.username() # raise exception, username not provided | 678 ui.username() # raise exception, username not provided |
457 | 679 |
458 def recordfunc(ui, repo, message, match, opts): | 680 func = functools.partial( |
459 """This is generic record driver. | 681 _record, |
460 | 682 commitfunc=commitfunc, |
461 Its job is to interactively filter local changes, and | 683 backupall=backupall, |
462 accordingly prepare working directory into a state in which the | 684 filterfn=filterfn, |
463 job can be delegated to a non-interactive commit command such as | 685 pats=pats, |
464 'commit' or 'qrefresh'. | 686 ) |
465 | 687 |
466 After the actual job is done by non-interactive command, the | 688 return commit(ui, repo, func, pats, opts) |
467 working directory is restored to its original state. | |
468 | |
469 In the end we'll record interesting changes, and everything else | |
470 will be left in place, so the user can continue working. | |
471 """ | |
472 assert repo.currentwlock() is not None | |
473 if not opts.get(b'interactive-unshelve'): | |
474 checkunfinished(repo, commit=True) | |
475 wctx = repo[None] | |
476 merge = len(wctx.parents()) > 1 | |
477 if merge: | |
478 raise error.InputError( | |
479 _( | |
480 b'cannot partially commit a merge ' | |
481 b'(use "hg commit" instead)' | |
482 ) | |
483 ) | |
484 | |
485 def fail(f, msg): | |
486 raise error.InputError(b'%s: %s' % (f, msg)) | |
487 | |
488 force = opts.get(b'force') | |
489 if not force: | |
490 match = matchmod.badmatch(match, fail) | |
491 | |
492 status = repo.status(match=match) | |
493 | |
494 overrides = {(b'ui', b'commitsubrepos'): True} | |
495 | |
496 with repo.ui.configoverride(overrides, b'record'): | |
497 # subrepoutil.precommit() modifies the status | |
498 tmpstatus = scmutil.status( | |
499 copymod.copy(status.modified), | |
500 copymod.copy(status.added), | |
501 copymod.copy(status.removed), | |
502 copymod.copy(status.deleted), | |
503 copymod.copy(status.unknown), | |
504 copymod.copy(status.ignored), | |
505 copymod.copy(status.clean), # pytype: disable=wrong-arg-count | |
506 ) | |
507 | |
508 # Force allows -X subrepo to skip the subrepo. | |
509 subs, commitsubs, newstate = subrepoutil.precommit( | |
510 repo.ui, wctx, tmpstatus, match, force=True | |
511 ) | |
512 for s in subs: | |
513 if s in commitsubs: | |
514 dirtyreason = wctx.sub(s).dirtyreason(True) | |
515 raise error.Abort(dirtyreason) | |
516 | |
517 if not force: | |
518 repo.checkcommitpatterns(wctx, match, status, fail) | |
519 diffopts = patch.difffeatureopts( | |
520 ui, | |
521 opts=opts, | |
522 whitespace=True, | |
523 section=b'commands', | |
524 configprefix=b'commit.interactive.', | |
525 ) | |
526 diffopts.nodates = True | |
527 diffopts.git = True | |
528 diffopts.showfunc = True | |
529 originaldiff = patch.diff(repo, changes=status, opts=diffopts) | |
530 original_headers = patch.parsepatch(originaldiff) | |
531 match = scmutil.match(repo[None], pats) | |
532 | |
533 # 1. filter patch, since we are intending to apply subset of it | |
534 try: | |
535 chunks, newopts = filterfn(ui, original_headers, match) | |
536 except error.PatchParseError as err: | |
537 raise error.InputError(_(b'error parsing patch: %s') % err) | |
538 except error.PatchApplicationError as err: | |
539 raise error.StateError(_(b'error applying patch: %s') % err) | |
540 opts.update(newopts) | |
541 | |
542 # We need to keep a backup of files that have been newly added and | |
543 # modified during the recording process because there is a previous | |
544 # version without the edit in the workdir. We also will need to restore | |
545 # files that were the sources of renames so that the patch application | |
546 # works. | |
547 newlyaddedandmodifiedfiles, alsorestore = newandmodified(chunks) | |
548 contenders = set() | |
549 for h in chunks: | |
550 if isheader(h): | |
551 contenders.update(set(h.files())) | |
552 | |
553 changed = status.modified + status.added + status.removed | |
554 newfiles = [f for f in changed if f in contenders] | |
555 if not newfiles: | |
556 ui.status(_(b'no changes to record\n')) | |
557 return 0 | |
558 | |
559 modified = set(status.modified) | |
560 | |
561 # 2. backup changed files, so we can restore them in the end | |
562 | |
563 if backupall: | |
564 tobackup = changed | |
565 else: | |
566 tobackup = [ | |
567 f | |
568 for f in newfiles | |
569 if f in modified or f in newlyaddedandmodifiedfiles | |
570 ] | |
571 backups = {} | |
572 if tobackup: | |
573 backupdir = repo.vfs.join(b'record-backups') | |
574 try: | |
575 os.mkdir(backupdir) | |
576 except FileExistsError: | |
577 pass | |
578 try: | |
579 # backup continues | |
580 for f in tobackup: | |
581 fd, tmpname = pycompat.mkstemp( | |
582 prefix=os.path.basename(f) + b'.', dir=backupdir | |
583 ) | |
584 os.close(fd) | |
585 ui.debug(b'backup %r as %r\n' % (f, tmpname)) | |
586 util.copyfile(repo.wjoin(f), tmpname, copystat=True) | |
587 backups[f] = tmpname | |
588 | |
589 fp = stringio() | |
590 for c in chunks: | |
591 fname = c.filename() | |
592 if fname in backups: | |
593 c.write(fp) | |
594 dopatch = fp.tell() | |
595 fp.seek(0) | |
596 | |
597 # 2.5 optionally review / modify patch in text editor | |
598 if opts.get(b'review', False): | |
599 patchtext = ( | |
600 crecordmod.diffhelptext | |
601 + crecordmod.patchhelptext | |
602 + fp.read() | |
603 ) | |
604 reviewedpatch = ui.edit( | |
605 patchtext, b"", action=b"diff", repopath=repo.path | |
606 ) | |
607 fp.truncate(0) | |
608 fp.write(reviewedpatch) | |
609 fp.seek(0) | |
610 | |
611 [os.unlink(repo.wjoin(c)) for c in newlyaddedandmodifiedfiles] | |
612 # 3a. apply filtered patch to clean repo (clean) | |
613 if backups: | |
614 m = scmutil.matchfiles(repo, set(backups.keys()) | alsorestore) | |
615 mergemod.revert_to(repo[b'.'], matcher=m) | |
616 | |
617 # 3b. (apply) | |
618 if dopatch: | |
619 try: | |
620 ui.debug(b'applying patch\n') | |
621 ui.debug(fp.getvalue()) | |
622 patch.internalpatch(ui, repo, fp, 1, eolmode=None) | |
623 except error.PatchParseError as err: | |
624 raise error.InputError(pycompat.bytestr(err)) | |
625 except error.PatchApplicationError as err: | |
626 raise error.StateError(pycompat.bytestr(err)) | |
627 del fp | |
628 | |
629 # 4. We prepared working directory according to filtered | |
630 # patch. Now is the time to delegate the job to | |
631 # commit/qrefresh or the like! | |
632 | |
633 # Make all of the pathnames absolute. | |
634 newfiles = [repo.wjoin(nf) for nf in newfiles] | |
635 return commitfunc(ui, repo, *newfiles, **pycompat.strkwargs(opts)) | |
636 finally: | |
637 # 5. finally restore backed-up files | |
638 try: | |
639 dirstate = repo.dirstate | |
640 for realname, tmpname in backups.items(): | |
641 ui.debug(b'restoring %r to %r\n' % (tmpname, realname)) | |
642 | |
643 if dirstate.get_entry(realname).maybe_clean: | |
644 # without normallookup, restoring timestamp | |
645 # may cause partially committed files | |
646 # to be treated as unmodified | |
647 | |
648 # XXX-PENDINGCHANGE: We should clarify the context in | |
649 # which this function is called to make sure it | |
650 # already called within a `pendingchange`, However we | |
651 # are taking a shortcut here in order to be able to | |
652 # quickly deprecated the older API. | |
653 with dirstate.changing_parents(repo): | |
654 dirstate.update_file( | |
655 realname, | |
656 p1_tracked=True, | |
657 wc_tracked=True, | |
658 possibly_dirty=True, | |
659 ) | |
660 | |
661 # copystat=True here and above are a hack to trick any | |
662 # editors that have f open that we haven't modified them. | |
663 # | |
664 # Also note that this racy as an editor could notice the | |
665 # file's mtime before we've finished writing it. | |
666 util.copyfile(tmpname, repo.wjoin(realname), copystat=True) | |
667 os.unlink(tmpname) | |
668 if tobackup: | |
669 os.rmdir(backupdir) | |
670 except OSError: | |
671 pass | |
672 | |
673 return commit(ui, repo, recordfunc, pats, opts) | |
674 | 689 |
675 | 690 |
676 class dirnode: | 691 class dirnode: |
677 """ | 692 """ |
678 Represent a directory in user working copy with information required for | 693 Represent a directory in user working copy with information required for |