1 # Copyright (c) 2008 David Aguilar
2 """This module provides the central cola model.
10 from cStringIO
import StringIO
14 from cola
import utils
15 from cola
import errors
16 from cola
.models
.observable
import ObservableModel
18 #+-------------------------------------------------------------------------
19 #+ A regex for matching the output of git(log|rev-list) --pretty=oneline
20 REV_LIST_REGEX
= re
.compile('([0-9a-f]+)\W(.*)')
22 # Provides access to a global MainModel instance
25 """Returns the main model singleton"""
29 _instance
= MainModel()
32 class GitInitError(errors
.ColaError
):
35 class GitCola(git
.Git
):
36 """GitPython throws exceptions by default.
37 We suppress exceptions in favor of return values.
40 git
.Git
.__init
__(self
)
41 self
.load_worktree(os
.getcwd())
43 def load_worktree(self
, path
):
53 curdir
= self
._git
_dir
57 if self
._is
_git
_dir
(os
.path
.join(curdir
, '.git')):
60 # Handle bare repositories
61 if (len(os
.path
.basename(curdir
)) > 4
62 and curdir
.endswith('.git')):
64 if 'GIT_WORK_TREE' in os
.environ
:
65 self
._worktree
= os
.getenv('GIT_WORK_TREE')
66 if not self
._worktree
or not os
.path
.isdir(self
._worktree
):
68 gitparent
= os
.path
.join(os
.path
.abspath(self
._git
_dir
), '..')
69 self
._worktree
= os
.path
.abspath(gitparent
)
70 self
.set_cwd(self
._worktree
)
74 return self
._git
_dir
and self
._is
_git
_dir
(self
._git
_dir
)
79 if 'GIT_DIR' in os
.environ
:
80 self
._git
_dir
= os
.getenv('GIT_DIR')
82 curpath
= os
.path
.abspath(self
._git
_dir
)
84 curpath
= os
.path
.abspath(os
.getcwd())
85 # Search for a .git directory
87 if self
._is
_git
_dir
(curpath
):
88 self
._git
_dir
= curpath
90 gitpath
= os
.path
.join(curpath
, '.git')
91 if self
._is
_git
_dir
(gitpath
):
92 self
._git
_dir
= gitpath
94 curpath
, dummy
= os
.path
.split(curpath
)
99 def _is_git_dir(self
, d
):
100 """ This is taken from the git setup.c:is_git_directory
103 and os
.path
.isdir(os
.path
.join(d
, 'objects'))
104 and os
.path
.isdir(os
.path
.join(d
, 'refs'))):
105 headref
= os
.path
.join(d
, 'HEAD')
106 return (os
.path
.isfile(headref
)
107 or (os
.path
.islink(headref
)
108 and os
.readlink(headref
).startswith('refs')))
113 """handles quoted paths."""
114 if path
.startswith('"') and path
.endswith('"'):
115 return core
.decode(eval(path
))
120 class MainModel(ObservableModel
):
121 """Provides a friendly wrapper for doing common git operations."""
123 # Observable messages
124 message_updated
= 'updated'
125 message_about_to_update
= 'about_to_update'
126 def __init__(self
, cwd
=None):
127 """Reads git repository settings and sets several methods
128 so that they refer to the git module. This object
129 encapsulates cola's interaction with git."""
130 ObservableModel
.__init
__(self
)
132 # Initialize the git command object
135 #####################################################
136 # Used in various places
137 self
.currentbranch
= ''
138 self
.trackedbranch
= ''
140 self
.git_version
= self
.git
.version()
144 self
.local_branch
= ''
145 self
.remote_branch
= ''
147 #####################################################
148 # Used primarily by the main UI
155 self
.upstream_changed
= []
157 #####################################################
158 # Used by the create branch dialog
160 self
.local_branches
= []
161 self
.remote_branches
= []
164 #####################################################
165 # Used by the commit/repo browser
169 # These are parallel lists
174 # All items below here are re-calculated in
175 # init_browser_data()
176 self
.directories
= []
177 self
.directory_entries
= {}
179 # These are also parallel lists
180 self
.subtree_types
= []
181 self
.subtree_sha1s
= []
182 self
.subtree_names
= []
184 self
.fetch_helper
= None
185 self
.push_helper
= None
186 self
.pull_helper
= None
187 self
.generate_remote_helpers()
189 self
.use_worktree(cwd
)
192 """Returns the names of all files in the repository"""
193 return [core
.decode(f
)
194 for f
in self
.git
.ls_files(z
=True)
195 .strip('\0').split('\0') if f
]
197 def generate_remote_helpers(self
):
198 """Generates helper methods for fetch, push and pull"""
199 self
.push_helper
= self
.gen_remote_helper(self
.git
.push
, push
=True)
200 self
.fetch_helper
= self
.gen_remote_helper(self
.git
.fetch
)
201 self
.pull_helper
= self
.gen_remote_helper(self
.git
.pull
)
203 def use_worktree(self
, worktree
):
204 self
.git
.load_worktree(worktree
)
205 is_valid
= self
.git
.is_valid()
207 self
._init
_config
_data
()
208 self
.project
= os
.path
.basename(self
.git
.worktree())
211 def _init_config_data(self
):
212 """Reads git config --list and creates parameters
214 # These parameters are saved in .gitconfig,
215 # so ideally these should be as short as possible.
217 # config items that are controllable globally
219 self
._local
_and
_global
_defaults
= {
222 'merge_summary': False,
223 'merge_diffstat': True,
224 'merge_verbosity': 2,
225 'gui_diffcontext': 3,
226 'gui_pruneduringfetch': False,
228 # config items that are purely git config --global settings
229 self
.__global
_defaults
= {
232 'cola_fontdiff_size': 12,
233 'cola_savewindowsettings': False,
234 'cola_showoutput': 'errors',
236 'merge_keepbackup': True,
237 'diff_tool': os
.getenv('GIT_DIFF_TOOL', 'xxdiff'),
238 'merge_tool': os
.getenv('GIT_MERGE_TOOL', 'xxdiff'),
239 'gui_editor': os
.getenv('EDITOR', 'gvim'),
240 'gui_historybrowser': 'gitk',
243 local_dict
= self
.config_dict(local
=True)
244 global_dict
= self
.config_dict(local
=False)
246 for k
,v
in local_dict
.iteritems():
247 self
.set_param('local_'+k
, v
)
248 for k
,v
in global_dict
.iteritems():
249 self
.set_param('global_'+k
, v
)
250 if k
not in local_dict
:
252 self
.set_param('local_'+k
, v
)
254 # Bootstrap the internal font*size variables
255 for param
in ('global_cola_fontdiff'):
257 if hasattr(self
, param
):
258 font
= getattr(self
, param
)
261 size
= int(font
.split(',')[1])
262 self
.set_param(param
+'_size', size
)
263 param
= param
[len('global_'):]
264 global_dict
[param
] = font
265 global_dict
[param
+'_size'] = size
267 # Load defaults for all undefined items
268 local_and_global_defaults
= self
._local
_and
_global
_defaults
269 for k
,v
in local_and_global_defaults
.iteritems():
270 if k
not in local_dict
:
271 self
.set_param('local_'+k
, v
)
272 if k
not in global_dict
:
273 self
.set_param('global_'+k
, v
)
275 global_defaults
= self
.__global
_defaults
276 for k
,v
in global_defaults
.iteritems():
277 if k
not in global_dict
:
278 self
.set_param('global_'+k
, v
)
280 # Load the diff context
281 self
.diff_context
= self
.local_config('gui.diffcontext', 3)
283 def global_config(self
, key
, default
=None):
284 return self
.param('global_'+key
.replace('.', '_'),
287 def local_config(self
, key
, default
=None):
288 return self
.param('local_'+key
.replace('.', '_'),
291 def cola_config(self
, key
):
292 return getattr(self
, 'global_cola_'+key
)
294 def gui_config(self
, key
):
295 return getattr(self
, 'global_gui_'+key
)
297 def default_remote(self
):
298 branch
= self
.currentbranch
299 branchconfig
= 'branch.%s.remote' % branch
300 return self
.local_config(branchconfig
, 'origin')
302 def corresponding_remote_ref(self
):
303 remote
= self
.default_remote()
304 branch
= self
.currentbranch
305 best_match
= '%s/%s' % (remote
, branch
)
306 remote_branches
= self
.remote_branches
307 if not remote_branches
:
309 for rb
in remote_branches
:
312 return remote_branches
[0]
314 def diff_filenames(self
, arg
):
315 """Returns a list of filenames that have been modified"""
316 diff_zstr
= self
.git
.diff(arg
, name_only
=True, z
=True).rstrip('\0')
317 return [core
.decode(f
) for f
in diff_zstr
.split('\0') if f
]
319 def branch_list(self
, remote
=False):
320 """Returns a list of local or remote branches
322 This explicitly removes HEAD from the list of remote branches.
324 branches
= map(lambda x
: x
.lstrip('* '),
325 self
.git
.branch(r
=remote
).splitlines())
327 return [b
for b
in branches
if b
.find('/HEAD') == -1]
330 def config_params(self
):
332 params
.extend(map(lambda x
: 'local_' + x
,
333 self
._local
_and
_global
_defaults
.keys()))
334 params
.extend(map(lambda x
: 'global_' + x
,
335 self
._local
_and
_global
_defaults
.keys()))
336 params
.extend(map(lambda x
: 'global_' + x
,
337 self
.__global
_defaults
.keys()))
338 return [ p
for p
in params
if not p
.endswith('_size') ]
340 def save_config_param(self
, param
):
341 if param
not in self
.config_params():
343 value
= getattr(self
, param
)
344 if param
== 'local_gui_diffcontext':
345 self
.diff_context
= value
346 if param
.startswith('local_'):
347 param
= param
[len('local_'):]
349 elif param
.startswith('global_'):
350 param
= param
[len('global_'):]
353 raise Exception("Invalid param '%s' passed to " % param
354 +'save_config_param()')
355 param
= param
.replace('_', '.') # model -> git
356 return self
.config_set(param
, value
, local
=is_local
)
358 def init_browser_data(self
):
359 """This scans over self.(names, sha1s, types) to generate
360 directories, directory_entries, and subtree_*"""
362 # Collect data for the model
363 if not self
.currentbranch
:
366 self
.subtree_types
= []
367 self
.subtree_sha1s
= []
368 self
.subtree_names
= []
369 self
.directories
= []
370 self
.directory_entries
= {}
372 # Lookup the tree info
373 tree_info
= self
.parse_ls_tree(self
.currentbranch
)
375 self
.set_types(map( lambda(x
): x
[1], tree_info
))
376 self
.set_sha1s(map( lambda(x
): x
[2], tree_info
))
377 self
.set_names(map( lambda(x
): x
[3], tree_info
))
379 if self
.directory
: self
.directories
.append('..')
381 dir_entries
= self
.directory_entries
382 dir_regex
= re
.compile('([^/]+)/')
386 for idx
, name
in enumerate(self
.names
):
387 if not name
.startswith(self
.directory
):
389 name
= name
[ len(self
.directory
): ]
391 # This is a directory...
392 match
= dir_regex
.match(name
)
395 dirent
= match
.group(1) + '/'
396 if dirent
not in self
.directory_entries
:
397 self
.directory_entries
[dirent
] = []
399 if dirent
not in dirs_seen
:
400 dirs_seen
[dirent
] = True
401 self
.directories
.append(dirent
)
403 entry
= name
.replace(dirent
, '')
404 entry_match
= dir_regex
.match(entry
)
406 subdir
= entry_match
.group(1) + '/'
407 if subdir
in subdirs_seen
:
409 subdirs_seen
[subdir
] = True
410 dir_entries
[dirent
].append(subdir
)
412 dir_entries
[dirent
].append(entry
)
414 self
.subtree_types
.append(self
.types
[idx
])
415 self
.subtree_sha1s
.append(self
.sha1s
[idx
])
416 self
.subtree_names
.append(name
)
418 def add_or_remove(self
, to_process
):
419 """Invokes 'git add' to index the filenames in to_process that exist
420 and 'git rm' for those that do not exist."""
423 return 'No files to add or remove.'
428 for filename
in to_process
:
429 encfilename
= core
.encode(filename
)
430 if os
.path
.exists(encfilename
):
431 to_add
.append(filename
)
435 newstatus
, output
= self
.git
.add(v
=True,
443 if len(to_add
) == len(to_process
):
444 # to_process only contained unremoved files --
445 # short-circuit the removal checks
446 return (status
, output
)
448 # Process files to remote
449 for filename
in to_process
:
450 if not os
.path
.exists(filename
):
451 to_remove
.append(filename
)
452 newstatus
, out
= self
.git
.rm(with_stderr
=True,
457 output
+ '\n\n' + out
458 return (status
, output
)
461 return self
.gui_config('editor')
463 def history_browser(self
):
464 return self
.gui_config('historybrowser')
466 def remember_gui_settings(self
):
467 return self
.cola_config('savewindowsettings')
469 def subtree_node(self
, idx
):
470 return (self
.subtree_types
[idx
],
471 self
.subtree_sha1s
[idx
],
472 self
.subtree_names
[idx
])
474 def all_branches(self
):
475 return (self
.local_branches
+ self
.remote_branches
)
477 def set_remote(self
, remote
):
480 self
.set_param('remote', remote
)
481 branches
= utils
.grep('%s/\S+$' % remote
,
482 self
.branch_list(remote
=True),
484 self
.set_remote_branches(branches
)
486 def add_signoff(self
,*rest
):
487 """Adds a standard Signed-off by: tag to the end
488 of the current commit message."""
490 signoff
=('\n\nSigned-off-by: %s <%s>\n'
491 % (self
.local_user_name
, self
.local_user_email
))
492 if signoff
not in msg
:
493 self
.set_commitmsg(msg
+ signoff
)
495 def apply_diff(self
, filename
):
496 return self
.git
.apply(filename
, index
=True, cached
=True)
498 def apply_diff_to_worktree(self
, filename
):
499 return self
.git
.apply(filename
)
501 def load_commitmsg(self
, path
):
503 contents
= core
.decode(core
.read_nointr(fh
))
505 self
.set_commitmsg(contents
)
507 def get_prev_commitmsg(self
,*rest
):
508 """Queries git for the latest commit message and sets it in
511 commit_lines
= core
.decode(self
.git
.show('HEAD')).split('\n')
512 for idx
, msg
in enumerate(commit_lines
):
516 if msg
.startswith('diff --git'):
519 commit_msg
.append(msg
)
520 self
.set_commitmsg('\n'.join(commit_msg
).rstrip())
522 def load_commitmsg_template(self
):
523 template
= self
.global_config('commit.template')
525 self
.load_commitmsg(template
)
527 def update_status(self
, head
='HEAD', staged_only
=False):
528 # Give observers a chance to respond
529 self
.notify_message_observers(self
.message_about_to_update
)
530 # This allows us to defer notification until the
531 # we finish processing data
532 notify_enabled
= self
.notification_enabled
533 self
.notification_enabled
= False
539 self
.upstream_changed
) = self
.worktree_state(head
=head
,
540 staged_only
=staged_only
)
541 # NOTE: the model's unstaged list holds an aggregate of the
542 # the modified, unmerged, and untracked file lists.
543 self
.set_unstaged(self
.modified
+ self
.unmerged
+ self
.untracked
)
544 self
.set_currentbranch(self
.current_branch())
545 self
.set_remotes(self
.git
.remote().splitlines())
546 self
.set_remote_branches(self
.branch_list(remote
=True))
547 self
.set_trackedbranch(self
.tracked_branch())
548 self
.set_local_branches(self
.branch_list(remote
=False))
549 self
.set_tags(self
.git
.tag().splitlines())
550 self
.set_revision('')
551 self
.set_local_branch('')
552 self
.set_remote_branch('')
553 # Re-enable notifications and emit changes
554 self
.notification_enabled
= notify_enabled
556 # Read the font size by default
557 self
.read_font_sizes()
559 self
.notify_observers('staged','unstaged')
560 self
.notify_message_observers(self
.message_updated
)
562 def read_font_sizes(self
):
563 """Read font sizes from the configuration."""
564 value
= self
.cola_config('fontdiff')
567 items
= value
.split(',')
570 self
.global_cola_fontdiff_size
= int(items
[1])
572 def set_diff_font(self
, fontstr
):
573 """Set the diff font string."""
574 self
.global_cola_fontdiff
= fontstr
575 self
.read_font_sizes()
577 def delete_branch(self
, branch
):
578 return self
.git
.branch(branch
,
583 def revision_sha1(self
, idx
):
584 return self
.revisions
[idx
]
586 def apply_diff_font_size(self
, default
):
587 old_font
= self
.cola_config('fontdiff')
590 size
= self
.cola_config('fontdiff_size')
591 props
= old_font
.split(',')
593 new_font
= ','.join(props
)
594 self
.global_cola_fontdiff
= new_font
595 self
.notify_observers('global_cola_fontdiff')
597 def commit_diff(self
, sha1
):
598 commit
= self
.git
.show(sha1
)
599 first_newline
= commit
.index('\n')
600 if commit
[first_newline
+1:].startswith('Merge:'):
601 return (core
.decode(commit
) + '\n\n' +
602 core
.decode(self
.diff_helper(commit
=sha1
,
604 suppress_header
=False)))
606 return core
.decode(commit
)
608 def filename(self
, idx
, staged
=True):
611 return self
.staged
[idx
]
613 return self
.unstaged
[idx
]
617 def diff_details(self
, idx
, ref
, staged
=True):
619 Return a "diff" for an entry by index relative to ref.
621 `staged` indicates whether we should consider this as a
622 staged or unstaged entry.
625 filename
= self
.filename(idx
, staged
=staged
)
628 encfilename
= core
.encode(filename
)
630 diff
= self
.diff_helper(filename
=filename
,
634 if os
.path
.isdir(encfilename
):
635 diff
= '\n'.join(os
.listdir(filename
))
637 elif filename
in self
.unmerged
:
638 diff
= ('@@@ Unmerged @@@\n'
639 '- %s is unmerged.\n+ ' % filename
+
640 'Right-click the file to launch "git mergetool".\n'
641 '@@@ Unmerged @@@\n\n')
642 diff
+= self
.diff_helper(filename
=filename
,
644 elif filename
in self
.modified
:
645 diff
= self
.diff_helper(filename
=filename
,
648 diff
= 'SHA1: ' + self
.git
.hash_object(filename
)
649 return (diff
, filename
)
651 def diff_for_expr(self
, idx
, expr
):
653 Return a diff for an arbitrary diff expression.
655 `idx` is the index of the entry in the staged files list.
658 filename
= self
.filename(idx
, staged
=True)
661 diff
= self
.diff_helper(filename
=filename
, ref
=expr
, cached
=False)
662 return (diff
, filename
)
664 def stage_modified(self
):
665 status
, output
= self
.git
.add(v
=True,
670 return (status
, output
)
672 def stage_untracked(self
):
673 status
, output
= self
.git
.add(v
=True,
678 return (status
, output
)
680 def reset(self
, *items
):
681 status
, output
= self
.git
.reset('--',
686 return (status
, output
)
688 def unstage_all(self
):
689 status
, output
= self
.git
.reset(with_stderr
=True,
692 return (status
, output
)
695 status
, output
= self
.git
.add(v
=True,
700 return (status
, output
)
702 def config_set(self
, key
=None, value
=None, local
=True):
703 if key
and value
is not None:
704 # git config category.key value
705 strval
= unicode(value
)
706 if type(value
) is bool:
707 # git uses "true" and "false"
708 strval
= strval
.lower()
710 argv
= [ key
, strval
]
712 argv
= [ '--global', key
, strval
]
713 return self
.git
.config(*argv
)
715 msg
= "oops in config_set(key=%s,value=%s,local=%s)"
716 raise Exception(msg
% (key
, value
, local
))
718 def config_dict(self
, local
=True):
719 """parses the lines from git config --list into a dictionary"""
723 'global': not local
, # global is a python keyword
725 config_lines
= self
.git
.config(**kwargs
).splitlines()
727 for line
in config_lines
:
729 k
, v
= line
.split('=', 1)
731 # the user has an invalid entry in their git config
734 k
= k
.replace('.','_') # git -> model
735 if v
== 'true' or v
== 'false':
736 v
= bool(eval(v
.title()))
744 def commit_with_msg(self
, msg
, amend
=False):
745 """Creates a git commit."""
747 if not msg
.endswith('\n'):
749 # Sure, this is a potential "security risk," but if someone
750 # is trying to intercept/re-write commit messages on your system,
751 # then you probably have bigger problems to worry about.
752 tmpfile
= self
.tmp_filename()
754 # Create the commit message file
755 fh
= open(tmpfile
, 'w')
756 core
.write_nointr(fh
, msg
)
760 status
, out
= self
.git
.commit(F
=tmpfile
, v
=True, amend
=amend
,
767 return self
.git
.diff(unified
=self
.diff_context
,
773 # Allow TMPDIR/TMP with a fallback to /tmp
774 return os
.environ
.get('TMP', os
.environ
.get('TMPDIR', '/tmp'))
776 def tmp_file_pattern(self
):
777 return os
.path
.join(self
.tmp_dir(), '*.git-cola.%s.*' % os
.getpid())
779 def tmp_filename(self
, prefix
=''):
780 basename
= ((prefix
+'.git-cola.%s.%s'
781 % (os
.getpid(), time
.time())))
782 basename
= basename
.replace('/', '-')
783 basename
= basename
.replace('\\', '-')
784 tmpdir
= self
.tmp_dir()
785 return os
.path
.join(tmpdir
, basename
)
787 def log_helper(self
, all
=False, extra_args
=None):
789 Returns a pair of parallel arrays listing the revision sha1's
790 and commit summaries.
794 regex
= REV_LIST_REGEX
798 output
= self
.git
.log(pretty
='oneline', all
=all
, *args
)
799 for line
in map(core
.decode
, output
.splitlines()):
800 match
= regex
.match(line
)
802 revs
.append(match
.group(1))
803 summaries
.append(match
.group(2))
804 return (revs
, summaries
)
806 def parse_rev_list(self
, raw_revs
):
808 for line
in map(core
.decode
, raw_revs
.splitlines()):
809 match
= REV_LIST_REGEX
.match(line
)
811 rev_id
= match
.group(1)
812 summary
= match
.group(2)
813 revs
.append((rev_id
, summary
,))
816 def rev_list_range(self
, start
, end
):
817 range = '%s..%s' % (start
, end
)
818 raw_revs
= self
.git
.rev_list(range, pretty
='oneline')
819 return self
.parse_rev_list(raw_revs
)
821 def diff_helper(self
,
828 with_diff_header
=False,
829 suppress_header
=True,
831 "Invokes git diff on a filepath."
833 ref
, endref
= commit
+'^', commit
836 argv
.append('%s..%s' % (ref
, endref
))
838 for r
in ref
.strip().split():
845 if type(filename
) is list:
846 argv
.extend(filename
)
848 argv
.append(filename
)
851 del_tag
= 'deleted file mode '
854 deleted
= cached
and not os
.path
.exists(core
.encode(filename
))
856 diffoutput
= self
.git
.diff(R
=reverse
,
860 unified
=self
.diff_context
,
861 with_raw_output
=True,
866 if diffoutput
.startswith('fatal:'):
874 diff
= diffoutput
.split('\n')
875 for line
in map(core
.decode
, diff
):
876 if not start
and '@@' == line
[:2] and '@@' in line
[2:]:
878 if start
or (deleted
and del_tag
in line
):
879 output
.write(core
.encode(line
) + '\n')
882 headers
.append(core
.encode(line
))
883 elif not suppress_header
:
884 output
.write(core
.encode(line
) + '\n')
886 result
= core
.decode(output
.getvalue())
890 return('\n'.join(headers
), result
)
894 def git_repo_path(self
, *subpaths
):
895 paths
= [self
.git
.git_dir()]
896 paths
.extend(subpaths
)
897 return os
.path
.realpath(os
.path
.join(*paths
))
899 def merge_message_path(self
):
900 for file in ('MERGE_MSG', 'SQUASH_MSG'):
901 path
= self
.git_repo_path(file)
902 if os
.path
.exists(path
):
906 def merge_message(self
):
907 return self
.git
.fmt_merge_msg('--file',
908 self
.git_repo_path('FETCH_HEAD'))
910 def abort_merge(self
):
912 output
= self
.git
.read_tree('HEAD', reset
=True, u
=True, v
=True)
914 merge_head
= self
.git_repo_path('MERGE_HEAD')
915 if os
.path
.exists(merge_head
):
916 os
.unlink(merge_head
)
917 # remove MERGE_MESSAGE, etc.
918 merge_msg_path
= self
.merge_message_path()
919 while merge_msg_path
:
920 os
.unlink(merge_msg_path
)
921 merge_msg_path
= self
.merge_message_path()
923 def _is_modified(self
, name
):
924 status
, out
= self
.git
.diff('--', name
,
931 def _branch_status(self
, branch
):
933 Returns a tuple of staged, unstaged, untracked, and unmerged files
935 This shows only the changes that were introduced in branch
938 status
, output
= self
.git
.diff(name_only
=True,
942 *branch
.strip().split())
944 return ([], [], [], [])
946 for name
in output
.strip('\0').split('\0'):
949 staged
.append(core
.decode(name
))
951 return (staged
, [], [], [])
953 def worktree_state(self
, head
='HEAD', staged_only
=False):
954 """Return a tuple of files in various states of being
956 Can be staged, unstaged, untracked, unmerged, or changed
960 self
.git
.update_index(refresh
=True)
962 return self
._branch
_status
(head
)
966 upstream_changed_set
= set()
968 (staged
, modified
, unmerged
, untracked
, upstream_changed
) = (
971 output
= self
.git
.diff_index(head
,
974 if output
.startswith('fatal:'):
975 raise GitInitError('git init')
976 for line
in output
.splitlines():
977 rest
, name
= line
.split('\t', 1)
979 name
= eval_path(name
)
983 # This file will also show up as 'M' without --cached
984 # so by default don't consider it modified unless
985 # it's truly modified
986 modified_set
.add(name
)
987 if not staged_only
and self
._is
_modified
(name
):
988 modified
.append(name
)
995 modified_set
.add(name
)
997 unmerged
.append(name
)
998 modified_set
.add(name
)
1000 except GitInitError
:
1002 staged
.extend(self
.all_files())
1005 output
= self
.git
.diff_index(head
, with_stderr
=True)
1006 if output
.startswith('fatal:'):
1007 raise GitInitError('git init')
1008 for line
in output
.splitlines():
1009 info
, name
= line
.split('\t', 1)
1010 status
= info
.split()[-1]
1011 if status
== 'M' or status
== 'D':
1012 name
= eval_path(name
)
1013 if name
not in modified_set
:
1014 modified
.append(name
)
1016 name
= eval_path(name
)
1017 # newly-added yet modified
1018 if (name
not in modified_set
and not staged_only
and
1019 self
._is
_modified
(name
)):
1020 modified
.append(name
)
1022 except GitInitError
:
1024 for name
in (self
.git
.ls_files(modified
=True, z
=True)
1027 modified
.append(core
.decode(name
))
1029 for name
in self
.git
.ls_files(others
=True, exclude_standard
=True,
1030 z
=True).split('\0'):
1032 untracked
.append(core
.decode(name
))
1034 # Look for upstream modified files if this is a tracking branch
1035 if self
.trackedbranch
:
1037 output
= self
.git
.diff_index(self
.trackedbranch
,
1039 if output
.startswith('fatal:'):
1040 raise GitInitError('git init')
1041 for line
in output
.splitlines():
1042 info
, name
= line
.split('\t', 1)
1043 status
= info
.split()[-1]
1045 # For now we'll just call anything here 'changed
1046 # upstream'. Maybe one day we'll elaborate more on
1047 # what the change is.
1048 if status
== 'M' or status
== 'D':
1049 name
= eval_path(name
)
1050 if name
not in upstream_changed_set
:
1051 upstream_changed
.append(name
)
1052 upstream_changed_set
.add(name
)
1054 except GitInitError
:
1063 upstream_changed
.sort()
1065 return (staged
, modified
, unmerged
, untracked
, upstream_changed
)
1067 def reset_helper(self
, args
):
1068 """Removes files from the index
1070 This handles the git init case, which is why it's not
1071 just 'git reset name'. For the git init case this falls
1072 back to 'git rm --cached'.
1075 # fake the status because 'git reset' returns 1
1076 # regardless of success/failure
1078 output
= self
.git
.reset('--', with_stderr
=True, *args
)
1079 # handle git init: we have to use 'git rm --cached'
1080 # detect this condition by checking if the file is still staged
1081 state
= self
.worktree_state()
1083 rmargs
= [a
for a
in args
if a
in staged
]
1085 return (status
, output
)
1086 output
+= self
.git
.rm('--', cached
=True, with_stderr
=True, *rmargs
)
1087 return (status
, output
)
1089 def remote_url(self
, name
):
1090 return self
.git
.config('remote.%s.url' % name
, get
=True)
1092 def remote_args(self
, remote
,
1099 # Swap the branches in push mode (reverse of fetch)
1102 local_branch
= remote_branch
1105 branch_arg
= '%s:%s' % ( remote_branch
, local_branch
)
1107 branch_arg
= '+%s:%s' % ( remote_branch
, local_branch
)
1109 if local_branch
and remote_branch
:
1110 args
.append(branch_arg
)
1112 args
.append(local_branch
)
1114 args
.append(remote_branch
)
1119 'with_stderr': True,
1120 'with_status': True,
1122 return (args
, kwargs
)
1124 def gen_remote_helper(self
, gitaction
, push
=False):
1125 """Generates a closure that calls git fetch, push or pull
1127 def remote_helper(remote
, **kwargs
):
1128 args
, kwargs
= self
.remote_args(remote
, push
=push
, **kwargs
)
1129 return gitaction(*args
, **kwargs
)
1130 return remote_helper
1132 def parse_ls_tree(self
, rev
):
1133 """Returns a list of(mode, type, sha1, path) tuples."""
1134 lines
= self
.git
.ls_tree(rev
, r
=True).splitlines()
1136 regex
= re
.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
1138 match
= regex
.match(line
)
1140 mode
= match
.group(1)
1141 objtype
= match
.group(2)
1142 sha1
= match
.group(3)
1143 filename
= match
.group(4)
1144 output
.append((mode
, objtype
, sha1
, filename
,) )
1147 def format_patch_helper(self
, to_export
, revs
, output
='patches'):
1148 """writes patches named by to_export to the output directory."""
1152 cur_rev
= to_export
[0]
1153 cur_master_idx
= revs
.index(cur_rev
)
1155 patches_to_export
= [ [cur_rev
] ]
1158 # Group the patches into continuous sets
1159 for idx
, rev
in enumerate(to_export
[1:]):
1160 # Limit the search to the current neighborhood for efficiency
1161 master_idx
= revs
[ cur_master_idx
: ].index(rev
)
1162 master_idx
+= cur_master_idx
1163 if master_idx
== cur_master_idx
+ 1:
1164 patches_to_export
[ patchset_idx
].append(rev
)
1168 patches_to_export
.append([ rev
])
1169 cur_master_idx
= master_idx
1172 # Export each patchsets
1174 for patchset
in patches_to_export
:
1175 newstatus
, out
= self
.export_patchset(patchset
[0],
1178 n
=len(patchset
) > 1,
1180 patch_with_stat
=True)
1181 outlines
.append(out
)
1184 return (status
, '\n'.join(outlines
))
1186 def export_patchset(self
, start
, end
, output
="patches", **kwargs
):
1187 revarg
= '%s^..%s' % (start
, end
)
1188 return self
.git
.format_patch('-o', output
, revarg
,
1193 def current_branch(self
):
1194 """Parses 'git symbolic-ref' to find the current branch."""
1195 headref
= self
.git
.symbolic_ref('HEAD', with_stderr
=True)
1196 if headref
.startswith('refs/heads/'):
1198 elif headref
.startswith('fatal:'):
1202 def tracked_branch(self
):
1203 """The name of the branch that current branch is tracking"""
1204 remote
= self
.git
.config('branch.'+self
.currentbranch
+'.remote',
1205 get
=True, with_stderr
=True)
1208 headref
= self
.git
.config('branch.'+self
.currentbranch
+'.merge',
1209 get
=True, with_stderr
=True)
1210 if headref
.startswith('refs/heads/'):
1211 tracked_branch
= headref
[11:]
1212 return remote
+ '/' + tracked_branch
1215 def create_branch(self
, name
, base
, track
=False):
1216 """Create a branch named 'name' from revision 'base'
1218 Pass track=True to create a local tracking branch.
1220 return self
.git
.branch(name
, base
, track
=track
,
1224 def cherry_pick_list(self
, revs
, **kwargs
):
1225 """Cherry-picks each revision into the current branch.
1226 Returns a list of command output strings (1 per cherry pick)"""
1232 newstatus
, out
= self
.git
.cherry_pick(rev
,
1237 cherries
.append(out
)
1238 return (status
, '\n'.join(cherries
))
1240 def parse_stash_list(self
, revids
=False):
1241 """Parses "git stash list" and returns a list of stashes."""
1242 stashes
= self
.git
.stash("list").splitlines()
1244 return [ s
[:s
.index(':')] for s
in stashes
]
1246 return [ s
[s
.index(':')+1:] for s
in stashes
]
1249 return self
.git
.diff(
1251 unified
=self
.diff_context
,
1255 def pad(self
, pstr
, num
=22):
1256 topad
= num
-len(pstr
)
1258 return pstr
+ ' '*topad
1262 def describe(self
, revid
, descr
):
1263 version
= self
.git
.describe(revid
, tags
=True, always
=True,
1265 return version
+ ' - ' + descr
1267 def update_revision_lists(self
, filename
=None, show_versions
=False):
1268 num_results
= self
.num_results
1270 rev_list
= self
.git
.log('--', filename
,
1271 max_count
=num_results
,
1274 rev_list
= self
.git
.log(max_count
=num_results
,
1275 pretty
='oneline', all
=True)
1277 commit_list
= self
.parse_rev_list(rev_list
)
1278 commit_list
.reverse()
1279 commits
= map(lambda x
: x
[0], commit_list
)
1280 descriptions
= map(lambda x
: core
.decode(x
[1]), commit_list
)
1282 fancy_descr_list
= map(lambda x
: self
.describe(*x
), commit_list
)
1283 self
.set_descriptions_start(fancy_descr_list
)
1284 self
.set_descriptions_end(fancy_descr_list
)
1286 self
.set_descriptions_start(descriptions
)
1287 self
.set_descriptions_end(descriptions
)
1289 self
.set_revisions_start(commits
)
1290 self
.set_revisions_end(commits
)
1294 def changed_files(self
, start
, end
):
1295 zfiles_str
= self
.git
.diff('%s..%s' % (start
, end
),
1296 name_only
=True, z
=True).strip('\0')
1297 return [core
.decode(enc
) for enc
in zfiles_str
.split('\0') if enc
]
1299 def renamed_files(self
, start
, end
):
1300 difflines
= self
.git
.diff('%s..%s' % (start
, end
),
1302 M
=True).splitlines()
1303 return [ eval_path(r
[12:].rstrip())
1304 for r
in difflines
if r
.startswith('rename from ') ]
1306 def is_commit_published(self
):
1307 head
= self
.git
.rev_parse('HEAD')
1308 return bool(self
.git
.branch(r
=True, contains
=head
))
1310 def merge_base_to(self
, ref
):
1311 """Given `ref`, return $(git merge-base ref HEAD)..ref."""
1312 base
= self
.git
.merge_base('HEAD', ref
)
1313 return '%s..%s' % (base
, ref
)
1315 def everything(self
):
1316 """Returns a sorted list of all files, including untracked files."""
1317 files
= self
.all_files() + self
.untracked