1# Copyright (C) 2008, 2009, 2010 Canonical Ltd
2#
3# This program is free software; you can redistribute it and/or modify
4# it under the terms of the GNU General Public License as published by
5# the Free Software Foundation; either version 2 of the License, or
6# (at your option) any later version.
7#
8# This program is distributed in the hope that it will be useful,
9# but WITHOUT ANY WARRANTY; without even the implied warranty of
10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11# GNU General Public License for more details.
12#
13# You should have received a copy of the GNU General Public License
14# along with this program; if not, write to the Free Software
15# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
16
17import contextlib
18import patiencediff
19import shutil
20import sys
21import tempfile
22
23from io import BytesIO
24
25from . import (
26    builtins,
27    delta,
28    diff,
29    errors,
30    osutils,
31    patches,
32    shelf,
33    textfile,
34    trace,
35    ui,
36    workingtree,
37)
38from .i18n import gettext
39
40
41class UseEditor(Exception):
42    """Use an editor instead of selecting hunks."""
43
44
45class ShelfReporter(object):
46
47    vocab = {'add file': gettext('Shelve adding file "%(path)s"?'),
48             'binary': gettext('Shelve binary changes?'),
49             'change kind': gettext('Shelve changing "%s" from %(other)s'
50                                    ' to %(this)s?'),
51             'delete file': gettext('Shelve removing file "%(path)s"?'),
52             'final': gettext('Shelve %d change(s)?'),
53             'hunk': gettext('Shelve?'),
54             'modify target': gettext('Shelve changing target of'
55                                      ' "%(path)s" from "%(other)s" to "%(this)s"?'),
56             'rename': gettext('Shelve renaming "%(other)s" =>'
57                               ' "%(this)s"?')
58             }
59
60    invert_diff = False
61
62    def __init__(self):
63        self.delta_reporter = delta._ChangeReporter()
64
65    def no_changes(self):
66        """Report that no changes were selected to apply."""
67        trace.warning('No changes to shelve.')
68
69    def shelved_id(self, shelf_id):
70        """Report the id changes were shelved to."""
71        trace.note(gettext('Changes shelved with id "%d".') % shelf_id)
72
73    def changes_destroyed(self):
74        """Report that changes were made without shelving."""
75        trace.note(gettext('Selected changes destroyed.'))
76
77    def selected_changes(self, transform):
78        """Report the changes that were selected."""
79        trace.note(gettext("Selected changes:"))
80        changes = transform.iter_changes()
81        delta.report_changes(changes, self.delta_reporter)
82
83    def prompt_change(self, change):
84        """Determine the prompt for a change to apply."""
85        if change[0] == 'rename':
86            vals = {'this': change[3], 'other': change[2]}
87        elif change[0] == 'change kind':
88            vals = {'path': change[4], 'other': change[2], 'this': change[3]}
89        elif change[0] == 'modify target':
90            vals = {'path': change[2], 'other': change[3], 'this': change[4]}
91        else:
92            vals = {'path': change[3]}
93        prompt = self.vocab[change[0]] % vals
94        return prompt
95
96
97class ApplyReporter(ShelfReporter):
98
99    vocab = {'add file': gettext('Delete file "%(path)s"?'),
100             'binary': gettext('Apply binary changes?'),
101             'change kind': gettext('Change "%(path)s" from %(this)s'
102                                    ' to %(other)s?'),
103             'delete file': gettext('Add file "%(path)s"?'),
104             'final': gettext('Apply %d change(s)?'),
105             'hunk': gettext('Apply change?'),
106             'modify target': gettext('Change target of'
107                                      ' "%(path)s" from "%(this)s" to "%(other)s"?'),
108             'rename': gettext('Rename "%(this)s" => "%(other)s"?'),
109             }
110
111    invert_diff = True
112
113    def changes_destroyed(self):
114        pass
115
116
117class Shelver(object):
118    """Interactively shelve the changes in a working tree."""
119
120    def __init__(self, work_tree, target_tree, diff_writer=None, auto=False,
121                 auto_apply=False, file_list=None, message=None,
122                 destroy=False, manager=None, reporter=None):
123        """Constructor.
124
125        :param work_tree: The working tree to shelve changes from.
126        :param target_tree: The "unchanged" / old tree to compare the
127            work_tree to.
128        :param auto: If True, shelve each possible change.
129        :param auto_apply: If True, shelve changes with no final prompt.
130        :param file_list: If supplied, only files in this list may be shelved.
131        :param message: The message to associate with the shelved changes.
132        :param destroy: Change the working tree without storing the shelved
133            changes.
134        :param manager: The shelf manager to use.
135        :param reporter: Object for reporting changes to user.
136        """
137        self.work_tree = work_tree
138        self.target_tree = target_tree
139        self.diff_writer = diff_writer
140        if self.diff_writer is None:
141            self.diff_writer = sys.stdout
142        if manager is None:
143            manager = work_tree.get_shelf_manager()
144        self.manager = manager
145        self.auto = auto
146        self.auto_apply = auto_apply
147        self.file_list = file_list
148        self.message = message
149        self.destroy = destroy
150        if reporter is None:
151            reporter = ShelfReporter()
152        self.reporter = reporter
153        config = self.work_tree.branch.get_config()
154        self.change_editor = config.get_change_editor(target_tree, work_tree)
155        self.work_tree.lock_tree_write()
156
157    @classmethod
158    def from_args(klass, diff_writer, revision=None, all=False, file_list=None,
159                  message=None, directory=None, destroy=False):
160        """Create a shelver from commandline arguments.
161
162        The returned shelver wil have a work_tree that is locked and should
163        be unlocked.
164
165        :param revision: RevisionSpec of the revision to compare to.
166        :param all: If True, shelve all changes without prompting.
167        :param file_list: If supplied, only files in this list may be  shelved.
168        :param message: The message to associate with the shelved changes.
169        :param directory: The directory containing the working tree.
170        :param destroy: Change the working tree without storing the shelved
171            changes.
172        """
173        if directory is None:
174            directory = u'.'
175        elif file_list:
176            file_list = [osutils.pathjoin(directory, f) for f in file_list]
177        tree, path = workingtree.WorkingTree.open_containing(directory)
178        # Ensure that tree is locked for the lifetime of target_tree, as
179        # target tree may be reading from the same dirstate.
180        with tree.lock_tree_write():
181            target_tree = builtins._get_one_revision_tree('shelf2', revision,
182                                                          tree.branch, tree)
183            files = tree.safe_relpath_files(file_list)
184            return klass(tree, target_tree, diff_writer, all, all, files,
185                         message, destroy)
186
187    def run(self):
188        """Interactively shelve the changes."""
189        creator = shelf.ShelfCreator(self.work_tree, self.target_tree,
190                                     self.file_list)
191        self.tempdir = tempfile.mkdtemp()
192        changes_shelved = 0
193        try:
194            for change in creator.iter_shelvable():
195                if change[0] == 'modify text':
196                    try:
197                        changes_shelved += self.handle_modify_text(creator,
198                                                                   change[1])
199                    except errors.BinaryFile:
200                        if self.prompt_bool(self.reporter.vocab['binary']):
201                            changes_shelved += 1
202                            creator.shelve_content_change(change[1])
203                else:
204                    if self.prompt_bool(self.reporter.prompt_change(change)):
205                        creator.shelve_change(change)
206                        changes_shelved += 1
207            if changes_shelved > 0:
208                self.reporter.selected_changes(creator.work_transform)
209                if (self.auto_apply or self.prompt_bool(
210                        self.reporter.vocab['final'] % changes_shelved)):
211                    if self.destroy:
212                        creator.transform()
213                        self.reporter.changes_destroyed()
214                    else:
215                        shelf_id = self.manager.shelve_changes(creator,
216                                                               self.message)
217                        self.reporter.shelved_id(shelf_id)
218            else:
219                self.reporter.no_changes()
220        finally:
221            shutil.rmtree(self.tempdir)
222            creator.finalize()
223
224    def finalize(self):
225        if self.change_editor is not None:
226            self.change_editor.finish()
227        self.work_tree.unlock()
228
229    def get_parsed_patch(self, file_id, invert=False):
230        """Return a parsed version of a file's patch.
231
232        :param file_id: The id of the file to generate a patch for.
233        :param invert: If True, provide an inverted patch (insertions displayed
234            as removals, removals displayed as insertions).
235        :return: A patches.Patch.
236        """
237        diff_file = BytesIO()
238        if invert:
239            old_tree = self.work_tree
240            new_tree = self.target_tree
241        else:
242            old_tree = self.target_tree
243            new_tree = self.work_tree
244        old_path = old_tree.id2path(file_id)
245        new_path = new_tree.id2path(file_id)
246        path_encoding = osutils.get_terminal_encoding()
247        text_differ = diff.DiffText(old_tree, new_tree, diff_file,
248                                    path_encoding=path_encoding)
249        patch = text_differ.diff(old_path, new_path, 'file', 'file')
250        diff_file.seek(0)
251        return patches.parse_patch(diff_file)
252
253    def prompt(self, message, choices, default):
254        return ui.ui_factory.choose(message, choices, default=default)
255
256    def prompt_bool(self, question, allow_editor=False):
257        """Prompt the user with a yes/no question.
258
259        This may be overridden by self.auto.  It may also *set* self.auto.  It
260        may also raise UserAbort.
261        :param question: The question to ask the user.
262        :return: True or False
263        """
264        if self.auto:
265            return True
266        alternatives_chars = 'yn'
267        alternatives = '&yes\n&No'
268        if allow_editor:
269            alternatives_chars += 'e'
270            alternatives += '\n&edit manually'
271        alternatives_chars += 'fq'
272        alternatives += '\n&finish\n&quit'
273        choice = self.prompt(question, alternatives, 1)
274        if choice is None:
275            # EOF.
276            char = 'n'
277        else:
278            char = alternatives_chars[choice]
279        if char == 'y':
280            return True
281        elif char == 'e' and allow_editor:
282            raise UseEditor
283        elif char == 'f':
284            self.auto = True
285            return True
286        if char == 'q':
287            raise errors.UserAbort()
288        else:
289            return False
290
291    def handle_modify_text(self, creator, file_id):
292        """Handle modified text, by using hunk selection or file editing.
293
294        :param creator: A ShelfCreator.
295        :param file_id: The id of the file that was modified.
296        :return: The number of changes.
297        """
298        path = self.work_tree.id2path(file_id)
299        work_tree_lines = self.work_tree.get_file_lines(path, file_id)
300        try:
301            lines, change_count = self._select_hunks(creator, file_id,
302                                                     work_tree_lines)
303        except UseEditor:
304            lines, change_count = self._edit_file(file_id, work_tree_lines)
305        if change_count != 0:
306            creator.shelve_lines(file_id, lines)
307        return change_count
308
309    def _select_hunks(self, creator, file_id, work_tree_lines):
310        """Provide diff hunk selection for modified text.
311
312        If self.reporter.invert_diff is True, the diff is inverted so that
313        insertions are displayed as removals and vice versa.
314
315        :param creator: a ShelfCreator
316        :param file_id: The id of the file to shelve.
317        :param work_tree_lines: Line contents of the file in the working tree.
318        :return: number of shelved hunks.
319        """
320        if self.reporter.invert_diff:
321            target_lines = work_tree_lines
322        else:
323            path = self.target_tree.id2path(file_id)
324            target_lines = self.target_tree.get_file_lines(path)
325        textfile.check_text_lines(work_tree_lines)
326        textfile.check_text_lines(target_lines)
327        parsed = self.get_parsed_patch(file_id, self.reporter.invert_diff)
328        final_hunks = []
329        if not self.auto:
330            offset = 0
331            self.diff_writer.write(parsed.get_header())
332            for hunk in parsed.hunks:
333                self.diff_writer.write(hunk.as_bytes())
334                selected = self.prompt_bool(self.reporter.vocab['hunk'],
335                                            allow_editor=(self.change_editor
336                                                          is not None))
337                if not self.reporter.invert_diff:
338                    selected = (not selected)
339                if selected:
340                    hunk.mod_pos += offset
341                    final_hunks.append(hunk)
342                else:
343                    offset -= (hunk.mod_range - hunk.orig_range)
344        sys.stdout.flush()
345        if self.reporter.invert_diff:
346            change_count = len(final_hunks)
347        else:
348            change_count = len(parsed.hunks) - len(final_hunks)
349        patched = patches.iter_patched_from_hunks(target_lines,
350                                                  final_hunks)
351        lines = list(patched)
352        return lines, change_count
353
354    def _edit_file(self, file_id, work_tree_lines):
355        """
356        :param file_id: id of the file to edit.
357        :param work_tree_lines: Line contents of the file in the working tree.
358        :return: (lines, change_region_count), where lines is the new line
359            content of the file, and change_region_count is the number of
360            changed regions.
361        """
362        lines = osutils.split_lines(self.change_editor.edit_file(
363            self.change_editor.old_tree.id2path(file_id),
364            self.change_editor.new_tree.id2path(file_id)))
365        return lines, self._count_changed_regions(work_tree_lines, lines)
366
367    @staticmethod
368    def _count_changed_regions(old_lines, new_lines):
369        matcher = patiencediff.PatienceSequenceMatcher(None, old_lines,
370                                                       new_lines)
371        blocks = matcher.get_matching_blocks()
372        return len(blocks) - 2
373
374
375class Unshelver(object):
376    """Unshelve changes into a working tree."""
377
378    @classmethod
379    def from_args(klass, shelf_id=None, action='apply', directory='.',
380                  write_diff_to=None):
381        """Create an unshelver from commandline arguments.
382
383        The returned shelver will have a tree that is locked and should
384        be unlocked.
385
386        :param shelf_id: Integer id of the shelf, as a string.
387        :param action: action to perform.  May be 'apply', 'dry-run',
388            'delete', 'preview'.
389        :param directory: The directory to unshelve changes into.
390        :param write_diff_to: See Unshelver.__init__().
391        """
392        tree, path = workingtree.WorkingTree.open_containing(directory)
393        tree.lock_tree_write()
394        try:
395            manager = tree.get_shelf_manager()
396            if shelf_id is not None:
397                try:
398                    shelf_id = int(shelf_id)
399                except ValueError:
400                    raise shelf.InvalidShelfId(shelf_id)
401            else:
402                shelf_id = manager.last_shelf()
403                if shelf_id is None:
404                    raise errors.CommandError(
405                        gettext('No changes are shelved.'))
406            apply_changes = True
407            delete_shelf = True
408            read_shelf = True
409            show_diff = False
410            if action == 'dry-run':
411                apply_changes = False
412                delete_shelf = False
413            elif action == 'preview':
414                apply_changes = False
415                delete_shelf = False
416                show_diff = True
417            elif action == 'delete-only':
418                apply_changes = False
419                read_shelf = False
420            elif action == 'keep':
421                apply_changes = True
422                delete_shelf = False
423        except:
424            tree.unlock()
425            raise
426        return klass(tree, manager, shelf_id, apply_changes, delete_shelf,
427                     read_shelf, show_diff, write_diff_to)
428
429    def __init__(self, tree, manager, shelf_id, apply_changes=True,
430                 delete_shelf=True, read_shelf=True, show_diff=False,
431                 write_diff_to=None):
432        """Constructor.
433
434        :param tree: The working tree to unshelve into.
435        :param manager: The ShelveManager containing the shelved changes.
436        :param shelf_id:
437        :param apply_changes: If True, apply the shelved changes to the
438            working tree.
439        :param delete_shelf: If True, delete the changes from the shelf.
440        :param read_shelf: If True, read the changes from the shelf.
441        :param show_diff: If True, show the diff that would result from
442            unshelving the changes.
443        :param write_diff_to: A file-like object where the diff will be
444            written to. If None, ui.ui_factory.make_output_stream() will
445            be used.
446        """
447        self.tree = tree
448        manager = tree.get_shelf_manager()
449        self.manager = manager
450        self.shelf_id = shelf_id
451        self.apply_changes = apply_changes
452        self.delete_shelf = delete_shelf
453        self.read_shelf = read_shelf
454        self.show_diff = show_diff
455        self.write_diff_to = write_diff_to
456
457    def run(self):
458        """Perform the unshelving operation."""
459        with contextlib.ExitStack() as exit_stack:
460            exit_stack.enter_context(self.tree.lock_tree_write())
461            if self.read_shelf:
462                trace.note(gettext('Using changes with id "%d".') %
463                           self.shelf_id)
464                unshelver = self.manager.get_unshelver(self.shelf_id)
465                exit_stack.callback(unshelver.finalize)
466                if unshelver.message is not None:
467                    trace.note(gettext('Message: %s') % unshelver.message)
468                change_reporter = delta._ChangeReporter()
469                merger = unshelver.make_merger()
470                merger.change_reporter = change_reporter
471                if self.apply_changes:
472                    merger.do_merge()
473                elif self.show_diff:
474                    self.write_diff(merger)
475                else:
476                    self.show_changes(merger)
477            if self.delete_shelf:
478                self.manager.delete_shelf(self.shelf_id)
479                trace.note(gettext('Deleted changes with id "%d".') %
480                           self.shelf_id)
481
482    def write_diff(self, merger):
483        """Write this operation's diff to self.write_diff_to."""
484        tree_merger = merger.make_merger()
485        tt = tree_merger.make_preview_transform()
486        new_tree = tt.get_preview_tree()
487        if self.write_diff_to is None:
488            self.write_diff_to = ui.ui_factory.make_output_stream(
489                encoding_type='exact')
490        path_encoding = osutils.get_diff_header_encoding()
491        diff.show_diff_trees(merger.this_tree, new_tree, self.write_diff_to,
492                             path_encoding=path_encoding)
493        tt.finalize()
494
495    def show_changes(self, merger):
496        """Show the changes that this operation specifies."""
497        tree_merger = merger.make_merger()
498        # This implicitly shows the changes via the reporter, so we're done...
499        tt = tree_merger.make_preview_transform()
500        tt.finalize()
501