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
34 from qtpy
import QtWidgets
35 from qtpy
.QtCore
import Qt
38 # Qt 5.12 / PyQt 5.13 is unable to use QtWebEngineWidgets unless it is
39 # imported before QApplication is constructed.
40 from qtpy
import QtWebEngineWidgets
# noqa
42 # QtWebEngineWidgets / QtWebKit is not available -- no big deal.
47 from .interaction
import Interaction
48 from .models
import main
49 from .models
import selection
50 from .widgets
import cfgactions
51 from .widgets
import standard
52 from .widgets
import startup
53 from .settings
import Session
54 from .settings
import Settings
58 from . import fsmonitor
65 from . import qtcompat
67 from . import resources
73 def setup_environment():
74 """Set environment variables to control git's behavior"""
75 # Allow Ctrl-C to exit
76 signal
.signal(signal
.SIGINT
, signal
.SIG_DFL
)
78 # Session management wants an absolute path when restarting
79 sys
.argv
[0] = sys_argv0
= os
.path
.abspath(sys
.argv
[0])
81 # Spoof an X11 display for SSH
82 os
.environ
.setdefault('DISPLAY', ':0')
84 if not core
.getenv('SHELL', ''):
85 for shell
in ('/bin/zsh', '/bin/bash', '/bin/sh'):
86 if os
.path
.exists(shell
):
87 compat
.setenv('SHELL', shell
)
90 # Setup the path so that git finds us when we run 'git cola'
91 path_entries
= core
.getenv('PATH', '').split(os
.pathsep
)
92 bindir
= core
.decode(os
.path
.dirname(sys_argv0
))
93 path_entries
.append(bindir
)
94 path
= os
.pathsep
.join(path_entries
)
95 compat
.setenv('PATH', path
)
97 # We don't ever want a pager
98 compat
.setenv('GIT_PAGER', '')
101 git_askpass
= core
.getenv('GIT_ASKPASS')
102 ssh_askpass
= core
.getenv('SSH_ASKPASS')
104 askpass
= git_askpass
106 askpass
= ssh_askpass
107 elif sys
.platform
== 'darwin':
108 askpass
= resources
.package_command('ssh-askpass-darwin')
110 askpass
= resources
.package_command('ssh-askpass')
112 compat
.setenv('GIT_ASKPASS', askpass
)
113 compat
.setenv('SSH_ASKPASS', askpass
)
116 # Git v1.7.10 Release Notes
117 # =========================
119 # Compatibility Notes
120 # -------------------
122 # * From this release on, the "git merge" command in an interactive
123 # session will start an editor when it automatically resolves the
124 # merge for the user to explain the resulting commit, just like the
125 # "git commit" command does when it wasn't given a commit message.
127 # If you have a script that runs "git merge" and keeps its standard
128 # input and output attached to the user's terminal, and if you do not
129 # want the user to explain the resulting merge commits, you can
130 # export GIT_MERGE_AUTOEDIT environment variable set to "no", like
134 # GIT_MERGE_AUTOEDIT=no
135 # export GIT_MERGE_AUTOEDIT
137 # to disable this behavior (if you want your users to explain their
138 # merge commits, you do not have to do anything). Alternatively, you
139 # can give the "--no-edit" option to individual invocations of the
140 # "git merge" command if you know everybody who uses your script has
141 # Git v1.7.8 or newer.
143 # Longer-term: Use `git merge --no-commit` so that we always
144 # have a chance to explain our merges.
145 compat
.setenv('GIT_MERGE_AUTOEDIT', 'no')
147 # Gnome3 on Debian has XDG_SESSION_TYPE=wayland and
148 # XDG_CURRENT_DESKTOP=GNOME, which Qt warns about at startup:
150 # Warning: Ignoring XDG_SESSION_TYPE=wayland on Gnome.
151 # Use QT_QPA_PLATFORM=wayland to run on Wayland anyway.
153 # Follow the advice and opt-in to QT_QPA_PLATFORM=wayland unless the user
154 # has a custom QT_QPA_PLATFORM.
156 # In the past we would compat.unsetenv('XDG_SESSION_TYPE') when
157 # XDG_CURRENT_DESKTOP == GNOME and XDG_SESSION_TYPE == wayland
158 # to silence the warning. This was before
159 # https://bugreports.qt.io/browse/QTBUG-68619 was resolved.
161 # These days we can opt-in instead.
163 core
.getenv('XDG_SESSION_TYPE', '') == 'wayland'
164 and not core
.getenv('QT_QPA_PLATFORM', '')
166 compat
.setenv('QT_QPA_PLATFORM', 'wayland')
169 def get_icon_themes(context
):
170 """Return the default icon theme names"""
173 icon_themes_env
= core
.getenv('GIT_COLA_ICON_THEME')
175 result
.extend([x
for x
in icon_themes_env
.split(':') if x
])
177 icon_themes_cfg
= list(reversed(context
.cfg
.get_all('cola.icontheme')))
179 result
.extend(icon_themes_cfg
)
182 result
.append('light')
187 # style note: we use camelCase here since we're masquerading a Qt class
188 class ColaApplication(object):
189 """The main cola application
191 ColaApplication handles i18n of user-visible data
194 def __init__(self
, context
, argv
, locale
=None, icon_themes
=None, gui_theme
=None):
200 icons
.install(icon_themes
or get_icon_themes(context
))
202 self
.context
= context
204 self
._install
_hidpi
_config
()
205 self
._app
= ColaQApplication(context
, list(argv
))
206 self
._app
.setWindowIcon(icons
.cola())
207 self
._app
.setDesktopFileName('git-cola')
208 self
._install
_style
(gui_theme
)
210 def _install_style(self
, theme_str
):
211 """Generate and apply a stylesheet to the app"""
212 if theme_str
is None:
213 theme_str
= self
.context
.cfg
.get('cola.theme', default
='default')
214 theme
= themes
.find_theme(theme_str
)
216 self
._app
.setStyleSheet(theme
.build_style_sheet(self
._app
.palette()))
217 if theme_str
!= 'default':
218 self
._app
.setPalette(theme
.build_palette(self
._app
.palette()))
220 def _install_hidpi_config(self
):
221 """Sets QT HIDPI scalling (requires Qt 5.6)"""
222 value
= self
.context
.cfg
.get('cola.hidpi', default
=hidpi
.Option
.AUTO
)
223 hidpi
.apply_choice(value
)
225 def activeWindow(self
):
226 """QApplication::activeWindow() pass-through"""
227 return self
._app
.activeWindow()
230 """QApplication::desktop() pass-through"""
231 return self
._app
.desktop()
234 """QApplication::palette() pass-through"""
235 return self
._app
.palette()
238 """Wrap exec_() and start the application"""
239 # Defer connection so that local cola.inotify is honored
240 context
= self
.context
241 monitor
= context
.fsmonitor
242 monitor
.files_changed
.connect(
243 cmds
.run(cmds
.Refresh
, context
), type=Qt
.QueuedConnection
245 monitor
.config_changed
.connect(
246 cmds
.run(cmds
.RefreshConfig
, context
), type=Qt
.QueuedConnection
248 # Start the filesystem monitor thread
250 return self
._app
.exec_()
253 """Finalize the application"""
254 self
.context
.fsmonitor
.stop()
255 # Workaround QTBUG-52988 by deleting the app manually to prevent a
256 # crash during app shutdown.
257 # https://bugreports.qt.io/browse/QTBUG-52988
260 except (AttributeError, RuntimeError):
264 def exit(self
, status
):
265 """QApplication::exit(status) pass-through"""
266 return self
._app
.exit(status
)
269 class ColaQApplication(QtWidgets
.QApplication
):
270 """QApplication implementation for handling custom events"""
272 def __init__(self
, context
, argv
):
273 super(ColaQApplication
, self
).__init
__(argv
)
274 self
.context
= context
275 # Make icons sharp in HiDPI screen
276 if hasattr(Qt
, 'AA_UseHighDpiPixmaps'):
277 self
.setAttribute(Qt
.AA_UseHighDpiPixmaps
, True)
280 """Respond to focus events for the cola.refreshonfocus feature"""
281 if e
.type() == QtCore
.QEvent
.ApplicationActivate
:
282 context
= self
.context
285 if context
.git
.is_valid() and cfg
.get(
286 'cola.refreshonfocus', default
=False
288 cmds
.do(cmds
.Refresh
, context
)
289 return super(ColaQApplication
, self
).event(e
)
291 def commitData(self
, session_mgr
):
292 """Save session data"""
293 if not self
.context
or not self
.context
.view
:
295 view
= self
.context
.view
296 if not hasattr(view
, 'save_state'):
298 sid
= session_mgr
.sessionId()
299 skey
= session_mgr
.sessionKey()
300 session_id
= '%s_%s' % (sid
, skey
)
301 session
= Session(session_id
, repo
=core
.getcwd())
303 view
.save_state(settings
=session
)
306 def process_args(args
):
307 """Process and verify command-line arguments"""
309 # Accept 'git cola --version' or 'git cola version'
310 version
.print_version()
311 sys
.exit(core
.EXIT_SUCCESS
)
313 # Handle session management
314 restore_session(args
)
316 # Bail out if --repo is not a directory
317 repo
= core
.decode(args
.repo
)
318 if repo
.startswith('file:'):
319 repo
= repo
[len('file:') :]
320 repo
= core
.realpath(repo
)
321 if not core
.isdir(repo
):
324 'fatal: "%s" is not a directory. '
325 'Please specify a correct --repo <path>.'
329 core
.print_stderr(errmsg
)
330 sys
.exit(core
.EXIT_USAGE
)
333 def restore_session(args
):
334 """Load a session based on the window-manager provided arguments"""
335 # args.settings is provided when restoring from a session.
337 if args
.session
is None:
339 session
= Session(args
.session
)
341 args
.settings
= session
342 args
.repo
= session
.repo
345 def application_init(args
, update
=False):
346 """Parses the command-line arguments and starts git-cola"""
347 # Ensure that we're working in a valid git repository.
348 # If not, try to find one. When found, chdir there.
352 context
= new_context(args
)
353 timer
= context
.timer
356 new_worktree(context
, args
.repo
, args
.prompt
)
359 context
.model
.update_status()
363 timer
.display('init')
367 def new_context(args
):
368 """Create top-level ApplicationContext objects"""
369 context
= ApplicationContext(args
)
370 context
.settings
= args
.settings
or Settings
.read()
371 context
.git
= git
.create()
372 context
.cfg
= gitcfg
.create(context
)
373 context
.fsmonitor
= fsmonitor
.create(context
)
374 context
.selection
= selection
.create()
375 context
.model
= main
.create(context
)
376 context
.app
= new_application(context
, args
)
377 context
.timer
= Timer()
382 def application_run(context
, view
, start
=None, stop
=None):
383 """Run the application main loop"""
384 initialize_view(context
, view
)
388 # Start the event loop
389 result
= context
.app
.start()
398 def initialize_view(context
, view
):
399 """Register the main widget and display it"""
400 context
.set_view(view
)
402 if sys
.platform
== 'darwin':
406 def application_start(context
, view
):
407 """Show the GUI and start the main event loop"""
408 # Store the view for session management
409 return application_run(context
, view
, start
=default_start
, stop
=default_stop
)
412 def default_start(context
, _view
):
413 """Scan for the first time"""
414 QtCore
.QTimer
.singleShot(0, startup_message
)
415 QtCore
.QTimer
.singleShot(0, lambda: async_update(context
))
418 def default_stop(_context
, _view
):
419 """All done, cleanup"""
420 QtCore
.QThreadPool
.globalInstance().waitForDone()
423 def add_common_arguments(parser
):
424 """Add command arguments to the ArgumentParser"""
425 # We also accept 'git cola version'
427 '--version', default
=False, action
='store_true', help='print version number'
430 # Specifies a git repository to open
435 default
=core
.getcwd(),
436 help='open the specified git repository',
439 # Specifies that we should prompt for a repository at startup
441 '--prompt', action
='store_true', default
=False, help='prompt for a repository'
444 # Specify the icon theme
451 help='specify an icon theme (name or directory)',
454 # Resume an X Session Management session
456 '-session', metavar
='<session>', default
=None, help=argparse
.SUPPRESS
459 # Enable timing information
461 '--perf', action
='store_true', default
=False, help=argparse
.SUPPRESS
464 # Specify the GUI theme
466 '--theme', metavar
='<name>', default
=None, help='specify an GUI theme name'
470 def new_application(context
, args
):
471 """Create a new ColaApplication"""
472 return ColaApplication(
473 context
, sys
.argv
, icon_themes
=args
.icon_themes
, gui_theme
=args
.theme
477 def new_worktree(context
, repo
, prompt
):
478 """Find a Git repository, or prompt for one when not found"""
479 model
= context
.model
481 parent
= qtutils
.active_window()
485 valid
= model
.set_worktree(repo
)
487 # We are not currently in a git repository so we need to find one.
488 # Before prompting the user for a repository, check if they've
489 # configured a default repository and attempt to use it.
490 default_repo
= cfg
.get('cola.defaultrepo')
492 valid
= model
.set_worktree(default_repo
)
495 # If we've gotten into this loop then that means that neither the
496 # current directory nor the default repository were available.
497 # Prompt the user for a repository.
498 startup_dlg
= startup
.StartupDialog(context
, parent
)
499 gitdir
= startup_dlg
.find_git_repo()
501 sys
.exit(core
.EXIT_NOINPUT
)
503 if not core
.exists(os
.path
.join(gitdir
, '.git')):
504 offer_to_create_repo(context
, gitdir
)
505 valid
= model
.set_worktree(gitdir
)
508 valid
= model
.set_worktree(gitdir
)
512 N_('Error Opening Repository'),
513 message
=N_('Could not open %s.' % gitdir
),
518 def offer_to_create_repo(context
, gitdir
):
519 """Offer to create a new repo"""
520 title
= N_('Repository Not Found')
521 text
= N_('%s is not a Git repository.') % gitdir
522 informative_text
= N_('Create a new repository at that location?')
523 if standard
.confirm(title
, text
, informative_text
, N_('Create')):
524 status
, out
, err
= context
.git
.init(gitdir
)
525 title
= N_('Error Creating Repository')
527 Interaction
.command_error(title
, 'git init', status
, out
, err
)
530 def async_update(context
):
531 """Update the model in the background
533 git-cola should startup as quickly as possible.
536 update_status
= partial(context
.model
.update_status
, update_index
=True)
537 task
= qtutils
.SimpleTask(update_status
)
538 context
.runtask
.start(task
)
541 def startup_message():
542 """Print debug startup messages"""
543 trace
= git
.GIT_COLA_TRACE
544 if trace
in ('2', 'trace'):
545 msg1
= 'info: debug level 2: trace mode enabled'
546 msg2
= 'info: set GIT_COLA_TRACE=1 for less-verbose output'
547 Interaction
.log(msg1
)
548 Interaction
.log(msg2
)
550 msg1
= 'info: debug level 1'
551 msg2
= 'info: set GIT_COLA_TRACE=2 for trace mode'
552 Interaction
.log(msg1
)
553 Interaction
.log(msg2
)
557 """System-level initialization"""
558 # We support ~/.config/git-cola/git-bindir on Windows for configuring
559 # a custom location for finding the "git" executable.
560 git_path
= find_git()
562 prepend_path(git_path
)
564 # The current directory may have been deleted while we are still
565 # in that directory. We rectify this situation by walking up the
566 # directory tree and retrying.
568 # This is needed because because Python throws exceptions in lots of
569 # stdlib functions when in this situation, e.g. os.path.abspath() and
570 # os.path.realpath(), so it's simpler to mitigate the damage by changing
571 # the current directory to one that actually exists.
580 """Simple performance timer"""
585 def start(self
, key
):
588 self
._data
[key
] = [now
, now
]
591 """Stop a timer and return its elapsed time"""
592 entry
= self
._data
[key
]
593 entry
[1] = time
.time()
594 return self
.elapsed(key
)
596 def elapsed(self
, key
):
597 """Return the elapsed time for a timer"""
598 entry
= self
._data
[key
]
599 return entry
[1] - entry
[0]
601 def display(self
, key
):
602 """Display a timer"""
603 elapsed
= self
.elapsed(key
)
604 sys
.stdout
.write('%s: %.5fs\n' % (key
, elapsed
))
607 class NullArgs(object):
608 """Stub arguments for interactive API use"""
611 self
.icon_themes
= []
617 """Create a new instance of application arguments"""
621 class ApplicationContext(object):
622 """Context for performing operations on Git and related data models"""
624 def __init__(self
, args
):
626 self
.app
= None # ColaApplication
627 self
.git
= None # git.Git
628 self
.cfg
= None # gitcfg.GitConfig
629 self
.model
= None # main.MainModel
630 self
.timer
= None # Timer
631 self
.runtask
= None # qtutils.RunTask
632 self
.settings
= None # settings.Settings
633 self
.selection
= None # selection.SelectionModel
634 self
.fsmonitor
= None # fsmonitor
635 self
.view
= None # QWidget
636 self
.browser_windows
= [] # list of browse.Browser
638 def set_view(self
, view
):
639 """Initialize view-specific members"""
641 self
.runtask
= qtutils
.RunTask(parent
=view
)
645 """Return the path of git.exe, or None if we can't find it."""
646 if not utils
.is_win32():
647 return None # UNIX systems have git in their $PATH
649 # If the user wants to use a Git/bin/ directory from a non-standard
650 # directory then they can write its location into
651 # ~/.config/git-cola/git-bindir
652 git_bindir
= resources
.config_home('git-bindir')
653 if core
.exists(git_bindir
):
654 custom_path
= core
.read(git_bindir
).strip()
655 if custom_path
and core
.exists(custom_path
):
658 # Try to find Git's bin/ directory in one of the typical locations
659 pf
= os
.environ
.get('ProgramFiles', 'C:\\Program Files')
660 pf32
= os
.environ
.get('ProgramFiles(x86)', 'C:\\Program Files (x86)')
661 pf64
= os
.environ
.get('ProgramW6432', 'C:\\Program Files')
662 for p
in [pf64
, pf32
, pf
, 'C:\\']:
663 candidate
= os
.path
.join(p
, 'Git\\bin')
664 if os
.path
.isdir(candidate
):
670 def prepend_path(path
):
671 """Adds git to the PATH. This is needed on Windows."""
672 path
= core
.decode(path
)
673 path_entries
= core
.getenv('PATH', '').split(os
.pathsep
)
674 if path
not in path_entries
:
675 path_entries
.insert(0, path
)
676 compat
.setenv('PATH', os
.pathsep
.join(path_entries
))