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
12 Copyright (C) 2007-2017 David Aguilar and contributors
16 from qtpy
import QtCore
20 You do not seem to have PyQt5, PySide, or PyQt4 installed.
21 Please install it before using git-cola, e.g. on a Debian/Ubutnu system:
23 sudo apt-get install python-pyqt5 python-pyqt5.qtwebkit
29 from qtpy
import QtWidgets
30 from qtpy
.QtCore
import Qt
33 # Qt 5.12 / PyQt 5.13 is unable to use QtWebEngineWidgets unless it is
34 # imported before QApplication is constructed.
35 from qtpy
import QtWebEngineWidgets
# noqa
37 # QtWebEngineWidgets / QtWebKit is not available -- no big deal.
42 from .interaction
import Interaction
43 from .models
import main
44 from .models
import selection
45 from .widgets
import cfgactions
46 from .widgets
import standard
47 from .widgets
import startup
48 from .settings
import Session
52 from . import fsmonitor
59 from . import qtcompat
61 from . import resources
67 def setup_environment():
68 """Set environment variables to control git's behavior"""
69 # Allow Ctrl-C to exit
70 signal
.signal(signal
.SIGINT
, signal
.SIG_DFL
)
72 # Session management wants an absolute path when restarting
73 sys
.argv
[0] = sys_argv0
= os
.path
.abspath(sys
.argv
[0])
75 # Spoof an X11 display for SSH
76 os
.environ
.setdefault('DISPLAY', ':0')
78 if not core
.getenv('SHELL', ''):
79 for shell
in ('/bin/zsh', '/bin/bash', '/bin/sh'):
80 if os
.path
.exists(shell
):
81 compat
.setenv('SHELL', shell
)
84 # Setup the path so that git finds us when we run 'git cola'
85 path_entries
= core
.getenv('PATH', '').split(os
.pathsep
)
86 bindir
= core
.decode(os
.path
.dirname(sys_argv0
))
87 path_entries
.append(bindir
)
88 path
= os
.pathsep
.join(path_entries
)
89 compat
.setenv('PATH', path
)
91 # We don't ever want a pager
92 compat
.setenv('GIT_PAGER', '')
95 git_askpass
= core
.getenv('GIT_ASKPASS')
96 ssh_askpass
= core
.getenv('SSH_ASKPASS')
100 askpass
= ssh_askpass
101 elif sys
.platform
== 'darwin':
102 askpass
= resources
.share('bin', 'ssh-askpass-darwin')
104 askpass
= resources
.share('bin', 'ssh-askpass')
106 compat
.setenv('GIT_ASKPASS', askpass
)
107 compat
.setenv('SSH_ASKPASS', askpass
)
110 # Git v1.7.10 Release Notes
111 # =========================
113 # Compatibility Notes
114 # -------------------
116 # * From this release on, the "git merge" command in an interactive
117 # session will start an editor when it automatically resolves the
118 # merge for the user to explain the resulting commit, just like the
119 # "git commit" command does when it wasn't given a commit message.
121 # If you have a script that runs "git merge" and keeps its standard
122 # input and output attached to the user's terminal, and if you do not
123 # want the user to explain the resulting merge commits, you can
124 # export GIT_MERGE_AUTOEDIT environment variable set to "no", like
128 # GIT_MERGE_AUTOEDIT=no
129 # export GIT_MERGE_AUTOEDIT
131 # to disable this behavior (if you want your users to explain their
132 # merge commits, you do not have to do anything). Alternatively, you
133 # can give the "--no-edit" option to individual invocations of the
134 # "git merge" command if you know everybody who uses your script has
135 # Git v1.7.8 or newer.
137 # Longer-term: Use `git merge --no-commit` so that we always
138 # have a chance to explain our merges.
139 compat
.setenv('GIT_MERGE_AUTOEDIT', 'no')
141 # Gnome3 on Debian has XDG_SESSION_TYPE=wayland and
142 # XDG_CURRENT_DESKTOP=GNOME, which Qt warns about at startup:
144 # Warning: Ignoring XDG_SESSION_TYPE=wayland on Gnome.
145 # Use QT_QPA_PLATFORM=wayland to run on Wayland anyway.
147 # This annoying, so we silence the warning.
148 # We'll need to keep this hack here until a future version of Qt provides
149 # Qt Wayland widgets that are usable in gnome-shell.
150 # Cf. https://bugreports.qt.io/browse/QTBUG-68619
152 core
.getenv('XDG_CURRENT_DESKTOP', '') == 'GNOME'
153 and core
.getenv('XDG_SESSION_TYPE', '') == 'wayland'
155 compat
.unsetenv('XDG_SESSION_TYPE')
158 def get_icon_themes(context
):
159 """Return the default icon theme names"""
162 icon_themes_env
= core
.getenv('GIT_COLA_ICON_THEME')
164 result
.extend([x
for x
in icon_themes_env
.split(':') if x
])
166 icon_themes_cfg
= context
.cfg
.get_all('cola.icontheme')
168 result
.extend(icon_themes_cfg
)
171 result
.append('light')
176 # style note: we use camelCase here since we're masquerading a Qt class
177 class ColaApplication(object):
178 """The main cola application
180 ColaApplication handles i18n of user-visible data
183 def __init__(self
, context
, argv
, locale
=None, icon_themes
=None, gui_theme
=None):
189 icons
.install(icon_themes
or get_icon_themes(context
))
191 self
.context
= context
192 self
._install
_hidpi
_config
()
193 self
._app
= ColaQApplication(context
, list(argv
))
194 self
._app
.setWindowIcon(icons
.cola())
195 self
._install
_style
(gui_theme
)
197 def _install_style(self
, theme_str
):
198 """Generate and apply a stylesheet to the app"""
199 if theme_str
is None:
200 theme_str
= self
.context
.cfg
.get('cola.theme', default
='default')
201 theme
= themes
.find_theme(theme_str
)
202 self
._app
.setStyleSheet(theme
.build_style_sheet(self
._app
.palette()))
203 if theme_str
!= 'default':
204 self
._app
.setPalette(theme
.build_palette(self
._app
.palette()))
206 def _install_hidpi_config(self
):
207 """Sets QT HIDPI scalling (requires Qt 5.6)"""
208 value
= self
.context
.cfg
.get('cola.hidpi', default
=hidpi
.Option
.AUTO
)
209 hidpi
.apply_choice(value
)
211 def activeWindow(self
):
212 """QApplication::activeWindow() pass-through"""
213 return self
._app
.activeWindow()
216 """QApplication::desktop() pass-through"""
217 return self
._app
.desktop()
220 """Wrap exec_() and start the application"""
221 # Defer connection so that local cola.inotify is honored
222 context
= self
.context
223 monitor
= context
.fsmonitor
224 monitor
.files_changed
.connect(
225 cmds
.run(cmds
.Refresh
, context
), type=Qt
.QueuedConnection
227 monitor
.config_changed
.connect(
228 cmds
.run(cmds
.RefreshConfig
, context
), type=Qt
.QueuedConnection
230 # Start the filesystem monitor thread
232 return self
._app
.exec_()
235 """Finalize the application"""
236 self
.context
.fsmonitor
.stop()
237 # Workaround QTBUG-52988 by deleting the app manually to prevent a
238 # crash during app shutdown.
239 # https://bugreports.qt.io/browse/QTBUG-52988
242 except (AttributeError, RuntimeError):
246 def exit(self
, status
):
247 """QApplication::exit(status) pass-through"""
248 return self
._app
.exit(status
)
251 class ColaQApplication(QtWidgets
.QApplication
):
252 """QApplication implementation for handling custom events"""
254 def __init__(self
, context
, argv
):
255 super(ColaQApplication
, self
).__init
__(argv
)
256 self
.context
= context
257 # Make icons sharp in HiDPI screen
258 if hasattr(Qt
, 'AA_UseHighDpiPixmaps'):
259 self
.setAttribute(Qt
.AA_UseHighDpiPixmaps
, True)
262 """Respond to focus events for the cola.refreshonfocus feature"""
263 if e
.type() == QtCore
.QEvent
.ApplicationActivate
:
264 context
= self
.context
267 if context
.git
.is_valid() and cfg
.get(
268 'cola.refreshonfocus', default
=False
270 cmds
.do(cmds
.Refresh
, context
)
271 return super(ColaQApplication
, self
).event(e
)
273 def commitData(self
, session_mgr
):
274 """Save session data"""
275 if not self
.context
or not self
.context
.view
:
277 view
= self
.context
.view
278 if not hasattr(view
, 'save_state'):
280 sid
= session_mgr
.sessionId()
281 skey
= session_mgr
.sessionKey()
282 session_id
= '%s_%s' % (sid
, skey
)
283 session
= Session(session_id
, repo
=core
.getcwd())
284 view
.save_state(settings
=session
)
287 def process_args(args
):
288 """Process and verify command-line arguments"""
290 # Accept 'git cola --version' or 'git cola version'
291 version
.print_version()
292 sys
.exit(core
.EXIT_SUCCESS
)
294 # Handle session management
295 restore_session(args
)
297 # Bail out if --repo is not a directory
298 repo
= core
.decode(args
.repo
)
299 if repo
.startswith('file:'):
300 repo
= repo
[len('file:') :]
301 repo
= core
.realpath(repo
)
302 if not core
.isdir(repo
):
305 'fatal: "%s" is not a directory. '
306 'Please specify a correct --repo <path>.'
310 core
.print_stderr(errmsg
)
311 sys
.exit(core
.EXIT_USAGE
)
314 def restore_session(args
):
315 """Load a session based on the window-manager provided arguments"""
316 # args.settings is provided when restoring from a session.
318 if args
.session
is None:
320 session
= Session(args
.session
)
322 args
.settings
= session
323 args
.repo
= session
.repo
326 def application_init(args
, update
=False):
327 """Parses the command-line arguments and starts git-cola
329 # Ensure that we're working in a valid git repository.
330 # If not, try to find one. When found, chdir there.
334 context
= new_context(args
)
335 timer
= context
.timer
338 new_worktree(context
, args
.repo
, args
.prompt
, args
.settings
)
341 context
.model
.update_status()
345 timer
.display('init')
349 def new_context(args
):
350 """Create top-level ApplicationContext objects"""
351 context
= ApplicationContext(args
)
352 context
.git
= git
.create()
353 context
.cfg
= gitcfg
.create(context
)
354 context
.fsmonitor
= fsmonitor
.create(context
)
355 context
.selection
= selection
.create()
356 context
.model
= main
.create(context
)
357 context
.app
= new_application(context
, args
)
358 context
.timer
= Timer()
363 def application_run(context
, view
, start
=None, stop
=None):
364 """Run the application main loop"""
365 initialize_view(context
, view
)
369 # Start the event loop
370 result
= context
.app
.start()
379 def initialize_view(context
, view
):
380 """Register the main widget and display it"""
381 context
.set_view(view
)
383 if sys
.platform
== 'darwin':
387 def application_start(context
, view
):
388 """Show the GUI and start the main event loop"""
389 # Store the view for session management
390 return application_run(context
, view
, start
=default_start
, stop
=default_stop
)
393 def default_start(context
, _view
):
394 """Scan for the first time"""
395 QtCore
.QTimer
.singleShot(0, startup_message
)
396 QtCore
.QTimer
.singleShot(0, lambda: async_update(context
))
399 def default_stop(_context
, _view
):
400 """All done, cleanup"""
401 QtCore
.QThreadPool
.globalInstance().waitForDone()
404 def add_common_arguments(parser
):
405 """Add command arguments to the ArgumentParser"""
406 # We also accept 'git cola version'
408 '--version', default
=False, action
='store_true', help='print version number'
411 # Specifies a git repository to open
416 default
=core
.getcwd(),
417 help='open the specified git repository',
420 # Specifies that we should prompt for a repository at startup
422 '--prompt', action
='store_true', default
=False, help='prompt for a repository'
425 # Specify the icon theme
432 help='specify an icon theme (name or directory)',
435 # Resume an X Session Management session
437 '-session', metavar
='<session>', default
=None, help=argparse
.SUPPRESS
440 # Enable timing information
442 '--perf', action
='store_true', default
=False, help=argparse
.SUPPRESS
445 # Specify the GUI theme
447 '--theme', metavar
='<name>', default
=None, help='specify an GUI theme name'
451 def new_application(context
, args
):
452 """Create a new ColaApplication"""
453 return ColaApplication(
454 context
, sys
.argv
, icon_themes
=args
.icon_themes
, gui_theme
=args
.theme
458 def new_worktree(context
, repo
, prompt
, settings
):
459 """Find a Git repository, or prompt for one when not found"""
460 model
= context
.model
462 parent
= qtutils
.active_window()
466 valid
= model
.set_worktree(repo
)
468 # We are not currently in a git repository so we need to find one.
469 # Before prompting the user for a repository, check if they've
470 # configured a default repository and attempt to use it.
471 default_repo
= cfg
.get('cola.defaultrepo')
473 valid
= model
.set_worktree(default_repo
)
476 # If we've gotten into this loop then that means that neither the
477 # current directory nor the default repository were available.
478 # Prompt the user for a repository.
479 startup_dlg
= startup
.StartupDialog(context
, parent
, settings
=settings
)
480 gitdir
= startup_dlg
.find_git_repo()
482 sys
.exit(core
.EXIT_NOINPUT
)
483 valid
= model
.set_worktree(gitdir
)
486 def async_update(context
):
487 """Update the model in the background
489 git-cola should startup as quickly as possible.
492 update_status
= partial(context
.model
.update_status
, update_index
=True)
493 task
= qtutils
.SimpleTask(context
.view
, update_status
)
494 context
.runtask
.start(task
)
497 def startup_message():
498 """Print debug startup messages"""
499 trace
= git
.GIT_COLA_TRACE
500 if trace
in ('2', 'trace'):
501 msg1
= 'info: debug level 2: trace mode enabled'
502 msg2
= 'info: set GIT_COLA_TRACE=1 for less-verbose output'
503 Interaction
.log(msg1
)
504 Interaction
.log(msg2
)
506 msg1
= 'info: debug level 1'
507 msg2
= 'info: set GIT_COLA_TRACE=2 for trace mode'
508 Interaction
.log(msg1
)
509 Interaction
.log(msg2
)
513 """System-level initialization"""
514 # The current directory may have been deleted while we are still
515 # in that directory. We rectify this situation by walking up the
516 # directory tree and retrying.
518 # This is needed because because Python throws exceptions in lots of
519 # stdlib functions when in this situation, e.g. os.path.abspath() and
520 # os.path.realpath(), so it's simpler to mitigate the damage by changing
521 # the current directory to one that actually exists.
530 """Simple performance timer"""
535 def start(self
, key
):
538 self
._data
[key
] = [now
, now
]
541 """Stop a timer and return its elapsed time"""
542 entry
= self
._data
[key
]
543 entry
[1] = time
.time()
544 return self
.elapsed(key
)
546 def elapsed(self
, key
):
547 """Return the elapsed time for a timer"""
548 entry
= self
._data
[key
]
549 return entry
[1] - entry
[0]
551 def display(self
, key
):
552 """Display a timer"""
553 elapsed
= self
.elapsed(key
)
554 sys
.stdout
.write('%s: %.5fs\n' % (key
, elapsed
))
557 class ApplicationContext(object):
558 """Context for performing operations on Git and related data models"""
560 def __init__(self
, args
):
562 self
.app
= None # ColaApplication
563 self
.git
= None # git.Git
564 self
.cfg
= None # gitcfg.GitConfig
565 self
.model
= None # main.MainModel
566 self
.timer
= None # Timer
567 self
.runtask
= None # qtutils.RunTask
568 self
.selection
= None # selection.SelectionModel
569 self
.fsmonitor
= None # fsmonitor
570 self
.view
= None # QWidget
572 def set_view(self
, view
):
573 """Initialize view-specific members"""
575 self
.runtask
= qtutils
.RunTask(parent
=view
)
578 def winmain(main_fn
, *argv
):
579 """Find Git and launch main(argv)"""
580 git_path
= find_git()
582 prepend_path(git_path
)
583 return main_fn(*argv
)
587 """Return the path of git.exe, or None if we can't find it."""
588 if not utils
.is_win32():
589 return None # UNIX systems have git in their $PATH
591 # If the user wants to use a Git/bin/ directory from a non-standard
592 # directory then they can write its location into
593 # ~/.config/git-cola/git-bindir
594 git_bindir
= os
.path
.expanduser(
595 os
.path
.join('~', '.config', 'git-cola', 'git-bindir')
597 if core
.exists(git_bindir
):
598 custom_path
= core
.read(git_bindir
).strip()
599 if custom_path
and core
.exists(custom_path
):
602 # Try to find Git's bin/ directory in one of the typical locations
603 pf
= os
.environ
.get('ProgramFiles', 'C:\\Program Files')
604 pf32
= os
.environ
.get('ProgramFiles(x86)', 'C:\\Program Files (x86)')
605 pf64
= os
.environ
.get('ProgramW6432', 'C:\\Program Files')
606 for p
in [pf64
, pf32
, pf
, 'C:\\']:
607 candidate
= os
.path
.join(p
, 'Git\\bin')
608 if os
.path
.isdir(candidate
):
614 def prepend_path(path
):
615 """Adds git to the PATH. This is needed on Windows."""
616 path
= core
.decode(path
)
617 path_entries
= core
.getenv('PATH', '').split(os
.pathsep
)
618 if path
not in path_entries
:
619 path_entries
.insert(0, path
)
620 compat
.setenv('PATH', os
.pathsep
.join(path_entries
))