1 """Provides the main() routine and ColaApplication"""
2 from __future__
import division
, absolute_import
, unicode_literals
3 from functools
import partial
11 Copyright (C) 2007-2017 David Aguilar and contributors
15 from qtpy
import QtCore
18 You do not seem to have PyQt5, PySide, or PyQt4 installed.
19 Please install it before using git-cola, e.g. on a Debian/Ubutnu system:
21 sudo apt-get install python-pyqt5 python-pyqt5.qtwebkit
26 from qtpy
import QtGui
27 from qtpy
import QtWidgets
28 from qtpy
.QtCore
import Qt
32 from .interaction
import Interaction
33 from .models
import main
34 from .models
import selection
35 from .widgets
import cfgactions
36 from .widgets
import defs
37 from .widgets
import standard
38 from .widgets
import startup
39 from .settings
import Session
43 from . import fsmonitor
49 from . import qtcompat
51 from . import resources
56 def setup_environment():
57 """Set environment variables to control git's behavior"""
58 # Allow Ctrl-C to exit
59 signal
.signal(signal
.SIGINT
, signal
.SIG_DFL
)
61 # Session management wants an absolute path when restarting
62 sys
.argv
[0] = sys_argv0
= os
.path
.abspath(sys
.argv
[0])
64 # Spoof an X11 display for SSH
65 os
.environ
.setdefault('DISPLAY', ':0')
67 if not core
.getenv('SHELL', ''):
68 for shell
in ('/bin/zsh', '/bin/bash', '/bin/sh'):
69 if os
.path
.exists(shell
):
70 compat
.setenv('SHELL', shell
)
73 # Setup the path so that git finds us when we run 'git cola'
74 path_entries
= core
.getenv('PATH', '').split(os
.pathsep
)
75 bindir
= core
.decode(os
.path
.dirname(sys_argv0
))
76 path_entries
.append(bindir
)
77 path
= os
.pathsep
.join(path_entries
)
78 compat
.setenv('PATH', path
)
80 # We don't ever want a pager
81 compat
.setenv('GIT_PAGER', '')
84 git_askpass
= core
.getenv('GIT_ASKPASS')
85 ssh_askpass
= core
.getenv('SSH_ASKPASS')
90 elif sys
.platform
== 'darwin':
91 askpass
= resources
.share('bin', 'ssh-askpass-darwin')
93 askpass
= resources
.share('bin', 'ssh-askpass')
95 compat
.setenv('GIT_ASKPASS', askpass
)
96 compat
.setenv('SSH_ASKPASS', askpass
)
99 # Git v1.7.10 Release Notes
100 # =========================
102 # Compatibility Notes
103 # -------------------
105 # * From this release on, the "git merge" command in an interactive
106 # session will start an editor when it automatically resolves the
107 # merge for the user to explain the resulting commit, just like the
108 # "git commit" command does when it wasn't given a commit message.
110 # If you have a script that runs "git merge" and keeps its standard
111 # input and output attached to the user's terminal, and if you do not
112 # want the user to explain the resulting merge commits, you can
113 # export GIT_MERGE_AUTOEDIT environment variable set to "no", like
117 # GIT_MERGE_AUTOEDIT=no
118 # export GIT_MERGE_AUTOEDIT
120 # to disable this behavior (if you want your users to explain their
121 # merge commits, you do not have to do anything). Alternatively, you
122 # can give the "--no-edit" option to individual invocations of the
123 # "git merge" command if you know everybody who uses your script has
124 # Git v1.7.8 or newer.
126 # Longer-term: Use `git merge --no-commit` so that we always
127 # have a chance to explain our merges.
128 compat
.setenv('GIT_MERGE_AUTOEDIT', 'no')
131 def get_icon_themes(context
):
132 """Return the default icon theme names"""
135 icon_themes_env
= core
.getenv('GIT_COLA_ICON_THEME')
137 themes
.extend([x
for x
in icon_themes_env
.split(':') if x
])
139 icon_themes_cfg
= context
.cfg
.get_all('cola.icontheme')
141 themes
.extend(icon_themes_cfg
)
144 themes
.append('light')
149 # style note: we use camelCase here since we're masquerading a Qt class
150 class ColaApplication(object):
151 """The main cola application
153 ColaApplication handles i18n of user-visible data
156 def __init__(self
, context
, argv
, locale
=None, icon_themes
=None):
162 icons
.install(icon_themes
or get_icon_themes(context
))
164 self
.context
= context
165 self
._app
= ColaQApplication(context
, list(argv
))
166 self
._app
.setWindowIcon(icons
.cola())
167 self
._install
_style
()
169 def _install_style(self
):
170 """Generate and apply a stylesheet to the app"""
171 palette
= self
._app
.palette()
172 window
= palette
.color(QtGui
.QPalette
.Window
)
173 highlight
= palette
.color(QtGui
.QPalette
.Highlight
)
174 shadow
= palette
.color(QtGui
.QPalette
.Shadow
)
175 base
= palette
.color(QtGui
.QPalette
.Base
)
177 window_rgb
= qtutils
.rgb_css(window
)
178 highlight_rgb
= qtutils
.rgb_css(highlight
)
179 shadow_rgb
= qtutils
.rgb_css(shadow
)
180 base_rgb
= qtutils
.rgb_css(base
)
182 self
._app
.setStyleSheet("""
183 QCheckBox::indicator {
184 width: %(checkbox_size)spx;
185 height: %(checkbox_size)spx;
187 QCheckBox::indicator::unchecked {
188 border: %(checkbox_border)spx solid %(shadow_rgb)s;
189 background: %(base_rgb)s;
191 QCheckBox::indicator::checked {
192 image: url(%(checkbox_icon)s);
193 border: %(checkbox_border)spx solid %(shadow_rgb)s;
194 background: %(base_rgb)s;
197 QRadioButton::indicator {
198 width: %(radio_size)spx;
199 height: %(radio_size)spx;
201 QRadioButton::indicator::unchecked {
202 border: %(radio_border)spx solid %(shadow_rgb)s;
203 border-radius: %(radio_radius)spx;
204 background: %(base_rgb)s;
206 QRadioButton::indicator::checked {
207 image: url(%(radio_icon)s);
208 border: %(radio_border)spx solid %(shadow_rgb)s;
209 border-radius: %(radio_radius)spx;
210 background: %(base_rgb)s;
213 QSplitter::handle:hover {
214 background: %(highlight_rgb)s;
217 QMainWindow::separator {
218 background: %(window_rgb)s;
219 width: %(separator)spx;
220 height: %(separator)spx;
222 QMainWindow::separator:hover {
223 background: %(highlight_rgb)s;
226 """ % dict(separator
=defs
.separator
,
227 window_rgb
=window_rgb
,
228 highlight_rgb
=highlight_rgb
,
229 shadow_rgb
=shadow_rgb
,
231 checkbox_border
=defs
.border
,
232 checkbox_icon
=icons
.check_name(),
233 checkbox_size
=defs
.checkbox
,
234 radio_border
=defs
.radio_border
,
235 radio_icon
=icons
.dot_name(),
236 radio_radius
=defs
.checkbox
//2,
237 radio_size
=defs
.checkbox
))
239 def activeWindow(self
):
240 """QApplication::activeWindow() pass-through"""
241 return self
._app
.activeWindow()
244 """QApplication::desktop() pass-through"""
245 return self
._app
.desktop()
248 """Wrap exec_() and start the application"""
249 # Defer connection so that local cola.inotify is honored
250 context
= self
.context
251 monitor
= context
.fsmonitor
252 monitor
.files_changed
.connect(
253 cmds
.run(cmds
.Refresh
, context
), type=Qt
.QueuedConnection
)
254 monitor
.config_changed
.connect(
255 cmds
.run(cmds
.RefreshConfig
, context
), type=Qt
.QueuedConnection
)
256 # Start the filesystem monitor thread
258 return self
._app
.exec_()
261 """Finalize the application"""
262 self
.context
.fsmonitor
.stop()
263 # Workaround QTBUG-52988 by deleting the app manually to prevent a
264 # crash during app shutdown.
265 # https://bugreports.qt.io/browse/QTBUG-52988
268 except (AttributeError, RuntimeError):
272 def exit(self
, status
):
273 """QApplication::exit(status) pass-through"""
274 return self
._app
.exit(status
)
277 class ColaQApplication(QtWidgets
.QApplication
):
278 """QApplication implementation for handling custom events"""
280 def __init__(self
, context
, argv
):
281 super(ColaQApplication
, self
).__init
__(argv
)
282 self
.context
= context
285 """Respond to focus events for the cola.refreshonfocus feature"""
286 if e
.type() == QtCore
.QEvent
.ApplicationActivate
:
287 context
= self
.context
290 if (context
.git
.is_valid()
291 and cfg
.get('cola.refreshonfocus', default
=False)):
292 cmds
.do(cmds
.Refresh
, context
)
293 return super(ColaQApplication
, self
).event(e
)
295 def commitData(self
, session_mgr
):
296 """Save session data"""
297 if not self
.context
or not self
.context
.view
:
299 view
= self
.context
.view
300 if not hasattr(view
, 'save_state'):
302 sid
= session_mgr
.sessionId()
303 skey
= session_mgr
.sessionKey()
304 session_id
= '%s_%s' % (sid
, skey
)
305 session
= Session(session_id
, repo
=core
.getcwd())
306 view
.save_state(settings
=session
)
309 def process_args(args
):
310 """Process and verify command-line arguments"""
312 # Accept 'git cola --version' or 'git cola version'
313 version
.print_version()
314 sys
.exit(core
.EXIT_SUCCESS
)
316 # Handle session management
317 restore_session(args
)
319 # Bail out if --repo is not a directory
320 repo
= core
.decode(args
.repo
)
321 if repo
.startswith('file:'):
322 repo
= repo
[len('file:'):]
323 repo
= core
.realpath(repo
)
324 if not core
.isdir(repo
):
325 errmsg
= N_('fatal: "%s" is not a directory. '
326 'Please specify a correct --repo <path>.') % repo
327 core
.print_stderr(errmsg
)
328 sys
.exit(core
.EXIT_USAGE
)
331 def restore_session(args
):
332 """Load a session based on the window-manager provided arguments"""
333 # args.settings is provided when restoring from a session.
335 if args
.session
is None:
337 session
= Session(args
.session
)
339 args
.settings
= session
340 args
.repo
= session
.repo
343 def application_init(args
, update
=False):
344 """Parses the command-line arguments and starts git-cola
346 # Ensure that we're working in a valid git repository.
347 # If not, try to find one. When found, chdir there.
351 context
= new_context(args
)
352 timer
= context
.timer
355 new_worktree(context
, args
.repo
, args
.prompt
, args
.settings
)
358 context
.model
.update_status()
362 timer
.display('init')
366 def new_context(args
):
367 """Create top-level ApplicationContext objects"""
368 context
= ApplicationContext(args
)
369 context
.git
= git
.create()
370 context
.cfg
= gitcfg
.create(context
)
371 context
.fsmonitor
= fsmonitor
.create(context
)
372 context
.selection
= selection
.create()
373 context
.model
= main
.create(context
)
374 context
.app
= new_application(context
, args
)
375 context
.timer
= Timer()
380 def application_run(context
, view
, start
=None, stop
=None):
381 """Run the application main loop"""
382 context
.set_view(view
)
388 # Start the event loop
389 result
= context
.app
.start()
398 def application_start(context
, view
):
399 """Show the GUI and start the main event loop"""
400 # Store the view for session management
401 return application_run(context
, view
,
402 start
=default_start
, stop
=default_stop
)
405 def default_start(context
, _view
):
406 """Scan for the first time"""
407 QtCore
.QTimer
.singleShot(0, startup_message
)
408 QtCore
.QTimer
.singleShot(0, lambda: async_update(context
))
411 def default_stop(_context
, _view
):
412 """All done, cleanup"""
413 QtCore
.QThreadPool
.globalInstance().waitForDone()
416 def add_common_arguments(parser
):
417 """Add command arguments to the ArgumentParser"""
418 # We also accept 'git cola version'
419 parser
.add_argument('--version', default
=False, action
='store_true',
420 help='print version number')
422 # Specifies a git repository to open
423 parser
.add_argument('-r', '--repo', metavar
='<repo>', default
=core
.getcwd(),
424 help='open the specified git repository')
426 # Specifies that we should prompt for a repository at startup
427 parser
.add_argument('--prompt', action
='store_true', default
=False,
428 help='prompt for a repository')
430 # Specify the icon theme
431 parser
.add_argument('--icon-theme', metavar
='<theme>',
432 dest
='icon_themes', action
='append', default
=[],
433 help='specify an icon theme (name or directory)')
435 # Resume an X Session Management session
436 parser
.add_argument('-session', metavar
='<session>', default
=None,
437 help=argparse
.SUPPRESS
)
439 # Enable timing information
440 parser
.add_argument('--perf', action
='store_true', default
=False,
441 help=argparse
.SUPPRESS
)
444 def new_application(context
, args
):
445 """Create a new ColaApplication"""
446 return ColaApplication(context
, sys
.argv
, icon_themes
=args
.icon_themes
)
449 def new_worktree(context
, repo
, prompt
, settings
):
450 """Find a Git repository, or prompt for one when not found"""
451 model
= context
.model
453 parent
= qtutils
.active_window()
457 valid
= model
.set_worktree(repo
)
459 # We are not currently in a git repository so we need to find one.
460 # Before prompting the user for a repository, check if they've
461 # configured a default repository and attempt to use it.
462 default_repo
= cfg
.get('cola.defaultrepo')
464 valid
= model
.set_worktree(default_repo
)
467 # If we've gotten into this loop then that means that neither the
468 # current directory nor the default repository were available.
469 # Prompt the user for a repository.
470 startup_dlg
= startup
.StartupDialog(context
, parent
, settings
=settings
)
471 gitdir
= startup_dlg
.find_git_repo()
473 sys
.exit(core
.EXIT_NOINPUT
)
474 valid
= model
.set_worktree(gitdir
)
477 def async_update(context
):
478 """Update the model in the background
480 git-cola should startup as quickly as possible.
483 update_status
= partial(context
.model
.update_status
, update_index
=True)
484 task
= qtutils
.SimpleTask(context
.view
, update_status
)
485 context
.runtask
.start(task
)
488 def startup_message():
489 """Print debug startup messages"""
490 trace
= git
.GIT_COLA_TRACE
491 if trace
== '2' or trace
== 'trace':
492 msg1
= 'info: debug level 2: trace mode enabled'
493 msg2
= 'info: set GIT_COLA_TRACE=1 for less-verbose output'
494 Interaction
.log(msg1
)
495 Interaction
.log(msg2
)
497 msg1
= 'info: debug level 1'
498 msg2
= 'info: set GIT_COLA_TRACE=2 for trace mode'
499 Interaction
.log(msg1
)
500 Interaction
.log(msg2
)
504 """Simple performance timer"""
509 def start(self
, key
):
512 self
._data
[key
] = [now
, now
]
515 """Stop a timer and return its elapsed time"""
516 entry
= self
._data
[key
]
517 entry
[1] = time
.time()
518 return self
.elapsed(key
)
520 def elapsed(self
, key
):
521 """Return the elapsed time for a timer"""
522 entry
= self
._data
[key
]
523 return entry
[1] - entry
[0]
525 def display(self
, key
):
526 """Display a timer"""
527 elapsed
= self
.elapsed(key
)
528 sys
.stdout
.write('%s: %.5fs\n' % (key
, elapsed
))
531 class ApplicationContext(object):
532 """Context for performing operations on Git and related data models"""
534 def __init__(self
, args
):
536 self
.app
= None # ColaApplication
537 self
.git
= None # git.Git
538 self
.cfg
= None # gitcfg.GitConfig
539 self
.model
= None # main.MainModel
540 self
.timer
= None # Timer
541 self
.runtask
= None # qtutils.RunTask
542 self
.selection
= None # selection.SelectionModel
543 self
.fsmonitor
= None # fsmonitor
544 self
.view
= None # QWidget
546 def set_view(self
, view
):
547 """Initialize view-specific members"""
549 self
.runtask
= qtutils
.RunTask(parent
=view
)
552 def winmain(main_fn
, *argv
):
553 """Find Git and launch main(argv)"""
554 git_path
= find_git()
556 prepend_path(git_path
)
557 return main_fn(*argv
)
561 """Return the path of git.exe, or None if we can't find it."""
562 if not utils
.is_win32():
563 return None # UNIX systems have git in their $PATH
565 # If the user wants to use a Git/bin/ directory from a non-standard
566 # directory then they can write its location into
567 # ~/.config/git-cola/git-bindir
568 git_bindir
= os
.path
.expanduser(os
.path
.join('~', '.config', 'git-cola',
570 if core
.exists(git_bindir
):
571 custom_path
= core
.read(git_bindir
).strip()
572 if custom_path
and core
.exists(custom_path
):
575 # Try to find Git's bin/ directory in one of the typical locations
576 pf
= os
.environ
.get('ProgramFiles', 'C:\\Program Files')
577 pf32
= os
.environ
.get('ProgramFiles(x86)', 'C:\\Program Files (x86)')
578 for p
in [pf32
, pf
, 'C:\\']:
579 candidate
= os
.path
.join(p
, 'Git\\bin')
580 if os
.path
.isdir(candidate
):
586 def prepend_path(path
):
587 """Adds git to the PATH. This is needed on Windows."""
588 path
= core
.decode(path
)
589 path_entries
= core
.getenv('PATH', '').split(os
.pathsep
)
590 if path
not in path_entries
:
591 path_entries
.insert(0, path
)
592 compat
.setenv('PATH', os
.pathsep
.join(path_entries
))