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