1 """Provides the main() routine and ColaApplication"""
2 # pylint: disable=unused-import
3 from functools
import partial
11 from qtpy
import QtCore
12 except ImportError as error
:
15 Your Python environment does not have qtpy and PyQt (or PySide).
16 The following error was encountered when importing "qtpy":
20 Install qtpy and PyQt (or PySide) into your Python environment.
21 On a Debian/Ubuntu system you can install these modules using apt:
23 sudo apt install python3-pyqt5 python3-pyqt5.qtwebengine python3-qtpy
31 from qtpy
import QtWidgets
32 from qtpy
.QtCore
import Qt
35 # Qt 5.12 / PyQt 5.13 is unable to use QtWebEngineWidgets unless it is
36 # imported before QApplication is constructed.
37 from qtpy
import QtWebEngineWidgets
# noqa
39 # QtWebEngineWidgets / QtWebKit is not available -- no big deal.
44 from .interaction
import Interaction
45 from .models
import main
46 from .models
import selection
47 from .widgets
import cfgactions
48 from .widgets
import standard
49 from .widgets
import startup
50 from .settings
import Session
51 from .settings
import Settings
55 from . import fsmonitor
62 from . import qtcompat
64 from . import resources
70 def setup_environment():
71 """Set environment variables to control git's behavior"""
72 # Allow Ctrl-C to exit
73 signal
.signal(signal
.SIGINT
, signal
.SIG_DFL
)
75 # Session management wants an absolute path when restarting
76 sys
.argv
[0] = sys_argv0
= os
.path
.abspath(sys
.argv
[0])
78 # Spoof an X11 display for SSH
79 os
.environ
.setdefault('DISPLAY', ':0')
81 if not core
.getenv('SHELL', ''):
82 for shell
in ('/bin/zsh', '/bin/bash', '/bin/sh'):
83 if os
.path
.exists(shell
):
84 compat
.setenv('SHELL', shell
)
87 # Setup the path so that git finds us when we run 'git cola'
88 path_entries
= core
.getenv('PATH', '').split(os
.pathsep
)
89 bindir
= core
.decode(os
.path
.dirname(sys_argv0
))
90 path_entries
.append(bindir
)
91 path
= os
.pathsep
.join(path_entries
)
92 compat
.setenv('PATH', path
)
94 # We don't ever want a pager
95 compat
.setenv('GIT_PAGER', '')
98 git_askpass
= core
.getenv('GIT_ASKPASS')
99 ssh_askpass
= core
.getenv('SSH_ASKPASS')
101 askpass
= git_askpass
103 askpass
= ssh_askpass
104 elif sys
.platform
== 'darwin':
105 askpass
= resources
.package_command('ssh-askpass-darwin')
107 askpass
= resources
.package_command('ssh-askpass')
109 compat
.setenv('GIT_ASKPASS', askpass
)
110 compat
.setenv('SSH_ASKPASS', askpass
)
113 # Git v1.7.10 Release Notes
114 # =========================
116 # Compatibility Notes
117 # -------------------
119 # * From this release on, the "git merge" command in an interactive
120 # session will start an editor when it automatically resolves the
121 # merge for the user to explain the resulting commit, just like the
122 # "git commit" command does when it wasn't given a commit message.
124 # If you have a script that runs "git merge" and keeps its standard
125 # input and output attached to the user's terminal, and if you do not
126 # want the user to explain the resulting merge commits, you can
127 # export GIT_MERGE_AUTOEDIT environment variable set to "no", like
131 # GIT_MERGE_AUTOEDIT=no
132 # export GIT_MERGE_AUTOEDIT
134 # to disable this behavior (if you want your users to explain their
135 # merge commits, you do not have to do anything). Alternatively, you
136 # can give the "--no-edit" option to individual invocations of the
137 # "git merge" command if you know everybody who uses your script has
138 # Git v1.7.8 or newer.
140 # Longer-term: Use `git merge --no-commit` so that we always
141 # have a chance to explain our merges.
142 compat
.setenv('GIT_MERGE_AUTOEDIT', 'no')
145 def get_icon_themes(context
):
146 """Return the default icon theme names"""
149 icon_themes_env
= core
.getenv('GIT_COLA_ICON_THEME')
151 result
.extend([x
for x
in icon_themes_env
.split(':') if x
])
153 icon_themes_cfg
= list(reversed(context
.cfg
.get_all('cola.icontheme')))
155 result
.extend(icon_themes_cfg
)
158 result
.append('light')
163 # style note: we use camelCase here since we're masquerading a Qt class
164 class ColaApplication
:
165 """The main cola application
167 ColaApplication handles i18n of user-visible data
170 def __init__(self
, context
, argv
, locale
=None, icon_themes
=None, gui_theme
=None):
176 icons
.install(icon_themes
or get_icon_themes(context
))
178 self
.context
= context
180 self
._install
_hidpi
_config
()
181 self
._app
= ColaQApplication(context
, list(argv
))
182 self
._app
.setWindowIcon(icons
.cola())
183 self
._app
.setDesktopFileName('git-cola')
184 self
._install
_style
(gui_theme
)
186 def _install_style(self
, theme_str
):
187 """Generate and apply a stylesheet to the app"""
188 if theme_str
is None:
189 theme_str
= self
.context
.cfg
.get('cola.theme', default
='default')
190 theme
= themes
.find_theme(theme_str
)
192 self
._app
.setStyleSheet(theme
.build_style_sheet(self
._app
.palette()))
194 is_macos_theme
= theme_str
.startswith('macos-')
196 themes
.apply_platform_theme(theme_str
)
197 elif theme_str
!= 'default':
198 self
._app
.setPalette(theme
.build_palette(self
._app
.palette()))
200 def _install_hidpi_config(self
):
201 """Sets QT HiDPI scaling (requires Qt 5.6)"""
202 value
= self
.context
.cfg
.get('cola.hidpi', default
=hidpi
.Option
.AUTO
)
203 hidpi
.apply_choice(value
)
205 def activeWindow(self
):
206 """QApplication::activeWindow() pass-through"""
207 return self
._app
.activeWindow()
210 """QApplication::palette() pass-through"""
211 return self
._app
.palette()
214 """Wrap exec_() and start the application"""
215 # Defer connection so that local cola.inotify is honored
216 context
= self
.context
217 monitor
= context
.fsmonitor
218 monitor
.files_changed
.connect(
219 cmds
.run(cmds
.Refresh
, context
), type=Qt
.QueuedConnection
221 monitor
.config_changed
.connect(
222 cmds
.run(cmds
.RefreshConfig
, context
), type=Qt
.QueuedConnection
224 # Start the filesystem monitor thread
226 return self
._app
.exec_()
229 """Finalize the application"""
230 self
.context
.fsmonitor
.stop()
231 # Workaround QTBUG-52988 by deleting the app manually to prevent a
232 # crash during app shutdown.
233 # https://bugreports.qt.io/browse/QTBUG-52988
236 except (AttributeError, RuntimeError):
240 def exit(self
, status
):
241 """QApplication::exit(status) pass-through"""
242 return self
._app
.exit(status
)
245 class ColaQApplication(QtWidgets
.QApplication
):
246 """QApplication implementation for handling custom events"""
248 def __init__(self
, context
, argv
):
249 super().__init
__(argv
)
250 self
.context
= context
251 # Make icons sharp in HiDPI screen
252 if hasattr(Qt
, 'AA_UseHighDpiPixmaps'):
253 self
.setAttribute(Qt
.AA_UseHighDpiPixmaps
, True)
256 """Respond to focus events for the cola.refreshonfocus feature"""
257 if e
.type() == QtCore
.QEvent
.ApplicationActivate
:
258 context
= self
.context
261 if context
.git
.is_valid() and cfg
.get(
262 'cola.refreshonfocus', default
=False
264 cmds
.do(cmds
.Refresh
, context
)
265 return super().event(e
)
267 def commitData(self
, session_mgr
):
268 """Save session data"""
269 if not self
.context
or not self
.context
.view
:
271 view
= self
.context
.view
272 if not hasattr(view
, 'save_state'):
274 sid
= session_mgr
.sessionId()
275 skey
= session_mgr
.sessionKey()
276 session_id
= f
'{sid}_{skey}'
277 session
= Session(session_id
, repo
=core
.getcwd())
279 view
.save_state(settings
=session
)
282 def process_args(args
):
283 """Process and verify command-line arguments"""
285 # Accept 'git cola --version' or 'git cola version'
286 version
.print_version()
287 sys
.exit(core
.EXIT_SUCCESS
)
289 # Handle session management
290 restore_session(args
)
292 # Bail out if --repo is not a directory
293 repo
= core
.decode(args
.repo
)
294 if repo
.startswith('file:'):
295 repo
= repo
[len('file:') :]
296 repo
= core
.realpath(repo
)
297 if not core
.isdir(repo
):
300 'fatal: "%s" is not a directory. '
301 'Please specify a correct --repo <path>.'
305 core
.print_stderr(errmsg
)
306 sys
.exit(core
.EXIT_USAGE
)
309 def restore_session(args
):
310 """Load a session based on the window-manager provided arguments"""
311 # args.settings is provided when restoring from a session.
313 if args
.session
is None:
315 session
= Session(args
.session
)
317 args
.settings
= session
318 args
.repo
= session
.repo
321 def application_init(args
, update
=False):
322 """Parses the command-line arguments and starts git-cola"""
323 # Ensure that we're working in a valid git repository.
324 # If not, try to find one. When found, chdir there.
328 context
= new_context(args
)
329 timer
= context
.timer
332 new_worktree(context
, args
.repo
, args
.prompt
)
335 context
.model
.update_status()
339 timer
.display('init')
343 def new_context(args
):
344 """Create top-level ApplicationContext objects"""
345 context
= ApplicationContext(args
)
346 context
.settings
= args
.settings
or Settings
.read()
347 context
.git
= git
.create()
348 context
.cfg
= gitcfg
.create(context
)
349 context
.fsmonitor
= fsmonitor
.create(context
)
350 context
.selection
= selection
.create()
351 context
.model
= main
.create(context
)
352 context
.app
= new_application(context
, args
)
353 context
.timer
= Timer()
358 def application_run(context
, view
, start
=None, stop
=None):
359 """Run the application main loop"""
360 initialize_view(context
, view
)
364 # Start the event loop
365 result
= context
.app
.start()
374 def initialize_view(context
, view
):
375 """Register the main widget and display it"""
376 context
.set_view(view
)
378 if sys
.platform
== 'darwin':
382 def application_start(context
, view
):
383 """Show the GUI and start the main event loop"""
384 # Store the view for session management
385 return application_run(context
, view
, start
=default_start
, stop
=default_stop
)
388 def default_start(context
, _view
):
389 """Scan for the first time"""
390 QtCore
.QTimer
.singleShot(0, startup_message
)
391 QtCore
.QTimer
.singleShot(0, lambda: async_update(context
))
394 def default_stop(_context
, _view
):
395 """All done, cleanup"""
396 QtCore
.QThreadPool
.globalInstance().waitForDone()
399 def add_common_arguments(parser
):
400 """Add command arguments to the ArgumentParser"""
401 # We also accept 'git cola version'
403 '--version', default
=False, action
='store_true', help='print version number'
406 # Specifies a git repository to open
411 default
=core
.getcwd(),
412 help='open the specified git repository',
415 # Specifies that we should prompt for a repository at startup
417 '--prompt', action
='store_true', default
=False, help='prompt for a repository'
420 # Specify the icon theme
427 help='specify an icon theme (name or directory)',
430 # Resume an X Session Management session
432 '-session', metavar
='<session>', default
=None, help=argparse
.SUPPRESS
435 # Enable timing information
437 '--perf', action
='store_true', default
=False, help=argparse
.SUPPRESS
440 # Specify the GUI theme
442 '--theme', metavar
='<name>', default
=None, help='specify an GUI theme name'
446 def new_application(context
, args
):
447 """Create a new ColaApplication"""
448 return ColaApplication(
449 context
, sys
.argv
, icon_themes
=args
.icon_themes
, gui_theme
=args
.theme
453 def new_worktree(context
, repo
, prompt
):
454 """Find a Git repository, or prompt for one when not found"""
455 model
= context
.model
457 parent
= qtutils
.active_window()
461 valid
= model
.set_worktree(repo
)
463 # We are not currently in a git repository so we need to find one.
464 # Before prompting the user for a repository, check if they've
465 # configured a default repository and attempt to use it.
466 default_repo
= cfg
.get('cola.defaultrepo')
468 valid
= model
.set_worktree(default_repo
)
471 # If we've gotten into this loop then that means that neither the
472 # current directory nor the default repository were available.
473 # Prompt the user for a repository.
474 startup_dlg
= startup
.StartupDialog(context
, parent
)
475 gitdir
= startup_dlg
.find_git_repo()
477 sys
.exit(core
.EXIT_NOINPUT
)
479 if not core
.exists(os
.path
.join(gitdir
, '.git')):
480 offer_to_create_repo(context
, gitdir
)
481 valid
= model
.set_worktree(gitdir
)
484 valid
= model
.set_worktree(gitdir
)
488 N_('Error Opening Repository'),
489 message
=N_('Could not open %s.' % gitdir
),
494 def offer_to_create_repo(context
, gitdir
):
495 """Offer to create a new repo"""
496 title
= N_('Repository Not Found')
497 text
= N_('%s is not a Git repository.') % gitdir
498 informative_text
= N_('Create a new repository at that location?')
499 if standard
.confirm(title
, text
, informative_text
, N_('Create')):
500 status
, out
, err
= context
.git
.init(gitdir
)
501 title
= N_('Error Creating Repository')
503 Interaction
.command_error(title
, 'git init', status
, out
, err
)
506 def async_update(context
):
507 """Update the model in the background
509 git-cola should startup as quickly as possible.
511 update_status
= partial(context
.model
.update_status
, update_index
=True)
512 task
= qtutils
.SimpleTask(update_status
)
513 context
.runtask
.start(task
)
516 def startup_message():
517 """Print debug startup messages"""
518 trace
= git
.GIT_COLA_TRACE
519 if trace
in ('2', 'trace'):
520 msg1
= 'info: debug level 2: trace mode enabled'
521 msg2
= 'info: set GIT_COLA_TRACE=1 for less-verbose output'
522 Interaction
.log(msg1
)
523 Interaction
.log(msg2
)
525 msg1
= 'info: debug level 1'
526 msg2
= 'info: set GIT_COLA_TRACE=2 for trace mode'
527 Interaction
.log(msg1
)
528 Interaction
.log(msg2
)
532 """System-level initialization"""
533 # We support ~/.config/git-cola/git-bindir on Windows for configuring
534 # a custom location for finding the "git" executable.
535 git_path
= find_git()
537 prepend_path(git_path
)
539 # The current directory may have been deleted while we are still
540 # in that directory. We rectify this situation by walking up the
541 # directory tree and retrying.
543 # This is needed because because Python throws exceptions in lots of
544 # stdlib functions when in this situation, e.g. os.path.abspath() and
545 # os.path.realpath(), so it's simpler to mitigate the damage by changing
546 # the current directory to one that actually exists.
555 """Simple performance timer"""
560 def start(self
, key
):
563 self
._data
[key
] = [now
, now
]
566 """Stop a timer and return its elapsed time"""
567 entry
= self
._data
[key
]
568 entry
[1] = time
.time()
569 return self
.elapsed(key
)
571 def elapsed(self
, key
):
572 """Return the elapsed time for a timer"""
573 entry
= self
._data
[key
]
574 return entry
[1] - entry
[0]
576 def display(self
, key
):
577 """Display a timer"""
578 elapsed
= self
.elapsed(key
)
579 sys
.stdout
.write(f
'{key}: {elapsed:.5f}s\n')
583 """Stub arguments for interactive API use"""
586 self
.icon_themes
= []
589 self
.repo
= core
.getcwd()
597 """Create a new instance of application arguments"""
601 class ApplicationContext
:
602 """Context for performing operations on Git and related data models"""
604 def __init__(self
, args
):
606 self
.app
= None # ColaApplication
607 self
.git
= None # git.Git
608 self
.cfg
= None # gitcfg.GitConfig
609 self
.model
= None # main.MainModel
610 self
.timer
= None # Timer
611 self
.runtask
= None # qtutils.RunTask
612 self
.settings
= None # settings.Settings
613 self
.selection
= None # selection.SelectionModel
614 self
.fsmonitor
= None # fsmonitor
615 self
.view
= None # QWidget
616 self
.browser_windows
= [] # list of browse.Browser
618 def set_view(self
, view
):
619 """Initialize view-specific members"""
621 self
.runtask
= qtutils
.RunTask(parent
=view
)
625 """Return the path of git.exe, or None if we can't find it."""
626 if not utils
.is_win32():
627 return None # UNIX systems have git in their $PATH
629 # If the user wants to use a Git/bin/ directory from a non-standard
630 # directory then they can write its location into
631 # ~/.config/git-cola/git-bindir
632 git_bindir
= resources
.config_home('git-bindir')
633 if core
.exists(git_bindir
):
634 custom_path
= core
.read(git_bindir
).strip()
635 if custom_path
and core
.exists(custom_path
):
638 # Try to find Git's bin/ directory in one of the typical locations
639 pf
= os
.environ
.get('ProgramFiles', 'C:\\Program Files')
640 pf32
= os
.environ
.get('ProgramFiles(x86)', 'C:\\Program Files (x86)')
641 pf64
= os
.environ
.get('ProgramW6432', 'C:\\Program Files')
642 for p
in [pf64
, pf32
, pf
, 'C:\\']:
643 candidate
= os
.path
.join(p
, 'Git\\bin')
644 if os
.path
.isdir(candidate
):
650 def prepend_path(path
):
651 """Adds git to the PATH. This is needed on Windows."""
652 path
= core
.decode(path
)
653 path_entries
= core
.getenv('PATH', '').split(os
.pathsep
)
654 if path
not in path_entries
:
655 path_entries
.insert(0, path
)
656 compat
.setenv('PATH', os
.pathsep
.join(path_entries
))