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
12 Copyright (C) 2007-2022 David Aguilar and contributors
16 from qtpy
import QtCore
17 except ImportError as error
:
20 Your Python environment does not have qtpy and PyQt (or PySide).
21 The following error was encountered when importing "qtpy":
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
36 from qtpy
import QtGui
37 from qtpy
import QtWidgets
38 from qtpy
.QtCore
import Qt
41 # Qt 5.12 / PyQt 5.13 is unable to use QtWebEngineWidgets unless it is
42 # imported before QApplication is constructed.
43 from qtpy
import QtWebEngineWidgets
# noqa
45 # QtWebEngineWidgets / QtWebKit is not available -- no big deal.
50 from .interaction
import Interaction
51 from .models
import main
52 from .models
import selection
53 from .widgets
import cfgactions
54 from .widgets
import standard
55 from .widgets
import startup
56 from .settings
import Session
57 from .settings
import Settings
61 from . import fsmonitor
68 from . import qtcompat
70 from . import resources
76 def setup_environment():
77 """Set environment variables to control git's behavior"""
78 # Allow Ctrl-C to exit
79 signal
.signal(signal
.SIGINT
, signal
.SIG_DFL
)
81 # Session management wants an absolute path when restarting
82 sys
.argv
[0] = sys_argv0
= os
.path
.abspath(sys
.argv
[0])
84 # Spoof an X11 display for SSH
85 os
.environ
.setdefault('DISPLAY', ':0')
87 if not core
.getenv('SHELL', ''):
88 for shell
in ('/bin/zsh', '/bin/bash', '/bin/sh'):
89 if os
.path
.exists(shell
):
90 compat
.setenv('SHELL', shell
)
93 # Setup the path so that git finds us when we run 'git cola'
94 path_entries
= core
.getenv('PATH', '').split(os
.pathsep
)
95 bindir
= core
.decode(os
.path
.dirname(sys_argv0
))
96 path_entries
.append(bindir
)
97 path
= os
.pathsep
.join(path_entries
)
98 compat
.setenv('PATH', path
)
100 # We don't ever want a pager
101 compat
.setenv('GIT_PAGER', '')
104 git_askpass
= core
.getenv('GIT_ASKPASS')
105 ssh_askpass
= core
.getenv('SSH_ASKPASS')
107 askpass
= git_askpass
109 askpass
= ssh_askpass
110 elif sys
.platform
== 'darwin':
111 askpass
= resources
.package_command('ssh-askpass-darwin')
113 askpass
= resources
.package_command('ssh-askpass')
115 compat
.setenv('GIT_ASKPASS', askpass
)
116 compat
.setenv('SSH_ASKPASS', askpass
)
119 # Git v1.7.10 Release Notes
120 # =========================
122 # Compatibility Notes
123 # -------------------
125 # * From this release on, the "git merge" command in an interactive
126 # session will start an editor when it automatically resolves the
127 # merge for the user to explain the resulting commit, just like the
128 # "git commit" command does when it wasn't given a commit message.
130 # If you have a script that runs "git merge" and keeps its standard
131 # input and output attached to the user's terminal, and if you do not
132 # want the user to explain the resulting merge commits, you can
133 # export GIT_MERGE_AUTOEDIT environment variable set to "no", like
137 # GIT_MERGE_AUTOEDIT=no
138 # export GIT_MERGE_AUTOEDIT
140 # to disable this behavior (if you want your users to explain their
141 # merge commits, you do not have to do anything). Alternatively, you
142 # can give the "--no-edit" option to individual invocations of the
143 # "git merge" command if you know everybody who uses your script has
144 # Git v1.7.8 or newer.
146 # Longer-term: Use `git merge --no-commit` so that we always
147 # have a chance to explain our merges.
148 compat
.setenv('GIT_MERGE_AUTOEDIT', 'no')
151 def get_icon_themes(context
):
152 """Return the default icon theme names"""
155 icon_themes_env
= core
.getenv('GIT_COLA_ICON_THEME')
157 result
.extend([x
for x
in icon_themes_env
.split(':') if x
])
159 icon_themes_cfg
= list(reversed(context
.cfg
.get_all('cola.icontheme')))
161 result
.extend(icon_themes_cfg
)
164 result
.append('light')
169 # style note: we use camelCase here since we're masquerading a Qt class
170 class ColaApplication(object):
171 """The main cola application
173 ColaApplication handles i18n of user-visible data
176 def __init__(self
, context
, argv
, locale
=None, icon_themes
=None, gui_theme
=None):
182 icons
.install(icon_themes
or get_icon_themes(context
))
184 self
.context
= context
186 self
._set
_rendering
_options
()
187 self
._install
_hidpi
_config
()
188 self
._app
= ColaQApplication(context
, list(argv
))
189 self
._app
.setWindowIcon(icons
.cola())
190 self
._app
.setDesktopFileName('git-cola')
191 self
._install
_style
(gui_theme
)
193 def _set_rendering_options(self
):
194 """Configure rendering options for QGraphicsView"""
196 QtCore
.QCoreApplication
.setAttribute(QtCore
.Qt
.AA_UseOpenGLES
)
197 format
= QtGui
.QSurfaceFormat()
198 format
.setVersion(2, 0)
199 format
.setProfile(QtGui
.QSurfaceFormat
.NoProfile
)
200 format
.setSwapBehavior(QtGui
.QSurfaceFormat
.DoubleBuffer
)
201 QtGui
.QSurfaceFormat
.setDefaultFormat(format
)
205 def _install_style(self
, theme_str
):
206 """Generate and apply a stylesheet to the app"""
207 if theme_str
is None:
208 theme_str
= self
.context
.cfg
.get('cola.theme', default
='default')
209 theme
= themes
.find_theme(theme_str
)
211 self
._app
.setStyleSheet(theme
.build_style_sheet(self
._app
.palette()))
212 if theme_str
!= 'default':
213 self
._app
.setPalette(theme
.build_palette(self
._app
.palette()))
215 def _install_hidpi_config(self
):
216 """Sets QT HIDPI scalling (requires Qt 5.6)"""
217 value
= self
.context
.cfg
.get('cola.hidpi', default
=hidpi
.Option
.AUTO
)
218 hidpi
.apply_choice(value
)
220 def activeWindow(self
):
221 """QApplication::activeWindow() pass-through"""
222 return self
._app
.activeWindow()
225 """QApplication::desktop() pass-through"""
226 return self
._app
.desktop()
229 """QApplication::palette() pass-through"""
230 return self
._app
.palette()
233 """Wrap exec_() and start the application"""
234 # Defer connection so that local cola.inotify is honored
235 context
= self
.context
236 monitor
= context
.fsmonitor
237 monitor
.files_changed
.connect(
238 cmds
.run(cmds
.Refresh
, context
), type=Qt
.QueuedConnection
240 monitor
.config_changed
.connect(
241 cmds
.run(cmds
.RefreshConfig
, context
), type=Qt
.QueuedConnection
243 # Start the filesystem monitor thread
245 return self
._app
.exec_()
248 """Finalize the application"""
249 self
.context
.fsmonitor
.stop()
250 # Workaround QTBUG-52988 by deleting the app manually to prevent a
251 # crash during app shutdown.
252 # https://bugreports.qt.io/browse/QTBUG-52988
255 except (AttributeError, RuntimeError):
259 def exit(self
, status
):
260 """QApplication::exit(status) pass-through"""
261 return self
._app
.exit(status
)
264 class ColaQApplication(QtWidgets
.QApplication
):
265 """QApplication implementation for handling custom events"""
267 def __init__(self
, context
, argv
):
268 super(ColaQApplication
, self
).__init
__(argv
)
269 self
.context
= context
270 # Make icons sharp in HiDPI screen
271 if hasattr(Qt
, 'AA_UseHighDpiPixmaps'):
272 self
.setAttribute(Qt
.AA_UseHighDpiPixmaps
, True)
275 """Respond to focus events for the cola.refreshonfocus feature"""
276 if e
.type() == QtCore
.QEvent
.ApplicationActivate
:
277 context
= self
.context
280 if context
.git
.is_valid() and cfg
.get(
281 'cola.refreshonfocus', default
=False
283 cmds
.do(cmds
.Refresh
, context
)
284 return super(ColaQApplication
, self
).event(e
)
286 def commitData(self
, session_mgr
):
287 """Save session data"""
288 if not self
.context
or not self
.context
.view
:
290 view
= self
.context
.view
291 if not hasattr(view
, 'save_state'):
293 sid
= session_mgr
.sessionId()
294 skey
= session_mgr
.sessionKey()
295 session_id
= '%s_%s' % (sid
, skey
)
296 session
= Session(session_id
, repo
=core
.getcwd())
298 view
.save_state(settings
=session
)
301 def process_args(args
):
302 """Process and verify command-line arguments"""
304 # Accept 'git cola --version' or 'git cola version'
305 version
.print_version()
306 sys
.exit(core
.EXIT_SUCCESS
)
308 # Handle session management
309 restore_session(args
)
311 # Bail out if --repo is not a directory
312 repo
= core
.decode(args
.repo
)
313 if repo
.startswith('file:'):
314 repo
= repo
[len('file:') :]
315 repo
= core
.realpath(repo
)
316 if not core
.isdir(repo
):
319 'fatal: "%s" is not a directory. '
320 'Please specify a correct --repo <path>.'
324 core
.print_stderr(errmsg
)
325 sys
.exit(core
.EXIT_USAGE
)
328 def restore_session(args
):
329 """Load a session based on the window-manager provided arguments"""
330 # args.settings is provided when restoring from a session.
332 if args
.session
is None:
334 session
= Session(args
.session
)
336 args
.settings
= session
337 args
.repo
= session
.repo
340 def application_init(args
, update
=False):
341 """Parses the command-line arguments and starts git-cola"""
342 # Ensure that we're working in a valid git repository.
343 # If not, try to find one. When found, chdir there.
347 context
= new_context(args
)
348 timer
= context
.timer
351 new_worktree(context
, args
.repo
, args
.prompt
)
354 context
.model
.update_status()
358 timer
.display('init')
362 def new_context(args
):
363 """Create top-level ApplicationContext objects"""
364 context
= ApplicationContext(args
)
365 context
.settings
= args
.settings
or Settings
.read()
366 context
.git
= git
.create()
367 context
.cfg
= gitcfg
.create(context
)
368 context
.fsmonitor
= fsmonitor
.create(context
)
369 context
.selection
= selection
.create()
370 context
.model
= main
.create(context
)
371 context
.app
= new_application(context
, args
)
372 context
.timer
= Timer()
377 def application_run(context
, view
, start
=None, stop
=None):
378 """Run the application main loop"""
379 initialize_view(context
, view
)
383 # Start the event loop
384 result
= context
.app
.start()
393 def initialize_view(context
, view
):
394 """Register the main widget and display it"""
395 context
.set_view(view
)
397 if sys
.platform
== 'darwin':
401 def application_start(context
, view
):
402 """Show the GUI and start the main event loop"""
403 # Store the view for session management
404 return application_run(context
, view
, start
=default_start
, stop
=default_stop
)
407 def default_start(context
, _view
):
408 """Scan for the first time"""
409 QtCore
.QTimer
.singleShot(0, startup_message
)
410 QtCore
.QTimer
.singleShot(0, lambda: async_update(context
))
413 def default_stop(_context
, _view
):
414 """All done, cleanup"""
415 QtCore
.QThreadPool
.globalInstance().waitForDone()
418 def add_common_arguments(parser
):
419 """Add command arguments to the ArgumentParser"""
420 # We also accept 'git cola version'
422 '--version', default
=False, action
='store_true', help='print version number'
425 # Specifies a git repository to open
430 default
=core
.getcwd(),
431 help='open the specified git repository',
434 # Specifies that we should prompt for a repository at startup
436 '--prompt', action
='store_true', default
=False, help='prompt for a repository'
439 # Specify the icon theme
446 help='specify an icon theme (name or directory)',
449 # Resume an X Session Management session
451 '-session', metavar
='<session>', default
=None, help=argparse
.SUPPRESS
454 # Enable timing information
456 '--perf', action
='store_true', default
=False, help=argparse
.SUPPRESS
459 # Specify the GUI theme
461 '--theme', metavar
='<name>', default
=None, help='specify an GUI theme name'
465 def new_application(context
, args
):
466 """Create a new ColaApplication"""
467 return ColaApplication(
468 context
, sys
.argv
, icon_themes
=args
.icon_themes
, gui_theme
=args
.theme
472 def new_worktree(context
, repo
, prompt
):
473 """Find a Git repository, or prompt for one when not found"""
474 model
= context
.model
476 parent
= qtutils
.active_window()
480 valid
= model
.set_worktree(repo
)
482 # We are not currently in a git repository so we need to find one.
483 # Before prompting the user for a repository, check if they've
484 # configured a default repository and attempt to use it.
485 default_repo
= cfg
.get('cola.defaultrepo')
487 valid
= model
.set_worktree(default_repo
)
490 # If we've gotten into this loop then that means that neither the
491 # current directory nor the default repository were available.
492 # Prompt the user for a repository.
493 startup_dlg
= startup
.StartupDialog(context
, parent
)
494 gitdir
= startup_dlg
.find_git_repo()
496 sys
.exit(core
.EXIT_NOINPUT
)
498 if not core
.exists(os
.path
.join(gitdir
, '.git')):
499 offer_to_create_repo(context
, gitdir
)
500 valid
= model
.set_worktree(gitdir
)
503 valid
= model
.set_worktree(gitdir
)
507 N_('Error Opening Repository'),
508 message
=N_('Could not open %s.' % gitdir
),
513 def offer_to_create_repo(context
, gitdir
):
514 """Offer to create a new repo"""
515 title
= N_('Repository Not Found')
516 text
= N_('%s is not a Git repository.') % gitdir
517 informative_text
= N_('Create a new repository at that location?')
518 if standard
.confirm(title
, text
, informative_text
, N_('Create')):
519 status
, out
, err
= context
.git
.init(gitdir
)
520 title
= N_('Error Creating Repository')
522 Interaction
.command_error(title
, 'git init', status
, out
, err
)
525 def async_update(context
):
526 """Update the model in the background
528 git-cola should startup as quickly as possible.
531 update_status
= partial(context
.model
.update_status
, update_index
=True)
532 task
= qtutils
.SimpleTask(update_status
)
533 context
.runtask
.start(task
)
536 def startup_message():
537 """Print debug startup messages"""
538 trace
= git
.GIT_COLA_TRACE
539 if trace
in ('2', 'trace'):
540 msg1
= 'info: debug level 2: trace mode enabled'
541 msg2
= 'info: set GIT_COLA_TRACE=1 for less-verbose output'
542 Interaction
.log(msg1
)
543 Interaction
.log(msg2
)
545 msg1
= 'info: debug level 1'
546 msg2
= 'info: set GIT_COLA_TRACE=2 for trace mode'
547 Interaction
.log(msg1
)
548 Interaction
.log(msg2
)
552 """System-level initialization"""
553 # We support ~/.config/git-cola/git-bindir on Windows for configuring
554 # a custom location for finding the "git" executable.
555 git_path
= find_git()
557 prepend_path(git_path
)
559 # The current directory may have been deleted while we are still
560 # in that directory. We rectify this situation by walking up the
561 # directory tree and retrying.
563 # This is needed because because Python throws exceptions in lots of
564 # stdlib functions when in this situation, e.g. os.path.abspath() and
565 # os.path.realpath(), so it's simpler to mitigate the damage by changing
566 # the current directory to one that actually exists.
575 """Simple performance timer"""
580 def start(self
, key
):
583 self
._data
[key
] = [now
, now
]
586 """Stop a timer and return its elapsed time"""
587 entry
= self
._data
[key
]
588 entry
[1] = time
.time()
589 return self
.elapsed(key
)
591 def elapsed(self
, key
):
592 """Return the elapsed time for a timer"""
593 entry
= self
._data
[key
]
594 return entry
[1] - entry
[0]
596 def display(self
, key
):
597 """Display a timer"""
598 elapsed
= self
.elapsed(key
)
599 sys
.stdout
.write('%s: %.5fs\n' % (key
, elapsed
))
602 class NullArgs(object):
603 """Stub arguments for interactive API use"""
606 self
.icon_themes
= []
612 """Create a new instance of application arguments"""
616 class ApplicationContext(object):
617 """Context for performing operations on Git and related data models"""
619 def __init__(self
, args
):
621 self
.app
= None # ColaApplication
622 self
.git
= None # git.Git
623 self
.cfg
= None # gitcfg.GitConfig
624 self
.model
= None # main.MainModel
625 self
.timer
= None # Timer
626 self
.runtask
= None # qtutils.RunTask
627 self
.settings
= None # settings.Settings
628 self
.selection
= None # selection.SelectionModel
629 self
.fsmonitor
= None # fsmonitor
630 self
.view
= None # QWidget
631 self
.browser_windows
= [] # list of browse.Browser
633 def set_view(self
, view
):
634 """Initialize view-specific members"""
636 self
.runtask
= qtutils
.RunTask(parent
=view
)
640 """Return the path of git.exe, or None if we can't find it."""
641 if not utils
.is_win32():
642 return None # UNIX systems have git in their $PATH
644 # If the user wants to use a Git/bin/ directory from a non-standard
645 # directory then they can write its location into
646 # ~/.config/git-cola/git-bindir
647 git_bindir
= resources
.config_home('git-bindir')
648 if core
.exists(git_bindir
):
649 custom_path
= core
.read(git_bindir
).strip()
650 if custom_path
and core
.exists(custom_path
):
653 # Try to find Git's bin/ directory in one of the typical locations
654 pf
= os
.environ
.get('ProgramFiles', 'C:\\Program Files')
655 pf32
= os
.environ
.get('ProgramFiles(x86)', 'C:\\Program Files (x86)')
656 pf64
= os
.environ
.get('ProgramW6432', 'C:\\Program Files')
657 for p
in [pf64
, pf32
, pf
, 'C:\\']:
658 candidate
= os
.path
.join(p
, 'Git\\bin')
659 if os
.path
.isdir(candidate
):
665 def prepend_path(path
):
666 """Adds git to the PATH. This is needed on Windows."""
667 path
= core
.decode(path
)
668 path_entries
= core
.getenv('PATH', '').split(os
.pathsep
)
669 if path
not in path_entries
:
670 path_entries
.insert(0, path
)
671 compat
.setenv('PATH', os
.pathsep
.join(path_entries
))