summaryrefslogtreecommitdiff
path: root/git_crecord/crecord_core.py
blob: 38a7191a8de1f9c2d47559a845680a7941132f6c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
# crecord.py
#
# Copyright 2008 Mark Edgington <edgimar@gmail.com>
#
# This software may be used and distributed according to the terms of
# the GNU General Public License, incorporated herein by reference.
#
# Much of this extension is based on Bryan O'Sullivan's record extension.

'''text-gui based change selection during commit or qrefresh'''
from gettext import gettext as _

import io
import errno
import os
import tempfile
import subprocess
from typing import IO, cast

from .crpatch import Header, parsepatch, filterpatch
from .chunk_selector import chunkselector
from .gitrepo import GitRepo
from .util import Abort, system, closefds, copyfile


def dorecord(ui, repo, commitfunc, *pats, **opts):
    def recordfunc(ui, repo: GitRepo, message, match, opts):
        """This is generic record driver.

        Its job is to interactively filter local changes, and accordingly
        prepare working dir into a state, where the job can be delegated to
        non-interactive commit command such as 'commit' or 'qrefresh'.

        After the actual job is done by non-interactive command, working dir
        state is restored to original.

        In the end we'll record interesting changes, and everything else will be
        left in place, so the user can continue his work.

        Disable `core.quotePath` to support non-ASCII filenames.
        By default (with `core.quotePath=true`) `git diff` only shows filename characters printable in ASCII,
        and the presence of any character higher than U+007F will cause `git diff`'s output to double-quote
        the filename and replace the non-ASCII characters in that filename with their octal representations.
        The double-quoting (i.e. `diff --git "a/` instead of `diff --git a/`) breaks `crecord`'s stdout parsing.
        https://git-scm.com/docs/git-config#Documentation/git-config.txt-corequotePath
        """

        git_args = ["git", "-c", "core.quotepath=false", "-c", "diff.mnemonicprefix=false", "diff", "--binary"]
        git_base = []

        if opts['cached']:
            git_args.append("--cached")

        if not opts['index'] and repo.head():
            git_base.append("HEAD")

        p = subprocess.Popen(git_args + git_base, stdout=subprocess.PIPE, close_fds=closefds)
        fp = cast(IO[bytes], p.stdout)

        # 0. parse patch
        fromfiles = set()
        tofiles = set()

        chunks = parsepatch(fp)
        for c in chunks:
            if isinstance(c, Header):
                fromfile, tofile = c.files()
                if fromfile is not None:
                    fromfiles.add(os.fsdecode(fromfile))
                if tofile is not None:
                    tofiles.add(os.fsdecode(tofile))

        added = tofiles - fromfiles
        removed = fromfiles - tofiles
        modified = tofiles - added - removed
        changes = [modified, added, removed]

        # 1. filter patch, so we have intending-to apply subset of it
        chunks = filterpatch(opts,
                             chunks,
                             chunkselector, ui)
        p.wait()
        del fp

        contenders = set()
        for h in chunks:
            fromfile, tofile = h.files()
            if fromfile is not None:
                contenders.add(os.fsdecode(fromfile))
            if tofile is not None:
                contenders.add(os.fsdecode(tofile))

        changed = changes[0] | changes[1] | changes[2]
        newfiles: list = [f for f in changed if f in contenders]

        if not newfiles:
            ui.status(_('no changes to record'))
            return 0

        # 2. backup changed files, so we can restore them in the end
        backups = {}
        newly_added_backups = {}
        backupdir = repo.controldir / 'record-backups'
        try:
            os.mkdir(backupdir)
        except OSError as err:
            if err.errno != errno.EEXIST:
                raise
        index_backup = None
        try:
            index_backup = repo.open_index()
            index_backup.backup_tree()

            # backup continues
            for f in newfiles:
                if f not in (modified | added):
                    continue
                prefix = os.fsdecode(f).replace('/', '_') + '.'
                fd, tmpname = tempfile.mkstemp(prefix=prefix,
                                               dir=backupdir)
                os.close(fd)
                ui.debug('backup %r as %r' % (f, tmpname))
                pathname = repo.path / f
                if os.path.isfile(pathname):
                    copyfile(pathname, tmpname)
                if f in modified:
                    backups[f] = tmpname
                elif f in added:
                    newly_added_backups[f] = tmpname

            fp = io.BytesIO()
            all_backups = {}
            all_backups.update(backups)
            all_backups.update(newly_added_backups)
            for c in chunks:
                c.write(fp)
            dopatch = fp.tell()
            fp.seek(0)

            # 2.5 optionally review / modify patch in text editor
            if opts['crecord_reviewpatch']:
                patchtext = fp.read()
                reviewedpatch = ui.edit(patchtext, "")
                fp.truncate(0)
                fp.write(reviewedpatch)
                fp.seek(0)

            # 3a. apply filtered patch to clean repo  (clean)
            if backups or any((f in contenders for f in removed)):
                system(['git', 'checkout', '-f'] + git_base + ['--'] + [f for f in newfiles if f not in added],
                       onerr=Abort, errprefix=_("checkout failed"))
            # remove newly added files from 'clean' repo (so patch can apply)
            for f in newly_added_backups:
                pathname = repo.path / f
                pathname.unlink(missing_ok=True)

            # 3b. (apply)
            if dopatch:
                try:
                    ui.debug('applying patch')
                    ui.debug(fp.getvalue().decode("UTF-8", "hexreplace"))
                    p = subprocess.Popen(
                        ["git", "apply", "--whitespace=nowarn"],
                        stdin=subprocess.PIPE,
                        close_fds=closefds
                    )
                    p.stdin.write(fp.getvalue())
                    p.stdin.close()
                    p.wait()
                except Exception as err:
                    s = str(err)
                    if s:
                        raise Abort(s)
                    else:
                        raise Abort(_('patch failed to apply'))
            del fp

            # 4. We prepared working directory according to filtered patch.
            #    Now is the time to delegate the job to commit/qrefresh or the like!

            # it is important to first chdir to repo root -- we'll call a
            # highlevel command with list of pathnames relative to repo root
            newfiles = [repo.path / n for n in newfiles]
            if opts['operation'] == 'crecord':
                ui.commit(*newfiles, **opts)
            else:
                ui.stage(*newfiles, **opts)
            ui.debug('previous staging contents backed up as tree %r' % index_backup.indextree)
            index_backup = None

            return 0
        finally:
            # 5. finally restore backed-up files
            try:
                for realname, tmpname in backups.items():
                    ui.debug('restoring %r to %r' % (tmpname, realname))
                    copyfile(tmpname, os.path.join(repo.path, realname))
                    os.unlink(tmpname)
                for realname, tmpname in newly_added_backups.items():
                    ui.debug('restoring %r to %r' % (tmpname, realname))
                    copyfile(tmpname, os.path.join(repo.path, realname))
                    os.unlink(tmpname)
                os.rmdir(backupdir)
                if index_backup:
                    index_backup.write()
            except (OSError, NameError):
                pass

    return recordfunc(ui, repo, "", None, opts)