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 QtWidgets
37 from qtpy
.QtCore
import Qt
40 # Qt 5.12 / PyQt 5.13 is unable to use QtWebEngineWidgets unless it is
41 # imported before QApplication is constructed.
42 from qtpy
import QtWebEngineWidgets
# noqa
44 # QtWebEngineWidgets / QtWebKit is not available -- no big deal.
49 from .interaction
import Interaction
50 from .models
import main
51 from .models
import selection
52 from .widgets
import cfgactions
53 from .widgets
import standard
54 from .widgets
import startup
55 from .settings
import Session
56 from .settings
import Settings
60 from . import fsmonitor
67 from . import qtcompat
69 from . import resources
75 def setup_environment():
76 """Set environment variables to control git's behavior"""
77 # Allow Ctrl-C to exit
78 signal
.signal(signal
.SIGINT
, signal
.SIG_DFL
)
80 # Session management wants an absolute path when restarting
81 sys
.argv
[0] = sys_argv0
= os
.path
.abspath(sys
.argv
[0])
83 # Spoof an X11 display for SSH
84 os
.environ
.setdefault('DISPLAY', ':0')
86 if not core
.getenv('SHELL', ''):
87 for shell
in ('/bin/zsh', '/bin/bash', '/bin/sh'):
88 if os
.path
.exists(shell
):
89 compat
.setenv('SHELL', shell
)
92 # Setup the path so that git finds us when we run 'git cola'
93 path_entries
= core
.getenv('PATH', '').split(os
.pathsep
)
94 bindir
= core
.decode(os
.path
.dirname(sys_argv0
))
95 path_entries
.append(bindir
)
96 path
= os
.pathsep
.join(path_entries
)
97 compat
.setenv('PATH', path
)
99 # We don't ever want a pager
100 compat
.setenv('GIT_PAGER', '')
103 git_askpass
= core
.getenv('GIT_ASKPASS')
104 ssh_askpass
= core
.getenv('SSH_ASKPASS')
106 askpass
= git_askpass
108 askpass
= ssh_askpass
109 elif sys
.platform
== 'darwin':
110 askpass
= resources
.package_command('ssh-askpass-darwin')
112 askpass
= resources
.package_command('ssh-askpass')
114 compat
.setenv('GIT_ASKPASS', askpass
)
115 compat
.setenv('SSH_ASKPASS', askpass
)
118 # Git v1.7.10 Release Notes
119 # =========================
121 # Compatibility Notes
122 # -------------------
124 # * From this release on, the "git merge" command in an interactive
125 # session will start an editor when it automatically resolves the
126 # merge for the user to explain the resulting commit, just like the
127 # "git commit" command does when it wasn't given a commit message.
129 # If you have a script that runs "git merge" and keeps its standard
130 # input and output attached to the user's terminal, and if you do not
131 # want the user to explain the resulting merge commits, you can
132 # export GIT_MERGE_AUTOEDIT environment variable set to "no", like
136 # GIT_MERGE_AUTOEDIT=no
137 # export GIT_MERGE_AUTOEDIT
139 # to disable this behavior (if you want your users to explain their
140 # merge commits, you do not have to do anything). Alternatively, you
141 # can give the "--no-edit" option to individual invocations of the
142 # "git merge" command if you know everybody who uses your script has
143 # Git v1.7.8 or newer.
145 # Longer-term: Use `git merge --no-commit` so that we always
146 # have a chance to explain our merges.
147 compat
.setenv('GIT_MERGE_AUTOEDIT', 'no')
150 def get_icon_themes(context
):
151 """Return the default icon theme names"""
154 icon_themes_env
= core
.getenv('GIT_COLA_ICON_THEME')
156 result
.extend([x
for x
in icon_themes_env
.split(':') if x
])
158 icon_themes_cfg
= list(reversed(context
.cfg
.get_all('cola.icontheme')))
160 result
.extend(icon_themes_cfg
)
163 result
.append('light')
168 # style note: we use camelCase here since we're masquerading a Qt class
169 class ColaApplication(object):
170 """The main cola application
172 ColaApplication handles i18n of user-visible data
175 def __init__(self
, context
, argv
, locale
=None, icon_themes
=None, gui_theme
=None):
181 icons
.install(icon_themes
or get_icon_themes(context
))
183 self
.context
= context
185 self
._install
_hidpi
_config
()
186 self
._app
= ColaQApplication(context
, list(argv
))
187 self
._app
.setWindowIcon(icons
.cola())
188 self
._app
.setDesktopFileName('git-cola')
189 self
._install
_style
(gui_theme
)
191 def _install_style(self
, theme_str
):
192 """Generate and apply a stylesheet to the app"""
193 if theme_str
is None:
194 theme_str
= self
.context
.cfg
.get('cola.theme', default
='default')
195 theme
= themes
.find_theme(theme_str
)
197 self
._app
.setStyleSheet(theme
.build_style_sheet(self
._app
.palette()))
198 if theme_str
!= 'default':
199 self
._app
.setPalette(theme
.build_palette(self
._app
.palette()))
201 def _install_hidpi_config(self
):
202 """Sets QT HIDPI scalling (requires Qt 5.6)"""
203 value
= self
.context
.cfg
.get('cola.hidpi', default
=hidpi
.Option
.AUTO
)
204 hidpi
.apply_choice(value
)
206 def activeWindow(self
):
207 """QApplication::activeWindow() pass-through"""
208 return self
._app
.activeWindow()
211 """QApplication::desktop() pass-through"""
212 return self
._app
.desktop()
215 """QApplication::palette() pass-through"""
216 return self
._app
.palette()
219 """Wrap exec_() and start the application"""
220 # Defer connection so that local cola.inotify is honored
221 context
= self
.context
222 monitor
= context
.fsmonitor
223 monitor
.files_changed
.connect(
224 cmds
.run(cmds
.Refresh
, context
), type=Qt
.QueuedConnection
226 monitor
.config_changed
.connect(
227 cmds
.run(cmds
.RefreshConfig
, context
), type=Qt
.QueuedConnection
229 # Start the filesystem monitor thread
231 return self
._app
.exec_()
234 """Finalize the application"""
235 self
.context
.fsmonitor
.stop()
236 # Workaround QTBUG-52988 by deleting the app manually to prevent a
237 # crash during app shutdown.
238 # https://bugreports.qt.io/browse/QTBUG-52988
241 except (AttributeError, RuntimeError):
245 def exit(self
, status
):
246 """QApplication::exit(status) pass-through"""
247 return self
._app
.exit(status
)
250 class ColaQApplication(QtWidgets
.QApplication
):
251 """QApplication implementation for handling custom events"""
253 def __init__(self
, context
, argv
):
254 super(ColaQApplication
, self
).__init
__(argv
)
255 self
.context
= context
256 # Make icons sharp in HiDPI screen
257 if hasattr(Qt
, 'AA_UseHighDpiPixmaps'):
258 self
.setAttribute(Qt
.AA_UseHighDpiPixmaps
, True)
261 """Respond to focus events for the cola.refreshonfocus feature"""
262 if e
.type() == QtCore
.QEvent
.ApplicationActivate
:
263 context
= self
.context
266 if context
.git
.is_valid() and cfg
.get(
267 'cola.refreshonfocus', default
=False
269 cmds
.do(cmds
.Refresh
, context
)
270 return super(ColaQApplication
, self
).event(e
)
272 def commitData(self
, session_mgr
):
273 """Save session data"""
274 if not self
.context
or not self
.context
.view
:
276 view
= self
.context
.view
277 if not hasattr(view
, 'save_state'):
279 sid
= session_mgr
.sessionId()
280 skey
= session_mgr
.sessionKey()
281 session_id
= '%s_%s' % (sid
, skey
)
282 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"""
328 # Ensure that we're working in a valid git repository.
329 # If not, try to find one. When found, chdir there.
333 context
= new_context(args
)
334 timer
= context
.timer
337 new_worktree(context
, args
.repo
, args
.prompt
)
340 context
.model
.update_status()
344 timer
.display('init')
348 def new_context(args
):
349 """Create top-level ApplicationContext objects"""
350 context
= ApplicationContext(args
)
351 context
.settings
= args
.settings
or Settings
.read()
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
):
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
)
480 gitdir
= startup_dlg
.find_git_repo()
482 sys
.exit(core
.EXIT_NOINPUT
)
484 if not core
.exists(os
.path
.join(gitdir
, '.git')):
485 offer_to_create_repo(context
, gitdir
)
486 valid
= model
.set_worktree(gitdir
)
489 valid
= model
.set_worktree(gitdir
)
493 N_('Error Opening Repository'),
494 message
=N_('Could not open %s.' % gitdir
),
499 def offer_to_create_repo(context
, gitdir
):
500 """Offer to create a new repo"""
501 title
= N_('Repository Not Found')
502 text
= N_('%s is not a Git repository.') % gitdir
503 informative_text
= N_('Create a new repository at that location?')
504 if standard
.confirm(title
, text
, informative_text
, N_('Create')):
505 status
, out
, err
= context
.git
.init(gitdir
)
506 title
= N_('Error Creating Repository')
508 Interaction
.command_error(title
, 'git init', status
, out
, err
)
511 def async_update(context
):
512 """Update the model in the background
514 git-cola should startup as quickly as possible.
517 update_status
= partial(context
.model
.update_status
, update_index
=True)
518 task
= qtutils
.SimpleTask(update_status
)
519 context
.runtask
.start(task
)
522 def startup_message():
523 """Print debug startup messages"""
524 trace
= git
.GIT_COLA_TRACE
525 if trace
in ('2', 'trace'):
526 msg1
= 'info: debug level 2: trace mode enabled'
527 msg2
= 'info: set GIT_COLA_TRACE=1 for less-verbose output'
528 Interaction
.log(msg1
)
529 Interaction
.log(msg2
)
531 msg1
= 'info: debug level 1'
532 msg2
= 'info: set GIT_COLA_TRACE=2 for trace mode'
533 Interaction
.log(msg1
)
534 Interaction
.log(msg2
)
538 """System-level initialization"""
539 # We support ~/.config/git-cola/git-bindir on Windows for configuring
540 # a custom location for finding the "git" executable.
541 git_path
= find_git()
543 prepend_path(git_path
)
545 # The current directory may have been deleted while we are still
546 # in that directory. We rectify this situation by walking up the
547 # directory tree and retrying.
549 # This is needed because because Python throws exceptions in lots of
550 # stdlib functions when in this situation, e.g. os.path.abspath() and
551 # os.path.realpath(), so it's simpler to mitigate the damage by changing
552 # the current directory to one that actually exists.
561 """Simple performance timer"""
566 def start(self
, key
):
569 self
._data
[key
] = [now
, now
]
572 """Stop a timer and return its elapsed time"""
573 entry
= self
._data
[key
]
574 entry
[1] = time
.time()
575 return self
.elapsed(key
)
577 def elapsed(self
, key
):
578 """Return the elapsed time for a timer"""
579 entry
= self
._data
[key
]
580 return entry
[1] - entry
[0]
582 def display(self
, key
):
583 """Display a timer"""
584 elapsed
= self
.elapsed(key
)
585 sys
.stdout
.write('%s: %.5fs\n' % (key
, elapsed
))
588 class NullArgs(object):
589 """Stub arguments for interactive API use"""
592 self
.icon_themes
= []
598 """Create a new instance of application arguments"""
602 class ApplicationContext(object):
603 """Context for performing operations on Git and related data models"""
605 def __init__(self
, args
):
607 self
.app
= None # ColaApplication
608 self
.git
= None # git.Git
609 self
.cfg
= None # gitcfg.GitConfig
610 self
.model
= None # main.MainModel
611 self
.timer
= None # Timer
612 self
.runtask
= None # qtutils.RunTask
613 self
.settings
= None # settings.Settings
614 self
.selection
= None # selection.SelectionModel
615 self
.fsmonitor
= None # fsmonitor
616 self
.view
= None # QWidget
617 self
.browser_windows
= [] # list of browse.Browser
619 def set_view(self
, view
):
620 """Initialize view-specific members"""
622 self
.runtask
= qtutils
.RunTask(parent
=view
)
626 """Return the path of git.exe, or None if we can't find it."""
627 if not utils
.is_win32():
628 return None # UNIX systems have git in their $PATH
630 # If the user wants to use a Git/bin/ directory from a non-standard
631 # directory then they can write its location into
632 # ~/.config/git-cola/git-bindir
633 git_bindir
= resources
.config_home('git-bindir')
634 if core
.exists(git_bindir
):
635 custom_path
= core
.read(git_bindir
).strip()
636 if custom_path
and core
.exists(custom_path
):
639 # Try to find Git's bin/ directory in one of the typical locations
640 pf
= os
.environ
.get('ProgramFiles', 'C:\\Program Files')
641 pf32
= os
.environ
.get('ProgramFiles(x86)', 'C:\\Program Files (x86)')
642 pf64
= os
.environ
.get('ProgramW6432', 'C:\\Program Files')
643 for p
in [pf64
, pf32
, pf
, 'C:\\']:
644 candidate
= os
.path
.join(p
, 'Git\\bin')
645 if os
.path
.isdir(candidate
):
651 def prepend_path(path
):
652 """Adds git to the PATH. This is needed on Windows."""
653 path
= core
.decode(path
)
654 path_entries
= core
.getenv('PATH', '').split(os
.pathsep
)
655 if path
not in path_entries
:
656 path_entries
.insert(0, path
)
657 compat
.setenv('PATH', os
.pathsep
.join(path_entries
))