push: set the remote branch field when selecting a local branch
[git-cola.git] / cola / app.py
blob8360e46a4ecee238005d214cb5fde775cd7f38cd
1 """Provides the main() routine and ColaApplication"""
2 from functools import partial
3 import argparse
4 import os
5 import signal
6 import sys
7 import time
9 try:
10 from qtpy import QtCore
11 except ImportError as error:
12 sys.stderr.write(
13 """
14 Your Python environment does not have qtpy and PyQt (or PySide).
15 The following error was encountered when importing "qtpy":
17 ImportError: {err}
19 Install qtpy and PyQt (or PySide) into your Python environment.
20 On a Debian/Ubuntu system you can install these modules using apt:
22 sudo apt install python3-pyqt5 python3-pyqt5.qtwebengine python3-qtpy
24 """.format(
25 err=error
28 sys.exit(1)
30 from qtpy import QtWidgets
31 from qtpy.QtCore import Qt
33 try:
34 # Qt 5.12 / PyQt 5.13 is unable to use QtWebEngineWidgets unless it is
35 # imported before QApplication is constructed.
36 from qtpy import QtWebEngineWidgets # noqa
37 except ImportError:
38 # QtWebEngineWidgets / QtWebKit is not available -- no big deal.
39 pass
41 # Import cola modules
42 from .i18n import N_
43 from .interaction import Interaction
44 from .models import main
45 from .models import selection
46 from .widgets import cfgactions
47 from .widgets import standard
48 from .widgets import startup
49 from .settings import Session
50 from .settings import Settings
51 from . import cmds
52 from . import core
53 from . import compat
54 from . import fsmonitor
55 from . import git
56 from . import gitcfg
57 from . import guicmds
58 from . import hidpi
59 from . import icons
60 from . import i18n
61 from . import qtcompat
62 from . import qtutils
63 from . import resources
64 from . import themes
65 from . import utils
66 from . import version
69 def setup_environment():
70 """Set environment variables to control git's behavior"""
71 # Allow Ctrl-C to exit
72 signal.signal(signal.SIGINT, signal.SIG_DFL)
74 # Session management wants an absolute path when restarting
75 sys.argv[0] = sys_argv0 = os.path.abspath(sys.argv[0])
77 # Spoof an X11 display for SSH
78 os.environ.setdefault('DISPLAY', ':0')
80 if not core.getenv('SHELL', ''):
81 for shell in ('/bin/zsh', '/bin/bash', '/bin/sh'):
82 if os.path.exists(shell):
83 compat.setenv('SHELL', shell)
84 break
86 # Setup the path so that git finds us when we run 'git cola'
87 path_entries = core.getenv('PATH', '').split(os.pathsep)
88 bindir = core.decode(os.path.dirname(sys_argv0))
89 path_entries.append(bindir)
90 path = os.pathsep.join(path_entries)
91 compat.setenv('PATH', path)
93 # We don't ever want a pager
94 compat.setenv('GIT_PAGER', '')
96 # Setup *SSH_ASKPASS
97 git_askpass = core.getenv('GIT_ASKPASS')
98 ssh_askpass = core.getenv('SSH_ASKPASS')
99 if git_askpass:
100 askpass = git_askpass
101 elif ssh_askpass:
102 askpass = ssh_askpass
103 elif sys.platform == 'darwin':
104 askpass = resources.package_command('ssh-askpass-darwin')
105 else:
106 askpass = resources.package_command('ssh-askpass')
108 compat.setenv('GIT_ASKPASS', askpass)
109 compat.setenv('SSH_ASKPASS', askpass)
111 # --- >8 --- >8 ---
112 # Git v1.7.10 Release Notes
113 # =========================
115 # Compatibility Notes
116 # -------------------
118 # * From this release on, the "git merge" command in an interactive
119 # session will start an editor when it automatically resolves the
120 # merge for the user to explain the resulting commit, just like the
121 # "git commit" command does when it wasn't given a commit message.
123 # If you have a script that runs "git merge" and keeps its standard
124 # input and output attached to the user's terminal, and if you do not
125 # want the user to explain the resulting merge commits, you can
126 # export GIT_MERGE_AUTOEDIT environment variable set to "no", like
127 # this:
129 # #!/bin/sh
130 # GIT_MERGE_AUTOEDIT=no
131 # export GIT_MERGE_AUTOEDIT
133 # to disable this behavior (if you want your users to explain their
134 # merge commits, you do not have to do anything). Alternatively, you
135 # can give the "--no-edit" option to individual invocations of the
136 # "git merge" command if you know everybody who uses your script has
137 # Git v1.7.8 or newer.
138 # --- >8 --- >8 ---
139 # Longer-term: Use `git merge --no-commit` so that we always
140 # have a chance to explain our merges.
141 compat.setenv('GIT_MERGE_AUTOEDIT', 'no')
144 def get_icon_themes(context):
145 """Return the default icon theme names"""
146 result = []
148 icon_themes_env = core.getenv('GIT_COLA_ICON_THEME')
149 if icon_themes_env:
150 result.extend([x for x in icon_themes_env.split(':') if x])
152 icon_themes_cfg = list(reversed(context.cfg.get_all('cola.icontheme')))
153 if icon_themes_cfg:
154 result.extend(icon_themes_cfg)
156 if not result:
157 result.append('light')
159 return result
162 # style note: we use camelCase here since we're masquerading a Qt class
163 class ColaApplication:
164 """The main cola application
166 ColaApplication handles i18n of user-visible data
169 def __init__(self, context, argv, locale=None, icon_themes=None, gui_theme=None):
170 cfgactions.install()
171 i18n.install(locale)
172 qtcompat.install()
173 guicmds.install()
174 standard.install()
175 icons.install(icon_themes or get_icon_themes(context))
177 self.context = context
178 self.theme = None
179 self._install_hidpi_config()
180 self._app = ColaQApplication(context, list(argv))
181 self._app.setWindowIcon(icons.cola())
182 self._app.setDesktopFileName('git-cola')
183 self._install_style(gui_theme)
185 def _install_style(self, theme_str):
186 """Generate and apply a stylesheet to the app"""
187 if theme_str is None:
188 theme_str = self.context.cfg.get('cola.theme', default='default')
189 theme = themes.find_theme(theme_str)
190 self.theme = theme
191 self._app.setStyleSheet(theme.build_style_sheet(self._app.palette()))
193 is_macos_theme = theme_str.startswith('macos-')
194 if is_macos_theme:
195 themes.apply_platform_theme(theme_str)
196 elif theme_str != 'default':
197 self._app.setPalette(theme.build_palette(self._app.palette()))
199 def _install_hidpi_config(self):
200 """Sets QT HiDPI scaling (requires Qt 5.6)"""
201 value = self.context.cfg.get('cola.hidpi', default=hidpi.Option.AUTO)
202 hidpi.apply_choice(value)
204 def activeWindow(self):
205 """QApplication::activeWindow() pass-through"""
206 return self._app.activeWindow()
208 def palette(self):
209 """QApplication::palette() pass-through"""
210 return self._app.palette()
212 def start(self):
213 """Wrap exec_() and start the application"""
214 # Defer connection so that local cola.inotify is honored
215 context = self.context
216 monitor = context.fsmonitor
217 monitor.files_changed.connect(
218 cmds.run(cmds.Refresh, context), type=Qt.QueuedConnection
220 monitor.config_changed.connect(
221 cmds.run(cmds.RefreshConfig, context), type=Qt.QueuedConnection
223 # Start the filesystem monitor thread
224 monitor.start()
225 return self._app.exec_()
227 def stop(self):
228 """Finalize the application"""
229 self.context.fsmonitor.stop()
230 # Workaround QTBUG-52988 by deleting the app manually to prevent a
231 # crash during app shutdown.
232 # https://bugreports.qt.io/browse/QTBUG-52988
233 try:
234 del self._app
235 except (AttributeError, RuntimeError):
236 pass
237 self._app = None
239 def exit(self, status):
240 """QApplication::exit(status) pass-through"""
241 return self._app.exit(status)
244 class ColaQApplication(QtWidgets.QApplication):
245 """QApplication implementation for handling custom events"""
247 def __init__(self, context, argv):
248 super().__init__(argv)
249 self.context = context
250 # Make icons sharp in HiDPI screen
251 if hasattr(Qt, 'AA_UseHighDpiPixmaps'):
252 self.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
254 def event(self, e):
255 """Respond to focus events for the cola.refreshonfocus feature"""
256 if e.type() == QtCore.QEvent.ApplicationActivate:
257 context = self.context
258 if context:
259 cfg = context.cfg
260 if context.git.is_valid() and cfg.get(
261 'cola.refreshonfocus', default=False
263 cmds.do(cmds.Refresh, context)
264 return super().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 = f'{sid}_{skey}'
276 session = Session(session_id, repo=core.getcwd())
277 session.update()
278 view.save_state(settings=session)
281 def process_args(args):
282 """Process and verify command-line arguments"""
283 if args.version:
284 # Accept 'git cola --version' or 'git cola version'
285 version.print_version()
286 sys.exit(core.EXIT_SUCCESS)
288 # Handle session management
289 restore_session(args)
291 # Bail out if --repo is not a directory
292 repo = core.decode(args.repo)
293 if repo.startswith('file:'):
294 repo = repo[len('file:') :]
295 repo = core.realpath(repo)
296 if not core.isdir(repo):
297 errmsg = (
299 'fatal: "%s" is not a directory. '
300 'Please specify a correct --repo <path>.'
302 % repo
304 core.print_stderr(errmsg)
305 sys.exit(core.EXIT_USAGE)
308 def restore_session(args):
309 """Load a session based on the window-manager provided arguments"""
310 # args.settings is provided when restoring from a session.
311 args.settings = None
312 if args.session is None:
313 return
314 session = Session(args.session)
315 if session.load():
316 args.settings = session
317 args.repo = session.repo
320 def application_init(args, update=False):
321 """Parses the command-line arguments and starts git-cola"""
322 # Ensure that we're working in a valid git repository.
323 # If not, try to find one. When found, chdir there.
324 setup_environment()
325 process_args(args)
327 context = new_context(args)
328 timer = context.timer
329 timer.start('init')
331 new_worktree(context, args.repo, args.prompt)
333 if update:
334 context.model.update_status()
336 timer.stop('init')
337 if args.perf:
338 timer.display('init')
339 return context
342 def new_context(args):
343 """Create top-level ApplicationContext objects"""
344 context = ApplicationContext(args)
345 context.settings = args.settings or Settings.read()
346 context.git = git.create()
347 context.cfg = gitcfg.create(context)
348 context.fsmonitor = fsmonitor.create(context)
349 context.selection = selection.create()
350 context.model = main.create(context)
351 context.app = new_application(context, args)
352 context.timer = Timer()
354 return context
357 def application_run(context, view, start=None, stop=None):
358 """Run the application main loop"""
359 initialize_view(context, view)
360 # Startup callbacks
361 if start:
362 start(context, view)
363 # Start the event loop
364 result = context.app.start()
365 # Finish
366 if stop:
367 stop(context, view)
368 context.app.stop()
370 return result
373 def initialize_view(context, view):
374 """Register the main widget and display it"""
375 context.set_view(view)
376 view.show()
377 if sys.platform == 'darwin':
378 view.raise_()
381 def application_start(context, view):
382 """Show the GUI and start the main event loop"""
383 # Store the view for session management
384 return application_run(context, view, start=default_start, stop=default_stop)
387 def default_start(context, _view):
388 """Scan for the first time"""
389 QtCore.QTimer.singleShot(0, startup_message)
390 QtCore.QTimer.singleShot(0, lambda: async_update(context))
393 def default_stop(_context, _view):
394 """All done, cleanup"""
395 QtCore.QThreadPool.globalInstance().waitForDone()
398 def add_common_arguments(parser):
399 """Add command arguments to the ArgumentParser"""
400 # We also accept 'git cola version'
401 parser.add_argument(
402 '--version', default=False, action='store_true', help='print version number'
405 # Specifies a git repository to open
406 parser.add_argument(
407 '-r',
408 '--repo',
409 metavar='<repo>',
410 default=core.getcwd(),
411 help='open the specified git repository',
414 # Specifies that we should prompt for a repository at startup
415 parser.add_argument(
416 '--prompt', action='store_true', default=False, help='prompt for a repository'
419 # Specify the icon theme
420 parser.add_argument(
421 '--icon-theme',
422 metavar='<theme>',
423 dest='icon_themes',
424 action='append',
425 default=[],
426 help='specify an icon theme (name or directory)',
429 # Resume an X Session Management session
430 parser.add_argument(
431 '-session', metavar='<session>', default=None, help=argparse.SUPPRESS
434 # Enable timing information
435 parser.add_argument(
436 '--perf', action='store_true', default=False, help=argparse.SUPPRESS
439 # Specify the GUI theme
440 parser.add_argument(
441 '--theme', metavar='<name>', default=None, help='specify an GUI theme name'
445 def new_application(context, args):
446 """Create a new ColaApplication"""
447 return ColaApplication(
448 context, sys.argv, icon_themes=args.icon_themes, gui_theme=args.theme
452 def new_worktree(context, repo, prompt):
453 """Find a Git repository, or prompt for one when not found"""
454 model = context.model
455 cfg = context.cfg
456 parent = qtutils.active_window()
457 valid = False
459 if not prompt:
460 valid = model.set_worktree(repo)
461 if not valid:
462 # We are not currently in a git repository so we need to find one.
463 # Before prompting the user for a repository, check if they've
464 # configured a default repository and attempt to use it.
465 default_repo = cfg.get('cola.defaultrepo')
466 if default_repo:
467 valid = model.set_worktree(default_repo)
469 while not valid:
470 # If we've gotten into this loop then that means that neither the
471 # current directory nor the default repository were available.
472 # Prompt the user for a repository.
473 startup_dlg = startup.StartupDialog(context, parent)
474 gitdir = startup_dlg.find_git_repo()
475 if not gitdir:
476 sys.exit(core.EXIT_NOINPUT)
478 if not core.exists(os.path.join(gitdir, '.git')):
479 offer_to_create_repo(context, gitdir)
480 valid = model.set_worktree(gitdir)
481 continue
483 valid = model.set_worktree(gitdir)
484 if not valid:
485 err = model.error
486 standard.critical(
487 N_('Error Opening Repository'),
488 message=N_('Could not open %s.' % gitdir),
489 details=err,
493 def offer_to_create_repo(context, gitdir):
494 """Offer to create a new repo"""
495 title = N_('Repository Not Found')
496 text = N_('%s is not a Git repository.') % gitdir
497 informative_text = N_('Create a new repository at that location?')
498 if standard.confirm(title, text, informative_text, N_('Create')):
499 status, out, err = context.git.init(gitdir)
500 title = N_('Error Creating Repository')
501 if status != 0:
502 Interaction.command_error(title, 'git init', status, out, err)
505 def async_update(context):
506 """Update the model in the background
508 git-cola should startup as quickly as possible.
510 update_status = partial(context.model.update_status, update_index=True)
511 task = qtutils.SimpleTask(update_status)
512 context.runtask.start(task)
515 def startup_message():
516 """Print debug startup messages"""
517 trace = git.GIT_COLA_TRACE
518 if trace in ('2', 'trace'):
519 msg1 = 'info: debug level 2: trace mode enabled'
520 msg2 = 'info: set GIT_COLA_TRACE=1 for less-verbose output'
521 Interaction.log(msg1)
522 Interaction.log(msg2)
523 elif trace:
524 msg1 = 'info: debug level 1'
525 msg2 = 'info: set GIT_COLA_TRACE=2 for trace mode'
526 Interaction.log(msg1)
527 Interaction.log(msg2)
530 def initialize():
531 """System-level initialization"""
532 # We support ~/.config/git-cola/git-bindir on Windows for configuring
533 # a custom location for finding the "git" executable.
534 git_path = find_git()
535 if git_path:
536 prepend_path(git_path)
538 # The current directory may have been deleted while we are still
539 # in that directory. We rectify this situation by walking up the
540 # directory tree and retrying.
542 # This is needed because because Python throws exceptions in lots of
543 # stdlib functions when in this situation, e.g. os.path.abspath() and
544 # os.path.realpath(), so it's simpler to mitigate the damage by changing
545 # the current directory to one that actually exists.
546 while True:
547 try:
548 return core.getcwd()
549 except OSError:
550 os.chdir('..')
553 class Timer:
554 """Simple performance timer"""
556 def __init__(self):
557 self._data = {}
559 def start(self, key):
560 """Start a timer"""
561 now = time.time()
562 self._data[key] = [now, now]
564 def stop(self, key):
565 """Stop a timer and return its elapsed time"""
566 entry = self._data[key]
567 entry[1] = time.time()
568 return self.elapsed(key)
570 def elapsed(self, key):
571 """Return the elapsed time for a timer"""
572 entry = self._data[key]
573 return entry[1] - entry[0]
575 def display(self, key):
576 """Display a timer"""
577 elapsed = self.elapsed(key)
578 sys.stdout.write(f'{key}: {elapsed:.5f}s\n')
581 class NullArgs:
582 """Stub arguments for interactive API use"""
584 def __init__(self):
585 self.icon_themes = []
586 self.perf = False
587 self.prompt = False
588 self.repo = core.getcwd()
589 self.session = None
590 self.settings = None
591 self.theme = None
592 self.version = False
595 def null_args():
596 """Create a new instance of application arguments"""
597 return NullArgs()
600 class ApplicationContext:
601 """Context for performing operations on Git and related data models"""
603 def __init__(self, args):
604 self.args = args
605 self.app = None # ColaApplication
606 self.git = None # git.Git
607 self.cfg = None # gitcfg.GitConfig
608 self.model = None # main.MainModel
609 self.timer = None # Timer
610 self.runtask = None # qtutils.RunTask
611 self.settings = None # settings.Settings
612 self.selection = None # selection.SelectionModel
613 self.fsmonitor = None # fsmonitor
614 self.view = None # QWidget
615 self.browser_windows = [] # list of browse.Browser
617 def set_view(self, view):
618 """Initialize view-specific members"""
619 self.view = view
620 self.runtask = qtutils.RunTask(parent=view)
623 def find_git():
624 """Return the path of git.exe, or None if we can't find it."""
625 if not utils.is_win32():
626 return None # UNIX systems have git in their $PATH
628 # If the user wants to use a Git/bin/ directory from a non-standard
629 # directory then they can write its location into
630 # ~/.config/git-cola/git-bindir
631 git_bindir = resources.config_home('git-bindir')
632 if core.exists(git_bindir):
633 custom_path = core.read(git_bindir).strip()
634 if custom_path and core.exists(custom_path):
635 return custom_path
637 # Try to find Git's bin/ directory in one of the typical locations
638 pf = os.environ.get('ProgramFiles', 'C:\\Program Files')
639 pf32 = os.environ.get('ProgramFiles(x86)', 'C:\\Program Files (x86)')
640 pf64 = os.environ.get('ProgramW6432', 'C:\\Program Files')
641 for p in [pf64, pf32, pf, 'C:\\']:
642 candidate = os.path.join(p, 'Git\\bin')
643 if os.path.isdir(candidate):
644 return candidate
646 return None
649 def prepend_path(path):
650 """Adds git to the PATH. This is needed on Windows."""
651 path = core.decode(path)
652 path_entries = core.getenv('PATH', '').split(os.pathsep)
653 if path not in path_entries:
654 path_entries.insert(0, path)
655 compat.setenv('PATH', os.pathsep.join(path_entries))