pylint: remove unused variable
[git-cola.git] / cola / app.py
blob1a791dec990e077a45d5d2964ef80116d1a40261
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 QtWidgets
37 from qtpy.QtCore import Qt
39 try:
40 # Qt 5.12 / PyQt 5.13 is unable to use QtWebEngineWidgets unless it is
41 # imported before QApplication is constructed.
42 from qtpy import QtWebEngineWidgets # noqa
43 except ImportError:
44 # QtWebEngineWidgets / QtWebKit is not available -- no big deal.
45 pass
47 # Import cola modules
48 from .i18n import N_
49 from .interaction import Interaction
50 from .models import main
51 from .models import selection
52 from .widgets import cfgactions
53 from .widgets import standard
54 from .widgets import startup
55 from .settings import Session
56 from .settings import Settings
57 from . import cmds
58 from . import core
59 from . import compat
60 from . import fsmonitor
61 from . import git
62 from . import gitcfg
63 from . import guicmds
64 from . import hidpi
65 from . import icons
66 from . import i18n
67 from . import qtcompat
68 from . import qtutils
69 from . import resources
70 from . import themes
71 from . import utils
72 from . import version
75 def setup_environment():
76 """Set environment variables to control git's behavior"""
77 # Allow Ctrl-C to exit
78 signal.signal(signal.SIGINT, signal.SIG_DFL)
80 # Session management wants an absolute path when restarting
81 sys.argv[0] = sys_argv0 = os.path.abspath(sys.argv[0])
83 # Spoof an X11 display for SSH
84 os.environ.setdefault('DISPLAY', ':0')
86 if not core.getenv('SHELL', ''):
87 for shell in ('/bin/zsh', '/bin/bash', '/bin/sh'):
88 if os.path.exists(shell):
89 compat.setenv('SHELL', shell)
90 break
92 # Setup the path so that git finds us when we run 'git cola'
93 path_entries = core.getenv('PATH', '').split(os.pathsep)
94 bindir = core.decode(os.path.dirname(sys_argv0))
95 path_entries.append(bindir)
96 path = os.pathsep.join(path_entries)
97 compat.setenv('PATH', path)
99 # We don't ever want a pager
100 compat.setenv('GIT_PAGER', '')
102 # Setup *SSH_ASKPASS
103 git_askpass = core.getenv('GIT_ASKPASS')
104 ssh_askpass = core.getenv('SSH_ASKPASS')
105 if git_askpass:
106 askpass = git_askpass
107 elif ssh_askpass:
108 askpass = ssh_askpass
109 elif sys.platform == 'darwin':
110 askpass = resources.package_command('ssh-askpass-darwin')
111 else:
112 askpass = resources.package_command('ssh-askpass')
114 compat.setenv('GIT_ASKPASS', askpass)
115 compat.setenv('SSH_ASKPASS', askpass)
117 # --- >8 --- >8 ---
118 # Git v1.7.10 Release Notes
119 # =========================
121 # Compatibility Notes
122 # -------------------
124 # * From this release on, the "git merge" command in an interactive
125 # session will start an editor when it automatically resolves the
126 # merge for the user to explain the resulting commit, just like the
127 # "git commit" command does when it wasn't given a commit message.
129 # If you have a script that runs "git merge" and keeps its standard
130 # input and output attached to the user's terminal, and if you do not
131 # want the user to explain the resulting merge commits, you can
132 # export GIT_MERGE_AUTOEDIT environment variable set to "no", like
133 # this:
135 # #!/bin/sh
136 # GIT_MERGE_AUTOEDIT=no
137 # export GIT_MERGE_AUTOEDIT
139 # to disable this behavior (if you want your users to explain their
140 # merge commits, you do not have to do anything). Alternatively, you
141 # can give the "--no-edit" option to individual invocations of the
142 # "git merge" command if you know everybody who uses your script has
143 # Git v1.7.8 or newer.
144 # --- >8 --- >8 ---
145 # Longer-term: Use `git merge --no-commit` so that we always
146 # have a chance to explain our merges.
147 compat.setenv('GIT_MERGE_AUTOEDIT', 'no')
150 def get_icon_themes(context):
151 """Return the default icon theme names"""
152 result = []
154 icon_themes_env = core.getenv('GIT_COLA_ICON_THEME')
155 if icon_themes_env:
156 result.extend([x for x in icon_themes_env.split(':') if x])
158 icon_themes_cfg = list(reversed(context.cfg.get_all('cola.icontheme')))
159 if icon_themes_cfg:
160 result.extend(icon_themes_cfg)
162 if not result:
163 result.append('light')
165 return result
168 # style note: we use camelCase here since we're masquerading a Qt class
169 class ColaApplication(object):
170 """The main cola application
172 ColaApplication handles i18n of user-visible data
175 def __init__(self, context, argv, locale=None, icon_themes=None, gui_theme=None):
176 cfgactions.install()
177 i18n.install(locale)
178 qtcompat.install()
179 guicmds.install()
180 standard.install()
181 icons.install(icon_themes or get_icon_themes(context))
183 self.context = context
184 self.theme = None
185 self._install_hidpi_config()
186 self._app = ColaQApplication(context, list(argv))
187 self._app.setWindowIcon(icons.cola())
188 self._app.setDesktopFileName('git-cola')
189 self._install_style(gui_theme)
191 def _install_style(self, theme_str):
192 """Generate and apply a stylesheet to the app"""
193 if theme_str is None:
194 theme_str = self.context.cfg.get('cola.theme', default='default')
195 theme = themes.find_theme(theme_str)
196 self.theme = theme
197 self._app.setStyleSheet(theme.build_style_sheet(self._app.palette()))
198 if theme_str != 'default':
199 self._app.setPalette(theme.build_palette(self._app.palette()))
201 def _install_hidpi_config(self):
202 """Sets QT HIDPI scalling (requires Qt 5.6)"""
203 value = self.context.cfg.get('cola.hidpi', default=hidpi.Option.AUTO)
204 hidpi.apply_choice(value)
206 def activeWindow(self):
207 """QApplication::activeWindow() pass-through"""
208 return self._app.activeWindow()
210 def palette(self):
211 """QApplication::palette() pass-through"""
212 return self._app.palette()
214 def start(self):
215 """Wrap exec_() and start the application"""
216 # Defer connection so that local cola.inotify is honored
217 context = self.context
218 monitor = context.fsmonitor
219 monitor.files_changed.connect(
220 cmds.run(cmds.Refresh, context), type=Qt.QueuedConnection
222 monitor.config_changed.connect(
223 cmds.run(cmds.RefreshConfig, context), type=Qt.QueuedConnection
225 # Start the filesystem monitor thread
226 monitor.start()
227 return self._app.exec_()
229 def stop(self):
230 """Finalize the application"""
231 self.context.fsmonitor.stop()
232 # Workaround QTBUG-52988 by deleting the app manually to prevent a
233 # crash during app shutdown.
234 # https://bugreports.qt.io/browse/QTBUG-52988
235 try:
236 del self._app
237 except (AttributeError, RuntimeError):
238 pass
239 self._app = None
241 def exit(self, status):
242 """QApplication::exit(status) pass-through"""
243 return self._app.exit(status)
246 class ColaQApplication(QtWidgets.QApplication):
247 """QApplication implementation for handling custom events"""
249 def __init__(self, context, argv):
250 super(ColaQApplication, self).__init__(argv)
251 self.context = context
252 # Make icons sharp in HiDPI screen
253 if hasattr(Qt, 'AA_UseHighDpiPixmaps'):
254 self.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
256 def event(self, e):
257 """Respond to focus events for the cola.refreshonfocus feature"""
258 if e.type() == QtCore.QEvent.ApplicationActivate:
259 context = self.context
260 if context:
261 cfg = context.cfg
262 if context.git.is_valid() and cfg.get(
263 'cola.refreshonfocus', default=False
265 cmds.do(cmds.Refresh, context)
266 return super(ColaQApplication, self).event(e)
268 def commitData(self, session_mgr):
269 """Save session data"""
270 if not self.context or not self.context.view:
271 return
272 view = self.context.view
273 if not hasattr(view, 'save_state'):
274 return
275 sid = session_mgr.sessionId()
276 skey = session_mgr.sessionKey()
277 session_id = '%s_%s' % (sid, skey)
278 session = Session(session_id, repo=core.getcwd())
279 session.update()
280 view.save_state(settings=session)
283 def process_args(args):
284 """Process and verify command-line arguments"""
285 if args.version:
286 # Accept 'git cola --version' or 'git cola version'
287 version.print_version()
288 sys.exit(core.EXIT_SUCCESS)
290 # Handle session management
291 restore_session(args)
293 # Bail out if --repo is not a directory
294 repo = core.decode(args.repo)
295 if repo.startswith('file:'):
296 repo = repo[len('file:') :]
297 repo = core.realpath(repo)
298 if not core.isdir(repo):
299 errmsg = (
301 'fatal: "%s" is not a directory. '
302 'Please specify a correct --repo <path>.'
304 % repo
306 core.print_stderr(errmsg)
307 sys.exit(core.EXIT_USAGE)
310 def restore_session(args):
311 """Load a session based on the window-manager provided arguments"""
312 # args.settings is provided when restoring from a session.
313 args.settings = None
314 if args.session is None:
315 return
316 session = Session(args.session)
317 if session.load():
318 args.settings = session
319 args.repo = session.repo
322 def application_init(args, update=False):
323 """Parses the command-line arguments and starts git-cola"""
324 # Ensure that we're working in a valid git repository.
325 # If not, try to find one. When found, chdir there.
326 setup_environment()
327 process_args(args)
329 context = new_context(args)
330 timer = context.timer
331 timer.start('init')
333 new_worktree(context, args.repo, args.prompt)
335 if update:
336 context.model.update_status()
338 timer.stop('init')
339 if args.perf:
340 timer.display('init')
341 return context
344 def new_context(args):
345 """Create top-level ApplicationContext objects"""
346 context = ApplicationContext(args)
347 context.settings = args.settings or Settings.read()
348 context.git = git.create()
349 context.cfg = gitcfg.create(context)
350 context.fsmonitor = fsmonitor.create(context)
351 context.selection = selection.create()
352 context.model = main.create(context)
353 context.app = new_application(context, args)
354 context.timer = Timer()
356 return context
359 def application_run(context, view, start=None, stop=None):
360 """Run the application main loop"""
361 initialize_view(context, view)
362 # Startup callbacks
363 if start:
364 start(context, view)
365 # Start the event loop
366 result = context.app.start()
367 # Finish
368 if stop:
369 stop(context, view)
370 context.app.stop()
372 return result
375 def initialize_view(context, view):
376 """Register the main widget and display it"""
377 context.set_view(view)
378 view.show()
379 if sys.platform == 'darwin':
380 view.raise_()
383 def application_start(context, view):
384 """Show the GUI and start the main event loop"""
385 # Store the view for session management
386 return application_run(context, view, start=default_start, stop=default_stop)
389 def default_start(context, _view):
390 """Scan for the first time"""
391 QtCore.QTimer.singleShot(0, startup_message)
392 QtCore.QTimer.singleShot(0, lambda: async_update(context))
395 def default_stop(_context, _view):
396 """All done, cleanup"""
397 QtCore.QThreadPool.globalInstance().waitForDone()
400 def add_common_arguments(parser):
401 """Add command arguments to the ArgumentParser"""
402 # We also accept 'git cola version'
403 parser.add_argument(
404 '--version', default=False, action='store_true', help='print version number'
407 # Specifies a git repository to open
408 parser.add_argument(
409 '-r',
410 '--repo',
411 metavar='<repo>',
412 default=core.getcwd(),
413 help='open the specified git repository',
416 # Specifies that we should prompt for a repository at startup
417 parser.add_argument(
418 '--prompt', action='store_true', default=False, help='prompt for a repository'
421 # Specify the icon theme
422 parser.add_argument(
423 '--icon-theme',
424 metavar='<theme>',
425 dest='icon_themes',
426 action='append',
427 default=[],
428 help='specify an icon theme (name or directory)',
431 # Resume an X Session Management session
432 parser.add_argument(
433 '-session', metavar='<session>', default=None, help=argparse.SUPPRESS
436 # Enable timing information
437 parser.add_argument(
438 '--perf', action='store_true', default=False, help=argparse.SUPPRESS
441 # Specify the GUI theme
442 parser.add_argument(
443 '--theme', metavar='<name>', default=None, help='specify an GUI theme name'
447 def new_application(context, args):
448 """Create a new ColaApplication"""
449 return ColaApplication(
450 context, sys.argv, icon_themes=args.icon_themes, gui_theme=args.theme
454 def new_worktree(context, repo, prompt):
455 """Find a Git repository, or prompt for one when not found"""
456 model = context.model
457 cfg = context.cfg
458 parent = qtutils.active_window()
459 valid = False
461 if not prompt:
462 valid = model.set_worktree(repo)
463 if not valid:
464 # We are not currently in a git repository so we need to find one.
465 # Before prompting the user for a repository, check if they've
466 # configured a default repository and attempt to use it.
467 default_repo = cfg.get('cola.defaultrepo')
468 if default_repo:
469 valid = model.set_worktree(default_repo)
471 while not valid:
472 # If we've gotten into this loop then that means that neither the
473 # current directory nor the default repository were available.
474 # Prompt the user for a repository.
475 startup_dlg = startup.StartupDialog(context, parent)
476 gitdir = startup_dlg.find_git_repo()
477 if not gitdir:
478 sys.exit(core.EXIT_NOINPUT)
480 if not core.exists(os.path.join(gitdir, '.git')):
481 offer_to_create_repo(context, gitdir)
482 valid = model.set_worktree(gitdir)
483 continue
485 valid = model.set_worktree(gitdir)
486 if not valid:
487 err = model.error
488 standard.critical(
489 N_('Error Opening Repository'),
490 message=N_('Could not open %s.' % gitdir),
491 details=err,
495 def offer_to_create_repo(context, gitdir):
496 """Offer to create a new repo"""
497 title = N_('Repository Not Found')
498 text = N_('%s is not a Git repository.') % gitdir
499 informative_text = N_('Create a new repository at that location?')
500 if standard.confirm(title, text, informative_text, N_('Create')):
501 status, out, err = context.git.init(gitdir)
502 title = N_('Error Creating Repository')
503 if status != 0:
504 Interaction.command_error(title, 'git init', status, out, err)
507 def async_update(context):
508 """Update the model in the background
510 git-cola should startup as quickly as possible.
513 update_status = partial(context.model.update_status, update_index=True)
514 task = qtutils.SimpleTask(update_status)
515 context.runtask.start(task)
518 def startup_message():
519 """Print debug startup messages"""
520 trace = git.GIT_COLA_TRACE
521 if trace in ('2', 'trace'):
522 msg1 = 'info: debug level 2: trace mode enabled'
523 msg2 = 'info: set GIT_COLA_TRACE=1 for less-verbose output'
524 Interaction.log(msg1)
525 Interaction.log(msg2)
526 elif trace:
527 msg1 = 'info: debug level 1'
528 msg2 = 'info: set GIT_COLA_TRACE=2 for trace mode'
529 Interaction.log(msg1)
530 Interaction.log(msg2)
533 def initialize():
534 """System-level initialization"""
535 # We support ~/.config/git-cola/git-bindir on Windows for configuring
536 # a custom location for finding the "git" executable.
537 git_path = find_git()
538 if git_path:
539 prepend_path(git_path)
541 # The current directory may have been deleted while we are still
542 # in that directory. We rectify this situation by walking up the
543 # directory tree and retrying.
545 # This is needed because because Python throws exceptions in lots of
546 # stdlib functions when in this situation, e.g. os.path.abspath() and
547 # os.path.realpath(), so it's simpler to mitigate the damage by changing
548 # the current directory to one that actually exists.
549 while True:
550 try:
551 return core.getcwd()
552 except OSError:
553 os.chdir('..')
556 class Timer(object):
557 """Simple performance timer"""
559 def __init__(self):
560 self._data = {}
562 def start(self, key):
563 """Start a timer"""
564 now = time.time()
565 self._data[key] = [now, now]
567 def stop(self, key):
568 """Stop a timer and return its elapsed time"""
569 entry = self._data[key]
570 entry[1] = time.time()
571 return self.elapsed(key)
573 def elapsed(self, key):
574 """Return the elapsed time for a timer"""
575 entry = self._data[key]
576 return entry[1] - entry[0]
578 def display(self, key):
579 """Display a timer"""
580 elapsed = self.elapsed(key)
581 sys.stdout.write('%s: %.5fs\n' % (key, elapsed))
584 class NullArgs(object):
585 """Stub arguments for interactive API use"""
587 def __init__(self):
588 self.icon_themes = []
589 self.theme = None
590 self.settings = None
593 def null_args():
594 """Create a new instance of application arguments"""
595 return NullArgs()
598 class ApplicationContext(object):
599 """Context for performing operations on Git and related data models"""
601 def __init__(self, args):
602 self.args = args
603 self.app = None # ColaApplication
604 self.git = None # git.Git
605 self.cfg = None # gitcfg.GitConfig
606 self.model = None # main.MainModel
607 self.timer = None # Timer
608 self.runtask = None # qtutils.RunTask
609 self.settings = None # settings.Settings
610 self.selection = None # selection.SelectionModel
611 self.fsmonitor = None # fsmonitor
612 self.view = None # QWidget
613 self.browser_windows = [] # list of browse.Browser
615 def set_view(self, view):
616 """Initialize view-specific members"""
617 self.view = view
618 self.runtask = qtutils.RunTask(parent=view)
621 def find_git():
622 """Return the path of git.exe, or None if we can't find it."""
623 if not utils.is_win32():
624 return None # UNIX systems have git in their $PATH
626 # If the user wants to use a Git/bin/ directory from a non-standard
627 # directory then they can write its location into
628 # ~/.config/git-cola/git-bindir
629 git_bindir = resources.config_home('git-bindir')
630 if core.exists(git_bindir):
631 custom_path = core.read(git_bindir).strip()
632 if custom_path and core.exists(custom_path):
633 return custom_path
635 # Try to find Git's bin/ directory in one of the typical locations
636 pf = os.environ.get('ProgramFiles', 'C:\\Program Files')
637 pf32 = os.environ.get('ProgramFiles(x86)', 'C:\\Program Files (x86)')
638 pf64 = os.environ.get('ProgramW6432', 'C:\\Program Files')
639 for p in [pf64, pf32, pf, 'C:\\']:
640 candidate = os.path.join(p, 'Git\\bin')
641 if os.path.isdir(candidate):
642 return candidate
644 return None
647 def prepend_path(path):
648 """Adds git to the PATH. This is needed on Windows."""
649 path = core.decode(path)
650 path_entries = core.getenv('PATH', '').split(os.pathsep)
651 if path not in path_entries:
652 path_entries.insert(0, path)
653 compat.setenv('PATH', os.pathsep.join(path_entries))