1 """Provides the main() routine and ColaApplication"""
2 # pylint: disable=unused-import
3 from __future__
import division
, absolute_import
, unicode_literals
4 from functools
import partial
12 Copyright (C) 2007-2017 David Aguilar and contributors
16 from qtpy
import QtCore
19 You do not seem to have PyQt5, PySide, or PyQt4 installed.
20 Please install it before using git-cola, e.g. on a Debian/Ubutnu system:
22 sudo apt-get install python-pyqt5 python-pyqt5.qtwebkit
27 from qtpy
import QtWidgets
28 from qtpy
.QtCore
import Qt
30 # Qt 5.12 / PyQt 5.13 is unable to use QtWebEngineWidgets unless it is
31 # imported before QApplication is constructed.
32 from qtpy
import QtWebEngineWidgets
# noqa
34 # QtWebEngineWidgets / QtWebKit is not available -- no big deal.
39 from .interaction
import Interaction
40 from .models
import main
41 from .models
import selection
42 from .widgets
import cfgactions
43 from .widgets
import standard
44 from .widgets
import startup
45 from .settings
import Session
49 from . import fsmonitor
56 from . import qtcompat
58 from . import resources
64 def setup_environment():
65 """Set environment variables to control git's behavior"""
66 # Allow Ctrl-C to exit
67 signal
.signal(signal
.SIGINT
, signal
.SIG_DFL
)
69 # Session management wants an absolute path when restarting
70 sys
.argv
[0] = sys_argv0
= os
.path
.abspath(sys
.argv
[0])
72 # Spoof an X11 display for SSH
73 os
.environ
.setdefault('DISPLAY', ':0')
75 if not core
.getenv('SHELL', ''):
76 for shell
in ('/bin/zsh', '/bin/bash', '/bin/sh'):
77 if os
.path
.exists(shell
):
78 compat
.setenv('SHELL', shell
)
81 # Setup the path so that git finds us when we run 'git cola'
82 path_entries
= core
.getenv('PATH', '').split(os
.pathsep
)
83 bindir
= core
.decode(os
.path
.dirname(sys_argv0
))
84 path_entries
.append(bindir
)
85 path
= os
.pathsep
.join(path_entries
)
86 compat
.setenv('PATH', path
)
88 # We don't ever want a pager
89 compat
.setenv('GIT_PAGER', '')
92 git_askpass
= core
.getenv('GIT_ASKPASS')
93 ssh_askpass
= core
.getenv('SSH_ASKPASS')
98 elif sys
.platform
== 'darwin':
99 askpass
= resources
.share('bin', 'ssh-askpass-darwin')
101 askpass
= resources
.share('bin', 'ssh-askpass')
103 compat
.setenv('GIT_ASKPASS', askpass
)
104 compat
.setenv('SSH_ASKPASS', askpass
)
107 # Git v1.7.10 Release Notes
108 # =========================
110 # Compatibility Notes
111 # -------------------
113 # * From this release on, the "git merge" command in an interactive
114 # session will start an editor when it automatically resolves the
115 # merge for the user to explain the resulting commit, just like the
116 # "git commit" command does when it wasn't given a commit message.
118 # If you have a script that runs "git merge" and keeps its standard
119 # input and output attached to the user's terminal, and if you do not
120 # want the user to explain the resulting merge commits, you can
121 # export GIT_MERGE_AUTOEDIT environment variable set to "no", like
125 # GIT_MERGE_AUTOEDIT=no
126 # export GIT_MERGE_AUTOEDIT
128 # to disable this behavior (if you want your users to explain their
129 # merge commits, you do not have to do anything). Alternatively, you
130 # can give the "--no-edit" option to individual invocations of the
131 # "git merge" command if you know everybody who uses your script has
132 # Git v1.7.8 or newer.
134 # Longer-term: Use `git merge --no-commit` so that we always
135 # have a chance to explain our merges.
136 compat
.setenv('GIT_MERGE_AUTOEDIT', 'no')
138 # Gnome3 on Debian has XDG_SESSION_TYPE=wayland and
139 # XDG_CURRENT_DESKTOP=GNOME, which Qt warns about at startup:
141 # Warning: Ignoring XDG_SESSION_TYPE=wayland on Gnome.
142 # Use QT_QPA_PLATFORM=wayland to run on Wayland anyway.
144 # This annoying, so we silence the warning.
145 # We'll need to keep this hack here until a future version of Qt provides
146 # Qt Wayland widgets that are usable in gnome-shell.
147 # Cf. https://bugreports.qt.io/browse/QTBUG-68619
148 if (core
.getenv('XDG_CURRENT_DESKTOP', '') == 'GNOME'
149 and core
.getenv('XDG_SESSION_TYPE', '') == 'wayland'):
150 compat
.unsetenv('XDG_SESSION_TYPE')
153 def get_icon_themes(context
):
154 """Return the default icon theme names"""
157 icon_themes_env
= core
.getenv('GIT_COLA_ICON_THEME')
159 result
.extend([x
for x
in icon_themes_env
.split(':') if x
])
161 icon_themes_cfg
= context
.cfg
.get_all('cola.icontheme')
163 result
.extend(icon_themes_cfg
)
166 result
.append('light')
171 # style note: we use camelCase here since we're masquerading a Qt class
172 class ColaApplication(object):
173 """The main cola application
175 ColaApplication handles i18n of user-visible data
178 def __init__(self
, context
, argv
, locale
=None,
179 icon_themes
=None, gui_theme
=None):
185 icons
.install(icon_themes
or get_icon_themes(context
))
187 self
.context
= context
188 self
._install
_hidpi
_config
()
189 self
._app
= ColaQApplication(context
, list(argv
))
190 self
._app
.setWindowIcon(icons
.cola())
191 self
._install
_style
(gui_theme
)
193 def _install_style(self
, theme_str
):
194 """Generate and apply a stylesheet to the app"""
195 if theme_str
is None:
196 theme_str
= self
.context
.cfg
.get('cola.theme', default
='default')
197 theme
= themes
.find_theme(theme_str
)
198 self
._app
.setStyleSheet(theme
.build_style_sheet(self
._app
.palette()))
199 if theme_str
!= 'default':
200 self
._app
.setPalette(theme
.build_palette(self
._app
.palette()))
202 def _install_hidpi_config(self
):
203 """Sets QT HIDPI scalling (requires Qt 5.6)"""
204 value
= self
.context
.cfg
.get('cola.hidpi', default
=hidpi
.Option
.AUTO
)
205 hidpi
.apply_choice(value
)
207 def activeWindow(self
):
208 """QApplication::activeWindow() pass-through"""
209 return self
._app
.activeWindow()
212 """QApplication::desktop() pass-through"""
213 return self
._app
.desktop()
216 """Wrap exec_() and start the application"""
217 # Defer connection so that local cola.inotify is honored
218 context
= self
.context
219 monitor
= context
.fsmonitor
220 monitor
.files_changed
.connect(
221 cmds
.run(cmds
.Refresh
, context
), type=Qt
.QueuedConnection
)
222 monitor
.config_changed
.connect(
223 cmds
.run(cmds
.RefreshConfig
, context
), type=Qt
.QueuedConnection
)
224 # Start the filesystem monitor thread
226 return self
._app
.exec_()
229 """Finalize the application"""
230 self
.context
.fsmonitor
.stop()
231 # Workaround QTBUG-52988 by deleting the app manually to prevent a
232 # crash during app shutdown.
233 # https://bugreports.qt.io/browse/QTBUG-52988
236 except (AttributeError, RuntimeError):
240 def exit(self
, status
):
241 """QApplication::exit(status) pass-through"""
242 return self
._app
.exit(status
)
245 class ColaQApplication(QtWidgets
.QApplication
):
246 """QApplication implementation for handling custom events"""
248 def __init__(self
, context
, argv
):
249 super(ColaQApplication
, self
).__init
__(argv
)
250 self
.context
= context
251 # Make icons sharp in HiDPI screen
252 if hasattr(Qt
, 'AA_UseHighDpiPixmaps'):
253 self
.setAttribute(Qt
.AA_UseHighDpiPixmaps
, True)
256 """Respond to focus events for the cola.refreshonfocus feature"""
257 if e
.type() == QtCore
.QEvent
.ApplicationActivate
:
258 context
= self
.context
261 if (context
.git
.is_valid()
262 and cfg
.get('cola.refreshonfocus', default
=False)):
263 cmds
.do(cmds
.Refresh
, context
)
264 return super(ColaQApplication
, self
).event(e
)
266 def commitData(self
, session_mgr
):
267 """Save session data"""
268 if not self
.context
or not self
.context
.view
:
270 view
= self
.context
.view
271 if not hasattr(view
, 'save_state'):
273 sid
= session_mgr
.sessionId()
274 skey
= session_mgr
.sessionKey()
275 session_id
= '%s_%s' % (sid
, skey
)
276 session
= Session(session_id
, repo
=core
.getcwd())
277 view
.save_state(settings
=session
)
280 def process_args(args
):
281 """Process and verify command-line arguments"""
283 # Accept 'git cola --version' or 'git cola version'
284 version
.print_version()
285 sys
.exit(core
.EXIT_SUCCESS
)
287 # Handle session management
288 restore_session(args
)
290 # Bail out if --repo is not a directory
291 repo
= core
.decode(args
.repo
)
292 if repo
.startswith('file:'):
293 repo
= repo
[len('file:'):]
294 repo
= core
.realpath(repo
)
295 if not core
.isdir(repo
):
296 errmsg
= N_('fatal: "%s" is not a directory. '
297 'Please specify a correct --repo <path>.') % repo
298 core
.print_stderr(errmsg
)
299 sys
.exit(core
.EXIT_USAGE
)
302 def restore_session(args
):
303 """Load a session based on the window-manager provided arguments"""
304 # args.settings is provided when restoring from a session.
306 if args
.session
is None:
308 session
= Session(args
.session
)
310 args
.settings
= session
311 args
.repo
= session
.repo
314 def application_init(args
, update
=False):
315 """Parses the command-line arguments and starts git-cola
317 # Ensure that we're working in a valid git repository.
318 # If not, try to find one. When found, chdir there.
322 context
= new_context(args
)
323 timer
= context
.timer
326 new_worktree(context
, args
.repo
, args
.prompt
, args
.settings
)
329 context
.model
.update_status()
333 timer
.display('init')
337 def new_context(args
):
338 """Create top-level ApplicationContext objects"""
339 context
= ApplicationContext(args
)
340 context
.git
= git
.create()
341 context
.cfg
= gitcfg
.create(context
)
342 context
.fsmonitor
= fsmonitor
.create(context
)
343 context
.selection
= selection
.create()
344 context
.model
= main
.create(context
)
345 context
.app
= new_application(context
, args
)
346 context
.timer
= Timer()
351 def application_run(context
, view
, start
=None, stop
=None):
352 """Run the application main loop"""
353 context
.set_view(view
)
359 # Start the event loop
360 result
= context
.app
.start()
369 def application_start(context
, view
):
370 """Show the GUI and start the main event loop"""
371 # Store the view for session management
372 return application_run(context
, view
,
373 start
=default_start
, stop
=default_stop
)
376 def default_start(context
, _view
):
377 """Scan for the first time"""
378 QtCore
.QTimer
.singleShot(0, startup_message
)
379 QtCore
.QTimer
.singleShot(0, lambda: async_update(context
))
382 def default_stop(_context
, _view
):
383 """All done, cleanup"""
384 QtCore
.QThreadPool
.globalInstance().waitForDone()
387 def add_common_arguments(parser
):
388 """Add command arguments to the ArgumentParser"""
389 # We also accept 'git cola version'
390 parser
.add_argument('--version', default
=False, action
='store_true',
391 help='print version number')
393 # Specifies a git repository to open
394 parser
.add_argument('-r', '--repo', metavar
='<repo>', default
=core
.getcwd(),
395 help='open the specified git repository')
397 # Specifies that we should prompt for a repository at startup
398 parser
.add_argument('--prompt', action
='store_true', default
=False,
399 help='prompt for a repository')
401 # Specify the icon theme
402 parser
.add_argument('--icon-theme', metavar
='<theme>',
403 dest
='icon_themes', action
='append', default
=[],
404 help='specify an icon theme (name or directory)')
406 # Resume an X Session Management session
407 parser
.add_argument('-session', metavar
='<session>', default
=None,
408 help=argparse
.SUPPRESS
)
410 # Enable timing information
411 parser
.add_argument('--perf', action
='store_true', default
=False,
412 help=argparse
.SUPPRESS
)
414 # Specify the GUI theme
415 parser
.add_argument('--theme', metavar
='<name>', default
=None,
416 help='specify an GUI theme name')
419 def new_application(context
, args
):
420 """Create a new ColaApplication"""
421 return ColaApplication(context
,
423 icon_themes
=args
.icon_themes
,
428 def new_worktree(context
, repo
, prompt
, settings
):
429 """Find a Git repository, or prompt for one when not found"""
430 model
= context
.model
432 parent
= qtutils
.active_window()
436 valid
= model
.set_worktree(repo
)
438 # We are not currently in a git repository so we need to find one.
439 # Before prompting the user for a repository, check if they've
440 # configured a default repository and attempt to use it.
441 default_repo
= cfg
.get('cola.defaultrepo')
443 valid
= model
.set_worktree(default_repo
)
446 # If we've gotten into this loop then that means that neither the
447 # current directory nor the default repository were available.
448 # Prompt the user for a repository.
449 startup_dlg
= startup
.StartupDialog(context
, parent
, settings
=settings
)
450 gitdir
= startup_dlg
.find_git_repo()
452 sys
.exit(core
.EXIT_NOINPUT
)
453 valid
= model
.set_worktree(gitdir
)
456 def async_update(context
):
457 """Update the model in the background
459 git-cola should startup as quickly as possible.
462 update_status
= partial(context
.model
.update_status
, update_index
=True)
463 task
= qtutils
.SimpleTask(context
.view
, update_status
)
464 context
.runtask
.start(task
)
467 def startup_message():
468 """Print debug startup messages"""
469 trace
= git
.GIT_COLA_TRACE
470 if trace
in ('2', 'trace'):
471 msg1
= 'info: debug level 2: trace mode enabled'
472 msg2
= 'info: set GIT_COLA_TRACE=1 for less-verbose output'
473 Interaction
.log(msg1
)
474 Interaction
.log(msg2
)
476 msg1
= 'info: debug level 1'
477 msg2
= 'info: set GIT_COLA_TRACE=2 for trace mode'
478 Interaction
.log(msg1
)
479 Interaction
.log(msg2
)
483 """System-level initialization"""
484 # The current directory may have been deleted while we are still
485 # in that directory. We rectify this situation by walking up the
486 # directory tree and retrying.
488 # This is needed because because Python throws exceptions in lots of
489 # stdlib functions when in this situation, e.g. os.path.abspath() and
490 # os.path.realpath(), so it's simpler to mitigate the damage by changing
491 # the current directory to one that actually exists.
500 """Simple performance timer"""
505 def start(self
, key
):
508 self
._data
[key
] = [now
, now
]
511 """Stop a timer and return its elapsed time"""
512 entry
= self
._data
[key
]
513 entry
[1] = time
.time()
514 return self
.elapsed(key
)
516 def elapsed(self
, key
):
517 """Return the elapsed time for a timer"""
518 entry
= self
._data
[key
]
519 return entry
[1] - entry
[0]
521 def display(self
, key
):
522 """Display a timer"""
523 elapsed
= self
.elapsed(key
)
524 sys
.stdout
.write('%s: %.5fs\n' % (key
, elapsed
))
527 class ApplicationContext(object):
528 """Context for performing operations on Git and related data models"""
530 def __init__(self
, args
):
532 self
.app
= None # ColaApplication
533 self
.git
= None # git.Git
534 self
.cfg
= None # gitcfg.GitConfig
535 self
.model
= None # main.MainModel
536 self
.timer
= None # Timer
537 self
.runtask
= None # qtutils.RunTask
538 self
.selection
= None # selection.SelectionModel
539 self
.fsmonitor
= None # fsmonitor
540 self
.view
= None # QWidget
542 def set_view(self
, view
):
543 """Initialize view-specific members"""
545 self
.runtask
= qtutils
.RunTask(parent
=view
)
548 def winmain(main_fn
, *argv
):
549 """Find Git and launch main(argv)"""
550 git_path
= find_git()
552 prepend_path(git_path
)
553 return main_fn(*argv
)
557 """Return the path of git.exe, or None if we can't find it."""
558 if not utils
.is_win32():
559 return None # UNIX systems have git in their $PATH
561 # If the user wants to use a Git/bin/ directory from a non-standard
562 # directory then they can write its location into
563 # ~/.config/git-cola/git-bindir
564 git_bindir
= os
.path
.expanduser(os
.path
.join('~', '.config', 'git-cola',
566 if core
.exists(git_bindir
):
567 custom_path
= core
.read(git_bindir
).strip()
568 if custom_path
and core
.exists(custom_path
):
571 # Try to find Git's bin/ directory in one of the typical locations
572 pf
= os
.environ
.get('ProgramFiles', 'C:\\Program Files')
573 pf32
= os
.environ
.get('ProgramFiles(x86)', 'C:\\Program Files (x86)')
574 pf64
= os
.environ
.get('ProgramW6432', 'C:\\Program Files')
575 for p
in [pf64
, pf32
, pf
, 'C:\\']:
576 candidate
= os
.path
.join(p
, 'Git\\bin')
577 if os
.path
.isdir(candidate
):
583 def prepend_path(path
):
584 """Adds git to the PATH. This is needed on Windows."""
585 path
= core
.decode(path
)
586 path_entries
= core
.getenv('PATH', '').split(os
.pathsep
)
587 if path
not in path_entries
:
588 path_entries
.insert(0, path
)
589 compat
.setenv('PATH', os
.pathsep
.join(path_entries
))