1 """Provides the main() routine and ColaApplication"""
2 # pylint: disable=unused-import
3 from functools
import partial
11 Copyright (C) 2007-2022 David Aguilar and contributors
15 from qtpy
import QtCore
16 except ImportError as error
:
19 Your Python environment does not have qtpy and PyQt (or PySide).
20 The following error was encountered when importing "qtpy":
24 Install qtpy and PyQt (or PySide) into your Python environment.
25 On a Debian/Ubuntu system you can install these modules using apt:
27 sudo apt install python3-pyqt5 python3-pyqt5.qtwebengine python3-qtpy
35 from qtpy
import QtWidgets
36 from qtpy
.QtCore
import Qt
39 # Qt 5.12 / PyQt 5.13 is unable to use QtWebEngineWidgets unless it is
40 # imported before QApplication is constructed.
41 from qtpy
import QtWebEngineWidgets
# noqa
43 # QtWebEngineWidgets / QtWebKit is not available -- no big deal.
48 from .interaction
import Interaction
49 from .models
import main
50 from .models
import selection
51 from .widgets
import cfgactions
52 from .widgets
import standard
53 from .widgets
import startup
54 from .settings
import Session
55 from .settings
import Settings
59 from . import fsmonitor
66 from . import qtcompat
68 from . import resources
74 def setup_environment():
75 """Set environment variables to control git's behavior"""
76 # Allow Ctrl-C to exit
77 signal
.signal(signal
.SIGINT
, signal
.SIG_DFL
)
79 # Session management wants an absolute path when restarting
80 sys
.argv
[0] = sys_argv0
= os
.path
.abspath(sys
.argv
[0])
82 # Spoof an X11 display for SSH
83 os
.environ
.setdefault('DISPLAY', ':0')
85 if not core
.getenv('SHELL', ''):
86 for shell
in ('/bin/zsh', '/bin/bash', '/bin/sh'):
87 if os
.path
.exists(shell
):
88 compat
.setenv('SHELL', shell
)
91 # Setup the path so that git finds us when we run 'git cola'
92 path_entries
= core
.getenv('PATH', '').split(os
.pathsep
)
93 bindir
= core
.decode(os
.path
.dirname(sys_argv0
))
94 path_entries
.append(bindir
)
95 path
= os
.pathsep
.join(path_entries
)
96 compat
.setenv('PATH', path
)
98 # We don't ever want a pager
99 compat
.setenv('GIT_PAGER', '')
102 git_askpass
= core
.getenv('GIT_ASKPASS')
103 ssh_askpass
= core
.getenv('SSH_ASKPASS')
105 askpass
= git_askpass
107 askpass
= ssh_askpass
108 elif sys
.platform
== 'darwin':
109 askpass
= resources
.package_command('ssh-askpass-darwin')
111 askpass
= resources
.package_command('ssh-askpass')
113 compat
.setenv('GIT_ASKPASS', askpass
)
114 compat
.setenv('SSH_ASKPASS', askpass
)
117 # Git v1.7.10 Release Notes
118 # =========================
120 # Compatibility Notes
121 # -------------------
123 # * From this release on, the "git merge" command in an interactive
124 # session will start an editor when it automatically resolves the
125 # merge for the user to explain the resulting commit, just like the
126 # "git commit" command does when it wasn't given a commit message.
128 # If you have a script that runs "git merge" and keeps its standard
129 # input and output attached to the user's terminal, and if you do not
130 # want the user to explain the resulting merge commits, you can
131 # export GIT_MERGE_AUTOEDIT environment variable set to "no", like
135 # GIT_MERGE_AUTOEDIT=no
136 # export GIT_MERGE_AUTOEDIT
138 # to disable this behavior (if you want your users to explain their
139 # merge commits, you do not have to do anything). Alternatively, you
140 # can give the "--no-edit" option to individual invocations of the
141 # "git merge" command if you know everybody who uses your script has
142 # Git v1.7.8 or newer.
144 # Longer-term: Use `git merge --no-commit` so that we always
145 # have a chance to explain our merges.
146 compat
.setenv('GIT_MERGE_AUTOEDIT', 'no')
149 def get_icon_themes(context
):
150 """Return the default icon theme names"""
153 icon_themes_env
= core
.getenv('GIT_COLA_ICON_THEME')
155 result
.extend([x
for x
in icon_themes_env
.split(':') if x
])
157 icon_themes_cfg
= list(reversed(context
.cfg
.get_all('cola.icontheme')))
159 result
.extend(icon_themes_cfg
)
162 result
.append('light')
167 # style note: we use camelCase here since we're masquerading a Qt class
168 class ColaApplication
:
169 """The main cola application
171 ColaApplication handles i18n of user-visible data
174 def __init__(self
, context
, argv
, locale
=None, icon_themes
=None, gui_theme
=None):
180 icons
.install(icon_themes
or get_icon_themes(context
))
182 self
.context
= context
184 self
._install
_hidpi
_config
()
185 self
._app
= ColaQApplication(context
, list(argv
))
186 self
._app
.setWindowIcon(icons
.cola())
187 self
._app
.setDesktopFileName('git-cola')
188 self
._install
_style
(gui_theme
)
190 def _install_style(self
, theme_str
):
191 """Generate and apply a stylesheet to the app"""
192 if theme_str
is None:
193 theme_str
= self
.context
.cfg
.get('cola.theme', default
='default')
194 theme
= themes
.find_theme(theme_str
)
196 self
._app
.setStyleSheet(theme
.build_style_sheet(self
._app
.palette()))
198 is_macos_theme
= theme_str
.startswith('macos-')
200 themes
.apply_platform_theme(theme_str
)
201 elif theme_str
!= 'default':
202 self
._app
.setPalette(theme
.build_palette(self
._app
.palette()))
204 def _install_hidpi_config(self
):
205 """Sets QT HIDPI scalling (requires Qt 5.6)"""
206 value
= self
.context
.cfg
.get('cola.hidpi', default
=hidpi
.Option
.AUTO
)
207 hidpi
.apply_choice(value
)
209 def activeWindow(self
):
210 """QApplication::activeWindow() pass-through"""
211 return self
._app
.activeWindow()
214 """QApplication::palette() pass-through"""
215 return self
._app
.palette()
218 """Wrap exec_() and start the application"""
219 # Defer connection so that local cola.inotify is honored
220 context
= self
.context
221 monitor
= context
.fsmonitor
222 monitor
.files_changed
.connect(
223 cmds
.run(cmds
.Refresh
, context
), type=Qt
.QueuedConnection
225 monitor
.config_changed
.connect(
226 cmds
.run(cmds
.RefreshConfig
, context
), type=Qt
.QueuedConnection
228 # Start the filesystem monitor thread
230 return self
._app
.exec_()
233 """Finalize the application"""
234 self
.context
.fsmonitor
.stop()
235 # Workaround QTBUG-52988 by deleting the app manually to prevent a
236 # crash during app shutdown.
237 # https://bugreports.qt.io/browse/QTBUG-52988
240 except (AttributeError, RuntimeError):
244 def exit(self
, status
):
245 """QApplication::exit(status) pass-through"""
246 return self
._app
.exit(status
)
249 class ColaQApplication(QtWidgets
.QApplication
):
250 """QApplication implementation for handling custom events"""
252 def __init__(self
, context
, argv
):
253 super().__init
__(argv
)
254 self
.context
= context
255 # Make icons sharp in HiDPI screen
256 if hasattr(Qt
, 'AA_UseHighDpiPixmaps'):
257 self
.setAttribute(Qt
.AA_UseHighDpiPixmaps
, True)
260 """Respond to focus events for the cola.refreshonfocus feature"""
261 if e
.type() == QtCore
.QEvent
.ApplicationActivate
:
262 context
= self
.context
265 if context
.git
.is_valid() and cfg
.get(
266 'cola.refreshonfocus', default
=False
268 cmds
.do(cmds
.Refresh
, context
)
269 return super().event(e
)
271 def commitData(self
, session_mgr
):
272 """Save session data"""
273 if not self
.context
or not self
.context
.view
:
275 view
= self
.context
.view
276 if not hasattr(view
, 'save_state'):
278 sid
= session_mgr
.sessionId()
279 skey
= session_mgr
.sessionKey()
280 session_id
= f
'{sid}_{skey}'
281 session
= Session(session_id
, repo
=core
.getcwd())
283 view
.save_state(settings
=session
)
286 def process_args(args
):
287 """Process and verify command-line arguments"""
289 # Accept 'git cola --version' or 'git cola version'
290 version
.print_version()
291 sys
.exit(core
.EXIT_SUCCESS
)
293 # Handle session management
294 restore_session(args
)
296 # Bail out if --repo is not a directory
297 repo
= core
.decode(args
.repo
)
298 if repo
.startswith('file:'):
299 repo
= repo
[len('file:') :]
300 repo
= core
.realpath(repo
)
301 if not core
.isdir(repo
):
304 'fatal: "%s" is not a directory. '
305 'Please specify a correct --repo <path>.'
309 core
.print_stderr(errmsg
)
310 sys
.exit(core
.EXIT_USAGE
)
313 def restore_session(args
):
314 """Load a session based on the window-manager provided arguments"""
315 # args.settings is provided when restoring from a session.
317 if args
.session
is None:
319 session
= Session(args
.session
)
321 args
.settings
= session
322 args
.repo
= session
.repo
325 def application_init(args
, update
=False):
326 """Parses the command-line arguments and starts git-cola"""
327 # Ensure that we're working in a valid git repository.
328 # If not, try to find one. When found, chdir there.
332 context
= new_context(args
)
333 timer
= context
.timer
336 new_worktree(context
, args
.repo
, args
.prompt
)
339 context
.model
.update_status()
343 timer
.display('init')
347 def new_context(args
):
348 """Create top-level ApplicationContext objects"""
349 context
= ApplicationContext(args
)
350 context
.settings
= args
.settings
or Settings
.read()
351 context
.git
= git
.create()
352 context
.cfg
= gitcfg
.create(context
)
353 context
.fsmonitor
= fsmonitor
.create(context
)
354 context
.selection
= selection
.create()
355 context
.model
= main
.create(context
)
356 context
.app
= new_application(context
, args
)
357 context
.timer
= Timer()
362 def application_run(context
, view
, start
=None, stop
=None):
363 """Run the application main loop"""
364 initialize_view(context
, view
)
368 # Start the event loop
369 result
= context
.app
.start()
378 def initialize_view(context
, view
):
379 """Register the main widget and display it"""
380 context
.set_view(view
)
382 if sys
.platform
== 'darwin':
386 def application_start(context
, view
):
387 """Show the GUI and start the main event loop"""
388 # Store the view for session management
389 return application_run(context
, view
, start
=default_start
, stop
=default_stop
)
392 def default_start(context
, _view
):
393 """Scan for the first time"""
394 QtCore
.QTimer
.singleShot(0, startup_message
)
395 QtCore
.QTimer
.singleShot(0, lambda: async_update(context
))
398 def default_stop(_context
, _view
):
399 """All done, cleanup"""
400 QtCore
.QThreadPool
.globalInstance().waitForDone()
403 def add_common_arguments(parser
):
404 """Add command arguments to the ArgumentParser"""
405 # We also accept 'git cola version'
407 '--version', default
=False, action
='store_true', help='print version number'
410 # Specifies a git repository to open
415 default
=core
.getcwd(),
416 help='open the specified git repository',
419 # Specifies that we should prompt for a repository at startup
421 '--prompt', action
='store_true', default
=False, help='prompt for a repository'
424 # Specify the icon theme
431 help='specify an icon theme (name or directory)',
434 # Resume an X Session Management session
436 '-session', metavar
='<session>', default
=None, help=argparse
.SUPPRESS
439 # Enable timing information
441 '--perf', action
='store_true', default
=False, help=argparse
.SUPPRESS
444 # Specify the GUI theme
446 '--theme', metavar
='<name>', default
=None, help='specify an GUI theme name'
450 def new_application(context
, args
):
451 """Create a new ColaApplication"""
452 return ColaApplication(
453 context
, sys
.argv
, icon_themes
=args
.icon_themes
, gui_theme
=args
.theme
457 def new_worktree(context
, repo
, prompt
):
458 """Find a Git repository, or prompt for one when not found"""
459 model
= context
.model
461 parent
= qtutils
.active_window()
465 valid
= model
.set_worktree(repo
)
467 # We are not currently in a git repository so we need to find one.
468 # Before prompting the user for a repository, check if they've
469 # configured a default repository and attempt to use it.
470 default_repo
= cfg
.get('cola.defaultrepo')
472 valid
= model
.set_worktree(default_repo
)
475 # If we've gotten into this loop then that means that neither the
476 # current directory nor the default repository were available.
477 # Prompt the user for a repository.
478 startup_dlg
= startup
.StartupDialog(context
, parent
)
479 gitdir
= startup_dlg
.find_git_repo()
481 sys
.exit(core
.EXIT_NOINPUT
)
483 if not core
.exists(os
.path
.join(gitdir
, '.git')):
484 offer_to_create_repo(context
, gitdir
)
485 valid
= model
.set_worktree(gitdir
)
488 valid
= model
.set_worktree(gitdir
)
492 N_('Error Opening Repository'),
493 message
=N_('Could not open %s.' % gitdir
),
498 def offer_to_create_repo(context
, gitdir
):
499 """Offer to create a new repo"""
500 title
= N_('Repository Not Found')
501 text
= N_('%s is not a Git repository.') % gitdir
502 informative_text
= N_('Create a new repository at that location?')
503 if standard
.confirm(title
, text
, informative_text
, N_('Create')):
504 status
, out
, err
= context
.git
.init(gitdir
)
505 title
= N_('Error Creating Repository')
507 Interaction
.command_error(title
, 'git init', status
, out
, err
)
510 def async_update(context
):
511 """Update the model in the background
513 git-cola should startup as quickly as possible.
516 update_status
= partial(context
.model
.update_status
, update_index
=True)
517 task
= qtutils
.SimpleTask(update_status
)
518 context
.runtask
.start(task
)
521 def startup_message():
522 """Print debug startup messages"""
523 trace
= git
.GIT_COLA_TRACE
524 if trace
in ('2', 'trace'):
525 msg1
= 'info: debug level 2: trace mode enabled'
526 msg2
= 'info: set GIT_COLA_TRACE=1 for less-verbose output'
527 Interaction
.log(msg1
)
528 Interaction
.log(msg2
)
530 msg1
= 'info: debug level 1'
531 msg2
= 'info: set GIT_COLA_TRACE=2 for trace mode'
532 Interaction
.log(msg1
)
533 Interaction
.log(msg2
)
537 """System-level initialization"""
538 # We support ~/.config/git-cola/git-bindir on Windows for configuring
539 # a custom location for finding the "git" executable.
540 git_path
= find_git()
542 prepend_path(git_path
)
544 # The current directory may have been deleted while we are still
545 # in that directory. We rectify this situation by walking up the
546 # directory tree and retrying.
548 # This is needed because because Python throws exceptions in lots of
549 # stdlib functions when in this situation, e.g. os.path.abspath() and
550 # os.path.realpath(), so it's simpler to mitigate the damage by changing
551 # the current directory to one that actually exists.
560 """Simple performance timer"""
565 def start(self
, key
):
568 self
._data
[key
] = [now
, now
]
571 """Stop a timer and return its elapsed time"""
572 entry
= self
._data
[key
]
573 entry
[1] = time
.time()
574 return self
.elapsed(key
)
576 def elapsed(self
, key
):
577 """Return the elapsed time for a timer"""
578 entry
= self
._data
[key
]
579 return entry
[1] - entry
[0]
581 def display(self
, key
):
582 """Display a timer"""
583 elapsed
= self
.elapsed(key
)
584 sys
.stdout
.write(f
'{key}: {elapsed:.5f}s\n')
588 """Stub arguments for interactive API use"""
591 self
.icon_themes
= []
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
))