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')
130 # Gnome3 on Debian has XDG_SESSION_TYPE=wayland and
131 # XDG_CURRENT_DESKTOP=GNOME, which Qt warns about at startup:
133 # Warning: Ignoring XDG_SESSION_TYPE=wayland on Gnome.
134 # Use QT_QPA_PLATFORM=wayland to run on Wayland anyway.
136 # This annoying, so we silence the warning.
137 # We'll need to keep this hack here until a future version of Qt provides
138 # Qt Wayland widgets that are usable in gnome-shell.
139 # Cf. https://bugreports.qt.io/browse/QTBUG-68619
140 if (core
.getenv('XDG_CURRENT_DESKTOP', '') == 'GNOME'
141 and core
.getenv('XDG_SESSION_TYPE', '') == 'wayland'):
142 compat
.unsetenv('XDG_SESSION_TYPE')
145 def get_icon_themes(context
):
146 """Return the default icon theme names"""
149 icon_themes_env
= core
.getenv('GIT_COLA_ICON_THEME')
151 themes
.extend([x
for x
in icon_themes_env
.split(':') if x
])
153 icon_themes_cfg
= context
.cfg
.get_all('cola.icontheme')
155 themes
.extend(icon_themes_cfg
)
158 themes
.append('light')
163 # style note: we use camelCase here since we're masquerading a Qt class
164 class ColaApplication(object):
165 """The main cola application
167 ColaApplication handles i18n of user-visible data
170 def __init__(self
, context
, argv
, locale
=None, icon_themes
=None):
176 icons
.install(icon_themes
or get_icon_themes(context
))
178 self
.context
= context
179 self
._app
= ColaQApplication(context
, list(argv
))
180 self
._app
.setWindowIcon(icons
.cola())
181 self
._install
_style
()
183 def _install_style(self
):
184 """Generate and apply a stylesheet to the app"""
185 palette
= self
._app
.palette()
186 window
= palette
.color(QtGui
.QPalette
.Window
)
187 highlight
= palette
.color(QtGui
.QPalette
.Highlight
)
188 shadow
= palette
.color(QtGui
.QPalette
.Shadow
)
189 base
= palette
.color(QtGui
.QPalette
.Base
)
191 window_rgb
= qtutils
.rgb_css(window
)
192 highlight_rgb
= qtutils
.rgb_css(highlight
)
193 shadow_rgb
= qtutils
.rgb_css(shadow
)
194 base_rgb
= qtutils
.rgb_css(base
)
196 self
._app
.setStyleSheet("""
197 QCheckBox::indicator {
198 width: %(checkbox_size)spx;
199 height: %(checkbox_size)spx;
201 QCheckBox::indicator::unchecked {
202 border: %(checkbox_border)spx solid %(shadow_rgb)s;
203 background: %(base_rgb)s;
205 QCheckBox::indicator::checked {
206 image: url(%(checkbox_icon)s);
207 border: %(checkbox_border)spx solid %(shadow_rgb)s;
208 background: %(base_rgb)s;
211 QRadioButton::indicator {
212 width: %(radio_size)spx;
213 height: %(radio_size)spx;
215 QRadioButton::indicator::unchecked {
216 border: %(radio_border)spx solid %(shadow_rgb)s;
217 border-radius: %(radio_radius)spx;
218 background: %(base_rgb)s;
220 QRadioButton::indicator::checked {
221 image: url(%(radio_icon)s);
222 border: %(radio_border)spx solid %(shadow_rgb)s;
223 border-radius: %(radio_radius)spx;
224 background: %(base_rgb)s;
227 QSplitter::handle:hover {
228 background: %(highlight_rgb)s;
231 QMainWindow::separator {
232 background: %(window_rgb)s;
233 width: %(separator)spx;
234 height: %(separator)spx;
236 QMainWindow::separator:hover {
237 background: %(highlight_rgb)s;
240 """ % dict(separator
=defs
.separator
,
241 window_rgb
=window_rgb
,
242 highlight_rgb
=highlight_rgb
,
243 shadow_rgb
=shadow_rgb
,
245 checkbox_border
=defs
.border
,
246 checkbox_icon
=icons
.check_name(),
247 checkbox_size
=defs
.checkbox
,
248 radio_border
=defs
.radio_border
,
249 radio_icon
=icons
.dot_name(),
250 radio_radius
=defs
.checkbox
//2,
251 radio_size
=defs
.checkbox
))
253 def activeWindow(self
):
254 """QApplication::activeWindow() pass-through"""
255 return self
._app
.activeWindow()
258 """QApplication::desktop() pass-through"""
259 return self
._app
.desktop()
262 """Wrap exec_() and start the application"""
263 # Defer connection so that local cola.inotify is honored
264 context
= self
.context
265 monitor
= context
.fsmonitor
266 monitor
.files_changed
.connect(
267 cmds
.run(cmds
.Refresh
, context
), type=Qt
.QueuedConnection
)
268 monitor
.config_changed
.connect(
269 cmds
.run(cmds
.RefreshConfig
, context
), type=Qt
.QueuedConnection
)
270 # Start the filesystem monitor thread
272 return self
._app
.exec_()
275 """Finalize the application"""
276 self
.context
.fsmonitor
.stop()
277 # Workaround QTBUG-52988 by deleting the app manually to prevent a
278 # crash during app shutdown.
279 # https://bugreports.qt.io/browse/QTBUG-52988
282 except (AttributeError, RuntimeError):
286 def exit(self
, status
):
287 """QApplication::exit(status) pass-through"""
288 return self
._app
.exit(status
)
291 class ColaQApplication(QtWidgets
.QApplication
):
292 """QApplication implementation for handling custom events"""
294 def __init__(self
, context
, argv
):
295 super(ColaQApplication
, self
).__init
__(argv
)
296 self
.context
= context
299 """Respond to focus events for the cola.refreshonfocus feature"""
300 if e
.type() == QtCore
.QEvent
.ApplicationActivate
:
301 context
= self
.context
304 if (context
.git
.is_valid()
305 and cfg
.get('cola.refreshonfocus', default
=False)):
306 cmds
.do(cmds
.Refresh
, context
)
307 return super(ColaQApplication
, self
).event(e
)
309 def commitData(self
, session_mgr
):
310 """Save session data"""
311 if not self
.context
or not self
.context
.view
:
313 view
= self
.context
.view
314 if not hasattr(view
, 'save_state'):
316 sid
= session_mgr
.sessionId()
317 skey
= session_mgr
.sessionKey()
318 session_id
= '%s_%s' % (sid
, skey
)
319 session
= Session(session_id
, repo
=core
.getcwd())
320 view
.save_state(settings
=session
)
323 def process_args(args
):
324 """Process and verify command-line arguments"""
326 # Accept 'git cola --version' or 'git cola version'
327 version
.print_version()
328 sys
.exit(core
.EXIT_SUCCESS
)
330 # Handle session management
331 restore_session(args
)
333 # Bail out if --repo is not a directory
334 repo
= core
.decode(args
.repo
)
335 if repo
.startswith('file:'):
336 repo
= repo
[len('file:'):]
337 repo
= core
.realpath(repo
)
338 if not core
.isdir(repo
):
339 errmsg
= N_('fatal: "%s" is not a directory. '
340 'Please specify a correct --repo <path>.') % repo
341 core
.print_stderr(errmsg
)
342 sys
.exit(core
.EXIT_USAGE
)
345 def restore_session(args
):
346 """Load a session based on the window-manager provided arguments"""
347 # args.settings is provided when restoring from a session.
349 if args
.session
is None:
351 session
= Session(args
.session
)
353 args
.settings
= session
354 args
.repo
= session
.repo
357 def application_init(args
, update
=False):
358 """Parses the command-line arguments and starts git-cola
360 # Ensure that we're working in a valid git repository.
361 # If not, try to find one. When found, chdir there.
365 context
= new_context(args
)
366 timer
= context
.timer
369 new_worktree(context
, args
.repo
, args
.prompt
, args
.settings
)
372 context
.model
.update_status()
376 timer
.display('init')
380 def new_context(args
):
381 """Create top-level ApplicationContext objects"""
382 context
= ApplicationContext(args
)
383 context
.git
= git
.create()
384 context
.cfg
= gitcfg
.create(context
)
385 context
.fsmonitor
= fsmonitor
.create(context
)
386 context
.selection
= selection
.create()
387 context
.model
= main
.create(context
)
388 context
.app
= new_application(context
, args
)
389 context
.timer
= Timer()
394 def application_run(context
, view
, start
=None, stop
=None):
395 """Run the application main loop"""
396 context
.set_view(view
)
402 # Start the event loop
403 result
= context
.app
.start()
412 def application_start(context
, view
):
413 """Show the GUI and start the main event loop"""
414 # Store the view for session management
415 return application_run(context
, view
,
416 start
=default_start
, stop
=default_stop
)
419 def default_start(context
, _view
):
420 """Scan for the first time"""
421 QtCore
.QTimer
.singleShot(0, startup_message
)
422 QtCore
.QTimer
.singleShot(0, lambda: async_update(context
))
425 def default_stop(_context
, _view
):
426 """All done, cleanup"""
427 QtCore
.QThreadPool
.globalInstance().waitForDone()
430 def add_common_arguments(parser
):
431 """Add command arguments to the ArgumentParser"""
432 # We also accept 'git cola version'
433 parser
.add_argument('--version', default
=False, action
='store_true',
434 help='print version number')
436 # Specifies a git repository to open
437 parser
.add_argument('-r', '--repo', metavar
='<repo>', default
=core
.getcwd(),
438 help='open the specified git repository')
440 # Specifies that we should prompt for a repository at startup
441 parser
.add_argument('--prompt', action
='store_true', default
=False,
442 help='prompt for a repository')
444 # Specify the icon theme
445 parser
.add_argument('--icon-theme', metavar
='<theme>',
446 dest
='icon_themes', action
='append', default
=[],
447 help='specify an icon theme (name or directory)')
449 # Resume an X Session Management session
450 parser
.add_argument('-session', metavar
='<session>', default
=None,
451 help=argparse
.SUPPRESS
)
453 # Enable timing information
454 parser
.add_argument('--perf', action
='store_true', default
=False,
455 help=argparse
.SUPPRESS
)
458 def new_application(context
, args
):
459 """Create a new ColaApplication"""
460 return ColaApplication(context
, sys
.argv
, icon_themes
=args
.icon_themes
)
463 def new_worktree(context
, repo
, prompt
, settings
):
464 """Find a Git repository, or prompt for one when not found"""
465 model
= context
.model
467 parent
= qtutils
.active_window()
471 valid
= model
.set_worktree(repo
)
473 # We are not currently in a git repository so we need to find one.
474 # Before prompting the user for a repository, check if they've
475 # configured a default repository and attempt to use it.
476 default_repo
= cfg
.get('cola.defaultrepo')
478 valid
= model
.set_worktree(default_repo
)
481 # If we've gotten into this loop then that means that neither the
482 # current directory nor the default repository were available.
483 # Prompt the user for a repository.
484 startup_dlg
= startup
.StartupDialog(context
, parent
, settings
=settings
)
485 gitdir
= startup_dlg
.find_git_repo()
487 sys
.exit(core
.EXIT_NOINPUT
)
488 valid
= model
.set_worktree(gitdir
)
491 def async_update(context
):
492 """Update the model in the background
494 git-cola should startup as quickly as possible.
497 update_status
= partial(context
.model
.update_status
, update_index
=True)
498 task
= qtutils
.SimpleTask(context
.view
, update_status
)
499 context
.runtask
.start(task
)
502 def startup_message():
503 """Print debug startup messages"""
504 trace
= git
.GIT_COLA_TRACE
505 if trace
in ('2', 'trace'):
506 msg1
= 'info: debug level 2: trace mode enabled'
507 msg2
= 'info: set GIT_COLA_TRACE=1 for less-verbose output'
508 Interaction
.log(msg1
)
509 Interaction
.log(msg2
)
511 msg1
= 'info: debug level 1'
512 msg2
= 'info: set GIT_COLA_TRACE=2 for trace mode'
513 Interaction
.log(msg1
)
514 Interaction
.log(msg2
)
518 """Simple performance timer"""
523 def start(self
, key
):
526 self
._data
[key
] = [now
, now
]
529 """Stop a timer and return its elapsed time"""
530 entry
= self
._data
[key
]
531 entry
[1] = time
.time()
532 return self
.elapsed(key
)
534 def elapsed(self
, key
):
535 """Return the elapsed time for a timer"""
536 entry
= self
._data
[key
]
537 return entry
[1] - entry
[0]
539 def display(self
, key
):
540 """Display a timer"""
541 elapsed
= self
.elapsed(key
)
542 sys
.stdout
.write('%s: %.5fs\n' % (key
, elapsed
))
545 class ApplicationContext(object):
546 """Context for performing operations on Git and related data models"""
548 def __init__(self
, args
):
550 self
.app
= None # ColaApplication
551 self
.git
= None # git.Git
552 self
.cfg
= None # gitcfg.GitConfig
553 self
.model
= None # main.MainModel
554 self
.timer
= None # Timer
555 self
.runtask
= None # qtutils.RunTask
556 self
.selection
= None # selection.SelectionModel
557 self
.fsmonitor
= None # fsmonitor
558 self
.view
= None # QWidget
560 def set_view(self
, view
):
561 """Initialize view-specific members"""
563 self
.runtask
= qtutils
.RunTask(parent
=view
)
566 def winmain(main_fn
, *argv
):
567 """Find Git and launch main(argv)"""
568 git_path
= find_git()
570 prepend_path(git_path
)
571 return main_fn(*argv
)
575 """Return the path of git.exe, or None if we can't find it."""
576 if not utils
.is_win32():
577 return None # UNIX systems have git in their $PATH
579 # If the user wants to use a Git/bin/ directory from a non-standard
580 # directory then they can write its location into
581 # ~/.config/git-cola/git-bindir
582 git_bindir
= os
.path
.expanduser(os
.path
.join('~', '.config', 'git-cola',
584 if core
.exists(git_bindir
):
585 custom_path
= core
.read(git_bindir
).strip()
586 if custom_path
and core
.exists(custom_path
):
589 # Try to find Git's bin/ directory in one of the typical locations
590 pf
= os
.environ
.get('ProgramFiles', 'C:\\Program Files')
591 pf32
= os
.environ
.get('ProgramFiles(x86)', 'C:\\Program Files (x86)')
592 pf64
= os
.environ
.get('ProgramW6432', 'C:\\Program Files')
593 for p
in [pf64
, pf32
, pf
, 'C:\\']:
594 candidate
= os
.path
.join(p
, 'Git\\bin')
595 if os
.path
.isdir(candidate
):
601 def prepend_path(path
):
602 """Adds git to the PATH. This is needed on Windows."""
603 path
= core
.decode(path
)
604 path_entries
= core
.getenv('PATH', '').split(os
.pathsep
)
605 if path
not in path_entries
:
606 path_entries
.insert(0, path
)
607 compat
.setenv('PATH', os
.pathsep
.join(path_entries
))