1# Copyright (C) 2007-2018 David Aguilar 2"""This module provides the central cola model. 3""" 4from __future__ import division, absolute_import, unicode_literals 5 6import os 7 8from .. import core 9from .. import gitcmds 10from .. import version 11from ..git import STDOUT 12from ..observable import Observable 13from . import prefs 14 15 16def create(context): 17 """Create the repository status model""" 18 return MainModel(context) 19 20 21# pylint: disable=too-many-public-methods 22class MainModel(Observable): 23 """Repository status model""" 24 25 # TODO this class can probably be split apart into a DiffModel, 26 # CommitMessageModel, StatusModel, and an AppStatusStateMachine. 27 28 # Observable messages 29 message_about_to_update = 'about_to_update' 30 message_commit_message_changed = 'commit_message_changed' 31 message_diff_text_changed = 'diff_text_changed' 32 message_diff_text_updated = 'diff_text_updated' 33 # "diff_type" {text,image} represents the diff viewer mode. 34 message_diff_type_changed = 'diff_type_changed' 35 # "file_type" {text,image} represents the selected file type. 36 message_file_type_changed = 'file_type_changed' 37 message_filename_changed = 'filename_changed' 38 message_images_changed = 'images_changed' 39 message_mode_about_to_change = 'mode_about_to_change' 40 message_mode_changed = 'mode_changed' 41 message_submodules_changed = 'message_submodules_changed' 42 message_refs_updated = 'message_refs_updated' 43 message_updated = 'updated' 44 message_worktree_changed = 'message_worktree_changed' 45 46 # States 47 mode_none = 'none' # Default: nothing's happened, do nothing 48 mode_worktree = 'worktree' # Comparing index to worktree 49 mode_diffstat = 'diffstat' # Showing a diffstat 50 mode_untracked = 'untracked' # Dealing with an untracked file 51 mode_index = 'index' # Comparing index to last commit 52 mode_amend = 'amend' # Amending a commit 53 54 # Modes where we can checkout files from the $head 55 modes_undoable = set((mode_amend, mode_index, mode_worktree)) 56 57 # Modes where we can partially stage files 58 modes_stageable = set((mode_amend, mode_worktree, mode_untracked)) 59 60 # Modes where we can partially unstage files 61 modes_unstageable = set((mode_amend, mode_index)) 62 63 unstaged = property(lambda self: self.modified + self.unmerged + self.untracked) 64 """An aggregate of the modified, unmerged, and untracked file lists.""" 65 66 def __init__(self, context, cwd=None): 67 """Interface to the main repository status""" 68 Observable.__init__(self) 69 70 self.context = context 71 self.git = context.git 72 self.cfg = context.cfg 73 self.selection = context.selection 74 75 self.initialized = False 76 self.annex = False 77 self.lfs = False 78 self.head = 'HEAD' 79 self.diff_text = '' 80 self.diff_type = Types.TEXT 81 self.file_type = Types.TEXT 82 self.mode = self.mode_none 83 self.filename = None 84 self.is_merging = False 85 self.is_rebasing = False 86 self.currentbranch = '' 87 self.directory = '' 88 self.project = '' 89 self.remotes = [] 90 self.filter_paths = None 91 self.images = [] 92 93 self.commitmsg = '' # current commit message 94 self._auto_commitmsg = '' # e.g. .git/MERGE_MSG 95 self._prev_commitmsg = '' # saved here when clobbered by .git/MERGE_MSG 96 97 self.modified = [] # modified, staged, untracked, unmerged paths 98 self.staged = [] 99 self.untracked = [] 100 self.unmerged = [] 101 self.upstream_changed = [] # paths that've changed upstream 102 self.staged_deleted = set() 103 self.unstaged_deleted = set() 104 self.submodules = set() 105 self.submodules_list = [] 106 107 self.ref_sort = 0 # (0: version, 1:reverse-chrono) 108 self.local_branches = [] 109 self.remote_branches = [] 110 self.tags = [] 111 if cwd: 112 self.set_worktree(cwd) 113 114 def unstageable(self): 115 return self.mode in self.modes_unstageable 116 117 def amending(self): 118 return self.mode == self.mode_amend 119 120 def undoable(self): 121 """Whether we can checkout files from the $head.""" 122 return self.mode in self.modes_undoable 123 124 def stageable(self): 125 """Whether staging should be allowed.""" 126 return self.mode in self.modes_stageable 127 128 def all_branches(self): 129 return self.local_branches + self.remote_branches 130 131 def set_worktree(self, worktree): 132 self.git.set_worktree(worktree) 133 is_valid = self.git.is_valid() 134 if is_valid: 135 cwd = self.git.getcwd() 136 self.project = os.path.basename(cwd) 137 self.set_directory(cwd) 138 core.chdir(cwd) 139 self.update_config(reset=True) 140 self.notify_observers(self.message_worktree_changed) 141 return is_valid 142 143 def is_git_lfs_enabled(self): 144 """Return True if `git lfs install` has been run 145 146 We check for the existence of the "lfs" object-storea, and one of the 147 "git lfs install"-provided hooks. This allows us to detect when 148 "git lfs uninstall" has been run. 149 150 """ 151 lfs_filter = self.cfg.get('filter.lfs.clean', default=False) 152 lfs_dir = lfs_filter and self.git.git_path('lfs') 153 lfs_hook = lfs_filter and self.cfg.hooks_path('post-merge') 154 return ( 155 lfs_filter 156 and lfs_dir 157 and core.exists(lfs_dir) 158 and lfs_hook 159 and core.exists(lfs_hook) 160 ) 161 162 def set_commitmsg(self, msg, notify=True): 163 self.commitmsg = msg 164 if notify: 165 self.notify_observers(self.message_commit_message_changed, msg) 166 167 def save_commitmsg(self, msg=None): 168 if msg is None: 169 msg = self.commitmsg 170 path = self.git.git_path('GIT_COLA_MSG') 171 try: 172 if not msg.endswith('\n'): 173 msg += '\n' 174 core.write(path, msg) 175 except (OSError, IOError): 176 pass 177 return path 178 179 def set_diff_text(self, txt): 180 """Update the text displayed in the diff editor""" 181 changed = txt != self.diff_text 182 self.diff_text = txt 183 self.notify_observers(self.message_diff_text_updated, txt) 184 if changed: 185 self.notify_observers(self.message_diff_text_changed) 186 187 def set_diff_type(self, diff_type): # text, image 188 """Set the diff type to either text or image""" 189 changed = diff_type != self.diff_type 190 self.diff_type = diff_type 191 if changed: 192 self.notify_observers(self.message_diff_type_changed, diff_type) 193 194 def set_file_type(self, file_type): # text, image 195 """Set the file type to either text or image""" 196 changed = file_type != self.file_type 197 self.file_type = file_type 198 if changed: 199 self.notify_observers(self.message_file_type_changed, file_type) 200 201 def set_images(self, images): 202 """Update the images shown in the preview pane""" 203 self.images = images 204 self.notify_observers(self.message_images_changed, images) 205 206 def set_directory(self, path): 207 self.directory = path 208 209 def set_filename(self, filename): 210 self.filename = filename 211 self.notify_observers(self.message_filename_changed, filename) 212 213 def set_mode(self, mode): 214 if self.amending(): 215 if mode != self.mode_none: 216 return 217 if self.is_merging and mode == self.mode_amend: 218 mode = self.mode 219 if mode == self.mode_amend: 220 head = 'HEAD^' 221 else: 222 head = 'HEAD' 223 self.notify_observers(self.message_mode_about_to_change, mode) 224 self.head = head 225 self.mode = mode 226 self.notify_observers(self.message_mode_changed, mode) 227 228 def update_path_filter(self, filter_paths): 229 self.filter_paths = filter_paths 230 self.update_file_status() 231 232 def emit_about_to_update(self): 233 self.notify_observers(self.message_about_to_update) 234 235 def emit_updated(self): 236 self.notify_observers(self.message_updated) 237 238 def update_file_status(self, update_index=False): 239 self.emit_about_to_update() 240 self.update_files(update_index=update_index, emit=True) 241 242 def update_status(self, update_index=False): 243 # Give observers a chance to respond 244 self.emit_about_to_update() 245 self.initialized = True 246 self._update_merge_rebase_status() 247 self._update_files(update_index=update_index) 248 self._update_remotes() 249 self._update_branches_and_tags() 250 self._update_commitmsg() 251 self.update_config() 252 self.update_submodules_list() 253 self.emit_updated() 254 255 def update_config(self, emit=False, reset=False): 256 if reset: 257 self.cfg.reset() 258 self.annex = self.cfg.is_annex() 259 self.lfs = self.is_git_lfs_enabled() 260 if emit: 261 self.emit_updated() 262 263 def update_files(self, update_index=False, emit=False): 264 self._update_files(update_index=update_index) 265 if emit: 266 self.emit_updated() 267 268 def _update_files(self, update_index=False): 269 context = self.context 270 display_untracked = prefs.display_untracked(context) 271 state = gitcmds.worktree_state( 272 context, 273 head=self.head, 274 update_index=update_index, 275 display_untracked=display_untracked, 276 paths=self.filter_paths, 277 ) 278 self.staged = state.get('staged', []) 279 self.modified = state.get('modified', []) 280 self.unmerged = state.get('unmerged', []) 281 self.untracked = state.get('untracked', []) 282 self.upstream_changed = state.get('upstream_changed', []) 283 self.staged_deleted = state.get('staged_deleted', set()) 284 self.unstaged_deleted = state.get('unstaged_deleted', set()) 285 self.submodules = state.get('submodules', set()) 286 287 selection = self.selection 288 if self.is_empty(): 289 selection.reset() 290 else: 291 selection.update(self) 292 if selection.is_empty(): 293 self.set_diff_text('') 294 295 def is_empty(self): 296 return not ( 297 bool(self.staged or self.modified or self.unmerged or self.untracked) 298 ) 299 300 def is_empty_repository(self): 301 return not self.local_branches 302 303 def _update_remotes(self): 304 self.remotes = self.git.remote()[STDOUT].splitlines() 305 306 def _update_branches_and_tags(self): 307 context = self.context 308 sort_types = ( 309 'version:refname', 310 '-committerdate', 311 ) 312 sort_key = sort_types[self.ref_sort] 313 local_branches, remote_branches, tags = gitcmds.all_refs( 314 context, split=True, sort_key=sort_key 315 ) 316 self.local_branches = local_branches 317 self.remote_branches = remote_branches 318 self.tags = tags 319 # Set these early since they are used to calculate 'upstream_changed'. 320 self.currentbranch = gitcmds.current_branch(self.context) 321 self.notify_observers(self.message_refs_updated) 322 323 def _update_merge_rebase_status(self): 324 merge_head = self.git.git_path('MERGE_HEAD') 325 rebase_merge = self.git.git_path('rebase-merge') 326 self.is_merging = merge_head and core.exists(merge_head) 327 self.is_rebasing = rebase_merge and core.exists(rebase_merge) 328 if self.is_merging and self.mode == self.mode_amend: 329 self.set_mode(self.mode_none) 330 331 def _update_commitmsg(self): 332 """Check for merge message files and update the commit message 333 334 The message is cleared when the merge completes 335 336 """ 337 if self.amending(): 338 return 339 # Check if there's a message file in .git/ 340 context = self.context 341 merge_msg_path = gitcmds.merge_message_path(context) 342 if merge_msg_path: 343 msg = core.read(merge_msg_path) 344 if msg != self._auto_commitmsg: 345 self._auto_commitmsg = msg 346 self._prev_commitmsg = self.commitmsg 347 self.set_commitmsg(msg) 348 349 elif self._auto_commitmsg and self._auto_commitmsg == self.commitmsg: 350 self._auto_commitmsg = '' 351 self.set_commitmsg(self._prev_commitmsg) 352 353 def update_submodules_list(self): 354 self.submodules_list = gitcmds.list_submodule(self.context) 355 self.notify_observers(self.message_submodules_changed) 356 357 def update_remotes(self): 358 self._update_remotes() 359 self.update_refs() 360 361 def update_refs(self): 362 """Update tag and branch names""" 363 self.emit_about_to_update() 364 self._update_branches_and_tags() 365 self.emit_updated() 366 367 def delete_branch(self, branch): 368 status, out, err = self.git.branch(branch, D=True) 369 self.update_refs() 370 return status, out, err 371 372 def rename_branch(self, branch, new_branch): 373 status, out, err = self.git.branch(branch, new_branch, M=True) 374 self.update_refs() 375 return status, out, err 376 377 def remote_url(self, name, action): 378 push = action == 'PUSH' 379 return gitcmds.remote_url(self.context, name, push=push) 380 381 def fetch(self, remote, **opts): 382 result = run_remote_action(self.context, self.git.fetch, remote, **opts) 383 self.update_refs() 384 return result 385 386 def push(self, remote, remote_branch='', local_branch='', **opts): 387 # Swap the branches in push mode (reverse of fetch) 388 opts.update(dict(local_branch=remote_branch, remote_branch=local_branch)) 389 result = run_remote_action( 390 self.context, self.git.push, remote, push=True, **opts 391 ) 392 self.update_refs() 393 return result 394 395 def pull(self, remote, **opts): 396 result = run_remote_action( 397 self.context, self.git.pull, remote, pull=True, **opts 398 ) 399 # Pull can result in merge conflicts 400 self.update_refs() 401 self.update_files(update_index=False, emit=True) 402 return result 403 404 def create_branch(self, name, base, track=False, force=False): 405 """Create a branch named 'name' from revision 'base' 406 407 Pass track=True to create a local tracking branch. 408 """ 409 return self.git.branch(name, base, track=track, force=force) 410 411 def cherry_pick_list(self, revs): 412 """Cherry-picks each revision into the current branch. 413 Returns a list of command output strings (1 per cherry pick)""" 414 if not revs: 415 return [] 416 outs = [] 417 errs = [] 418 status = 0 419 for rev in revs: 420 stat, out, err = self.git.cherry_pick(rev) 421 status = max(stat, status) 422 outs.append(out) 423 errs.append(err) 424 return (status, '\n'.join(outs), '\n'.join(errs)) 425 426 def is_commit_published(self): 427 """Return True if the latest commit exists in any remote branch""" 428 return bool(self.git.branch(r=True, contains='HEAD')[STDOUT]) 429 430 def untrack_paths(self, paths): 431 context = self.context 432 status, out, err = gitcmds.untrack_paths(context, paths) 433 self.update_file_status() 434 return status, out, err 435 436 def getcwd(self): 437 """If we've chosen a directory then use it, otherwise use current""" 438 if self.directory: 439 return self.directory 440 return core.getcwd() 441 442 def cycle_ref_sort(self): 443 """Choose the next ref sort type (version, reverse-chronological)""" 444 self.set_ref_sort(self.ref_sort + 1) 445 446 def set_ref_sort(self, raw_value): 447 value = raw_value % 2 # Currently two sort types 448 if value == self.ref_sort: 449 return 450 self.ref_sort = value 451 self.update_refs() 452 453 454class Types(object): 455 """File types (used for image diff modes)""" 456 457 IMAGE = 'image' 458 TEXT = 'text' 459 460 461# Helpers 462# pylint: disable=too-many-arguments 463def remote_args( 464 context, 465 remote, 466 local_branch='', 467 remote_branch='', 468 ff_only=False, 469 force=False, 470 no_ff=False, 471 tags=False, 472 rebase=False, 473 pull=False, 474 push=False, 475 set_upstream=False, 476 prune=False, 477): 478 """Return arguments for git fetch/push/pull""" 479 480 args = [remote] 481 what = refspec_arg(local_branch, remote_branch, pull, push) 482 if what: 483 args.append(what) 484 485 kwargs = { 486 'verbose': True, 487 } 488 if pull: 489 if rebase: 490 kwargs['rebase'] = True 491 elif ff_only: 492 kwargs['ff_only'] = True 493 elif no_ff: 494 kwargs['no_ff'] = True 495 elif force: 496 # pylint: disable=simplifiable-if-statement 497 if push and version.check_git(context, 'force-with-lease'): 498 kwargs['force_with_lease'] = True 499 else: 500 kwargs['force'] = True 501 502 if push and set_upstream: 503 kwargs['set_upstream'] = True 504 if tags: 505 kwargs['tags'] = True 506 if prune: 507 kwargs['prune'] = True 508 509 return (args, kwargs) 510 511 512def refspec(src, dst, push=False): 513 if push and src == dst: 514 spec = src 515 else: 516 spec = '%s:%s' % (src, dst) 517 return spec 518 519 520def refspec_arg(local_branch, remote_branch, pull, push): 521 """Return the refspec for a fetch or pull command""" 522 if not pull and local_branch and remote_branch: 523 what = refspec(remote_branch, local_branch, push=push) 524 else: 525 what = local_branch or remote_branch or None 526 return what 527 528 529def run_remote_action(context, action, remote, **kwargs): 530 args, kwargs = remote_args(context, remote, **kwargs) 531 return action(*args, **kwargs) 532