summaryrefslogtreecommitdiff
path: root/git_crecord/crecord_core.py
blob: 934fc3abdd6b176e2aacd0485153fec350cddf44 (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
210
211
# Record process driver
#
# Copyright 2008—2011, 2014 Mark Edgington <edgimar@gmail.com>
# Copyright 2016, 2018—2022 Andrej Shadura <andrew@shadura.me>
#
# 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.
#
# SPDX-License-Identifier: GPL-2.0-or-later

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, *pats, **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.

    We pass additional configuration options to Git to make the diff output
    consistent:
     - core.quotePath:
       Limit symbols requiring escaping to double-quotes, backslash and
       control characters.
     - diff.mnemonicPrefix:
       If set, git diff uses a prefix pair that is different from the standard
       "a/" and "b/" depending on what is being compared. Our parser only
       supports "a/" and "b/".
    """

    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