doc/relnotes: add #1000 and #1005 to the v3.6 release notes draft
[git-cola.git] / cola / app.py
blobb4bc96c5735016fe6efa33e8570601315d86da25
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
5 import argparse
6 import os
7 import signal
8 import sys
9 import time
11 __copyright__ = """
12 Copyright (C) 2007-2017 David Aguilar and contributors
13 """
15 try:
16 from qtpy import QtCore
17 except ImportError:
18 sys.stderr.write("""
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
24 """)
25 sys.exit(1)
27 from qtpy import QtWidgets
28 from qtpy.QtCore import Qt
29 try:
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
33 except ImportError:
34 # QtWebEngineWidgets / QtWebKit is not available -- no big deal.
35 pass
37 # Import cola modules
38 from .i18n import N_
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
46 from . import cmds
47 from . import core
48 from . import compat
49 from . import fsmonitor
50 from . import git
51 from . import gitcfg
52 from . import guicmds
53 from . import hidpi
54 from . import icons
55 from . import i18n
56 from . import qtcompat
57 from . import qtutils
58 from . import resources
59 from . import themes
60 from . import utils
61 from . import version
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)
79 break
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', '')
91 # Setup *SSH_ASKPASS
92 git_askpass = core.getenv('GIT_ASKPASS')
93 ssh_askpass = core.getenv('SSH_ASKPASS')
94 if git_askpass:
95 askpass = git_askpass
96 elif ssh_askpass:
97 askpass = ssh_askpass
98 elif sys.platform == 'darwin':
99 askpass = resources.share('bin', 'ssh-askpass-darwin')
100 else:
101 askpass = resources.share('bin', 'ssh-askpass')
103 compat.setenv('GIT_ASKPASS', askpass)
104 compat.setenv('SSH_ASKPASS', askpass)
106 # --- >8 --- >8 ---
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
122 # this:
124 # #!/bin/sh
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.
133 # --- >8 --- >8 ---
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"""
155 result = []
157 icon_themes_env = core.getenv('GIT_COLA_ICON_THEME')
158 if icon_themes_env:
159 result.extend([x for x in icon_themes_env.split(':') if x])
161 icon_themes_cfg = context.cfg.get_all('cola.icontheme')
162 if icon_themes_cfg:
163 result.extend(icon_themes_cfg)
165 if not result:
166 result.append('light')
168 return result
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):
180 cfgactions.install()
181 i18n.install(locale)
182 qtcompat.install()
183 guicmds.install()
184 standard.install()
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()
211 def desktop(self):
212 """QApplication::desktop() pass-through"""
213 return self._app.desktop()
215 def start(self):
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
225 monitor.start()
226 return self._app.exec_()
228 def stop(self):
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
234 try:
235 del self._app
236 except (AttributeError, RuntimeError):
237 pass
238 self._app = None
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)
255 def event(self, e):
256 """Respond to focus events for the cola.refreshonfocus feature"""
257 if e.type() == QtCore.QEvent.ApplicationActivate:
258 context = self.context
259 if context:
260 cfg = context.cfg
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:
269 return
270 view = self.context.view
271 if not hasattr(view, 'save_state'):
272 return
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"""
282 if args.version:
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.
305 args.settings = None
306 if args.session is None:
307 return
308 session = Session(args.session)
309 if session.load():
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.
319 setup_environment()
320 process_args(args)
322 context = new_context(args)
323 timer = context.timer
324 timer.start('init')
326 new_worktree(context, args.repo, args.prompt, args.settings)
328 if update:
329 context.model.update_status()
331 timer.stop('init')
332 if args.perf:
333 timer.display('init')
334 return context
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()
348 return context
351 def application_run(context, view, start=None, stop=None):
352 """Run the application main loop"""
353 context.set_view(view)
354 view.show()
356 # Startup callbacks
357 if start:
358 start(context, view)
359 # Start the event loop
360 result = context.app.start()
361 # Finish
362 if stop:
363 stop(context, view)
364 context.app.stop()
366 return result
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,
422 sys.argv,
423 icon_themes=args.icon_themes,
424 gui_theme=args.theme
428 def new_worktree(context, repo, prompt, settings):
429 """Find a Git repository, or prompt for one when not found"""
430 model = context.model
431 cfg = context.cfg
432 parent = qtutils.active_window()
433 valid = False
435 if not prompt:
436 valid = model.set_worktree(repo)
437 if not valid:
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')
442 if default_repo:
443 valid = model.set_worktree(default_repo)
445 while not valid:
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()
451 if not gitdir:
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)
475 elif trace:
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)
482 def initialize():
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.
492 while True:
493 try:
494 return core.getcwd()
495 except OSError:
496 os.chdir('..')
499 class Timer(object):
500 """Simple performance timer"""
502 def __init__(self):
503 self._data = {}
505 def start(self, key):
506 """Start a timer"""
507 now = time.time()
508 self._data[key] = [now, now]
510 def stop(self, key):
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):
531 self.args = 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"""
544 self.view = view
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()
551 if git_path:
552 prepend_path(git_path)
553 return main_fn(*argv)
556 def find_git():
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',
565 'git-bindir'))
566 if core.exists(git_bindir):
567 custom_path = core.read(git_bindir).strip()
568 if custom_path and core.exists(custom_path):
569 return 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):
578 return candidate
580 return None
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))