6 from cStringIO
import StringIO
8 # GitPython http://gitorious.org/projects/git-python
11 from ugit
import utils
12 from ugit
import model
14 #+-------------------------------------------------------------------------
15 #+ A regex for matching the output of git(log|rev-list) --pretty=oneline
16 REV_LIST_REGEX
= re
.compile('([0-9a-f]+)\W(.*)')
18 #+-------------------------------------------------------------------------
19 # List of functions available directly through model.command_name()
21 am annotate apply archive archive_recursive
22 bisect blame branch bundle
23 checkout checkout_index cherry cherry_pick citool
24 clean commit config count_objects
26 fast_export fetch filter_branch format_patch fsck
27 gc get_tar_commit_id grep gui
28 hard_repack imap_send init instaweb
29 log lost_found ls_files ls_remote ls_tree
30 merge mergetool mv name_rev pull push
31 read_tree rebase relink remote repack
32 request_pull reset revert rev_list rm
33 send_email shortlog show show_branch
34 show_ref stash status submodule svn
35 tag var verify_pack whatchanged
38 class Model(model
.Model
):
39 """Provides a friendly wrapper for doing commit git operations."""
42 """Reads git repository settings and sets several methods
43 so that they refer to the git module. This object is
44 encapsulates ugit's interaction with git."""
46 # chdir to the root of the git tree.
47 # This keeps paths relative.
49 os
.chdir( self
.git
.git_dir
)
52 self
.__init
_config
_data
()
54 # Import all git commands from git.py
55 for cmd
in GIT_COMMANDS
:
56 setattr(self
, cmd
, getattr(self
.git
, cmd
))
59 #####################################################
60 # Used in various places
67 git_version
= self
.git
.version(),
69 #####################################################
70 # Used primarily by the main UI
71 project
= os
.path
.basename(os
.getcwd()),
77 window_geom
= utils
.parse_geom(
78 self
.get_global_ugit_geometry()),
80 #####################################################
81 # Used by the create branch dialog
87 #####################################################
88 # Used by the commit/repo browser
93 # These are parallel lists
98 # All items below here are re-calculated in
101 directory_entries
= {},
103 # These are also parallel lists
109 def __init_config_data(self
):
110 """Reads git config --list and creates parameters
112 # These parameters are saved in .gitconfig,
113 # so ideally these should be as short as possible.
115 # config items that are controllable globally
117 self
.__local
_and
_global
_defaults
= {
120 'merge_summary': False,
121 'merge_diffstat': True,
122 'merge_verbosity': 2,
123 'gui_diffcontext': 3,
124 'gui_pruneduringfetch': False,
126 # config items that are purely git config --global settings
127 self
.__global
_defaults
= {
130 'ugit_fontui_size':12,
132 'ugit_fontdiff_size':12,
133 'ugit_historybrowser': 'gitk',
134 'ugit_savewindowsettings': False,
135 'ugit_saveatexit': False,
138 local_dict
= self
.config_dict(local
=True)
139 global_dict
= self
.config_dict(local
=False)
141 for k
,v
in local_dict
.iteritems():
142 self
.set_param('local_'+k
, v
)
143 for k
,v
in global_dict
.iteritems():
144 self
.set_param('global_'+k
, v
)
145 if k
not in local_dict
:
147 self
.set_param('local_'+k
, v
)
149 # Bootstrap the internal font*_size variables
150 for param
in ('global_ugit_fontui', 'global_ugit_fontdiff'):
151 if hasattr(self
, param
):
152 font
= self
.get_param(param
)
154 size
= int(font
.split(',')[1])
155 self
.set_param(param
+'_size', size
)
156 param
= param
[len('global_'):]
157 global_dict
[param
] = font
158 global_dict
[param
+'_size'] = size
160 # Load defaults for all undefined items
161 local_and_global_defaults
= self
.__local
_and
_global
_defaults
162 for k
,v
in local_and_global_defaults
.iteritems():
163 if k
not in local_dict
:
164 self
.set_param('local_'+k
, v
)
165 if k
not in global_dict
:
166 self
.set_param('global_'+k
, v
)
168 global_defaults
= self
.__global
_defaults
169 for k
,v
in global_defaults
.iteritems():
170 if k
not in global_dict
:
171 self
.set_param('global_'+k
, v
)
173 # Load the diff context
174 self
.diff_context
= self
.local_gui_diffcontext
176 def branch_list(self
, remote
=False):
177 branches
= map(lambda x
: x
.lstrip('* '),
178 self
.git
.branch(r
=remote
).splitlines())
181 for branch
in branches
:
182 if branch
.endswith('/HEAD'):
184 remotes
.append(branch
)
188 def get_config_params(self
):
190 params
.extend(map(lambda x
: 'local_' + x
,
191 self
.__local
_and
_global
_defaults
.keys()))
192 params
.extend(map(lambda x
: 'global_' + x
,
193 self
.__local
_and
_global
_defaults
.keys()))
194 params
.extend(map(lambda x
: 'global_' + x
,
195 self
.__global
_defaults
.keys()))
198 def save_config_param(self
, param
):
199 if param
not in self
.get_config_params():
201 value
= self
.get_param(param
)
202 if param
== 'local_gui_diffcontext':
203 self
.diff_context
= value
204 if param
.startswith('local_'):
205 param
= param
[len('local_'):]
207 elif param
.startswith('global_'):
208 param
= param
[len('global_'):]
211 raise Exception("Invalid param '%s' passed to " % param
212 + "save_config_param()")
213 param
= param
.replace('_','.') # model -> git
214 return self
.config_set(param
, value
, local
=is_local
)
216 def init_browser_data(self
):
217 '''This scans over self.(names, sha1s, types) to generate
218 directories, directory_entries, and subtree_*'''
220 # Collect data for the model
221 if not self
.get_currentbranch(): return
223 self
.subtree_types
= []
224 self
.subtree_sha1s
= []
225 self
.subtree_names
= []
226 self
.directories
= []
227 self
.directory_entries
= {}
229 # Lookup the tree info
230 tree_info
= self
.parse_ls_tree(self
.get_currentbranch())
232 self
.set_types(map( lambda(x
): x
[1], tree_info
))
233 self
.set_sha1s(map( lambda(x
): x
[2], tree_info
))
234 self
.set_names(map( lambda(x
): x
[3], tree_info
))
236 if self
.directory
: self
.directories
.append('..')
238 dir_entries
= self
.directory_entries
239 dir_regex
= re
.compile('([^/]+)/')
243 for idx
, name
in enumerate(self
.names
):
245 if not name
.startswith(self
.directory
): continue
246 name
= name
[ len(self
.directory
): ]
249 # This is a directory...
250 match
= dir_regex
.match(name
)
251 if not match
: continue
253 dirent
= match
.group(1) + '/'
254 if dirent
not in self
.directory_entries
:
255 self
.directory_entries
[dirent
] = []
257 if dirent
not in dirs_seen
:
258 dirs_seen
[dirent
] = True
259 self
.directories
.append(dirent
)
261 entry
= name
.replace(dirent
, '')
262 entry_match
= dir_regex
.match(entry
)
264 subdir
= entry_match
.group(1) + '/'
265 if subdir
in subdirs_seen
: continue
266 subdirs_seen
[subdir
] = True
267 dir_entries
[dirent
].append(subdir
)
269 dir_entries
[dirent
].append(entry
)
271 self
.subtree_types
.append(self
.types
[idx
])
272 self
.subtree_sha1s
.append(self
.sha1s
[idx
])
273 self
.subtree_names
.append(name
)
275 def add_or_remove(self
, *to_process
):
276 """Invokes 'git add' to index the filenames in to_process that exist
277 and 'git rm' for those that do not exist."""
280 return 'No files to add or remove.'
285 for filename
in to_process
:
286 if os
.path
.exists(filename
):
287 to_add
.append(filename
)
289 output
= self
.git
.add(verbose
=True, *to_add
)
291 if len(to_add
) == len(to_process
):
292 # to_process only contained unremoved files --
293 # short-circuit the removal checks
296 # Process files to remote
297 for filename
in to_process
:
298 if not os
.path
.exists(filename
):
299 to_remove
.append(filename
)
300 output
+ '\n\n' + self
.git
.rm(*to_remove
)
302 def get_history_browser(self
):
303 return self
.global_ugit_historybrowser
305 def remember_gui_settings(self
):
306 return self
.global_ugit_savewindowsettings
308 def save_at_exit(self
):
309 return self
.global_ugit_saveatexit
311 def get_tree_node(self
, idx
):
312 return (self
.get_types()[idx
],
313 self
.get_sha1s()[idx
],
314 self
.get_names()[idx
] )
316 def get_subtree_node(self
, idx
):
317 return (self
.get_subtree_types()[idx
],
318 self
.get_subtree_sha1s()[idx
],
319 self
.get_subtree_names()[idx
] )
321 def get_all_branches(self
):
322 return (self
.get_local_branches() + self
.get_remote_branches())
324 def set_remote(self
, remote
):
325 if not remote
: return
326 self
.set_param('remote', remote
)
327 branches
= utils
.grep( '%s/\S+$' % remote
,
328 self
.branch_list(remote
=True), squash
=False)
329 self
.set_remote_branches(branches
)
331 def add_signoff(self
,*rest
):
332 '''Adds a standard Signed-off by: tag to the end
333 of the current commit message.'''
335 msg
= self
.get_commitmsg()
336 signoff
=('\n\nSigned-off-by: %s <%s>\n' % (
337 self
.get_local_user_name(),
338 self
.get_local_user_email()))
340 if signoff
not in msg
:
341 self
.set_commitmsg(msg
+ signoff
)
343 def apply_diff(self
, filename
):
344 return self
.git
.apply(filename
, index
=True, cached
=True)
346 def load_commitmsg(self
, path
):
347 file = open(path
, 'r')
348 contents
= file.read()
350 self
.set_commitmsg(contents
)
352 def get_prev_commitmsg(self
,*rest
):
353 '''Queries git for the latest commit message and sets it in
356 commit_lines
= self
.git
.show('HEAD').split('\n')
357 for idx
, msg
in enumerate(commit_lines
):
360 if msg
.startswith('diff --git'):
363 commit_msg
.append(msg
)
364 self
.set_commitmsg('\n'.join(commit_msg
).rstrip())
366 def update_status(self
):
367 # This allows us to defer notification until the
368 # we finish processing data
369 notify_enabled
= self
.get_notify()
370 self
.set_notify(False)
372 # Reset the staged and unstaged model lists
373 # NOTE: the model's unstaged list is used to
374 # hold both modified and untracked files.
379 # Read git status items
382 untracked_items
) = self
.parse_status()
384 # Gather items to be committed
385 for staged
in staged_items
:
386 if staged
not in self
.get_staged():
387 self
.add_staged(staged
)
389 # Gather unindexed items
390 for modified
in modified_items
:
391 if modified
not in self
.get_modified():
392 self
.add_modified(modified
)
394 # Gather untracked items
395 for untracked
in untracked_items
:
396 if untracked
not in self
.get_untracked():
397 self
.add_untracked(untracked
)
399 self
.set_currentbranch(self
.current_branch())
400 self
.set_unstaged(self
.get_modified() + self
.get_untracked())
401 self
.set_remotes(self
.git
.remote().splitlines())
402 self
.set_remote_branches(self
.branch_list(remote
=True))
403 self
.set_local_branches(self
.branch_list(remote
=False))
404 self
.set_tags(self
.git
.tag().splitlines())
405 self
.set_revision('')
406 self
.set_local_branch('')
407 self
.set_remote_branch('')
408 # Re-enable notifications and emit changes
409 self
.set_notify(notify_enabled
)
410 self
.notify_observers('staged','unstaged')
412 def delete_branch(self
, branch
):
413 return self
.git
.branch(branch
, D
=True)
415 def get_revision_sha1(self
, idx
):
416 return self
.get_revisions()[idx
]
418 def apply_font_size(self
, param
, default
):
419 old_font
= self
.get_param(param
)
423 size
= self
.get_param(param
+'_size')
424 props
= old_font
.split(',')
426 new_font
= ','.join(props
)
428 self
.set_param(param
, new_font
)
430 def read_font_size(self
, param
, new_font
):
431 new_size
= int(new_font
.split(',')[1])
432 self
.set_param(param
, new_size
)
434 def get_commit_diff(self
, sha1
):
435 commit
= self
.git
.show(sha1
)
436 first_newline
= commit
.index('\n')
437 if commit
[first_newline
+1:].startswith('Merge:'):
443 suppress_header
=False,
449 def get_diff_and_status(self
, idx
, staged
=True):
451 filename
= self
.get_staged()[idx
]
452 if os
.path
.exists(filename
):
453 status
= 'Staged for commit'
455 status
= 'Staged for removal'
456 diff
= self
.diff_helper(
461 filename
= self
.get_unstaged()[idx
]
462 if os
.path
.isdir(filename
):
463 status
= 'Untracked directory'
464 diff
= '\n'.join(os
.listdir(filename
))
465 elif filename
in self
.get_modified():
466 status
= 'Modified, not staged'
467 diff
= self
.diff_helper(
472 status
= 'Untracked, not staged'
474 file_type
= utils
.run_cmd('file', '-b', filename
)
475 if 'binary' in file_type
or 'data' in file_type
:
476 diff
= utils
.run_cmd('hexdump', '-C', filename
)
478 if os
.path
.exists(filename
):
479 file = open(filename
, 'r')
486 def stage_modified(self
):
487 output
= self
.git
.add(self
.get_modified())
491 def stage_untracked(self
):
492 output
= self
.git
.add(self
.get_untracked())
496 def reset(self
, *items
):
497 output
= self
.git
.reset('--', *items
)
501 def unstage_all(self
):
502 self
.git
.reset('--', *self
.get_staged())
505 def save_gui_settings(self
):
506 self
.config_set('ugit.geometry', utils
.get_geom(), local
=False)
508 def config_set(self
, key
=None, value
=None, local
=True):
509 if key
and value
is not None:
510 # git config category.key value
512 if type(value
) is bool:
513 # git uses "true" and "false"
514 strval
= strval
.lower()
516 argv
= [ key
, strval
]
518 argv
= [ '--global', key
, strval
]
519 return self
.git
.config(*argv
)
521 msg
= "oops in config_set(key=%s,value=%s,local=%s"
522 raise Exception(msg
% (key
, value
, local
))
524 def config_dict(self
, local
=True):
525 """parses the lines from git config --list into a dictionary"""
531 config_lines
= self
.git
.config(**kwargs
).splitlines()
533 for line
in config_lines
:
534 k
, v
= line
.split('=', 1)
535 k
= k
.replace('.','_') # git -> model
536 if v
== 'true' or v
== 'false':
537 v
= bool(eval(v
.title()))
545 def commit_with_msg(self
, msg
, amend
=False):
546 """Creates a git commit."""
548 if not msg
.endswith('\n'):
550 # Sure, this is a potential "security risk," but if someone
551 # is trying to intercept/re-write commit messages on your system,
552 # then you probably have bigger problems to worry about.
553 tmpfile
= self
.get_tmp_filename()
555 # Create the commit message file
556 file = open(tmpfile
, 'w')
561 output
= self
.git
.commit(F
=tmpfile
, amend
=amend
)
564 return ('git commit -F %s --amend %s\n\n%s'
565 % ( tmpfile
, amend
, output
))
569 return self
.git
.diff(
570 unified
=self
.diff_context
,
575 def get_tmp_filename(self
):
576 # Allow TMPDIR/TMP with a fallback to /tmp
578 basename
= '.git.%s.%s' % ( os
.getpid(), time
.time() )
579 tmpdir
= env
.get('TMP', env
.get('TMPDIR', '/tmp'))
580 return os
.path
.join( tmpdir
, basename
)
582 def log_helper(self
, all
=False):
583 """Returns a pair of parallel arrays listing the revision sha1's
584 and commit summaries."""
587 regex
= REV_LIST_REGEX
588 output
= self
.git
.log(pretty
='oneline', all
=all
)
589 for line
in output
.splitlines():
590 match
= regex
.match(line
)
592 revs
.append(match
.group(1))
593 summaries
.append(match
.group(2))
594 return( revs
, summaries
)
596 def parse_rev_list(self
, raw_revs
):
598 for line
in raw_revs
.splitlines():
599 match
= REV_LIST_REGEX
.match(line
)
601 rev_id
= match
.group(1)
602 summary
= match
.group(2)
603 revs
.append((rev_id
, summary
,) )
606 def rev_list_range(self
, start
, end
):
607 range = '%s..%s' % ( start
, end
)
608 raw_revs
= self
.git
.rev_list(range, pretty
='oneline')
609 return self
.parse_rev_list(raw_revs
)
611 def diff_helper(self
,
616 with_diff_header
=False,
617 suppress_header
=True,
619 "Invokes git diff on a filepath."
623 argv
.append('%s^..%s' % (commit
, commit
))
627 if type(filename
) is list:
628 argv
.extend(filename
)
630 argv
.append(filename
)
632 diff
= self
.git
.diff(
637 unified
=self
.diff_context
,
643 del_tag
= 'deleted file mode '
646 deleted
= cached
and not os
.path
.exists(filename
)
648 if not start
and '@@ ' in line
and ' @@' in line
:
650 if start
or(deleted
and del_tag
in line
):
651 output
.write(line
+ '\n')
655 elif not suppress_header
:
656 output
.write(line
+ '\n')
657 result
= output
.getvalue()
660 return('\n'.join(headers
), result
)
664 def git_repo_path(self
, *subpaths
):
665 paths
= [ self
.git
.rev_parse(git_dir
=True) ]
666 paths
.extend(subpaths
)
667 return os
.path
.realpath(os
.path
.join(*paths
))
669 def get_merge_message_path(self
):
670 for file in ('MERGE_MSG', 'SQUASH_MSG'):
671 path
= self
.git_repo_path(file)
672 if os
.path
.exists(path
):
676 def get_merge_message(self
):
677 return self
.git
.fmt_merge_msg(
678 '--file', self
.git_repo_path('FETCH_HEAD')
681 def abort_merge(self
):
683 output
= self
.git
.read_tree("HEAD", reset
=True, u
=True, v
=True)
685 merge_head
= self
.git_repo_path('MERGE_HEAD')
686 if os
.path
.exists(merge_head
):
687 os
.unlink(merge_head
)
688 # remove MERGE_MESSAGE, etc.
689 merge_msg_path
= self
.get_merge_message_path()
690 while merge_msg_path
:
691 os
.unlink(merge_msg_path
)
692 merge_msg_path
= self
.get_merge_message_path()
695 def parse_status(self
):
696 """RETURNS: A tuple of staged, unstaged and untracked file lists.
699 """handles quoted paths."""
700 if path
.startswith('"') and path
.endswith('"'):
705 MODIFIED_TAG
= '# Changed but not updated:'
706 UNTRACKED_TAG
= '# Untracked files:'
707 RGX_RENAMED
= re
.compile(
711 RGX_MODIFIED
= re
.compile(
724 current_dest
= staged
727 for status_line
in self
.git
.status().splitlines():
728 if status_line
== MODIFIED_TAG
:
730 current_dest
= unstaged
732 elif status_line
== UNTRACKED_TAG
:
733 mode
= UNTRACKED_MODE
734 current_dest
= untracked
736 # Staged/unstaged modified/renamed/deleted files
737 if mode
is STAGED_MODE
or mode
is UNSTAGED_MODE
:
738 match
= RGX_MODIFIED
.match(status_line
)
741 filename
= status_line
.replace(tag
, '')
742 current_dest
.append(eval_path(filename
))
744 match
= RGX_RENAMED
.match(status_line
)
746 oldname
= match
.group(2)
747 newname
= match
.group(3)
748 current_dest
.append(eval_path(oldname
))
749 current_dest
.append(eval_path(newname
))
752 elif mode
is UNTRACKED_MODE
:
753 if status_line
.startswith('#\t'):
754 current_dest
.append(eval_path(status_line
[2:]))
756 return( staged
, unstaged
, untracked
)
758 def reset_helper(self
, *args
, **kwargs
):
759 return self
.git
.reset('--', *args
, **kwargs
)
761 def remote_url(self
, name
):
762 return self
.git
.config('remote.%s.url' % name
, get
=True)
764 def push_helper(self
, remote
, local_branch
, remote_branch
,
765 ffwd
=True, tags
=False):
767 branch_arg
= '%s:%s' % ( local_branch
, remote_branch
)
769 branch_arg
= '+%s:%s' % ( local_branch
, remote_branch
)
770 return self
.git
.push(remote
, branch_arg
,
771 with_status
=True, tags
=tags
774 def parse_ls_tree(self
, rev
):
775 """Returns a list of(mode, type, sha1, path) tuples."""
776 lines
= self
.git
.ls_tree(rev
, r
=True).splitlines()
778 regex
= re
.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
780 match
= regex
.match(line
)
782 mode
= match
.group(1)
783 objtype
= match
.group(2)
784 sha1
= match
.group(3)
785 filename
= match
.group(4)
786 output
.append((mode
, objtype
, sha1
, filename
,) )
789 def format_patch_helper(self
, to_export
, revs
, output
='patches'):
790 """writes patches named by to_export to the output directory."""
794 cur_rev
= to_export
[0]
795 cur_master_idx
= revs
.index(cur_rev
)
797 patches_to_export
= [ [cur_rev
] ]
800 for idx
, rev
in enumerate(to_export
[1:]):
801 # Limit the search to the current neighborhood for efficiency
802 master_idx
= revs
[ cur_master_idx
: ].index(rev
)
803 master_idx
+= cur_master_idx
804 if master_idx
== cur_master_idx
+ 1:
805 patches_to_export
[ patchset_idx
].append(rev
)
809 patches_to_export
.append([ rev
])
810 cur_master_idx
= master_idx
813 for patchset
in patches_to_export
:
814 revarg
= '%s^..%s' % (patchset
[0], patchset
[-1])
816 self
.git
.format_patch(
825 return '\n'.join(outlines
)
827 def current_branch(self
):
828 """Parses 'git branch' to find the current branch."""
829 branches
= self
.git
.branch().splitlines()
830 for branch
in branches
:
831 if branch
.startswith('* '):
832 return branch
.lstrip('* ')
833 return 'Detached HEAD'
835 def create_branch(self
, name
, base
, track
=False):
836 """Creates a branch starting from base. Pass track=True
837 to create a remote tracking branch."""
838 return self
.git
.branch(name
, base
, track
=track
)
840 def cherry_pick_list(self
, revs
, **kwargs
):
841 """Cherry-picks each revision into the current branch.
842 Returns a list of command output strings (1 per cherry pick)"""
847 cherries
.append(self
.git
.cherry_pick(rev
, **kwargs
))
848 return '\n'.join(cherries
)
850 def parse_stash_list(self
, revids
=False):
851 """Parses "git stash list" and returns a list of stashes."""
852 stashes
= self
.stash("list").splitlines()
854 return [ s
[:s
.index(':')] for s
in stashes
]
856 return [ s
[s
.index(':')+1:] for s
in stashes
]