app: flesh out NullArgs for recent changes
[git-cola.git] / cola / app.py
blob2a961e5bbe3ec650cfa914dab8274e860243a91b
1 """Provides the main() routine and ColaApplication"""
2 # pylint: disable=unused-import
3 from functools import partial
4 import argparse
5 import os
6 import signal
7 import sys
8 import time
10 try:
11 from qtpy import QtCore
12 except ImportError as error:
13 sys.stderr.write(
14 """
15 Your Python environment does not have qtpy and PyQt (or PySide).
16 The following error was encountered when importing "qtpy":
18 ImportError: {err}
20 Install qtpy and PyQt (or PySide) into your Python environment.
21 On a Debian/Ubuntu system you can install these modules using apt:
23 sudo apt install python3-pyqt5 python3-pyqt5.qtwebengine python3-qtpy
25 """.format(
26 err=error
29 sys.exit(1)
31 from qtpy import QtWidgets
32 from qtpy.QtCore import Qt
34 try:
35 # Qt 5.12 / PyQt 5.13 is unable to use QtWebEngineWidgets unless it is
36 # imported before QApplication is constructed.
37 from qtpy import QtWebEngineWidgets # noqa
38 except ImportError:
39 # QtWebEngineWidgets / QtWebKit is not available -- no big deal.
40 pass
42 # Import cola modules
43 from .i18n import N_
44 from .interaction import Interaction
45 from .models import main
46 from .models import selection
47 from .widgets import cfgactions
48 from .widgets import standard
49 from .widgets import startup
50 from .settings import Session
51 from .settings import Settings
52 from . import cmds
53 from . import core
54 from . import compat
55 from . import fsmonitor
56 from . import git
57 from . import gitcfg
58 from . import guicmds
59 from . import hidpi
60 from . import icons
61 from . import i18n
62 from . import qtcompat
63 from . import qtutils
64 from . import resources
65 from . import themes
66 from . import utils
67 from . import version
70 def setup_environment():
71 """Set environment variables to control git's behavior"""
72 # Allow Ctrl-C to exit
73 signal.signal(signal.SIGINT, signal.SIG_DFL)
75 # Session management wants an absolute path when restarting
76 sys.argv[0] = sys_argv0 = os.path.abspath(sys.argv[0])
78 # Spoof an X11 display for SSH
79 os.environ.setdefault('DISPLAY', ':0')
81 if not core.getenv('SHELL', ''):
82 for shell in ('/bin/zsh', '/bin/bash', '/bin/sh'):
83 if os.path.exists(shell):
84 compat.setenv('SHELL', shell)
85 break
87 # Setup the path so that git finds us when we run 'git cola'
88 path_entries = core.getenv('PATH', '').split(os.pathsep)
89 bindir = core.decode(os.path.dirname(sys_argv0))
90 path_entries.append(bindir)
91 path = os.pathsep.join(path_entries)
92 compat.setenv('PATH', path)
94 # We don't ever want a pager
95 compat.setenv('GIT_PAGER', '')
97 # Setup *SSH_ASKPASS
98 git_askpass = core.getenv('GIT_ASKPASS')
99 ssh_askpass = core.getenv('SSH_ASKPASS')
100 if git_askpass:
101 askpass = git_askpass
102 elif ssh_askpass:
103 askpass = ssh_askpass
104 elif sys.platform == 'darwin':
105 askpass = resources.package_command('ssh-askpass-darwin')
106 else:
107 askpass = resources.package_command('ssh-askpass')
109 compat.setenv('GIT_ASKPASS', askpass)
110 compat.setenv('SSH_ASKPASS', askpass)
112 # --- >8 --- >8 ---
113 # Git v1.7.10 Release Notes
114 # =========================
116 # Compatibility Notes
117 # -------------------
119 # * From this release on, the "git merge" command in an interactive
120 # session will start an editor when it automatically resolves the
121 # merge for the user to explain the resulting commit, just like the
122 # "git commit" command does when it wasn't given a commit message.
124 # If you have a script that runs "git merge" and keeps its standard
125 # input and output attached to the user's terminal, and if you do not
126 # want the user to explain the resulting merge commits, you can
127 # export GIT_MERGE_AUTOEDIT environment variable set to "no", like
128 # this:
130 # #!/bin/sh
131 # GIT_MERGE_AUTOEDIT=no
132 # export GIT_MERGE_AUTOEDIT
134 # to disable this behavior (if you want your users to explain their
135 # merge commits, you do not have to do anything). Alternatively, you
136 # can give the "--no-edit" option to individual invocations of the
137 # "git merge" command if you know everybody who uses your script has
138 # Git v1.7.8 or newer.
139 # --- >8 --- >8 ---
140 # Longer-term: Use `git merge --no-commit` so that we always
141 # have a chance to explain our merges.
142 compat.setenv('GIT_MERGE_AUTOEDIT', 'no')
145 def get_icon_themes(context):
146 """Return the default icon theme names"""
147 result = []
149 icon_themes_env = core.getenv('GIT_COLA_ICON_THEME')
150 if icon_themes_env:
151 result.extend([x for x in icon_themes_env.split(':') if x])
153 icon_themes_cfg = list(reversed(context.cfg.get_all('cola.icontheme')))
154 if icon_themes_cfg:
155 result.extend(icon_themes_cfg)
157 if not result:
158 result.append('light')
160 return result
163 # style note: we use camelCase here since we're masquerading a Qt class
164 class ColaApplication:
165 """The main cola application
167 ColaApplication handles i18n of user-visible data
170 def __init__(self, context, argv, locale=None, icon_themes=None, gui_theme=None):
171 cfgactions.install()
172 i18n.install(locale)
173 qtcompat.install()
174 guicmds.install()
175 standard.install()
176 icons.install(icon_themes or get_icon_themes(context))
178 self.context = context
179 self.theme = None
180 self._install_hidpi_config()
181 self._app = ColaQApplication(context, list(argv))
182 self._app.setWindowIcon(icons.cola())
183 self._app.setDesktopFileName('git-cola')
184 self._install_style(gui_theme)
186 def _install_style(self, theme_str):
187 """Generate and apply a stylesheet to the app"""
188 if theme_str is None:
189 theme_str = self.context.cfg.get('cola.theme', default='default')
190 theme = themes.find_theme(theme_str)
191 self.theme = theme
192 self._app.setStyleSheet(theme.build_style_sheet(self._app.palette()))
194 is_macos_theme = theme_str.startswith('macos-')
195 if is_macos_theme:
196 themes.apply_platform_theme(theme_str)
197 elif theme_str != 'default':
198 self._app.setPalette(theme.build_palette(self._app.palette()))
200 def _install_hidpi_config(self):
201 """Sets QT HiDPI scaling (requires Qt 5.6)"""
202 value = self.context.cfg.get('cola.hidpi', default=hidpi.Option.AUTO)
203 hidpi.apply_choice(value)
205 def activeWindow(self):
206 """QApplication::activeWindow() pass-through"""
207 return self._app.activeWindow()
209 def palette(self):
210 """QApplication::palette() pass-through"""
211 return self._app.palette()
213 def start(self):
214 """Wrap exec_() and start the application"""
215 # Defer connection so that local cola.inotify is honored
216 context = self.context
217 monitor = context.fsmonitor
218 monitor.files_changed.connect(
219 cmds.run(cmds.Refresh, context), type=Qt.QueuedConnection
221 monitor.config_changed.connect(
222 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().__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() and cfg.get(
262 'cola.refreshonfocus', default=False
264 cmds.do(cmds.Refresh, context)
265 return super().event(e)
267 def commitData(self, session_mgr):
268 """Save session data"""
269 if not self.context or not self.context.view:
270 return
271 view = self.context.view
272 if not hasattr(view, 'save_state'):
273 return
274 sid = session_mgr.sessionId()
275 skey = session_mgr.sessionKey()
276 session_id = f'{sid}_{skey}'
277 session = Session(session_id, repo=core.getcwd())
278 session.update()
279 view.save_state(settings=session)
282 def process_args(args):
283 """Process and verify command-line arguments"""
284 if args.version:
285 # Accept 'git cola --version' or 'git cola version'
286 version.print_version()
287 sys.exit(core.EXIT_SUCCESS)
289 # Handle session management
290 restore_session(args)
292 # Bail out if --repo is not a directory
293 repo = core.decode(args.repo)
294 if repo.startswith('file:'):
295 repo = repo[len('file:') :]
296 repo = core.realpath(repo)
297 if not core.isdir(repo):
298 errmsg = (
300 'fatal: "%s" is not a directory. '
301 'Please specify a correct --repo <path>.'
303 % repo
305 core.print_stderr(errmsg)
306 sys.exit(core.EXIT_USAGE)
309 def restore_session(args):
310 """Load a session based on the window-manager provided arguments"""
311 # args.settings is provided when restoring from a session.
312 args.settings = None
313 if args.session is None:
314 return
315 session = Session(args.session)
316 if session.load():
317 args.settings = session
318 args.repo = session.repo
321 def application_init(args, update=False):
322 """Parses the command-line arguments and starts git-cola"""
323 # Ensure that we're working in a valid git repository.
324 # If not, try to find one. When found, chdir there.
325 setup_environment()
326 process_args(args)
328 context = new_context(args)
329 timer = context.timer
330 timer.start('init')
332 new_worktree(context, args.repo, args.prompt)
334 if update:
335 context.model.update_status()
337 timer.stop('init')
338 if args.perf:
339 timer.display('init')
340 return context
343 def new_context(args):
344 """Create top-level ApplicationContext objects"""
345 context = ApplicationContext(args)
346 context.settings = args.settings or Settings.read()
347 context.git = git.create()
348 context.cfg = gitcfg.create(context)
349 context.fsmonitor = fsmonitor.create(context)
350 context.selection = selection.create()
351 context.model = main.create(context)
352 context.app = new_application(context, args)
353 context.timer = Timer()
355 return context
358 def application_run(context, view, start=None, stop=None):
359 """Run the application main loop"""
360 initialize_view(context, view)
361 # Startup callbacks
362 if start:
363 start(context, view)
364 # Start the event loop
365 result = context.app.start()
366 # Finish
367 if stop:
368 stop(context, view)
369 context.app.stop()
371 return result
374 def initialize_view(context, view):
375 """Register the main widget and display it"""
376 context.set_view(view)
377 view.show()
378 if sys.platform == 'darwin':
379 view.raise_()
382 def application_start(context, view):
383 """Show the GUI and start the main event loop"""
384 # Store the view for session management
385 return application_run(context, view, start=default_start, stop=default_stop)
388 def default_start(context, _view):
389 """Scan for the first time"""
390 QtCore.QTimer.singleShot(0, startup_message)
391 QtCore.QTimer.singleShot(0, lambda: async_update(context))
394 def default_stop(_context, _view):
395 """All done, cleanup"""
396 QtCore.QThreadPool.globalInstance().waitForDone()
399 def add_common_arguments(parser):
400 """Add command arguments to the ArgumentParser"""
401 # We also accept 'git cola version'
402 parser.add_argument(
403 '--version', default=False, action='store_true', help='print version number'
406 # Specifies a git repository to open
407 parser.add_argument(
408 '-r',
409 '--repo',
410 metavar='<repo>',
411 default=core.getcwd(),
412 help='open the specified git repository',
415 # Specifies that we should prompt for a repository at startup
416 parser.add_argument(
417 '--prompt', action='store_true', default=False, help='prompt for a repository'
420 # Specify the icon theme
421 parser.add_argument(
422 '--icon-theme',
423 metavar='<theme>',
424 dest='icon_themes',
425 action='append',
426 default=[],
427 help='specify an icon theme (name or directory)',
430 # Resume an X Session Management session
431 parser.add_argument(
432 '-session', metavar='<session>', default=None, help=argparse.SUPPRESS
435 # Enable timing information
436 parser.add_argument(
437 '--perf', action='store_true', default=False, help=argparse.SUPPRESS
440 # Specify the GUI theme
441 parser.add_argument(
442 '--theme', metavar='<name>', default=None, help='specify an GUI theme name'
446 def new_application(context, args):
447 """Create a new ColaApplication"""
448 return ColaApplication(
449 context, sys.argv, icon_themes=args.icon_themes, gui_theme=args.theme
453 def new_worktree(context, repo, prompt):
454 """Find a Git repository, or prompt for one when not found"""
455 model = context.model
456 cfg = context.cfg
457 parent = qtutils.active_window()
458 valid = False
460 if not prompt:
461 valid = model.set_worktree(repo)
462 if not valid:
463 # We are not currently in a git repository so we need to find one.
464 # Before prompting the user for a repository, check if they've
465 # configured a default repository and attempt to use it.
466 default_repo = cfg.get('cola.defaultrepo')
467 if default_repo:
468 valid = model.set_worktree(default_repo)
470 while not valid:
471 # If we've gotten into this loop then that means that neither the
472 # current directory nor the default repository were available.
473 # Prompt the user for a repository.
474 startup_dlg = startup.StartupDialog(context, parent)
475 gitdir = startup_dlg.find_git_repo()
476 if not gitdir:
477 sys.exit(core.EXIT_NOINPUT)
479 if not core.exists(os.path.join(gitdir, '.git')):
480 offer_to_create_repo(context, gitdir)
481 valid = model.set_worktree(gitdir)
482 continue
484 valid = model.set_worktree(gitdir)
485 if not valid:
486 err = model.error
487 standard.critical(
488 N_('Error Opening Repository'),
489 message=N_('Could not open %s.' % gitdir),
490 details=err,
494 def offer_to_create_repo(context, gitdir):
495 """Offer to create a new repo"""
496 title = N_('Repository Not Found')
497 text = N_('%s is not a Git repository.') % gitdir
498 informative_text = N_('Create a new repository at that location?')
499 if standard.confirm(title, text, informative_text, N_('Create')):
500 status, out, err = context.git.init(gitdir)
501 title = N_('Error Creating Repository')
502 if status != 0:
503 Interaction.command_error(title, 'git init', status, out, err)
506 def async_update(context):
507 """Update the model in the background
509 git-cola should startup as quickly as possible.
511 update_status = partial(context.model.update_status, update_index=True)
512 task = qtutils.SimpleTask(update_status)
513 context.runtask.start(task)
516 def startup_message():
517 """Print debug startup messages"""
518 trace = git.GIT_COLA_TRACE
519 if trace in ('2', 'trace'):
520 msg1 = 'info: debug level 2: trace mode enabled'
521 msg2 = 'info: set GIT_COLA_TRACE=1 for less-verbose output'
522 Interaction.log(msg1)
523 Interaction.log(msg2)
524 elif trace:
525 msg1 = 'info: debug level 1'
526 msg2 = 'info: set GIT_COLA_TRACE=2 for trace mode'
527 Interaction.log(msg1)
528 Interaction.log(msg2)
531 def initialize():
532 """System-level initialization"""
533 # We support ~/.config/git-cola/git-bindir on Windows for configuring
534 # a custom location for finding the "git" executable.
535 git_path = find_git()
536 if git_path:
537 prepend_path(git_path)
539 # The current directory may have been deleted while we are still
540 # in that directory. We rectify this situation by walking up the
541 # directory tree and retrying.
543 # This is needed because because Python throws exceptions in lots of
544 # stdlib functions when in this situation, e.g. os.path.abspath() and
545 # os.path.realpath(), so it's simpler to mitigate the damage by changing
546 # the current directory to one that actually exists.
547 while True:
548 try:
549 return core.getcwd()
550 except OSError:
551 os.chdir('..')
554 class Timer:
555 """Simple performance timer"""
557 def __init__(self):
558 self._data = {}
560 def start(self, key):
561 """Start a timer"""
562 now = time.time()
563 self._data[key] = [now, now]
565 def stop(self, key):
566 """Stop a timer and return its elapsed time"""
567 entry = self._data[key]
568 entry[1] = time.time()
569 return self.elapsed(key)
571 def elapsed(self, key):
572 """Return the elapsed time for a timer"""
573 entry = self._data[key]
574 return entry[1] - entry[0]
576 def display(self, key):
577 """Display a timer"""
578 elapsed = self.elapsed(key)
579 sys.stdout.write(f'{key}: {elapsed:.5f}s\n')
582 class NullArgs:
583 """Stub arguments for interactive API use"""
585 def __init__(self):
586 self.icon_themes = []
587 self.perf = False
588 self.prompt = False
589 self.repo = core.getcwd()
590 self.session = None
591 self.settings = None
592 self.theme = None
593 self.version = False
596 def null_args():
597 """Create a new instance of application arguments"""
598 return NullArgs()
601 class ApplicationContext:
602 """Context for performing operations on Git and related data models"""
604 def __init__(self, args):
605 self.args = args
606 self.app = None # ColaApplication
607 self.git = None # git.Git
608 self.cfg = None # gitcfg.GitConfig
609 self.model = None # main.MainModel
610 self.timer = None # Timer
611 self.runtask = None # qtutils.RunTask
612 self.settings = None # settings.Settings
613 self.selection = None # selection.SelectionModel
614 self.fsmonitor = None # fsmonitor
615 self.view = None # QWidget
616 self.browser_windows = [] # list of browse.Browser
618 def set_view(self, view):
619 """Initialize view-specific members"""
620 self.view = view
621 self.runtask = qtutils.RunTask(parent=view)
624 def find_git():
625 """Return the path of git.exe, or None if we can't find it."""
626 if not utils.is_win32():
627 return None # UNIX systems have git in their $PATH
629 # If the user wants to use a Git/bin/ directory from a non-standard
630 # directory then they can write its location into
631 # ~/.config/git-cola/git-bindir
632 git_bindir = resources.config_home('git-bindir')
633 if core.exists(git_bindir):
634 custom_path = core.read(git_bindir).strip()
635 if custom_path and core.exists(custom_path):
636 return custom_path
638 # Try to find Git's bin/ directory in one of the typical locations
639 pf = os.environ.get('ProgramFiles', 'C:\\Program Files')
640 pf32 = os.environ.get('ProgramFiles(x86)', 'C:\\Program Files (x86)')
641 pf64 = os.environ.get('ProgramW6432', 'C:\\Program Files')
642 for p in [pf64, pf32, pf, 'C:\\']:
643 candidate = os.path.join(p, 'Git\\bin')
644 if os.path.isdir(candidate):
645 return candidate
647 return None
650 def prepend_path(path):
651 """Adds git to the PATH. This is needed on Windows."""
652 path = core.decode(path)
653 path_entries = core.getenv('PATH', '').split(os.pathsep)
654 if path not in path_entries:
655 path_entries.insert(0, path)
656 compat.setenv('PATH', os.pathsep.join(path_entries))