fetch: add support for the traditional FETCH_HEAD behavior
[git-cola.git] / cola / models / main.py
blob7c9f57135617956a748425fc1dfb07830a7b115f
1 """The central cola model"""
2 import os
4 from qtpy import QtCore
5 from qtpy.QtCore import Signal
7 from .. import core
8 from .. import gitcmds
9 from .. import gitcfg
10 from .. import version
11 from ..git import STDOUT
12 from . import prefs
15 FETCH = 'fetch'
16 FETCH_HEAD = 'FETCH_HEAD'
17 PUSH = 'push'
18 PULL = 'pull'
21 def create(context):
22 """Create the repository status model"""
23 return MainModel(context)
26 class MainModel(QtCore.QObject):
27 """Repository status model"""
29 # Refactor: split this class apart into separate DiffModel, CommitMessageModel,
30 # StatusModel, and an DiffEditorState.
32 # Signals
33 about_to_update = Signal()
34 previous_contents = Signal(list, list, list, list)
35 commit_message_changed = Signal(object)
36 diff_text_changed = Signal()
37 diff_text_updated = Signal(str)
38 # "diff_type" {text,image} represents the diff viewer mode.
39 diff_type_changed = Signal(object)
40 # "file_type" {text,image} represents the selected file type.
41 file_type_changed = Signal(object)
42 images_changed = Signal(object)
43 mode_changed = Signal(str)
44 submodules_changed = Signal()
45 refs_updated = Signal()
46 updated = Signal()
47 worktree_changed = Signal()
49 # States
50 mode_none = 'none' # Default: nothing's happened, do nothing
51 mode_worktree = 'worktree' # Comparing index to worktree
52 mode_diffstat = 'diffstat' # Showing a diffstat
53 mode_display = 'display' # Displaying arbitrary information
54 mode_untracked = 'untracked' # Dealing with an untracked file
55 mode_untracked_diff = 'untracked-diff' # Diffing an untracked file
56 mode_index = 'index' # Comparing index to last commit
57 mode_amend = 'amend' # Amending a commit
58 mode_diff = 'diff' # Diffing against an arbitrary commit
60 # Modes where we can checkout files from the $head
61 modes_undoable = {mode_amend, mode_diff, mode_index, mode_worktree}
63 # Modes where we can partially stage files
64 modes_partially_stageable = {
65 mode_amend,
66 mode_diff,
67 mode_worktree,
68 mode_untracked_diff,
71 # Modes where we can partially unstage files
72 modes_unstageable = {mode_amend, mode_diff, mode_index}
74 unstaged = property(lambda self: self.modified + self.unmerged + self.untracked)
75 """An aggregate of the modified, unmerged, and untracked file lists."""
77 def __init__(self, context, cwd=None):
78 """Interface to the main repository status"""
79 super().__init__()
81 self.context = context
82 self.git = context.git
83 self.cfg = context.cfg
84 self.selection = context.selection
86 self.initialized = False
87 self.annex = False
88 self.lfs = False
89 self.head = 'HEAD'
90 self.diff_text = ''
91 self.diff_type = Types.TEXT
92 self.file_type = Types.TEXT
93 self.mode = self.mode_none
94 self.filename = None
95 self.is_cherry_picking = False
96 self.is_merging = False
97 self.is_rebasing = False
98 self.is_applying_patch = False
99 self.currentbranch = ''
100 self.directory = ''
101 self.project = ''
102 self.remotes = []
103 self.filter_paths = None
104 self.images = []
106 self.commitmsg = '' # current commit message
107 self._auto_commitmsg = '' # e.g. .git/MERGE_MSG
108 self._prev_commitmsg = '' # saved here when clobbered by .git/MERGE_MSG
110 self.modified = [] # modified, staged, untracked, unmerged paths
111 self.staged = []
112 self.untracked = []
113 self.unmerged = []
114 self.upstream_changed = [] # paths that've changed upstream
115 self.staged_deleted = set()
116 self.unstaged_deleted = set()
117 self.submodules = set()
118 self.submodules_list = None # lazy loaded
120 self.error = None # The last error message.
121 self.ref_sort = 0 # (0: version, 1:reverse-chrono)
122 self.local_branches = []
123 self.remote_branches = []
124 self.tags = []
125 if cwd:
126 self.set_worktree(cwd)
128 def is_diff_mode(self):
129 """Are we in diff mode?"""
130 return self.mode == self.mode_diff
132 def is_unstageable(self):
133 """Are we in a mode that supports "unstage" actions?"""
134 return self.mode in self.modes_unstageable
136 def is_amend_mode(self):
137 """Are we amending a commit?"""
138 return self.mode == self.mode_amend
140 def is_undoable(self):
141 """Can we checkout from the current branch or head ref?"""
142 return self.mode in self.modes_undoable
144 def is_partially_stageable(self):
145 """Whether partial staging should be allowed."""
146 return self.mode in self.modes_partially_stageable
148 def is_stageable(self):
149 """Whether staging should be allowed."""
150 return self.is_partially_stageable() or self.mode == self.mode_untracked
152 def all_branches(self):
153 return self.local_branches + self.remote_branches
155 def set_worktree(self, worktree):
156 last_worktree = self.git.paths.worktree
157 self.git.set_worktree(worktree)
159 is_valid = self.git.is_valid()
160 if is_valid:
161 reset = last_worktree is None or last_worktree != worktree
162 cwd = self.git.getcwd()
163 self.project = os.path.basename(cwd)
164 self.set_directory(cwd)
165 core.chdir(cwd)
166 self.update_config(reset=reset)
168 # Detect the "git init" scenario by checking for branches.
169 # If no branches exist then we cannot use "git rev-parse" yet.
170 err = None
171 refs = self.git.git_path('refs', 'heads')
172 if core.exists(refs) and core.listdir(refs):
173 # "git rev-parse" exits with a non-zero exit status when the
174 # safe.directory protection is active.
175 status, _, err = self.git.rev_parse('HEAD')
176 is_valid = status == 0
177 if is_valid:
178 self.error = None
179 self.worktree_changed.emit()
180 else:
181 self.error = err
183 return is_valid
185 def is_git_lfs_enabled(self):
186 """Return True if `git lfs install` has been run
188 We check for the existence of the "lfs" object-storea, and one of the
189 "git lfs install"-provided hooks. This allows us to detect when
190 "git lfs uninstall" has been run.
193 lfs_filter = self.cfg.get('filter.lfs.clean', default=False)
194 lfs_dir = lfs_filter and self.git.git_path('lfs')
195 lfs_hook = lfs_filter and self.cfg.hooks_path('post-merge')
196 return (
197 lfs_filter
198 and lfs_dir
199 and core.exists(lfs_dir)
200 and lfs_hook
201 and core.exists(lfs_hook)
204 def set_commitmsg(self, msg, notify=True):
205 self.commitmsg = msg
206 if notify:
207 self.commit_message_changed.emit(msg)
209 def save_commitmsg(self, msg=None):
210 if msg is None:
211 msg = self.commitmsg
212 path = self.git.git_path('GIT_COLA_MSG')
213 try:
214 if not msg.endswith('\n'):
215 msg += '\n'
216 core.write(path, msg)
217 except OSError:
218 pass
219 return path
221 def set_diff_text(self, txt):
222 """Update the text displayed in the diff editor"""
223 changed = txt != self.diff_text
224 self.diff_text = txt
225 self.diff_text_updated.emit(txt)
226 if changed:
227 self.diff_text_changed.emit()
229 def set_diff_type(self, diff_type): # text, image
230 """Set the diff type to either text or image"""
231 changed = diff_type != self.diff_type
232 self.diff_type = diff_type
233 if changed:
234 self.diff_type_changed.emit(diff_type)
236 def set_file_type(self, file_type): # text, image
237 """Set the file type to either text or image"""
238 changed = file_type != self.file_type
239 self.file_type = file_type
240 if changed:
241 self.file_type_changed.emit(file_type)
243 def set_images(self, images):
244 """Update the images shown in the preview pane"""
245 self.images = images
246 self.images_changed.emit(images)
248 def set_directory(self, path):
249 self.directory = path
251 def set_mode(self, mode, head=None):
252 """Set the current editing mode (worktree, index, amending, ...)"""
253 # Do not allow going into index or worktree mode when amending.
254 if self.is_amend_mode() and mode != self.mode_none:
255 return
256 # We cannot amend in the middle of git cherry-pick, git am or git merge.
257 if (
258 self.is_cherry_picking or self.is_merging or self.is_applying_patch
259 ) and mode == self.mode_amend:
260 mode = self.mode
262 # Stay in diff mode until explicitly reset.
263 if self.mode == self.mode_diff and mode != self.mode_none:
264 mode = self.mode_diff
265 head = head or self.head
266 else:
267 # If we are amending then we'll use "HEAD^", otherwise use the specified
268 # head or "HEAD" if head has not been specified.
269 if mode == self.mode_amend:
270 head = 'HEAD^'
271 elif not head:
272 head = 'HEAD'
274 self.head = head
275 self.mode = mode
276 self.mode_changed.emit(mode)
278 def update_path_filter(self, filter_paths):
279 self.filter_paths = filter_paths
280 self.update_file_status()
282 def emit_about_to_update(self):
283 self.previous_contents.emit(
284 self.staged, self.unmerged, self.modified, self.untracked
286 self.about_to_update.emit()
288 def emit_updated(self):
289 self.updated.emit()
291 def update_file_status(self, update_index=False):
292 """Update modified/staged files status"""
293 self.emit_about_to_update()
294 self.update_files(update_index=update_index, emit=True)
296 def update_file_merge_status(self):
297 """Update modified/staged files and Merge/Rebase/Cherry-pick status"""
298 self.emit_about_to_update()
299 self._update_merge_rebase_status()
300 self.update_file_status()
302 def update_status(self, update_index=False, reset=False):
303 # Give observers a chance to respond
304 self.emit_about_to_update()
305 self.initialized = True
306 self._update_merge_rebase_status()
307 self._update_files(update_index=update_index)
308 self._update_remotes()
309 self._update_branches_and_tags()
310 self._update_commitmsg()
311 self.update_config()
312 if reset:
313 self.update_submodules_list()
314 self.emit_updated()
316 def update_config(self, emit=False, reset=False):
317 if reset:
318 self.cfg.reset()
319 self.annex = self.cfg.is_annex()
320 self.lfs = self.is_git_lfs_enabled()
321 if emit:
322 self.emit_updated()
324 def update_files(self, update_index=False, emit=False):
325 self._update_files(update_index=update_index)
326 if emit:
327 self.emit_updated()
329 def _update_files(self, update_index=False):
330 context = self.context
331 display_untracked = prefs.display_untracked(context)
332 state = gitcmds.worktree_state(
333 context,
334 head=self.head,
335 update_index=update_index,
336 display_untracked=display_untracked,
337 paths=self.filter_paths,
339 self.staged = state.get('staged', [])
340 self.modified = state.get('modified', [])
341 self.unmerged = state.get('unmerged', [])
342 self.untracked = state.get('untracked', [])
343 self.upstream_changed = state.get('upstream_changed', [])
344 self.staged_deleted = state.get('staged_deleted', set())
345 self.unstaged_deleted = state.get('unstaged_deleted', set())
346 self.submodules = state.get('submodules', set())
348 selection = self.selection
349 if self.is_empty():
350 selection.reset()
351 else:
352 selection.update(self)
353 if selection.is_empty():
354 self.set_diff_text('')
356 def is_empty(self):
357 return not (
358 bool(self.staged or self.modified or self.unmerged or self.untracked)
361 def is_empty_repository(self):
362 return not self.local_branches
364 def _update_remotes(self):
365 self.remotes = gitcfg.get_remotes(self.cfg)
367 def _update_branches_and_tags(self):
368 context = self.context
369 sort_types = (
370 'version:refname',
371 '-committerdate',
373 sort_key = sort_types[self.ref_sort]
374 local_branches, remote_branches, tags = gitcmds.all_refs(
375 context, split=True, sort_key=sort_key
377 self.local_branches = local_branches
378 self.remote_branches = remote_branches
379 self.tags = tags
380 # Set these early since they are used to calculate 'upstream_changed'.
381 self.currentbranch = gitcmds.current_branch(self.context)
382 self.refs_updated.emit()
384 def _update_merge_rebase_status(self):
385 cherry_pick_head = self.git.git_path('CHERRY_PICK_HEAD')
386 merge_head = self.git.git_path('MERGE_HEAD')
387 rebase_merge = self.git.git_path('rebase-merge')
388 rebase_apply = self.git.git_path('rebase-apply', 'applying')
389 self.is_cherry_picking = cherry_pick_head and core.exists(cherry_pick_head)
390 self.is_merging = merge_head and core.exists(merge_head)
391 self.is_rebasing = rebase_merge and core.exists(rebase_merge)
392 self.is_applying_patch = rebase_apply and core.exists(rebase_apply)
393 if self.mode == self.mode_amend and (
394 self.is_merging or self.is_cherry_picking or self.is_applying_patch
396 self.set_mode(self.mode_none)
398 def _update_commitmsg(self):
399 """Check for merge message files and update the commit message
401 The message is cleared when the merge completes.
403 if self.is_amend_mode():
404 return
405 # Check if there's a message file in .git/
406 context = self.context
407 merge_msg_path = gitcmds.merge_message_path(context)
408 if merge_msg_path:
409 msg = gitcmds.read_merge_commit_message(context, merge_msg_path)
410 if msg != self._auto_commitmsg:
411 self._auto_commitmsg = msg
412 self._prev_commitmsg = self.commitmsg
413 self.set_commitmsg(msg)
415 elif self._auto_commitmsg and self._auto_commitmsg == self.commitmsg:
416 self._auto_commitmsg = ''
417 self.set_commitmsg(self._prev_commitmsg)
419 def update_submodules_list(self):
420 self.submodules_list = gitcmds.list_submodule(self.context)
421 self.submodules_changed.emit()
423 def update_remotes(self):
424 self._update_remotes()
425 self.update_refs()
427 def update_refs(self):
428 """Update tag and branch names"""
429 self.emit_about_to_update()
430 self._update_branches_and_tags()
431 self.emit_updated()
433 def delete_branch(self, branch):
434 status, out, err = self.git.branch(branch, D=True)
435 self.update_refs()
436 return status, out, err
438 def rename_branch(self, branch, new_branch):
439 status, out, err = self.git.branch(branch, new_branch, M=True)
440 self.update_refs()
441 return status, out, err
443 def remote_url(self, name, action):
444 push = action == 'PUSH'
445 return gitcmds.remote_url(self.context, name, push=push)
447 def fetch(self, remote, **opts):
448 result = run_remote_action(self.context, self.git.fetch, remote, FETCH, **opts)
449 self.update_refs()
450 return result
452 def push(self, remote, remote_branch='', local_branch='', **opts):
453 # Swap the branches in push mode (reverse of fetch)
454 opts.update({
455 'local_branch': remote_branch,
456 'remote_branch': local_branch,
458 result = run_remote_action(self.context, self.git.push, remote, PUSH, **opts)
459 self.update_refs()
460 return result
462 def pull(self, remote, **opts):
463 result = run_remote_action(self.context, self.git.pull, remote, PULL, **opts)
464 # Pull can result in merge conflicts
465 self.update_refs()
466 self.update_files(update_index=False, emit=True)
467 return result
469 def create_branch(self, name, base, track=False, force=False):
470 """Create a branch named 'name' from revision 'base'
472 Pass track=True to create a local tracking branch.
474 return self.git.branch(name, base, track=track, force=force)
476 def is_commit_published(self):
477 """Return True if the latest commit exists in any remote branch"""
478 return bool(self.git.branch(r=True, contains='HEAD')[STDOUT])
480 def untrack_paths(self, paths):
481 context = self.context
482 status, out, err = gitcmds.untrack_paths(context, paths)
483 self.update_file_status()
484 return status, out, err
486 def getcwd(self):
487 """If we've chosen a directory then use it, otherwise use current"""
488 if self.directory:
489 return self.directory
490 return core.getcwd()
492 def cycle_ref_sort(self):
493 """Choose the next ref sort type (version, reverse-chronological)"""
494 self.set_ref_sort(self.ref_sort + 1)
496 def set_ref_sort(self, raw_value):
497 value = raw_value % 2 # Currently two sort types
498 if value == self.ref_sort:
499 return
500 self.ref_sort = value
501 self.update_refs()
504 class Types:
505 """File types (used for image diff modes)"""
507 IMAGE = 'image'
508 TEXT = 'text'
511 def remote_args(
512 context,
513 remote,
514 action,
515 local_branch='',
516 remote_branch='',
517 ff_only=False,
518 force=False,
519 no_ff=False,
520 tags=False,
521 rebase=False,
522 set_upstream=False,
523 prune=False,
525 """Return arguments for git fetch/push/pull"""
527 args = [remote]
528 what = refspec_arg(local_branch, remote_branch, remote, action)
529 if what:
530 args.append(what)
532 kwargs = {
533 'verbose': True,
535 if action == PULL:
536 if rebase:
537 kwargs['rebase'] = True
538 elif ff_only:
539 kwargs['ff_only'] = True
540 elif no_ff:
541 kwargs['no_ff'] = True
542 elif force:
543 if action == PUSH and version.check_git(context, 'force-with-lease'):
544 kwargs['force_with_lease'] = True
545 else:
546 kwargs['force'] = True
548 if action == PUSH and set_upstream:
549 kwargs['set_upstream'] = True
550 if tags:
551 kwargs['tags'] = True
552 if prune:
553 kwargs['prune'] = True
555 return (args, kwargs)
558 def refspec(src, dst, action):
559 if action == PUSH and src == dst:
560 spec = src
561 else:
562 spec = f'{src}:{dst}'
563 return spec
566 def refspec_arg(local_branch, remote_branch, remote, action):
567 """Return the refspec for a fetch or pull command"""
568 ref = None
569 if action == PUSH and local_branch and remote_branch: # Push with local and remote.
570 ref = refspec(local_branch, remote_branch, action)
571 elif action == FETCH:
572 if local_branch and remote_branch: # Fetch with local and remote.
573 if local_branch == FETCH_HEAD:
574 ref = remote_branch
575 else:
576 ref = refspec(remote_branch, local_branch, action)
577 elif remote_branch:
578 # If we are fetching and only a remote branch was specified then setup
579 # a refspec that will fetch into the remote tracking branch only.
580 ref = refspec(
581 remote_branch,
582 f'refs/remotes/{remote}/{remote_branch}',
583 action,
585 if not ref and local_branch != FETCH_HEAD:
586 ref = local_branch or remote_branch or None
587 return ref
590 def run_remote_action(context, fn, remote, action, **kwargs):
591 """Run fetch, push or pull"""
592 args, kwargs = remote_args(context, remote, action, **kwargs)
593 return fn(*args, **kwargs)