dag: prefer the clicked commit when checking out branches
[git-cola.git] / cola / app.py
blobd4124ef21c6c40f12ae319fb85f2eeb9fa2f6bcf
1 """Provides the main() routine and ColaApplication"""
2 # pylint: disable=unused-import
3 from __future__ import absolute_import, division, print_function, unicode_literals
4 from functools import partial
5 import argparse
6 import os
7 import signal
8 import sys
9 import time
11 __copyright__ = """
12 Copyright (C) 2007-2022 David Aguilar and contributors
13 """
15 try:
16 from qtpy import QtCore
17 except ImportError as error:
18 sys.stderr.write(
19 """
20 Your Python environment does not have qtpy and PyQt (or PySide).
21 The following error was encountered when importing "qtpy":
23 ImportError: {err}
25 Install qtpy and PyQt (or PySide) into your Python environment.
26 On a Debian/Ubuntu system you can install these modules using apt:
28 sudo apt install python3-pyqt5 python3-pyqt5.qtwebengine python3-qtpy
30 """.format(
31 err=error
34 sys.exit(1)
36 from qtpy import QtGui
37 from qtpy import QtWidgets
38 from qtpy.QtCore import Qt
40 try:
41 # Qt 5.12 / PyQt 5.13 is unable to use QtWebEngineWidgets unless it is
42 # imported before QApplication is constructed.
43 from qtpy import QtWebEngineWidgets # noqa
44 except ImportError:
45 # QtWebEngineWidgets / QtWebKit is not available -- no big deal.
46 pass
48 # Import cola modules
49 from .i18n import N_
50 from .interaction import Interaction
51 from .models import main
52 from .models import selection
53 from .widgets import cfgactions
54 from .widgets import standard
55 from .widgets import startup
56 from .settings import Session
57 from .settings import Settings
58 from . import cmds
59 from . import core
60 from . import compat
61 from . import fsmonitor
62 from . import git
63 from . import gitcfg
64 from . import guicmds
65 from . import hidpi
66 from . import icons
67 from . import i18n
68 from . import qtcompat
69 from . import qtutils
70 from . import resources
71 from . import themes
72 from . import utils
73 from . import version
76 def setup_environment():
77 """Set environment variables to control git's behavior"""
78 # Allow Ctrl-C to exit
79 signal.signal(signal.SIGINT, signal.SIG_DFL)
81 # Session management wants an absolute path when restarting
82 sys.argv[0] = sys_argv0 = os.path.abspath(sys.argv[0])
84 # Spoof an X11 display for SSH
85 os.environ.setdefault('DISPLAY', ':0')
87 if not core.getenv('SHELL', ''):
88 for shell in ('/bin/zsh', '/bin/bash', '/bin/sh'):
89 if os.path.exists(shell):
90 compat.setenv('SHELL', shell)
91 break
93 # Setup the path so that git finds us when we run 'git cola'
94 path_entries = core.getenv('PATH', '').split(os.pathsep)
95 bindir = core.decode(os.path.dirname(sys_argv0))
96 path_entries.append(bindir)
97 path = os.pathsep.join(path_entries)
98 compat.setenv('PATH', path)
100 # We don't ever want a pager
101 compat.setenv('GIT_PAGER', '')
103 # Setup *SSH_ASKPASS
104 git_askpass = core.getenv('GIT_ASKPASS')
105 ssh_askpass = core.getenv('SSH_ASKPASS')
106 if git_askpass:
107 askpass = git_askpass
108 elif ssh_askpass:
109 askpass = ssh_askpass
110 elif sys.platform == 'darwin':
111 askpass = resources.package_command('ssh-askpass-darwin')
112 else:
113 askpass = resources.package_command('ssh-askpass')
115 compat.setenv('GIT_ASKPASS', askpass)
116 compat.setenv('SSH_ASKPASS', askpass)
118 # --- >8 --- >8 ---
119 # Git v1.7.10 Release Notes
120 # =========================
122 # Compatibility Notes
123 # -------------------
125 # * From this release on, the "git merge" command in an interactive
126 # session will start an editor when it automatically resolves the
127 # merge for the user to explain the resulting commit, just like the
128 # "git commit" command does when it wasn't given a commit message.
130 # If you have a script that runs "git merge" and keeps its standard
131 # input and output attached to the user's terminal, and if you do not
132 # want the user to explain the resulting merge commits, you can
133 # export GIT_MERGE_AUTOEDIT environment variable set to "no", like
134 # this:
136 # #!/bin/sh
137 # GIT_MERGE_AUTOEDIT=no
138 # export GIT_MERGE_AUTOEDIT
140 # to disable this behavior (if you want your users to explain their
141 # merge commits, you do not have to do anything). Alternatively, you
142 # can give the "--no-edit" option to individual invocations of the
143 # "git merge" command if you know everybody who uses your script has
144 # Git v1.7.8 or newer.
145 # --- >8 --- >8 ---
146 # Longer-term: Use `git merge --no-commit` so that we always
147 # have a chance to explain our merges.
148 compat.setenv('GIT_MERGE_AUTOEDIT', 'no')
151 def get_icon_themes(context):
152 """Return the default icon theme names"""
153 result = []
155 icon_themes_env = core.getenv('GIT_COLA_ICON_THEME')
156 if icon_themes_env:
157 result.extend([x for x in icon_themes_env.split(':') if x])
159 icon_themes_cfg = list(reversed(context.cfg.get_all('cola.icontheme')))
160 if icon_themes_cfg:
161 result.extend(icon_themes_cfg)
163 if not result:
164 result.append('light')
166 return result
169 # style note: we use camelCase here since we're masquerading a Qt class
170 class ColaApplication(object):
171 """The main cola application
173 ColaApplication handles i18n of user-visible data
176 def __init__(self, context, argv, locale=None, icon_themes=None, gui_theme=None):
177 cfgactions.install()
178 i18n.install(locale)
179 qtcompat.install()
180 guicmds.install()
181 standard.install()
182 icons.install(icon_themes or get_icon_themes(context))
184 self.context = context
185 self.theme = None
186 self._set_rendering_options()
187 self._install_hidpi_config()
188 self._app = ColaQApplication(context, list(argv))
189 self._app.setWindowIcon(icons.cola())
190 self._app.setDesktopFileName('git-cola')
191 self._install_style(gui_theme)
193 def _set_rendering_options(self):
194 """Configure rendering options for QGraphicsView"""
195 try:
196 QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_UseOpenGLES)
197 format = QtGui.QSurfaceFormat()
198 format.setVersion(2, 0)
199 format.setProfile(QtGui.QSurfaceFormat.NoProfile)
200 format.setSwapBehavior(QtGui.QSurfaceFormat.DoubleBuffer)
201 QtGui.QSurfaceFormat.setDefaultFormat(format)
202 except Exception:
203 pass
205 def _install_style(self, theme_str):
206 """Generate and apply a stylesheet to the app"""
207 if theme_str is None:
208 theme_str = self.context.cfg.get('cola.theme', default='default')
209 theme = themes.find_theme(theme_str)
210 self.theme = theme
211 self._app.setStyleSheet(theme.build_style_sheet(self._app.palette()))
212 if theme_str != 'default':
213 self._app.setPalette(theme.build_palette(self._app.palette()))
215 def _install_hidpi_config(self):
216 """Sets QT HIDPI scalling (requires Qt 5.6)"""
217 value = self.context.cfg.get('cola.hidpi', default=hidpi.Option.AUTO)
218 hidpi.apply_choice(value)
220 def activeWindow(self):
221 """QApplication::activeWindow() pass-through"""
222 return self._app.activeWindow()
224 def desktop(self):
225 """QApplication::desktop() pass-through"""
226 return self._app.desktop()
228 def palette(self):
229 """QApplication::palette() pass-through"""
230 return self._app.palette()
232 def start(self):
233 """Wrap exec_() and start the application"""
234 # Defer connection so that local cola.inotify is honored
235 context = self.context
236 monitor = context.fsmonitor
237 monitor.files_changed.connect(
238 cmds.run(cmds.Refresh, context), type=Qt.QueuedConnection
240 monitor.config_changed.connect(
241 cmds.run(cmds.RefreshConfig, context), type=Qt.QueuedConnection
243 # Start the filesystem monitor thread
244 monitor.start()
245 return self._app.exec_()
247 def stop(self):
248 """Finalize the application"""
249 self.context.fsmonitor.stop()
250 # Workaround QTBUG-52988 by deleting the app manually to prevent a
251 # crash during app shutdown.
252 # https://bugreports.qt.io/browse/QTBUG-52988
253 try:
254 del self._app
255 except (AttributeError, RuntimeError):
256 pass
257 self._app = None
259 def exit(self, status):
260 """QApplication::exit(status) pass-through"""
261 return self._app.exit(status)
264 class ColaQApplication(QtWidgets.QApplication):
265 """QApplication implementation for handling custom events"""
267 def __init__(self, context, argv):
268 super(ColaQApplication, self).__init__(argv)
269 self.context = context
270 # Make icons sharp in HiDPI screen
271 if hasattr(Qt, 'AA_UseHighDpiPixmaps'):
272 self.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
274 def event(self, e):
275 """Respond to focus events for the cola.refreshonfocus feature"""
276 if e.type() == QtCore.QEvent.ApplicationActivate:
277 context = self.context
278 if context:
279 cfg = context.cfg
280 if context.git.is_valid() and cfg.get(
281 'cola.refreshonfocus', default=False
283 cmds.do(cmds.Refresh, context)
284 return super(ColaQApplication, self).event(e)
286 def commitData(self, session_mgr):
287 """Save session data"""
288 if not self.context or not self.context.view:
289 return
290 view = self.context.view
291 if not hasattr(view, 'save_state'):
292 return
293 sid = session_mgr.sessionId()
294 skey = session_mgr.sessionKey()
295 session_id = '%s_%s' % (sid, skey)
296 session = Session(session_id, repo=core.getcwd())
297 session.update()
298 view.save_state(settings=session)
301 def process_args(args):
302 """Process and verify command-line arguments"""
303 if args.version:
304 # Accept 'git cola --version' or 'git cola version'
305 version.print_version()
306 sys.exit(core.EXIT_SUCCESS)
308 # Handle session management
309 restore_session(args)
311 # Bail out if --repo is not a directory
312 repo = core.decode(args.repo)
313 if repo.startswith('file:'):
314 repo = repo[len('file:') :]
315 repo = core.realpath(repo)
316 if not core.isdir(repo):
317 errmsg = (
319 'fatal: "%s" is not a directory. '
320 'Please specify a correct --repo <path>.'
322 % repo
324 core.print_stderr(errmsg)
325 sys.exit(core.EXIT_USAGE)
328 def restore_session(args):
329 """Load a session based on the window-manager provided arguments"""
330 # args.settings is provided when restoring from a session.
331 args.settings = None
332 if args.session is None:
333 return
334 session = Session(args.session)
335 if session.load():
336 args.settings = session
337 args.repo = session.repo
340 def application_init(args, update=False):
341 """Parses the command-line arguments and starts git-cola"""
342 # Ensure that we're working in a valid git repository.
343 # If not, try to find one. When found, chdir there.
344 setup_environment()
345 process_args(args)
347 context = new_context(args)
348 timer = context.timer
349 timer.start('init')
351 new_worktree(context, args.repo, args.prompt)
353 if update:
354 context.model.update_status()
356 timer.stop('init')
357 if args.perf:
358 timer.display('init')
359 return context
362 def new_context(args):
363 """Create top-level ApplicationContext objects"""
364 context = ApplicationContext(args)
365 context.settings = args.settings or Settings.read()
366 context.git = git.create()
367 context.cfg = gitcfg.create(context)
368 context.fsmonitor = fsmonitor.create(context)
369 context.selection = selection.create()
370 context.model = main.create(context)
371 context.app = new_application(context, args)
372 context.timer = Timer()
374 return context
377 def application_run(context, view, start=None, stop=None):
378 """Run the application main loop"""
379 initialize_view(context, view)
380 # Startup callbacks
381 if start:
382 start(context, view)
383 # Start the event loop
384 result = context.app.start()
385 # Finish
386 if stop:
387 stop(context, view)
388 context.app.stop()
390 return result
393 def initialize_view(context, view):
394 """Register the main widget and display it"""
395 context.set_view(view)
396 view.show()
397 if sys.platform == 'darwin':
398 view.raise_()
401 def application_start(context, view):
402 """Show the GUI and start the main event loop"""
403 # Store the view for session management
404 return application_run(context, view, start=default_start, stop=default_stop)
407 def default_start(context, _view):
408 """Scan for the first time"""
409 QtCore.QTimer.singleShot(0, startup_message)
410 QtCore.QTimer.singleShot(0, lambda: async_update(context))
413 def default_stop(_context, _view):
414 """All done, cleanup"""
415 QtCore.QThreadPool.globalInstance().waitForDone()
418 def add_common_arguments(parser):
419 """Add command arguments to the ArgumentParser"""
420 # We also accept 'git cola version'
421 parser.add_argument(
422 '--version', default=False, action='store_true', help='print version number'
425 # Specifies a git repository to open
426 parser.add_argument(
427 '-r',
428 '--repo',
429 metavar='<repo>',
430 default=core.getcwd(),
431 help='open the specified git repository',
434 # Specifies that we should prompt for a repository at startup
435 parser.add_argument(
436 '--prompt', action='store_true', default=False, help='prompt for a repository'
439 # Specify the icon theme
440 parser.add_argument(
441 '--icon-theme',
442 metavar='<theme>',
443 dest='icon_themes',
444 action='append',
445 default=[],
446 help='specify an icon theme (name or directory)',
449 # Resume an X Session Management session
450 parser.add_argument(
451 '-session', metavar='<session>', default=None, help=argparse.SUPPRESS
454 # Enable timing information
455 parser.add_argument(
456 '--perf', action='store_true', default=False, help=argparse.SUPPRESS
459 # Specify the GUI theme
460 parser.add_argument(
461 '--theme', metavar='<name>', default=None, help='specify an GUI theme name'
465 def new_application(context, args):
466 """Create a new ColaApplication"""
467 return ColaApplication(
468 context, sys.argv, icon_themes=args.icon_themes, gui_theme=args.theme
472 def new_worktree(context, repo, prompt):
473 """Find a Git repository, or prompt for one when not found"""
474 model = context.model
475 cfg = context.cfg
476 parent = qtutils.active_window()
477 valid = False
479 if not prompt:
480 valid = model.set_worktree(repo)
481 if not valid:
482 # We are not currently in a git repository so we need to find one.
483 # Before prompting the user for a repository, check if they've
484 # configured a default repository and attempt to use it.
485 default_repo = cfg.get('cola.defaultrepo')
486 if default_repo:
487 valid = model.set_worktree(default_repo)
489 while not valid:
490 # If we've gotten into this loop then that means that neither the
491 # current directory nor the default repository were available.
492 # Prompt the user for a repository.
493 startup_dlg = startup.StartupDialog(context, parent)
494 gitdir = startup_dlg.find_git_repo()
495 if not gitdir:
496 sys.exit(core.EXIT_NOINPUT)
498 if not core.exists(os.path.join(gitdir, '.git')):
499 offer_to_create_repo(context, gitdir)
500 valid = model.set_worktree(gitdir)
501 continue
503 valid = model.set_worktree(gitdir)
504 if not valid:
505 err = model.error
506 standard.critical(
507 N_('Error Opening Repository'),
508 message=N_('Could not open %s.' % gitdir),
509 details=err,
513 def offer_to_create_repo(context, gitdir):
514 """Offer to create a new repo"""
515 title = N_('Repository Not Found')
516 text = N_('%s is not a Git repository.') % gitdir
517 informative_text = N_('Create a new repository at that location?')
518 if standard.confirm(title, text, informative_text, N_('Create')):
519 status, out, err = context.git.init(gitdir)
520 title = N_('Error Creating Repository')
521 if status != 0:
522 Interaction.command_error(title, 'git init', status, out, err)
525 def async_update(context):
526 """Update the model in the background
528 git-cola should startup as quickly as possible.
531 update_status = partial(context.model.update_status, update_index=True)
532 task = qtutils.SimpleTask(update_status)
533 context.runtask.start(task)
536 def startup_message():
537 """Print debug startup messages"""
538 trace = git.GIT_COLA_TRACE
539 if trace in ('2', 'trace'):
540 msg1 = 'info: debug level 2: trace mode enabled'
541 msg2 = 'info: set GIT_COLA_TRACE=1 for less-verbose output'
542 Interaction.log(msg1)
543 Interaction.log(msg2)
544 elif trace:
545 msg1 = 'info: debug level 1'
546 msg2 = 'info: set GIT_COLA_TRACE=2 for trace mode'
547 Interaction.log(msg1)
548 Interaction.log(msg2)
551 def initialize():
552 """System-level initialization"""
553 # We support ~/.config/git-cola/git-bindir on Windows for configuring
554 # a custom location for finding the "git" executable.
555 git_path = find_git()
556 if git_path:
557 prepend_path(git_path)
559 # The current directory may have been deleted while we are still
560 # in that directory. We rectify this situation by walking up the
561 # directory tree and retrying.
563 # This is needed because because Python throws exceptions in lots of
564 # stdlib functions when in this situation, e.g. os.path.abspath() and
565 # os.path.realpath(), so it's simpler to mitigate the damage by changing
566 # the current directory to one that actually exists.
567 while True:
568 try:
569 return core.getcwd()
570 except OSError:
571 os.chdir('..')
574 class Timer(object):
575 """Simple performance timer"""
577 def __init__(self):
578 self._data = {}
580 def start(self, key):
581 """Start a timer"""
582 now = time.time()
583 self._data[key] = [now, now]
585 def stop(self, key):
586 """Stop a timer and return its elapsed time"""
587 entry = self._data[key]
588 entry[1] = time.time()
589 return self.elapsed(key)
591 def elapsed(self, key):
592 """Return the elapsed time for a timer"""
593 entry = self._data[key]
594 return entry[1] - entry[0]
596 def display(self, key):
597 """Display a timer"""
598 elapsed = self.elapsed(key)
599 sys.stdout.write('%s: %.5fs\n' % (key, elapsed))
602 class NullArgs(object):
603 """Stub arguments for interactive API use"""
605 def __init__(self):
606 self.icon_themes = []
607 self.theme = None
608 self.settings = None
611 def null_args():
612 """Create a new instance of application arguments"""
613 return NullArgs()
616 class ApplicationContext(object):
617 """Context for performing operations on Git and related data models"""
619 def __init__(self, args):
620 self.args = args
621 self.app = None # ColaApplication
622 self.git = None # git.Git
623 self.cfg = None # gitcfg.GitConfig
624 self.model = None # main.MainModel
625 self.timer = None # Timer
626 self.runtask = None # qtutils.RunTask
627 self.settings = None # settings.Settings
628 self.selection = None # selection.SelectionModel
629 self.fsmonitor = None # fsmonitor
630 self.view = None # QWidget
631 self.browser_windows = [] # list of browse.Browser
633 def set_view(self, view):
634 """Initialize view-specific members"""
635 self.view = view
636 self.runtask = qtutils.RunTask(parent=view)
639 def find_git():
640 """Return the path of git.exe, or None if we can't find it."""
641 if not utils.is_win32():
642 return None # UNIX systems have git in their $PATH
644 # If the user wants to use a Git/bin/ directory from a non-standard
645 # directory then they can write its location into
646 # ~/.config/git-cola/git-bindir
647 git_bindir = resources.config_home('git-bindir')
648 if core.exists(git_bindir):
649 custom_path = core.read(git_bindir).strip()
650 if custom_path and core.exists(custom_path):
651 return custom_path
653 # Try to find Git's bin/ directory in one of the typical locations
654 pf = os.environ.get('ProgramFiles', 'C:\\Program Files')
655 pf32 = os.environ.get('ProgramFiles(x86)', 'C:\\Program Files (x86)')
656 pf64 = os.environ.get('ProgramW6432', 'C:\\Program Files')
657 for p in [pf64, pf32, pf, 'C:\\']:
658 candidate = os.path.join(p, 'Git\\bin')
659 if os.path.isdir(candidate):
660 return candidate
662 return None
665 def prepend_path(path):
666 """Adds git to the PATH. This is needed on Windows."""
667 path = core.decode(path)
668 path_entries = core.getenv('PATH', '').split(os.pathsep)
669 if path not in path_entries:
670 path_entries.insert(0, path)
671 compat.setenv('PATH', os.pathsep.join(path_entries))