├── .github ├── FUNDING.yml └── workflows │ ├── appimage.yml │ ├── macapp.yml │ └── tests.yml ├── .gitignore ├── .idea ├── gitfourchette.iml ├── icon.svg ├── modules.xml └── runConfigurations │ ├── gitfourchette.run.xml │ └── pytest.run.xml ├── .vscode ├── launch.json └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── gitfourchette ├── __init__.py ├── __main__.py ├── appconsts.py ├── application.py ├── assets │ ├── icons │ │ ├── achtung.svg │ │ ├── achtung@dark.svg │ │ ├── back.svg │ │ ├── colorscheme-chip.svg │ │ ├── dark.svg │ │ ├── error.svg │ │ ├── forward.svg │ │ ├── git-branch.svg │ │ ├── git-change.svg │ │ ├── git-checkout.svg │ │ ├── git-cherrypick.svg │ │ ├── git-commit-amend.svg │ │ ├── git-commit.svg │ │ ├── git-discard-lines.svg │ │ ├── git-discard.svg │ │ ├── git-fetch.svg │ │ ├── git-folder.svg │ │ ├── git-head-detached.svg │ │ ├── git-head.svg │ │ ├── git-merge.svg │ │ ├── git-pull.svg │ │ ├── git-push.svg │ │ ├── git-remote.svg │ │ ├── git-settings.svg │ │ ├── git-stage-lines.svg │ │ ├── git-stage.svg │ │ ├── git-stash-black.svg │ │ ├── git-stash.svg │ │ ├── git-submodule.svg │ │ ├── git-tag.svg │ │ ├── git-unstage-lines.svg │ │ ├── git-unstage.svg │ │ ├── git-workdir.svg │ │ ├── gitfourchette.png │ │ ├── hint.svg │ │ ├── light-dark-toggle.svg │ │ ├── light.svg │ │ ├── linebg-chip-colorblind.svg │ │ ├── linebg-chip-redgreen.svg │ │ ├── magnifying-glass.svg │ │ ├── maximize.svg │ │ ├── mug.png │ │ ├── prefs-advanced.svg │ │ ├── prefs-diff.svg │ │ ├── prefs-external.svg │ │ ├── prefs-general.svg │ │ ├── prefs-graph.svg │ │ ├── prefs-imagediff.svg │ │ ├── prefs-tabs.svg │ │ ├── prefs-trash.svg │ │ ├── prefs-usercommands.svg │ │ ├── reveal.svg │ │ ├── right_ptr@1x.png │ │ ├── right_ptr@4x.png │ │ ├── status_a.svg │ │ ├── status_a@dark.svg │ │ ├── status_d.svg │ │ ├── status_d@dark.svg │ │ ├── status_m.svg │ │ ├── status_m@dark.svg │ │ ├── status_missing.svg │ │ ├── status_r.svg │ │ ├── status_r@dark.svg │ │ ├── status_t.svg │ │ ├── status_t@dark.svg │ │ ├── status_u.svg │ │ ├── status_u@dark.svg │ │ ├── status_x.svg │ │ ├── status_x@dark.svg │ │ ├── terminal.svg │ │ ├── urgent-tab.svg │ │ ├── view-exclusive.svg │ │ ├── view-hidden-indirect.svg │ │ ├── view-hidden.svg │ │ └── view-visible.svg │ ├── lang │ │ ├── README.md │ │ ├── fr.mo │ │ ├── fr.po │ │ └── gitfourchette.pot │ ├── mac │ │ ├── opendiff.sh │ │ └── terminal.scpt │ ├── style-dark.qss │ ├── style.qss │ └── termcmd.sh ├── colors.py ├── diffarea.py ├── diffview │ ├── diffdocument.py │ ├── diffgutter.py │ ├── diffrubberband.py │ ├── diffsyntaxhighlighter.py │ ├── diffview.py │ ├── specialdiff.py │ └── specialdiffview.py ├── exttools │ ├── mergedriver.py │ ├── toolcommands.py │ ├── toolpresets.py │ ├── toolprocess.py │ ├── usercommand.py │ └── usercommandsyntaxhighlighter.py ├── filelists │ ├── committedfiles.py │ ├── dirtyfiles.py │ ├── filelist.py │ ├── filelistmodel.py │ └── stagedfiles.py ├── forms │ ├── aboutdialog.py │ ├── aboutdialog.ui │ ├── banner.py │ ├── brandeddialog.py │ ├── checkoutcommitdialog.py │ ├── checkoutcommitdialog.ui │ ├── clonedialog.py │ ├── clonedialog.ui │ ├── commitdialog.py │ ├── commitdialog.ui │ ├── conflictview.py │ ├── conflictview.ui │ ├── contextheader.py │ ├── deletetagdialog.py │ ├── deletetagdialog.ui │ ├── donateprompt.py │ ├── donateprompt.ui │ ├── identitydialog.py │ ├── identitydialog.ui │ ├── ignorepatterndialog.py │ ├── ignorepatterndialog.ui │ ├── keyfilepickercheckbox.py │ ├── maintoolbar.py │ ├── newbranchdialog.py │ ├── newbranchdialog.ui │ ├── newtagdialog.py │ ├── newtagdialog.ui │ ├── openrepoprogress.py │ ├── openrepoprogress.ui │ ├── passphrasedialog.py │ ├── prefsdialog.py │ ├── protocolbutton.py │ ├── pushdialog.py │ ├── pushdialog.ui │ ├── registersubmoduledialog.py │ ├── registersubmoduledialog.ui │ ├── remotedialog.py │ ├── remotedialog.ui │ ├── remotelinkdialog.py │ ├── remotelinkdialog.ui │ ├── reposettingsdialog.py │ ├── reposettingsdialog.ui │ ├── resetheaddialog.py │ ├── resetheaddialog.ui │ ├── searchbar.py │ ├── searchbar.ui │ ├── signatureform.py │ ├── signatureform.ui │ ├── stashdialog.py │ ├── stashdialog.ui │ ├── statusform.py │ ├── textinputdialog.py │ ├── ui_aboutdialog.py │ ├── ui_checkoutcommitdialog.py │ ├── ui_clonedialog.py │ ├── ui_commitdialog.py │ ├── ui_conflictview.py │ ├── ui_deletetagdialog.py │ ├── ui_donateprompt.py │ ├── ui_identitydialog.py │ ├── ui_ignorepatterndialog.py │ ├── ui_newbranchdialog.py │ ├── ui_newtagdialog.py │ ├── ui_openrepoprogress.py │ ├── ui_pushdialog.py │ ├── ui_registersubmoduledialog.py │ ├── ui_remotedialog.py │ ├── ui_remotelinkdialog.py │ ├── ui_reposettingsdialog.py │ ├── ui_resetheaddialog.py │ ├── ui_searchbar.py │ ├── ui_signatureform.py │ ├── ui_stashdialog.py │ ├── ui_unloadedrepoplaceholder.py │ ├── ui_welcomewidget.py │ ├── unloadedrepoplaceholder.py │ ├── unloadedrepoplaceholder.ui │ ├── welcomewidget.py │ └── welcomewidget.ui ├── globalshortcuts.py ├── graph │ ├── __init__.py │ ├── __main__.py │ ├── graph.py │ ├── graphbuilder.py │ ├── graphdiagram.py │ ├── graphsplicer.py │ ├── graphtrickle.py │ └── graphweaver.py ├── graphview │ ├── commitlogdelegate.py │ ├── commitlogfilter.py │ ├── commitlogmodel.py │ ├── graphpaint.py │ └── graphview.py ├── localization.py ├── mainwindow.py ├── nav.py ├── porcelain.py ├── prefsfile.py ├── pycompat.py ├── qt.py ├── remotelink.py ├── repomodel.py ├── repoprefs.py ├── repowidget.py ├── reverseunidiff.py ├── settings.py ├── sidebar │ ├── sidebar.py │ ├── sidebardelegate.py │ └── sidebarmodel.py ├── subpatch.py ├── syntax │ ├── __init__.py │ ├── colorscheme.py │ ├── lexercache.py │ ├── lexjob.py │ └── lexjobcache.py ├── tasks │ ├── __init__.py │ ├── branchtasks.py │ ├── committasks.py │ ├── exporttasks.py │ ├── indextasks.py │ ├── jumptasks.py │ ├── loadtasks.py │ ├── misctasks.py │ ├── nettasks.py │ ├── remotetasks.py │ ├── repotask.py │ ├── stashtasks.py │ ├── submoduletasks.py │ └── taskbook.py ├── toolbox │ ├── __init__.py │ ├── actiondef.py │ ├── autohidemenubar.py │ ├── benchmark.py │ ├── calledfromqthread.py │ ├── excutils.py │ ├── fittedtext.py │ ├── fontpicker.py │ ├── gitutils.py │ ├── iconbank.py │ ├── memoryindicator.py │ ├── messageboxes.py │ ├── pathutils.py │ ├── persistentfiledialog.py │ ├── qbusyspinner.py │ ├── qcomboboxwithpreview.py │ ├── qelidedlabel.py │ ├── qfaintseparator.py │ ├── qfilepickercheckbox.py │ ├── qhintbutton.py │ ├── qsignalblockercontext.py │ ├── qstatusbar2.py │ ├── qtabwidget2.py │ ├── qtutils.py │ ├── recolorsvgiconengine.py │ ├── textutils.py │ ├── urltooltip.py │ └── validatormultiplexer.py ├── trash.py ├── trtables.py └── webhost.py ├── pkg ├── appimage │ ├── .gitignore │ ├── README.md │ ├── build-appimage.sh │ ├── entrypoint.sh │ ├── gitfourchette.desktop │ ├── gitfourchette.png │ └── junklist.txt ├── flatpak │ ├── org.gitfourchette.gitfourchette.desktop │ ├── org.gitfourchette.gitfourchette.metainfo.xml │ ├── org.gitfourchette.gitfourchette.png │ └── sync_changelog.py ├── gitfourchette.desktop └── pyinstaller │ ├── __init__.py │ ├── build-macos-app.sh │ ├── gitfourchette-linux.spec │ ├── gitfourchette-macos.spec │ ├── gitfourchette.icns │ └── spec_helper.py ├── pyproject.toml ├── run.desktop ├── run.sh ├── test.sh ├── test ├── __init__.py ├── conftest.py ├── data │ ├── TestEmptyRepository.zip │ ├── TestGitRepository.zip │ ├── editor-shim.py │ ├── image1.png │ ├── image2.png │ ├── keys │ │ ├── missingpriv.pub │ │ ├── missingpub │ │ ├── pygit2_empty │ │ ├── pygit2_empty.pub │ │ ├── simple │ │ └── simple.pub │ ├── merge-shim.py │ ├── pause.py │ ├── submoroot.zip │ └── testrepoformerging.zip ├── reposcenario.py ├── test_diff.py ├── test_externalchanges.py ├── test_exttools.py ├── test_filelist.py ├── test_gitfourchette.py ├── test_graphplayback.py ├── test_graphsplicer.py ├── test_graphtrickle.py ├── test_graphtricklestabilization.py ├── test_graphview.py ├── test_mainwindow.py ├── test_navigation.py ├── test_prefs.py ├── test_regexes.py ├── test_remotelink.py ├── test_sidebar.py ├── test_syntaxhighlighting.py ├── test_tasks_branch.py ├── test_tasks_commit.py ├── test_tasks_conflict.py ├── test_tasks_export.py ├── test_tasks_net.py ├── test_tasks_remote.py ├── test_tasks_stage.py ├── test_tasks_stash.py ├── test_tasks_submodule.py ├── test_trash.py ├── test_usercommands.py └── util.py └── update_resources.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: jorio 2 | -------------------------------------------------------------------------------- /.github/workflows/appimage.yml: -------------------------------------------------------------------------------- 1 | name: AppImage 2 | 3 | on: [workflow_dispatch] 4 | 5 | env: 6 | PYVER: "3.13" 7 | QT_API: "pyqt6" 8 | 9 | jobs: 10 | appimage: 11 | runs-on: ubuntu-22.04 # Use oldest available Ubuntu for maximum glibc compatibility 12 | timeout-minutes: 15 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-python@v5 16 | with: { python-version: "${{ env.PYVER }}" } 17 | - run: python -m pip install --upgrade pip setuptools wheel 18 | - run: python -m pip install --upgrade -e .[$QT_API,pygments] # install dependencies (installing GF itself isn't necessary) 19 | - run: python -m pip install --upgrade git+https://github.com/niess/python-appimage 20 | - name: Build AppImage 21 | run: | 22 | PYVER=$PYVER QT_API=$QT_API ./pkg/appimage/build-appimage.sh 23 | echo "ARTIFACT_NAME=$(cd build && ls GitFourchette*.AppImage)" >> $GITHUB_ENV 24 | - uses: actions/upload-artifact@v4 25 | with: 26 | path: build/GitFourchette*.AppImage 27 | name: ${{env.ARTIFACT_NAME}} 28 | compression-level: 0 29 | -------------------------------------------------------------------------------- /.github/workflows/macapp.yml: -------------------------------------------------------------------------------- 1 | name: Mac App 2 | 3 | on: [workflow_dispatch] 4 | 5 | env: 6 | PYVER: "3.13" 7 | QT_API: "pyqt6" 8 | 9 | jobs: 10 | macapp: 11 | runs-on: macos-latest 12 | timeout-minutes: 15 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-python@v5 16 | with: { python-version: "${{ env.PYVER }}" } 17 | - run: python --version 18 | - run: python -m pip install --upgrade pip setuptools wheel 19 | - run: python -m pip install --upgrade -e .[$QT_API,pygments] # install dependencies (installing GF itself isn't necessary) 20 | - run: python -m pip install --upgrade pyinstaller 21 | - run: ./pkg/pyinstaller/build-macos-app.sh 22 | # upload-artifact has trouble with symlinks, use ditto to zip up the app 23 | - run: ditto -c -k --keepParent dist/GitFourchette.app GitFourchette-mac.zip 24 | - uses: actions/upload-artifact@v4 25 | with: { name: mac-app, path: GitFourchette-mac.zip, compression-level: 0 } 26 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | paths-ignore: 7 | - '**.md' 8 | - '**.po' 9 | 10 | jobs: 11 | tests: 12 | runs-on: ${{ matrix.os }} 13 | name: ${{ matrix.name }} 14 | timeout-minutes: 10 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | include: 19 | # Legacy configs 20 | - {name: '310-pyqt5-outdated', os: ubuntu-22.04, python-version: '3.10', qt-binding: pyqt5, piptweak: 'pip install pygit2==1.14.1 pygments==2.12'} 21 | - {name: '310-pyqt5-nosyntax', os: ubuntu-22.04, python-version: '3.10', qt-binding: pyqt5, piptweak: 'pip uninstall -y pygments'} 22 | # Semi-legacy configs 23 | - {name: '311-pyqt6', os: ubuntu-latest, python-version: '3.11', qt-binding: pyqt6} 24 | - {name: '312-pyqt6', os: ubuntu-latest, python-version: '3.12', qt-binding: pyqt6} 25 | # Up-to-date configs 26 | - {name: '313-pyqt6', os: ubuntu-latest, python-version: '3.13', qt-binding: pyqt6, testenv: 'TESTFLATPAK=1'} 27 | - {name: '313-pyside6', os: ubuntu-latest, python-version: '3.13', qt-binding: pyside6, testenv: 'TESTFLATPAK=1', coverage: true} 28 | - {name: '313-pyqt6-mac', os: macos-latest, python-version: '3.13', qt-binding: pyqt6} 29 | 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v4 33 | 34 | - name: Qt dependencies 35 | if: ${{ runner.os == 'Linux' }} 36 | run: sudo apt install ${{ matrix.qt-binding == 'pyqt5' && 'libqt5gui5' || 'libqt6gui6' }} 37 | 38 | - name: Set up Python 39 | uses: actions/setup-python@v5 40 | with: 41 | python-version: ${{ matrix.python-version }} 42 | cache: 'pip' 43 | 44 | - name: Pip dependencies 45 | run: | 46 | pip install -e .[${{ matrix.qt-binding }},pygments,test] 47 | ${{ matrix.piptweak }} 48 | 49 | - run: ruff check 50 | 51 | - name: Unit tests 52 | run: TESTNET=1 ${{ matrix.testenv }} PYTEST_QT_API=${{ matrix.qt-binding }} ./test.sh ${{ matrix.coverage && '--cov' }} 53 | 54 | - name: Upload coverage report 55 | uses: actions/upload-artifact@v4 56 | if: ${{ matrix.coverage }} 57 | with: 58 | path: coverage_html_report 59 | name: coverage_${{ matrix.name }}_${{ github.sha }} 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .DS_Store 3 | .coverage 4 | build/ 5 | dist/ 6 | gitfourchette.egg-info/ 7 | 8 | /.idea/* 9 | !/.idea/icon.svg 10 | !/.idea/modules.xml 11 | !/.idea/runConfigurations/ 12 | -------------------------------------------------------------------------------- /.idea/gitfourchette.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.idea/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/runConfigurations/gitfourchette.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 24 | -------------------------------------------------------------------------------- /.idea/runConfigurations/pytest.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | // Use this single-threaded config to debug tasks 8 | // (the debugger struggles to hit breakpoints in background threads) 9 | { 10 | "name": "Debug (single-threaded)", 11 | "type": "debugpy", 12 | "request": "launch", 13 | "module": "gitfourchette", 14 | "args": ["--debug", "--no-threads"], 15 | }, 16 | 17 | { 18 | "name": "Debug (multi-threaded)", 19 | "type": "debugpy", 20 | "request": "launch", 21 | "module": "gitfourchette", 22 | "args": ["--debug"], 23 | }, 24 | 25 | // In VSCode's testing tab, click Debug Tests to run the tests offscreen. 26 | { 27 | "name": "Debug Tests Offscreen", 28 | "type": "debugpy", 29 | "request": "launch", 30 | "justMyCode": false, 31 | "purpose": ["debug-test"], 32 | "env": {"QT_QPA_PLATFORM": "offscreen"}, 33 | }, 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.unittestEnabled": false, 3 | "python.testing.pytestArgs": [], 4 | "python.testing.pytestEnabled": true 5 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitFourchette Get it on FlathubGitFourchette 2 | 3 | The comfortable Git UI for Linux. 4 | 5 | - A comfortable way to explore and understand your Git repositories 6 | - Powerful tools to stage code, create commits, and manage branches 7 | - Snappy and intuitive Qt UI designed to fit in snugly with KDE Plasma 8 | 9 | Learn more on GitFourchette’s homepage at [gitfourchette.org](https://gitfourchette.org). 10 | 11 | ![Screenshot of GitFourchette running under KDE Plasma 6](https://gitfourchette.org/_static/appstream/packshot-shadow-light.png) 12 | 13 | ## Documentation 14 | 15 | - [GitFourchette's website](https://gitfourchette.org) ([source code](https://github.com/jorio/gitfourchette.org)) 16 | - [How to install or run from source](https://gitfourchette.org/install.html) 17 | - [User’s Guide](https://gitfourchette.org/guide) 18 | - [Limitations](https://gitfourchette.org/limitations.html) 19 | - [Changelog](CHANGELOG.md) 20 | - [Localization guide](https://gitfourchette.org/localization.html) 21 | 22 | ## Install 23 | 24 | - **Recommended: Get the [Flatpak](https://flathub.org/apps/org.gitfourchette.gitfourchette):** 25 | ```sh 26 | flatpak install flathub org.gitfourchette.gitfourchette 27 | ``` 28 | 29 | - Or, get a standalone AppImage from the [releases](https://github.com/jorio/gitfourchette/releases). 30 | 31 | - Or, see [how to install or run from source](https://gitfourchette.org/install.html). 32 | 33 | ## About the project 34 | 35 | I started out writing GitFourchette in my spare time to scratch my itch for a Git UI I’d feel cozy in. After plenty of “dogfooding it” to develop my other projects, I’m finally taking the plunge and releasing it publicly—maybe it’ll become your favorite Git client too. 36 | 37 | ## Translations welcome 38 | 39 | Feel free to [localize GitFourchette for your language on Weblate](https://hosted.weblate.org/projects/gitfourchette/gitfourchette)—it's quick and easy, no need to set up any development tools. See also our [localization guide](https://gitfourchette.org/localization.html). 40 | 41 | ## Donate 🩷 42 | 43 | GitFourchette is free—both as in beer and as in freedom. But if it helped you get work done, feel free to [buy me a coffee](https://ko-fi.com/jorio)! Any contribution will encourage the continuation of the project. Thank you! 44 | 45 | ## License 46 | 47 | GitFourchette © 2025 Iliyas Jorio. 48 | Distributed under the terms of the [GNU General Public License v3](LICENSE). 49 | -------------------------------------------------------------------------------- /gitfourchette/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorio/gitfourchette/4a24e06dc38e8c4253a1d18215b6cb581cecfd4e/gitfourchette/__init__.py -------------------------------------------------------------------------------- /gitfourchette/__main__.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2024 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | import logging 8 | import signal 9 | import sys 10 | 11 | from gitfourchette.qt import * 12 | 13 | 14 | def excepthook(exctype, value, tb): 15 | sys.__excepthook__(exctype, value, tb) # run default excepthook 16 | 17 | from gitfourchette.toolbox import excMessageBox 18 | excMessageBox(value, printExc=False) 19 | 20 | 21 | def main(): 22 | logging.basicConfig( 23 | stream=sys.stdout, 24 | level=logging.DEBUG, 25 | format='%(levelname).1s %(asctime)s %(filename)-16s | %(message)s', 26 | datefmt="%H:%M:%S") 27 | logging.captureWarnings(True) 28 | 29 | # inject our own exception hook to show an error dialog in case of unhandled exceptions 30 | # (note that this may get overridden when running under a debugger) 31 | sys.excepthook = excepthook 32 | 33 | # Initialize the application 34 | from gitfourchette.application import GFApplication 35 | app = GFApplication(sys.argv, __file__) 36 | 37 | # Quit app cleanly on Ctrl+C (all repos and associated file handles will be freed) 38 | signal.signal(signal.SIGINT, lambda *args: app.quit()) 39 | 40 | # Force Python interpreter to run every now and then so it can run the Ctrl+C signal handler 41 | # (Otherwise the app won't actually die until the window regains focus, see https://stackoverflow.com/q/4938723) 42 | if __debug__: 43 | timer = QTimer() 44 | timer.start(300) 45 | timer.timeout.connect(lambda: None) 46 | 47 | app.beginSession() 48 | 49 | # Keep the app running 50 | app.exec() 51 | 52 | 53 | if __name__ == "__main__": 54 | main() 55 | -------------------------------------------------------------------------------- /gitfourchette/appconsts.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2025 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | APP_VERSION = "1.3.0" 8 | APP_SYSTEM_NAME = "gitfourchette" 9 | APP_DISPLAY_NAME = "GitFourchette" 10 | APP_URL_SCHEME = APP_SYSTEM_NAME 11 | APP_IDENTIFIER = "org.gitfourchette.gitfourchette" 12 | 13 | # BEGIN_FREEZE_CONSTS 14 | # These constants can be overwritten by `update_resources.py --freeze` 15 | # Do not commit changes in this file unless you know what you are doing! 16 | APP_FREEZE_COMMIT = "" 17 | APP_FREEZE_DATE = "" 18 | APP_FREEZE_QT = "" 19 | # END_FREEZE_CONSTS 20 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/achtung.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | ! 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/achtung@dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | ! 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/back.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/colorscheme-chip.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/error.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | ! 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/forward.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/git-branch.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/git-change.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/git-checkout.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/git-cherrypick.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/git-commit-amend.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/git-commit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/git-discard-lines.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/git-discard.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/git-fetch.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/git-folder.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/git-head-detached.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/git-head.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/git-merge.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/git-pull.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/git-push.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/git-remote.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/git-settings.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/git-stage-lines.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/git-stage.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/git-stash-black.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/git-stash.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/git-submodule.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/git-tag.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/git-unstage-lines.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/git-unstage.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/git-workdir.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/gitfourchette.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorio/gitfourchette/4a24e06dc38e8c4253a1d18215b6cb581cecfd4e/gitfourchette/assets/icons/gitfourchette.png -------------------------------------------------------------------------------- /gitfourchette/assets/icons/hint.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | ? 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/light-dark-toggle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/linebg-chip-colorblind.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/linebg-chip-redgreen.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/magnifying-glass.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/maximize.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/mug.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorio/gitfourchette/4a24e06dc38e8c4253a1d18215b6cb581cecfd4e/gitfourchette/assets/icons/mug.png -------------------------------------------------------------------------------- /gitfourchette/assets/icons/prefs-advanced.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/prefs-diff.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/prefs-external.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/prefs-general.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/prefs-graph.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/prefs-imagediff.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/prefs-tabs.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/prefs-trash.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/prefs-usercommands.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/reveal.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/right_ptr@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorio/gitfourchette/4a24e06dc38e8c4253a1d18215b6cb581cecfd4e/gitfourchette/assets/icons/right_ptr@1x.png -------------------------------------------------------------------------------- /gitfourchette/assets/icons/right_ptr@4x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorio/gitfourchette/4a24e06dc38e8c4253a1d18215b6cb581cecfd4e/gitfourchette/assets/icons/right_ptr@4x.png -------------------------------------------------------------------------------- /gitfourchette/assets/icons/status_a.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | A 4 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/status_a@dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | A 4 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/status_d.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | D 4 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/status_d@dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | D 4 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/status_m.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | M 4 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/status_m@dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | M 4 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/status_missing.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/status_r.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | R 4 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/status_r@dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | R 4 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/status_t.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | T 4 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/status_t@dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | T 4 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/status_u.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | ? 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/status_u@dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | ? 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/status_x.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | X 4 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/status_x@dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | X 4 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/terminal.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/urgent-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/view-exclusive.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/view-hidden-indirect.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/view-hidden.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /gitfourchette/assets/icons/view-visible.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/lang/README.md: -------------------------------------------------------------------------------- 1 | # [GitFourchette localization guide](https://gitfourchette.org/localization) 2 | 3 | Please see how to localize GitFourchette at: https://gitfourchette.org/localization 4 | -------------------------------------------------------------------------------- /gitfourchette/assets/lang/fr.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorio/gitfourchette/4a24e06dc38e8c4253a1d18215b6cb581cecfd4e/gitfourchette/assets/lang/fr.mo -------------------------------------------------------------------------------- /gitfourchette/assets/mac/opendiff.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # Prevent opendiff (launcher shim for FileMerge) from exiting immediately. 4 | /usr/bin/opendiff "$@" | cat 5 | -------------------------------------------------------------------------------- /gitfourchette/assets/mac/terminal.scpt: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env osascript -l JavaScript 2 | 3 | function run(argv) { 4 | var terminal = Application("Terminal"); 5 | terminal.activate(); 6 | var tab = terminal.doScript(); // Open new tab 7 | terminal.doScript("exec " + argv.join(" "), { in: tab }); 8 | } 9 | -------------------------------------------------------------------------------- /gitfourchette/assets/style-dark.qss: -------------------------------------------------------------------------------- 1 | /* Overrides for style.qss when a dark theme is active */ 2 | 3 | QFaintSeparator { background: rgba(255, 255, 255, 15%); } 4 | -------------------------------------------------------------------------------- /gitfourchette/assets/style.qss: -------------------------------------------------------------------------------- 1 | Sidebar { 2 | background: palette(window); 3 | } 4 | 5 | Banner.diff, ContextHeader { 6 | padding: 2px; 7 | } 8 | 9 | Banner.merge { 10 | padding: 8px; 11 | background-color: #ffbb00; 12 | } 13 | Banner.merge QLabel { color: black; } 14 | 15 | Banner[heeded="true"] { background-color: #3d9970; } 16 | Banner[heeded="true"] QLabel { color: white; } 17 | 18 | SearchBar[red="true"] QLineEdit { color: red; } 19 | 20 | Banner.diff QToolButton, ContextHeader QToolButton { text-decoration: underline; } 21 | 22 | /* Don't move dark override to style-dark.qss because DiffView can be dark independently from Qt theme */ 23 | DiffView[dark="false"] { selection-background-color: rgba(0, 0, 0, 20%); selection-color: black; } 24 | DiffView[dark="true"] { selection-background-color: rgba(255, 255, 255, 26%); } 25 | 26 | QFaintSeparator { border: none; background: rgba(0, 0, 0, 15%); } 27 | -------------------------------------------------------------------------------- /gitfourchette/assets/termcmd.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # This snippet is sourced by the launcher scripts generated by toolcommands.py. 4 | 5 | brand="\e[0;1;34m[$_GF_APPNAME]\e[0m" 6 | 7 | debrief() 8 | { 9 | exitcode=$? 10 | 11 | # Report command exit code 12 | exitcolor=32 # green 13 | [ $exitcode -ne 0 ] && exitcolor=31 # red 14 | printf "\n%b %s \e[${exitcolor}m$exitcode\e[0m" "$brand" "$_GF_EXITMESSAGE" 15 | 16 | 17 | while true; do 18 | # Ask user whether to start a shell or exit 19 | printf "\n%b %s " "$brand" "$_GF_KEYPROMPT" 20 | 21 | IFS='' read -rn1 reply 22 | reply="$(echo "$reply" | tr '[:upper:]' '[:lower:]')" # make lowercase 23 | [[ "$reply" == $'\033' || "$reply" == $_GF_NKEY ]] && exit # ESC or 'n' 24 | [[ "$reply" == '' || "$reply" == $_GF_YKEY ]] && break # ENTER or 'y' 25 | done 26 | printf "\n\n" 27 | } 28 | 29 | # Enter workdir (some terminals ignore the cwd) 30 | cd "$_GF_WORKDIR" || debrief 31 | 32 | # Run user command (if we just clicked the Terminal button, this would be empty) 33 | if [ -n "$_GF_COMMAND" ]; then 34 | printf "%b \e[4m%s\e[0m \e[2m(%s)\e[0m\n" "$brand" "$_GF_COMMAND" "$_GF_WORKDIR" 35 | eval "$_GF_COMMAND" 36 | debrief 37 | fi 38 | 39 | # Drop into a shell 40 | exec ${SHELL:-/usr/bin/sh} 41 | -------------------------------------------------------------------------------- /gitfourchette/colors.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2024 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | # Color scheme based on https://clrs.cc/ 8 | 9 | from gitfourchette.qt import QColor 10 | 11 | navy = QColor(0x001F3F) 12 | blue = QColor(0x0074D9) 13 | aqua = QColor(0x7FDBFF) 14 | teal = QColor(0x39CCCC) 15 | olive = QColor(0x3D9970) 16 | green = QColor(0x2ECC40) 17 | lime = QColor(0x00EE66) 18 | yellow = QColor(0xFFBB00) 19 | orange = QColor(0xFF851B) 20 | red = QColor(0xFF4136) 21 | fuchsia = QColor(0xF012BE) 22 | purple = QColor(0xB10DC9) 23 | maroon = QColor(0x85144B) 24 | white = QColor(0xFFFFFF) 25 | silver = QColor(0xDDDDDD) 26 | gray = QColor(0xAAAAAA) 27 | black = QColor(0x111111) 28 | 29 | rainbow = [ 30 | navy, blue, aqua, teal, olive, green, lime, yellow, orange, red, maroon, fuchsia, purple 31 | ] 32 | 33 | rainbowBright = [ 34 | orange, yellow, lime, teal, blue, purple, fuchsia, red 35 | ] 36 | 37 | grayscale = [ 38 | black, gray, silver, white 39 | ] 40 | -------------------------------------------------------------------------------- /gitfourchette/diffview/diffrubberband.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2025 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from gitfourchette import colors 8 | from gitfourchette.qt import * 9 | from gitfourchette.toolbox import isDarkTheme 10 | 11 | 12 | class DiffRubberBand(QWidget): 13 | def paintEvent(self, event: QPaintEvent): 14 | # Don't inherit QRubberBand (pen thickness ignored on Linux!) 15 | RX = 12 # rounded rect x radius 16 | RY = 4 # rounded rect y radius 17 | T = 4 # thickness 18 | HT = T//2 # half thickness 19 | CT = T//2 # clipped thickness 20 | OT = 1 # outline extra thickness 21 | 22 | palette: QPalette = self.palette() 23 | painter = QPainter(self) 24 | rect: QRect = self.rect().marginsRemoved(QMargins(HT, HT, HT, HT)) 25 | 26 | outlineColor: QColor = palette.color(QPalette.ColorRole.Base) 27 | outlineColor.setAlphaF(.75 if outlineColor.lightnessF() < .5 else .5) # light mode: subtler alpha 28 | 29 | if self.parent().hasFocus(): 30 | try: 31 | penColor = palette.accent().color() 32 | except AttributeError: 33 | # QPalette.accent() was introduced in Qt 6.7 34 | penColor = colors.teal if isDarkTheme() else colors.blue 35 | else: 36 | penColor = palette.color(QPalette.ColorGroup.Inactive, QPalette.ColorRole.Highlight) 37 | 38 | pen = QPen(penColor, T) 39 | outlinePen = QPen(outlineColor, T+2*OT) 40 | 41 | painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) 42 | painter.setClipRect(rect.marginsAdded(QMargins(HT, CT-HT, HT, CT-HT))) 43 | 44 | path = QPainterPath() 45 | path.addRoundedRect(QRectF(rect), RX, RY, Qt.SizeMode.AbsoluteSize) 46 | 47 | painter.setClipRect(rect.marginsAdded(QMargins(HT, CT-HT+OT, HT, CT-HT+OT))) 48 | painter.setPen(outlinePen) 49 | painter.drawPath(path) 50 | 51 | painter.setClipRect(rect.marginsAdded(QMargins(HT, CT-HT, HT, CT-HT))) 52 | painter.setPen(pen) 53 | painter.drawPath(path) 54 | -------------------------------------------------------------------------------- /gitfourchette/exttools/usercommandsyntaxhighlighter.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2025 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from gitfourchette import colors 8 | from gitfourchette.qt import * 9 | from gitfourchette.exttools.usercommand import UserCommand 10 | 11 | try: 12 | from pygments.token import Token 13 | except ImportError: # pragma: no cover 14 | # If Pygments isn't available, UserCommandSyntaxHighlighter should never be instantiated! 15 | pass 16 | 17 | 18 | class UserCommandSyntaxHighlighter(QSyntaxHighlighter): 19 | def __init__(self, parent): 20 | super().__init__(parent) 21 | 22 | from gitfourchette.syntax import LexerCache 23 | 24 | self.lexer = LexerCache.getLexerFromPath("GitFourchetteUserCommandsSyntaxHighlighter.bash", False) 25 | 26 | self.commentFormat = QTextCharFormat() 27 | self.commentFormat.setForeground(QColor(0x808080)) 28 | 29 | self.titleFormat = QTextCharFormat(self.commentFormat) 30 | self.titleFormat.setFontWeight(QFont.Weight.Bold) 31 | self.titleFormat.setFontItalic(True) 32 | 33 | self.badTokenFormat = QTextCharFormat() 34 | self.badTokenFormat.setForeground(colors.red) 35 | 36 | self.goodTokenFormat = QTextCharFormat() 37 | self.goodTokenFormat.setForeground(colors.blue) 38 | self.goodTokenFormat.setFontWeight(QFont.Weight.Bold) 39 | 40 | self.acceleratorFormat = QTextCharFormat(self.titleFormat) 41 | self.acceleratorFormat.setFontUnderline(True) 42 | 43 | def highlightBlock(self, text: str): 44 | tokens = self.lexer.get_tokens(text) 45 | start = 0 46 | isCommandLine = False 47 | 48 | for tokenType, token in tokens: 49 | tokenLength = len(token) 50 | 51 | if token.startswith("$"): 52 | isValid = token in UserCommand.Token._value2member_map_ 53 | self.setFormat(start, tokenLength, 54 | self.goodTokenFormat if isValid else self.badTokenFormat) 55 | 56 | elif token.startswith(UserCommand.AlwaysConfirmPrefix) and not isCommandLine: 57 | self.setFormat(start, len(UserCommand.AlwaysConfirmPrefix), self.goodTokenFormat) 58 | 59 | elif tokenType == Token.Comment.Single: 60 | self.setFormat(start, tokenLength, 61 | self.commentFormat if not isCommandLine else self.titleFormat) 62 | 63 | accelMatch = UserCommand.AcceleratorPattern.search(token) 64 | if accelMatch: 65 | matchStart, matchEnd = accelMatch.span() 66 | self.setFormat(start + matchStart, matchEnd - matchStart, self.acceleratorFormat) 67 | 68 | # Once we've seen at least one text token, we know that's a command line 69 | if tokenType == Token.Text: 70 | isCommandLine = True 71 | 72 | start += tokenLength 73 | -------------------------------------------------------------------------------- /gitfourchette/filelists/stagedfiles.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2025 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from gitfourchette import settings 8 | from gitfourchette.filelists.filelist import FileList 9 | from gitfourchette.globalshortcuts import GlobalShortcuts 10 | from gitfourchette.localization import * 11 | from gitfourchette.nav import NavContext 12 | from gitfourchette.porcelain import * 13 | from gitfourchette.qt import * 14 | from gitfourchette.tasks import * 15 | from gitfourchette.toolbox import * 16 | 17 | 18 | class StagedFiles(FileList): 19 | def __init__(self, parent): 20 | super().__init__(parent, NavContext.STAGED) 21 | 22 | self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) 23 | 24 | makeWidgetShortcut(self, self.unstage, *(GlobalShortcuts.discardHotkeys + GlobalShortcuts.stageHotkeys)) 25 | 26 | def contextMenuActions(self, patches: list[Patch]) -> list[ActionDef]: 27 | actions = [] 28 | 29 | n = len(patches) 30 | modeSet = {patch.delta.new_file.mode for patch in patches} 31 | anySubmodules = FileMode.COMMIT in modeSet 32 | onlySubmodules = anySubmodules and len(modeSet) == 1 33 | 34 | if not anySubmodules: 35 | contextMenuActionUnstage = ActionDef( 36 | _n("&Unstage File", "&Unstage {n} Files", n), 37 | self.unstage, 38 | icon="git-unstage", 39 | shortcuts=GlobalShortcuts.discardHotkeys[0]) 40 | 41 | actions += [ 42 | contextMenuActionUnstage, 43 | self.contextMenuActionStash(), 44 | self.contextMenuActionRevertMode(patches, self.unstageModeChange), 45 | ActionDef.SEPARATOR, 46 | *self.contextMenuActionsDiff(patches), 47 | ActionDef.SEPARATOR, 48 | *self.contextMenuActionsEdit(patches), 49 | ] 50 | 51 | elif onlySubmodules: 52 | actions += [ 53 | ActionDef( 54 | _n("Submodule", "{n} Submodules", n), 55 | kind=ActionDef.Kind.Section, 56 | ), 57 | 58 | ActionDef( 59 | _n("Unstage Submodule", "Unstage {n} Submodules", n), 60 | self.unstage, 61 | ), 62 | 63 | ActionDef( 64 | _n("Open Submodule in New Tab", "Open {n} Submodules in New Tabs", n), 65 | self.openSubmoduleTabs, 66 | ), 67 | ] 68 | 69 | else: 70 | sorry = _("Can’t unstage this selection in bulk.") + "\n" + _("Please review the files individually.") 71 | actions += [ 72 | ActionDef(sorry, enabled=False), 73 | ] 74 | 75 | actions += super().contextMenuActions(patches) 76 | return actions 77 | 78 | def unstage(self): 79 | patches = list(self.selectedPatches()) 80 | UnstageFiles.invoke(self, patches) 81 | 82 | def unstageModeChange(self): 83 | patches = list(self.selectedPatches()) 84 | UnstageModeChanges.invoke(self, patches) 85 | 86 | def onSpecialMouseClick(self): 87 | if settings.prefs.middleClickToStage: 88 | self.unstage() 89 | -------------------------------------------------------------------------------- /gitfourchette/forms/brandeddialog.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2024 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from gitfourchette.qt import * 8 | from gitfourchette.toolbox import * 9 | 10 | 11 | def makeBrandedDialogLayout( 12 | dialog: QDialog, 13 | titleText: str, 14 | subtitleText: str = "", 15 | multilineSubtitle: bool = False 16 | ): 17 | gridLayout = QGridLayout(dialog) 18 | 19 | iconLabel = QLabel(dialog) 20 | iconLabel.setMaximumSize(QSize(56, 56)) 21 | iconLabel.setPixmap(QPixmap("assets:icons/gitfourchette")) 22 | iconLabel.setScaledContents(True) 23 | iconLabel.setMargin(8) 24 | 25 | horizontalSpacer = QSpacerItem(0, 1, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum) 26 | 27 | titleLayout = QVBoxLayout() 28 | titleLayout.setSpacing(0) 29 | titleLayout.setContentsMargins(0, 0, 0, 0) 30 | title = QLabel(titleText, dialog) 31 | title.setTextFormat(Qt.TextFormat.RichText) 32 | title.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction) 33 | tweakWidgetFont(title, 150, bold=True) 34 | titleLayout.addWidget(title) 35 | 36 | if subtitleText: 37 | title.setAlignment(Qt.AlignmentFlag.AlignBottom) 38 | subtitleWidgets = [] 39 | 40 | if multilineSubtitle: 41 | subtitle = QLabel(subtitleText) 42 | subtitle.setWordWrap(True) 43 | subtitleWidgets.append(subtitle) 44 | else: 45 | for line in subtitleText.splitlines(): 46 | subtitleWidgets.append(QElidedLabel(line, dialog)) 47 | 48 | for subtitle in subtitleWidgets: 49 | subtitle.setAlignment(Qt.AlignmentFlag.AlignTop) 50 | tweakWidgetFont(subtitle, relativeSize=90) 51 | titleLayout.addWidget(subtitle) 52 | 53 | gridLayout.addWidget(iconLabel, 0, 0, 1, 1) 54 | gridLayout.addItem(horizontalSpacer, 0, 1, 1, 1) 55 | gridLayout.addLayout(titleLayout, 0, 3, 1, 1) 56 | 57 | if subtitleText: 58 | breather = QSpacerItem(0, 8, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) 59 | gridLayout.addItem(breather, 1, 0) 60 | 61 | return gridLayout 62 | 63 | 64 | def convertToBrandedDialog( 65 | dialog: QDialog, 66 | promptText: str = "", 67 | subtitleText: str = "", 68 | multilineSubtitle: bool = False, 69 | ): 70 | if not promptText: 71 | promptText = escape(dialog.windowTitle()) 72 | 73 | innerContent = QWidget(dialog) 74 | innerContent.setLayout(dialog.layout()) 75 | innerContent.layout().setContentsMargins(0, 0, 0, 0) 76 | 77 | gridLayout = makeBrandedDialogLayout(dialog, promptText, subtitleText, multilineSubtitle) 78 | gridLayout.addWidget(innerContent, 2, 3, 1, 1) 79 | -------------------------------------------------------------------------------- /gitfourchette/forms/checkoutcommitdialog.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2024 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from gitfourchette.forms.ui_checkoutcommitdialog import Ui_CheckoutCommitDialog 8 | from gitfourchette.localization import * 9 | from gitfourchette.porcelain import Oid 10 | from gitfourchette.qt import * 11 | from gitfourchette.toolbox import * 12 | 13 | 14 | class CheckoutCommitDialog(QDialog): 15 | def __init__( 16 | self, 17 | oid: Oid, 18 | refs: list[str], 19 | anySubmodules: bool, 20 | parent=None): 21 | 22 | super().__init__(parent) 23 | 24 | ui = Ui_CheckoutCommitDialog() 25 | self.ui = ui 26 | self.ui.setupUi(self) 27 | 28 | self.setWindowTitle(_("Check out commit {0}", shortHash(oid))) 29 | 30 | ok = self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Ok) 31 | ui.detachedHeadRadioButton.clicked.connect(lambda: ok.setText(_("Detach HEAD"))) 32 | ui.detachedHeadRadioButton.clicked.connect(lambda: ok.setIcon(stockIcon("git-head-detached"))) 33 | ui.switchToLocalBranchRadioButton.clicked.connect(lambda: ok.setText(_("Switch Branch"))) 34 | ui.switchToLocalBranchRadioButton.clicked.connect(lambda: ok.setIcon(stockIcon("git-branch"))) 35 | ui.createBranchRadioButton.clicked.connect(lambda: ok.setText(_("Create Branch…"))) 36 | ui.createBranchRadioButton.clicked.connect(lambda: ok.setIcon(stockIcon("vcs-branch-new"))) 37 | if refs: 38 | ui.switchToLocalBranchComboBox.addItems(refs) 39 | ui.switchToLocalBranchRadioButton.click() 40 | else: 41 | ui.detachedHeadRadioButton.click() 42 | ui.switchToLocalBranchComboBox.setVisible(False) 43 | ui.switchToLocalBranchRadioButton.setVisible(False) 44 | 45 | if not anySubmodules: 46 | ui.recurseSubmodulesSpacer.setVisible(False) 47 | ui.recurseSubmodulesGroupBox.setVisible(False) 48 | 49 | ui.createBranchRadioButton.toggled.connect(lambda t: ui.recurseSubmodulesGroupBox.setEnabled(not t)) 50 | 51 | -------------------------------------------------------------------------------- /gitfourchette/forms/deletetagdialog.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2024 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from gitfourchette.forms.brandeddialog import convertToBrandedDialog 8 | from gitfourchette.forms.newtagdialog import populateRemoteComboBox 9 | from gitfourchette.forms.ui_deletetagdialog import Ui_DeleteTagDialog 10 | from gitfourchette.localization import * 11 | from gitfourchette.qt import * 12 | from gitfourchette.toolbox import * 13 | 14 | 15 | class DeleteTagDialog(QDialog): 16 | def __init__( 17 | self, 18 | tagName: str, 19 | target: str, 20 | targetSubtitle: str, 21 | remotes: list[str], 22 | parent=None): 23 | 24 | super().__init__(parent) 25 | 26 | self.ui = Ui_DeleteTagDialog() 27 | self.ui.setupUi(self) 28 | 29 | formatWidgetText(self.ui.label, bquo(tagName)) 30 | 31 | okButton = self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Ok) 32 | okButton.setIcon(stockIcon("SP_DialogDiscardButton")) 33 | okCaptions = [_("&Delete Locally"), _("&Delete Locally && Remotely")] 34 | self.ui.pushCheckBox.toggled.connect(lambda push: okButton.setText(okCaptions[push])) 35 | 36 | populateRemoteComboBox(self.ui.remoteComboBox, remotes) 37 | 38 | # Prime enabled state 39 | self.ui.pushCheckBox.click() 40 | self.ui.pushCheckBox.click() 41 | if not remotes: 42 | self.ui.pushCheckBox.setChecked(False) 43 | self.ui.pushCheckBox.setEnabled(False) 44 | 45 | convertToBrandedDialog( 46 | self, 47 | _("Delete tag {0}", tquo(tagName)), 48 | _("Tagged commit: {0}", target) + " – " + tquo(targetSubtitle)) 49 | 50 | self.resize(max(512, self.width()), self.height()) 51 | -------------------------------------------------------------------------------- /gitfourchette/forms/deletetagdialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | DeleteTagDialog 4 | 5 | 6 | Qt::WindowModality::WindowModal 7 | 8 | 9 | 10 | 0 11 | 0 12 | 405 13 | 303 14 | 15 | 16 | 17 | Delete tag 18 | 19 | 20 | 21 | 22 | 23 | Really delete tag {0}? 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | &Push deletion to: 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 0 41 | 0 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | Qt::Orientation::Horizontal 52 | 53 | 54 | QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | buttonBox 64 | accepted() 65 | DeleteTagDialog 66 | accept() 67 | 68 | 69 | 260 70 | 129 71 | 72 | 73 | 157 74 | 274 75 | 76 | 77 | 78 | 79 | buttonBox 80 | rejected() 81 | DeleteTagDialog 82 | reject() 83 | 84 | 85 | 328 86 | 129 87 | 88 | 89 | 286 90 | 274 91 | 92 | 93 | 94 | 95 | pushCheckBox 96 | toggled(bool) 97 | remoteComboBox 98 | setEnabled(bool) 99 | 100 | 101 | 80 102 | 65 103 | 104 | 105 | 248 106 | 65 107 | 108 | 109 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /gitfourchette/forms/identitydialog.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2024 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from gitfourchette.forms.brandeddialog import convertToBrandedDialog 8 | from gitfourchette.forms.signatureform import SignatureForm 9 | from gitfourchette.forms.ui_identitydialog import Ui_IdentityDialog 10 | from gitfourchette.localization import * 11 | from gitfourchette.qt import * 12 | from gitfourchette.toolbox import * 13 | 14 | 15 | class IdentityDialog(QDialog): 16 | def __init__( 17 | self, 18 | firstRun: bool, 19 | initialName: str, 20 | initialEmail: str, 21 | configPath: str, 22 | repoHasLocalIdentity: bool, 23 | parent: QWidget 24 | ): 25 | super().__init__(parent) 26 | 27 | ui = Ui_IdentityDialog() 28 | ui.setupUi(self) 29 | self.ui = ui 30 | 31 | formatWidgetText(ui.configPathLabel, lquo(compactPath(configPath))) 32 | ui.warningLabel.setVisible(repoHasLocalIdentity) 33 | 34 | # Initialize with global identity values (if any) 35 | ui.nameEdit.setText(initialName) 36 | ui.emailEdit.setText(initialEmail) 37 | 38 | validator = ValidatorMultiplexer(self) 39 | validator.setGatedWidgets(ui.buttonBox.button(QDialogButtonBox.StandardButton.Ok)) 40 | validator.connectInput(ui.nameEdit, SignatureForm.validateInput) 41 | validator.connectInput(ui.emailEdit, SignatureForm.validateInput) 42 | validator.run(silenceEmptyWarnings=True) 43 | 44 | subtitle = _("This information will be embedded in the commits and tags that you create on this machine.") 45 | if firstRun: 46 | subtitle = _("Before editing this repository, please set up your identity for Git.") + " " + subtitle 47 | 48 | convertToBrandedDialog(self, subtitleText=subtitle, multilineSubtitle=True) 49 | 50 | def identity(self) -> tuple[str, str]: 51 | name = self.ui.nameEdit.text() 52 | email = self.ui.emailEdit.text() 53 | return name, email 54 | -------------------------------------------------------------------------------- /gitfourchette/forms/keyfilepickercheckbox.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2024 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from pathlib import Path 8 | 9 | from gitfourchette.localization import * 10 | from gitfourchette.qt import * 11 | from gitfourchette.toolbox import QFilePickerCheckBox, PersistentFileDialog, paragraphs 12 | 13 | 14 | class KeyFilePickerCheckBox(QFilePickerCheckBox): 15 | DefaultSshDir = "~/.ssh" 16 | 17 | def __init__(self, *args, **kwargs): 18 | super().__init__(*args, **kwargs) 19 | filePickerTip = paragraphs( 20 | _("{app} normally uses public/private keys in {path} to authenticate you with remote servers.", 21 | app=qAppName(), path=KeyFilePickerCheckBox.DefaultSshDir), 22 | _("Tick this box if you want to access this remote with a custom key.")) 23 | self.checkBox.setToolTip(filePickerTip) 24 | 25 | def fileDialog(self): 26 | sshDir = Path(KeyFilePickerCheckBox.DefaultSshDir).expanduser() 27 | fallbackPath = str(sshDir) if sshDir.exists() else "" 28 | 29 | prompt = _("Select public key file for this remote") 30 | publicKeyFilter = _("Public key file") + " (*.pub)" 31 | return PersistentFileDialog.openFile(self, "KeyFile", prompt, filter=publicKeyFilter, fallbackPath=fallbackPath) 32 | 33 | def validatePath(self, path: str): 34 | if not path: 35 | return "" 36 | 37 | p = Path(path) 38 | 39 | if not p.is_file(): 40 | return _("File not found.") 41 | 42 | if path.endswith(".pub"): 43 | privateKey = p.with_suffix("") 44 | if not privateKey.is_file(): 45 | return _("Accompanying private key not found.") 46 | else: 47 | privateKey = p 48 | if not privateKey.with_suffix(".pub").is_file(): 49 | return _("Accompanying public key not found.") 50 | 51 | return "" 52 | 53 | def privateKeyPath(self): 54 | return self.path().removesuffix(".pub") 55 | -------------------------------------------------------------------------------- /gitfourchette/forms/newbranchdialog.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2024 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from gitfourchette.forms.brandeddialog import convertToBrandedDialog 8 | from gitfourchette.forms.ui_newbranchdialog import Ui_NewBranchDialog 9 | from gitfourchette.localization import * 10 | from gitfourchette.qt import * 11 | from gitfourchette.toolbox import * 12 | 13 | 14 | class NewBranchDialog(QDialog): 15 | def __init__( 16 | self, 17 | initialName: str, 18 | target: str, 19 | targetSubtitle: str, 20 | upstreams: list[str], 21 | reservedNames: list[str], 22 | allowSwitching: bool, 23 | parent=None): 24 | 25 | super().__init__(parent) 26 | 27 | self.ui = Ui_NewBranchDialog() 28 | self.ui.setupUi(self) 29 | 30 | self.ui.nameEdit.setText(initialName) 31 | self.acceptButton.setText(_("&Create")) 32 | 33 | self.ui.upstreamComboBox.addItems(upstreams) 34 | 35 | # hack to trickle down initial 'toggled' signal to combobox 36 | self.ui.upstreamCheckBox.setChecked(True) 37 | self.ui.upstreamCheckBox.setChecked(False) 38 | 39 | if not upstreams: 40 | self.ui.upstreamCheckBox.setChecked(False) 41 | self.ui.upstreamCheckBox.setVisible(False) 42 | self.ui.upstreamComboBox.setVisible(False) 43 | 44 | if not allowSwitching: 45 | switchCheckBox = self.ui.switchToBranchCheckBox 46 | switchCheckBox.setEnabled(False) 47 | switchCheckBox.setChecked(False) 48 | switchCheckBox.setText(switchCheckBox.text() + "\n" + _("(blocked by conflicts)")) 49 | self.ui.recurseSubmodulesCheckBox.setChecked(False) 50 | 51 | nameTaken = _("This name is already taken by another local branch.") 52 | validator = ValidatorMultiplexer(self) 53 | validator.setGatedWidgets(self.acceptButton) 54 | validator.connectInput(self.ui.nameEdit, lambda name: nameValidationMessage(name, reservedNames, nameTaken)) 55 | validator.run() 56 | 57 | convertToBrandedDialog(self, _("New branch"), 58 | _("Commit at tip:") + " " + target + "\n" + tquo(targetSubtitle)) 59 | 60 | self.ui.nameEdit.setFocus() 61 | self.ui.nameEdit.selectAll() 62 | 63 | @property 64 | def acceptButton(self): 65 | return self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Ok) 66 | -------------------------------------------------------------------------------- /gitfourchette/forms/newtagdialog.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2024 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from gitfourchette.forms.brandeddialog import convertToBrandedDialog 8 | from gitfourchette.forms.ui_newtagdialog import Ui_NewTagDialog 9 | from gitfourchette.localization import * 10 | from gitfourchette.qt import * 11 | from gitfourchette.toolbox import * 12 | 13 | 14 | def populateRemoteComboBox(comboBox: QComboBox, remotes: list[str]): 15 | assert 0 == comboBox.count() 16 | if not remotes: 17 | comboBox.addItem(_("No Remotes")) 18 | else: 19 | comboBox.addItem(_("All Remotes"), userData="*") 20 | comboBox.insertSeparator(1) 21 | for remote in remotes: 22 | comboBox.addItem(remote, userData=remote) 23 | 24 | 25 | class NewTagDialog(QDialog): 26 | def __init__( 27 | self, 28 | target: str, 29 | targetSubtitle: str, 30 | reservedNames: list[str], 31 | remotes: list[str], 32 | parent=None): 33 | 34 | super().__init__(parent) 35 | 36 | self.ui = Ui_NewTagDialog() 37 | self.ui.setupUi(self) 38 | 39 | okButton = self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Ok) 40 | okButton.setIcon(stockIcon("git-tag")) 41 | okCaptions = [_("&Create"), _("&Create && Push")] 42 | self.ui.pushCheckBox.toggled.connect(lambda push: okButton.setText(okCaptions[push])) 43 | 44 | populateRemoteComboBox(self.ui.remoteComboBox, remotes) 45 | 46 | nameTaken = _("This name is already taken by another tag.") 47 | validator = ValidatorMultiplexer(self) 48 | validator.setGatedWidgets(okButton) 49 | validator.connectInput(self.ui.nameEdit, lambda name: nameValidationMessage(name, reservedNames, nameTaken)) 50 | validator.run(silenceEmptyWarnings=True) 51 | 52 | # Prime enabled state 53 | self.ui.pushCheckBox.click() 54 | self.ui.pushCheckBox.click() 55 | if not remotes: 56 | self.ui.pushCheckBox.setChecked(False) 57 | self.ui.pushCheckBox.setEnabled(False) 58 | 59 | convertToBrandedDialog( 60 | self, 61 | _("New tag on commit {0}", tquo(target)), 62 | tquo(targetSubtitle)) 63 | 64 | self.resize(max(512, self.width()), self.height()) 65 | -------------------------------------------------------------------------------- /gitfourchette/forms/openrepoprogress.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2024 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from gitfourchette.forms.ui_openrepoprogress import Ui_OpenRepoProgress 8 | from gitfourchette.localization import * 9 | from gitfourchette.qt import * 10 | from gitfourchette.toolbox import * 11 | 12 | 13 | class OpenRepoProgress(QWidget): 14 | def __init__(self, parent=None, name=""): 15 | super().__init__(parent) 16 | self.ui = Ui_OpenRepoProgress() 17 | self.ui.setupUi(self) 18 | 19 | abortIcon = stockIcon("SP_BrowserStop") 20 | 21 | self.ui.abortButton.setIcon(abortIcon) 22 | self.ui.abortButton.setEnabled(False) 23 | 24 | if name: 25 | self.initialMessage = _("Opening {0}…", tquo(name)) 26 | self.ui.label.setText(self.initialMessage) 27 | else: 28 | self.initialMessage = self.ui.label.text() 29 | 30 | def reset(self): 31 | self.ui.retranslateUi(self) 32 | self.ui.label.setText(self.initialMessage) 33 | self.ui.progressBar.setRange(0, 100) 34 | self.ui.progressBar.setValue(0) 35 | -------------------------------------------------------------------------------- /gitfourchette/forms/openrepoprogress.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | OpenRepoProgress 4 | 5 | 6 | 7 | 0 8 | 0 9 | 624 10 | 315 11 | 12 | 13 | 14 | Opening repository… 15 | 16 | 17 | 18 | 19 | 20 | Qt::Vertical 21 | 22 | 23 | 24 | 20 25 | 40 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | Qt::Horizontal 34 | 35 | 36 | 37 | 40 38 | 20 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | Qt::Vertical 47 | 48 | 49 | 50 | 20 51 | 40 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 300 61 | 0 62 | 63 | 64 | 65 | Opening repository… 66 | 67 | 68 | 69 | 70 | 71 | 72 | Qt::NoFocus 73 | 74 | 75 | Abort 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | Qt::Horizontal 86 | 87 | 88 | 89 | 40 90 | 20 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | false 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /gitfourchette/forms/passphrasedialog.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2025 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from gitfourchette import settings 8 | from gitfourchette.localization import * 9 | from gitfourchette.forms.textinputdialog import TextInputDialog 10 | from gitfourchette.qt import * 11 | from gitfourchette.toolbox import * 12 | 13 | 14 | class PassphraseDialog(TextInputDialog): 15 | passphraseReady = Signal(str, str) 16 | 17 | def __init__(self, parent: QWidget, keyfile: str): 18 | super().__init__( 19 | parent, 20 | _("Passphrase-protected key file"), 21 | _("Enter passphrase to use this key file:"), 22 | subtitle=escape(compactPath(keyfile))) 23 | 24 | self.keyfile = keyfile 25 | 26 | self.lineEdit.setEchoMode(QLineEdit.EchoMode.Password) 27 | self.lineEdit.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)) 28 | 29 | rememberCheckBox = QCheckBox(_("Remember passphrase for this session")) 30 | rememberCheckBox.setChecked(settings.prefs.rememberPassphrases) 31 | rememberCheckBox.checkStateChanged.connect(self.onRememberCheckStateChanged) 32 | self.setExtraWidget(rememberCheckBox) 33 | self.rememberCheckBox = rememberCheckBox 34 | 35 | self.echoModeAction = self.lineEdit.addAction(stockIcon("view-visible"), QLineEdit.ActionPosition.TrailingPosition) 36 | self.echoModeAction.setToolTip(_("Reveal passphrase")) 37 | self.echoModeAction.triggered.connect(self.onToggleEchoMode) 38 | 39 | self.finished.connect(self.onFinish) 40 | 41 | def onRememberCheckStateChanged(self, state: Qt.CheckState): 42 | settings.prefs.rememberPassphrases = state == Qt.CheckState.Checked 43 | settings.prefs.setDirty() 44 | 45 | def onToggleEchoMode(self): 46 | passwordMode = self.lineEdit.echoMode() == QLineEdit.EchoMode.Password 47 | passwordMode = not passwordMode 48 | self.lineEdit.setEchoMode(QLineEdit.EchoMode.Password if passwordMode else QLineEdit.EchoMode.Normal) 49 | self.echoModeAction.setIcon(stockIcon("view-visible" if passwordMode else "view-hidden")) 50 | self.echoModeAction.setToolTip(_("Reveal passphrase") if passwordMode else _("Hide passphrase")) 51 | self.echoModeAction.setChecked(not passwordMode) 52 | 53 | def onFinish(self, result): 54 | if result: 55 | secret = self.lineEdit.text() 56 | self.passphraseReady.emit(self.keyfile, secret) 57 | else: 58 | self.passphraseReady.emit("", "") 59 | -------------------------------------------------------------------------------- /gitfourchette/forms/protocolbutton.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2024 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from gitfourchette.localization import * 8 | from gitfourchette.qt import * 9 | from gitfourchette.toolbox import * 10 | 11 | 12 | class ProtocolButton(QToolButton): 13 | protocolChanged = Signal(str) 14 | 15 | def __init__(self, parent): 16 | super().__init__(parent) 17 | self.setFixedWidth(self.fontMetrics().horizontalAdvance("---https---")) 18 | self.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) 19 | self.setToolTip(_("Change URL protocol")) 20 | 21 | def connectTo(self, lineEdit: QLineEdit): 22 | def onUrlProtocolChanged(newUrl: str): 23 | # This pushes the new text to the QLineEdit's undo stack 24 | # (whereas setText clears the undo stack). 25 | lineEdit.selectAll() 26 | lineEdit.insert(newUrl) 27 | 28 | lineEdit.textChanged.connect(self.onUrlChanged) 29 | self.protocolChanged.connect(onUrlProtocolChanged) 30 | 31 | # Prime state 32 | self.onUrlChanged(lineEdit.text()) 33 | 34 | def onUrlChanged(self, url: str): 35 | """ Detect protocol when URL changes """ 36 | 37 | url = url.strip() 38 | protocol = remoteUrlProtocol(url) 39 | 40 | if not protocol: # unknown protocol, hide protocol button 41 | self.hide() 42 | return 43 | 44 | # Build alternate URL 45 | host, path = splitRemoteUrl(url) 46 | if protocol == "ssh": 47 | newUrl = f"https://{host}/{path}" 48 | newUrl = newUrl.removesuffix(".git") 49 | else: 50 | host = host.split(":", 1)[0] # remove port, if any 51 | newUrl = f"git@{host}:{path}" 52 | 53 | self.show() 54 | self.setText(protocol) 55 | 56 | menu = ActionDef.makeQMenu(self, [ActionDef(newUrl, lambda: self.protocolChanged.emit(newUrl))]) 57 | self.setMenu(menu) 58 | -------------------------------------------------------------------------------- /gitfourchette/forms/registersubmoduledialog.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2025 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from gitfourchette.forms.ui_registersubmoduledialog import Ui_RegisterSubmoduleDialog 8 | from gitfourchette.localization import * 9 | from gitfourchette.qt import * 10 | from gitfourchette.toolbox import * 11 | 12 | 13 | class RegisterSubmoduleDialog(QDialog): 14 | def __init__( 15 | self, 16 | workdirPath: str, 17 | superprojectName: str, 18 | remotes: dict[str, str], 19 | absorb: bool, 20 | reservedNames: set[str], 21 | parent): 22 | super().__init__(parent) 23 | ui = Ui_RegisterSubmoduleDialog() 24 | ui.setupUi(self) 25 | self.ui = ui 26 | self.reservedNames = reservedNames 27 | 28 | for k, v in remotes.items(): 29 | ui.remoteComboBox.addItemWithPreview(k, v, v) 30 | 31 | ui.pathValue.setText(workdirPath) 32 | ui.nameEdit.setText(workdirPath) 33 | ui.nameEdit.addAction(stockIcon("git-submodule"), QLineEdit.ActionPosition.LeadingPosition) 34 | self.resetNameAction = ui.nameEdit.addAction(stockIcon("SP_LineEditClearButton"), QLineEdit.ActionPosition.TrailingPosition) 35 | self.resetNameAction.setVisible(False) 36 | self.resetNameAction.setToolTip(_("Reset to default name")) 37 | self.resetNameAction.triggered.connect(lambda: ui.nameEdit.setText(workdirPath)) 38 | ui.nameEdit.textChanged.connect(lambda name: self.resetNameAction.setVisible(name != workdirPath)) 39 | 40 | if absorb: 41 | formatWidgetText(ui.absorbExplainer, sub=lquoe(workdirPath), super=lquoe(superprojectName)) 42 | self.okButton.setText(_("Absorb submodule")) 43 | self.setWindowTitle(_("Absorb submodule")) 44 | else: 45 | ui.absorbExplainer.hide() 46 | self.okButton.setText(_("Register submodule")) 47 | self.setWindowTitle(_("Register submodule")) 48 | 49 | validator = ValidatorMultiplexer(self) 50 | validator.setGatedWidgets(self.okButton) 51 | validator.connectInput(self.ui.nameEdit, self.validateSubmoduleName) 52 | validator.run() 53 | 54 | @property 55 | def remoteUrl(self) -> str: 56 | return self.ui.remoteComboBox.currentData(Qt.ItemDataRole.UserRole) 57 | 58 | @property 59 | def customName(self): 60 | return self.ui.nameEdit.text() 61 | 62 | @property 63 | def okButton(self): 64 | return self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Ok) 65 | 66 | def validateSubmoduleName(self, name: str): 67 | if not name.strip(): 68 | return _("Cannot be empty.") 69 | elif name in self.reservedNames: 70 | return _("This name is already taken by another submodule.") 71 | else: 72 | return "" 73 | -------------------------------------------------------------------------------- /gitfourchette/forms/remotelinkdialog.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2025 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from gitfourchette.forms.ui_remotelinkdialog import Ui_RemoteLinkDialog 8 | from gitfourchette.localization import * 9 | from gitfourchette.qt import * 10 | from gitfourchette.remotelink import RemoteLink 11 | from gitfourchette.toolbox import * 12 | 13 | 14 | class RemoteLinkDialog(QDialog): 15 | def __init__(self, title: str, parent: QWidget): 16 | super().__init__(parent) 17 | self.statusPrefix = "" 18 | title = title or _("Remote operation") 19 | 20 | self.ui = Ui_RemoteLinkDialog() 21 | self.ui.setupUi(self) 22 | 23 | statusFont = self.ui.statusLabel.font() 24 | setFontFeature(statusFont, "tnum") 25 | self.ui.statusLabel.setFont(statusFont) 26 | 27 | self.setMinimumWidth(self.fontMetrics().horizontalAdvance("W" * 40)) 28 | self.setWindowTitle(title) 29 | self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint) # hide close button 30 | self.setWindowModality(Qt.WindowModality.WindowModal) 31 | 32 | tweakWidgetFont(self.ui.remoteLabel, 88) 33 | self.setStatusText(title) 34 | self.beginRemote("", "...") 35 | 36 | self.resize(self.width(), 1) 37 | self.show() 38 | 39 | self.remoteLink = RemoteLink(self) 40 | self.remoteLink.message.connect(self.setStatusText) 41 | self.remoteLink.progress.connect(self.onRemoteLinkProgress) 42 | self.remoteLink.beginRemote.connect(self.beginRemote) 43 | 44 | @property 45 | def abortButton(self) -> QPushButton: 46 | return self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Abort) 47 | 48 | def beginRemote(self, name: str, url: str): 49 | if name: 50 | self.statusPrefix = f"{escape(name)}: " 51 | self.ui.remoteLabel.setText(escamp(url)) 52 | else: 53 | self.statusPrefix = "" 54 | self.ui.remoteLabel.setText(escamp(url)) 55 | self.setStatusText(_("Connecting…")) 56 | 57 | def onRemoteLinkProgress(self, value: int, maximum: int): 58 | self.ui.progressBar.setMaximum(maximum) 59 | self.ui.progressBar.setValue(value) 60 | 61 | def setStatusText(self, text: str): 62 | # Init dialog with room to fit 2 lines vertically, 63 | # so that it doesn't jump around when updating label text 64 | if "\n" not in text: 65 | text += "\n" 66 | text = "

" + self.statusPrefix + text + "

" 67 | self.ui.statusLabel.setText(text) 68 | 69 | def reject(self): # bound to abort button 70 | if not self.remoteLink.isBusy(): # allow close() 71 | super().reject() 72 | return 73 | 74 | if self.remoteLink.isAborting(): 75 | QApplication.beep() 76 | return 77 | 78 | self.remoteLink.raiseAbortFlag() 79 | self.abortButton.setEnabled(False) 80 | self.abortButton.setText(_("Aborting")) 81 | 82 | self.onRemoteLinkProgress(0, 0) 83 | -------------------------------------------------------------------------------- /gitfourchette/forms/remotelinkdialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | RemoteLinkDialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 400 10 | 300 11 | 12 | 13 | 14 | Remote operation 15 | 16 | 17 | 18 | 19 | 20 | 21 | 0 22 | 0 23 | 24 | 25 | 26 | STATUS1 27 | STATUS2 28 | 29 | 30 | Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignTop 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 0 39 | 0 40 | 41 | 42 | 43 | REMOTE 44 | 45 | 46 | 47 | 48 | 49 | 50 | 0 51 | 52 | 53 | 0 54 | 55 | 56 | 57 | 58 | 59 | 60 | Qt::Orientation::Vertical 61 | 62 | 63 | 64 | 20 65 | 40 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | Qt::Orientation::Horizontal 74 | 75 | 76 | QDialogButtonBox::StandardButton::Abort 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | buttonBox 86 | accepted() 87 | RemoteLinkDialog 88 | accept() 89 | 90 | 91 | 230 92 | 278 93 | 94 | 95 | 157 96 | 274 97 | 98 | 99 | 100 | 101 | buttonBox 102 | rejected() 103 | RemoteLinkDialog 104 | reject() 105 | 106 | 107 | 298 108 | 284 109 | 110 | 111 | 286 112 | 274 113 | 114 | 115 | 116 | 117 | 118 | -------------------------------------------------------------------------------- /gitfourchette/forms/searchbar.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | SearchBar 4 | 5 | 6 | 7 | 0 8 | 0 9 | 329 10 | 26 11 | 12 | 13 | 14 | 15 | 0 16 | 0 17 | 18 | 19 | 20 | Search 21 | 22 | 23 | true 24 | 25 | 26 | 27 | 2 28 | 29 | 30 | 2 31 | 32 | 33 | 2 34 | 35 | 36 | 2 37 | 38 | 39 | 40 | 41 | Search… 42 | 43 | 44 | true 45 | 46 | 47 | 48 | 49 | 50 | 51 | Next Occurrence 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | Previous Occurrence 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | Close Search Bar 72 | 73 | 74 | Done 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /gitfourchette/forms/statusform.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2025 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | import logging 8 | 9 | from gitfourchette.qt import * 10 | from gitfourchette.toolbox import setFontFeature 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class StatusForm(QStackedWidget): 16 | def __init__(self, parent): 17 | super().__init__(parent) 18 | 19 | self.blurbLabel = QLabel("") 20 | self.blurbLabel.setAlignment(Qt.AlignmentFlag.AlignTop) 21 | self.blurbLabel.setWordWrap(True) 22 | self.blurbLabel.setTextInteractionFlags(Qt.TextInteractionFlag.LinksAccessibleByMouse | Qt.TextInteractionFlag.TextSelectableByMouse) 23 | blurbPage = QScrollArea() 24 | blurbPage.setWidget(self.blurbLabel) 25 | blurbPage.setFrameShape(QFrame.Shape.NoFrame) 26 | blurbPage.setWidgetResizable(True) 27 | blurbPage.setFocusPolicy(Qt.FocusPolicy.NoFocus) 28 | 29 | self.progressMessage = QLabel("Line1\nLine2") 30 | self.progressMessage.setAlignment(Qt.AlignmentFlag.AlignTop) 31 | self.progressMessage.setWordWrap(True) 32 | self.progressBar = QProgressBar() 33 | progressPage = QWidget() 34 | progressLayout = QVBoxLayout(progressPage) 35 | progressLayout.addWidget(self.progressMessage) 36 | progressLayout.addWidget(self.progressBar) 37 | 38 | progressFont = self.progressMessage.font() 39 | setFontFeature(progressFont, "tnum") 40 | self.progressMessage.setFont(progressFont) 41 | 42 | self.blurbLabel.setContentsMargins(4, 4, 4, 4) 43 | progressLayout.setContentsMargins(4, 4, 4, 4) 44 | 45 | self.addWidget(blurbPage) 46 | self.addWidget(progressPage) 47 | 48 | def setBlurb(self, text: str): 49 | self.setCurrentIndex(0) 50 | self.blurbLabel.setText(text) 51 | 52 | def initProgress(self, text: str): 53 | self.setCurrentIndex(1) 54 | self.progressMessage.setText(text) 55 | self.progressBar.setMinimum(0) 56 | self.progressBar.setMaximum(0) 57 | self.progressBar.setValue(0) 58 | 59 | def setProgressValue(self, value: int, maximum: int): 60 | self.progressBar.setValue(value) 61 | self.progressBar.setMaximum(maximum) 62 | 63 | def setProgressMessage(self, message: str): 64 | if message.startswith("Sideband"): 65 | # Sideband messages may contain ASCII control characters, so sanitize them for printing 66 | sidebandBlob = message.encode('utf-8', errors='ignore') 67 | logger.info(f"Sideband >{sidebandBlob!r}<") 68 | self.progressMessage.setText(message) 69 | -------------------------------------------------------------------------------- /gitfourchette/forms/textinputdialog.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2025 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from gitfourchette.forms.brandeddialog import convertToBrandedDialog 8 | from gitfourchette.qt import * 9 | from gitfourchette.toolbox import ValidatorMultiplexer, setTabOrder 10 | 11 | 12 | class TextInputDialog(QDialog): 13 | textAccepted = Signal(str) 14 | 15 | def __init__(self, parent: QWidget, title: str, label: str, subtitle: str = ""): 16 | super().__init__(parent) 17 | 18 | self.setWindowTitle(title) 19 | self.validator: ValidatorMultiplexer | None = None 20 | 21 | self.lineEdit = QLineEdit(self) 22 | 23 | self.buttonBox = QDialogButtonBox(self) 24 | self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) 25 | 26 | layout = QGridLayout(self) 27 | 28 | if label: 29 | promptLabel = QLabel(label, parent=self) 30 | promptLabel.setTextFormat(Qt.TextFormat.AutoText) 31 | promptLabel.setWordWrap(True) 32 | layout.addWidget(promptLabel, 0, 0) 33 | 34 | layout.addWidget(self.lineEdit, 1, 0) 35 | layout.addWidget(self.buttonBox, 3, 0, 1, -1) 36 | # Leave row 2 free for setExtraWidget 37 | self.contentsLayout = layout 38 | 39 | convertToBrandedDialog(self, subtitleText=subtitle) 40 | 41 | self.lineEdit.setFocus() 42 | 43 | # This size isn't guaranteed. But it'll expand the dialog horizontally if the label is shorter. 44 | self.setMinimumWidth(512) 45 | self.setWindowModality(Qt.WindowModality.WindowModal) 46 | 47 | # Connect signals 48 | self.buttonBox.accepted.connect(self.accept) 49 | self.buttonBox.rejected.connect(self.reject) 50 | self.accepted.connect(lambda: self.textAccepted.emit(self.lineEdit.text())) 51 | 52 | def setText(self, text: str): 53 | assert self.validator is None, "initial text value must be set before installing the validator" 54 | self.lineEdit.setText(text) 55 | self.lineEdit.selectAll() 56 | 57 | def setValidator(self, validate: ValidatorMultiplexer.CallbackFunc): 58 | assert self.validator is None, "validator is already set!" 59 | validator = ValidatorMultiplexer(self) 60 | validator.setGatedWidgets(self.okButton) 61 | validator.connectInput(self.lineEdit, validate) 62 | validator.run() 63 | self.validator = validator 64 | 65 | def setExtraWidget(self, widget: QWidget): 66 | self.contentsLayout.addWidget(widget, 2, 0) 67 | setTabOrder(self.lineEdit, widget, self.buttonBox) 68 | 69 | @property 70 | def okButton(self) -> QPushButton: 71 | return self.buttonBox.button(QDialogButtonBox.StandardButton.Ok) 72 | 73 | def show(self): 74 | super().show() 75 | self.setMaximumHeight(self.height()) 76 | -------------------------------------------------------------------------------- /gitfourchette/forms/ui_deletetagdialog.py: -------------------------------------------------------------------------------- 1 | # Form implementation generated from reading ui file 'deletetagdialog.ui' 2 | # 3 | # Created by: PyQt6 UI code generator 6.8.0 4 | # 5 | # WARNING: Any manual changes made to this file will be lost when pyuic6 is 6 | # run again. Do not edit this file unless you know what you are doing. 7 | 8 | 9 | from gitfourchette.localization import * 10 | from gitfourchette.qt import * 11 | 12 | 13 | class Ui_DeleteTagDialog(object): 14 | def setupUi(self, DeleteTagDialog): 15 | DeleteTagDialog.setObjectName("DeleteTagDialog") 16 | DeleteTagDialog.setWindowModality(Qt.WindowModality.WindowModal) 17 | DeleteTagDialog.resize(405, 303) 18 | self.verticalLayout = QVBoxLayout(DeleteTagDialog) 19 | self.verticalLayout.setObjectName("verticalLayout") 20 | self.label = QLabel(parent=DeleteTagDialog) 21 | self.label.setObjectName("label") 22 | self.verticalLayout.addWidget(self.label) 23 | self.horizontalLayout = QHBoxLayout() 24 | self.horizontalLayout.setObjectName("horizontalLayout") 25 | self.pushCheckBox = QCheckBox(parent=DeleteTagDialog) 26 | self.pushCheckBox.setObjectName("pushCheckBox") 27 | self.horizontalLayout.addWidget(self.pushCheckBox) 28 | self.remoteComboBox = QComboBox(parent=DeleteTagDialog) 29 | sizePolicy = QSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Fixed) 30 | sizePolicy.setHorizontalStretch(0) 31 | sizePolicy.setVerticalStretch(0) 32 | sizePolicy.setHeightForWidth(self.remoteComboBox.sizePolicy().hasHeightForWidth()) 33 | self.remoteComboBox.setSizePolicy(sizePolicy) 34 | self.remoteComboBox.setObjectName("remoteComboBox") 35 | self.horizontalLayout.addWidget(self.remoteComboBox) 36 | self.verticalLayout.addLayout(self.horizontalLayout) 37 | self.buttonBox = QDialogButtonBox(parent=DeleteTagDialog) 38 | self.buttonBox.setOrientation(Qt.Orientation.Horizontal) 39 | self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Cancel|QDialogButtonBox.StandardButton.Ok) 40 | self.buttonBox.setObjectName("buttonBox") 41 | self.verticalLayout.addWidget(self.buttonBox) 42 | 43 | self.retranslateUi(DeleteTagDialog) 44 | self.buttonBox.accepted.connect(DeleteTagDialog.accept) # type: ignore 45 | self.buttonBox.rejected.connect(DeleteTagDialog.reject) # type: ignore 46 | self.pushCheckBox.toggled['bool'].connect(self.remoteComboBox.setEnabled) # type: ignore 47 | QMetaObject.connectSlotsByName(DeleteTagDialog) 48 | 49 | def retranslateUi(self, DeleteTagDialog): 50 | DeleteTagDialog.setWindowTitle(_p("DeleteTagDialog", "Delete tag")) 51 | self.label.setText(_p("DeleteTagDialog", "Really delete tag {0}?")) 52 | self.pushCheckBox.setText(_p("DeleteTagDialog", "&Push deletion to:")) 53 | -------------------------------------------------------------------------------- /gitfourchette/forms/ui_ignorepatterndialog.py: -------------------------------------------------------------------------------- 1 | # Form implementation generated from reading ui file 'ignorepatterndialog.ui' 2 | # 3 | # Created by: PyQt6 UI code generator 6.9.0 4 | # 5 | # WARNING: Any manual changes made to this file will be lost when pyuic6 is 6 | # run again. Do not edit this file unless you know what you are doing. 7 | 8 | 9 | from gitfourchette.localization import * 10 | from gitfourchette.qt import * 11 | 12 | 13 | class Ui_IgnorePatternDialog(object): 14 | def setupUi(self, IgnorePatternDialog): 15 | IgnorePatternDialog.setObjectName("IgnorePatternDialog") 16 | IgnorePatternDialog.resize(514, 131) 17 | self.formLayout = QFormLayout(IgnorePatternDialog) 18 | self.formLayout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) 19 | self.formLayout.setObjectName("formLayout") 20 | self.label = QLabel(parent=IgnorePatternDialog) 21 | self.label.setObjectName("label") 22 | self.formLayout.setWidget(0, QFormLayout.ItemRole.LabelRole, self.label) 23 | self.label_2 = QLabel(parent=IgnorePatternDialog) 24 | self.label_2.setObjectName("label_2") 25 | self.formLayout.setWidget(1, QFormLayout.ItemRole.LabelRole, self.label_2) 26 | spacerItem = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) 27 | self.formLayout.setItem(2, QFormLayout.ItemRole.FieldRole, spacerItem) 28 | self.buttonBox = QDialogButtonBox(parent=IgnorePatternDialog) 29 | self.buttonBox.setOrientation(Qt.Orientation.Horizontal) 30 | self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Cancel|QDialogButtonBox.StandardButton.Ok) 31 | self.buttonBox.setObjectName("buttonBox") 32 | self.formLayout.setWidget(3, QFormLayout.ItemRole.SpanningRole, self.buttonBox) 33 | self.fileEdit = QComboBoxWithPreview(parent=IgnorePatternDialog) 34 | self.fileEdit.setObjectName("fileEdit") 35 | self.formLayout.setWidget(1, QFormLayout.ItemRole.FieldRole, self.fileEdit) 36 | self.patternEdit = QComboBoxWithPreview(parent=IgnorePatternDialog) 37 | sizePolicy = QSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Fixed) 38 | sizePolicy.setHorizontalStretch(0) 39 | sizePolicy.setVerticalStretch(0) 40 | sizePolicy.setHeightForWidth(self.patternEdit.sizePolicy().hasHeightForWidth()) 41 | self.patternEdit.setSizePolicy(sizePolicy) 42 | self.patternEdit.setEditable(True) 43 | self.patternEdit.setObjectName("patternEdit") 44 | self.formLayout.setWidget(0, QFormLayout.ItemRole.FieldRole, self.patternEdit) 45 | self.label.setBuddy(self.patternEdit) 46 | self.label_2.setBuddy(self.fileEdit) 47 | 48 | self.retranslateUi(IgnorePatternDialog) 49 | self.buttonBox.accepted.connect(IgnorePatternDialog.accept) # type: ignore 50 | self.buttonBox.rejected.connect(IgnorePatternDialog.reject) # type: ignore 51 | QMetaObject.connectSlotsByName(IgnorePatternDialog) 52 | IgnorePatternDialog.setTabOrder(self.patternEdit, self.fileEdit) 53 | 54 | def retranslateUi(self, IgnorePatternDialog): 55 | IgnorePatternDialog.setWindowTitle(_p("IgnorePatternDialog", "Ignore file name pattern")) 56 | self.label.setText(_p("IgnorePatternDialog", "&Pattern:")) 57 | self.label_2.setText(_p("IgnorePatternDialog", "&Add to:")) 58 | from gitfourchette.toolbox.qcomboboxwithpreview import QComboBoxWithPreview 59 | -------------------------------------------------------------------------------- /gitfourchette/forms/ui_openrepoprogress.py: -------------------------------------------------------------------------------- 1 | # Form implementation generated from reading ui file 'openrepoprogress.ui' 2 | # 3 | # Created by: PyQt6 UI code generator 6.8.0 4 | # 5 | # WARNING: Any manual changes made to this file will be lost when pyuic6 is 6 | # run again. Do not edit this file unless you know what you are doing. 7 | 8 | 9 | from gitfourchette.localization import * 10 | from gitfourchette.qt import * 11 | 12 | 13 | class Ui_OpenRepoProgress(object): 14 | def setupUi(self, OpenRepoProgress): 15 | OpenRepoProgress.setObjectName("OpenRepoProgress") 16 | OpenRepoProgress.resize(624, 315) 17 | self.gridLayout = QGridLayout(OpenRepoProgress) 18 | self.gridLayout.setObjectName("gridLayout") 19 | spacerItem = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) 20 | self.gridLayout.addItem(spacerItem, 0, 1, 1, 1) 21 | spacerItem1 = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) 22 | self.gridLayout.addItem(spacerItem1, 2, 3, 1, 1) 23 | spacerItem2 = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) 24 | self.gridLayout.addItem(spacerItem2, 3, 1, 1, 1) 25 | self.label = QLabel(parent=OpenRepoProgress) 26 | self.label.setMinimumSize(QSize(300, 0)) 27 | self.label.setObjectName("label") 28 | self.gridLayout.addWidget(self.label, 1, 1, 1, 2) 29 | self.abortButton = QPushButton(parent=OpenRepoProgress) 30 | self.abortButton.setFocusPolicy(Qt.FocusPolicy.NoFocus) 31 | self.abortButton.setText("") 32 | self.abortButton.setObjectName("abortButton") 33 | self.gridLayout.addWidget(self.abortButton, 2, 2, 1, 1) 34 | spacerItem3 = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) 35 | self.gridLayout.addItem(spacerItem3, 2, 0, 1, 1) 36 | self.progressBar = QProgressBar(parent=OpenRepoProgress) 37 | self.progressBar.setTextVisible(False) 38 | self.progressBar.setObjectName("progressBar") 39 | self.gridLayout.addWidget(self.progressBar, 2, 1, 1, 1) 40 | 41 | self.retranslateUi(OpenRepoProgress) 42 | QMetaObject.connectSlotsByName(OpenRepoProgress) 43 | 44 | def retranslateUi(self, OpenRepoProgress): 45 | OpenRepoProgress.setWindowTitle(_p("OpenRepoProgress", "Opening repository…")) 46 | self.label.setText(_p("OpenRepoProgress", "Opening repository…")) 47 | self.abortButton.setToolTip(_p("OpenRepoProgress", "Abort")) 48 | -------------------------------------------------------------------------------- /gitfourchette/forms/ui_remotelinkdialog.py: -------------------------------------------------------------------------------- 1 | # Form implementation generated from reading ui file 'remotelinkdialog.ui' 2 | # 3 | # Created by: PyQt6 UI code generator 6.8.0 4 | # 5 | # WARNING: Any manual changes made to this file will be lost when pyuic6 is 6 | # run again. Do not edit this file unless you know what you are doing. 7 | 8 | 9 | from gitfourchette.localization import * 10 | from gitfourchette.qt import * 11 | 12 | 13 | class Ui_RemoteLinkDialog(object): 14 | def setupUi(self, RemoteLinkDialog): 15 | RemoteLinkDialog.setObjectName("RemoteLinkDialog") 16 | RemoteLinkDialog.resize(400, 300) 17 | self.verticalLayout = QVBoxLayout(RemoteLinkDialog) 18 | self.verticalLayout.setObjectName("verticalLayout") 19 | self.statusLabel = QLabel(parent=RemoteLinkDialog) 20 | sizePolicy = QSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) 21 | sizePolicy.setHorizontalStretch(0) 22 | sizePolicy.setVerticalStretch(0) 23 | sizePolicy.setHeightForWidth(self.statusLabel.sizePolicy().hasHeightForWidth()) 24 | self.statusLabel.setSizePolicy(sizePolicy) 25 | self.statusLabel.setText("STATUS1\n" 26 | "STATUS2") 27 | self.statusLabel.setAlignment(Qt.AlignmentFlag.AlignLeading|Qt.AlignmentFlag.AlignLeft|Qt.AlignmentFlag.AlignTop) 28 | self.statusLabel.setObjectName("statusLabel") 29 | self.verticalLayout.addWidget(self.statusLabel) 30 | self.remoteLabel = QLabel(parent=RemoteLinkDialog) 31 | sizePolicy = QSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) 32 | sizePolicy.setHorizontalStretch(0) 33 | sizePolicy.setVerticalStretch(0) 34 | sizePolicy.setHeightForWidth(self.remoteLabel.sizePolicy().hasHeightForWidth()) 35 | self.remoteLabel.setSizePolicy(sizePolicy) 36 | self.remoteLabel.setText("REMOTE") 37 | self.remoteLabel.setObjectName("remoteLabel") 38 | self.verticalLayout.addWidget(self.remoteLabel) 39 | self.progressBar = QProgressBar(parent=RemoteLinkDialog) 40 | self.progressBar.setMaximum(0) 41 | self.progressBar.setProperty("value", 0) 42 | self.progressBar.setObjectName("progressBar") 43 | self.verticalLayout.addWidget(self.progressBar) 44 | spacerItem = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) 45 | self.verticalLayout.addItem(spacerItem) 46 | self.buttonBox = QDialogButtonBox(parent=RemoteLinkDialog) 47 | self.buttonBox.setOrientation(Qt.Orientation.Horizontal) 48 | self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Abort) 49 | self.buttonBox.setObjectName("buttonBox") 50 | self.verticalLayout.addWidget(self.buttonBox) 51 | 52 | self.retranslateUi(RemoteLinkDialog) 53 | self.buttonBox.accepted.connect(RemoteLinkDialog.accept) # type: ignore 54 | self.buttonBox.rejected.connect(RemoteLinkDialog.reject) # type: ignore 55 | QMetaObject.connectSlotsByName(RemoteLinkDialog) 56 | 57 | def retranslateUi(self, RemoteLinkDialog): 58 | RemoteLinkDialog.setWindowTitle(_p("RemoteLinkDialog", "Remote operation")) 59 | -------------------------------------------------------------------------------- /gitfourchette/forms/ui_searchbar.py: -------------------------------------------------------------------------------- 1 | # Form implementation generated from reading ui file 'searchbar.ui' 2 | # 3 | # Created by: PyQt6 UI code generator 6.8.0 4 | # 5 | # WARNING: Any manual changes made to this file will be lost when pyuic6 is 6 | # run again. Do not edit this file unless you know what you are doing. 7 | 8 | 9 | from gitfourchette.localization import * 10 | from gitfourchette.qt import * 11 | 12 | 13 | class Ui_SearchBar(object): 14 | def setupUi(self, SearchBar): 15 | SearchBar.setObjectName("SearchBar") 16 | SearchBar.resize(329, 26) 17 | sizePolicy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) 18 | sizePolicy.setHorizontalStretch(0) 19 | sizePolicy.setVerticalStretch(0) 20 | sizePolicy.setHeightForWidth(SearchBar.sizePolicy().hasHeightForWidth()) 21 | SearchBar.setSizePolicy(sizePolicy) 22 | SearchBar.setAutoFillBackground(True) 23 | self.horizontalLayout = QHBoxLayout(SearchBar) 24 | self.horizontalLayout.setContentsMargins(2, 2, 2, 2) 25 | self.horizontalLayout.setObjectName("horizontalLayout") 26 | self.lineEdit = QLineEdit(parent=SearchBar) 27 | self.lineEdit.setClearButtonEnabled(True) 28 | self.lineEdit.setObjectName("lineEdit") 29 | self.horizontalLayout.addWidget(self.lineEdit) 30 | self.forwardButton = QToolButton(parent=SearchBar) 31 | self.forwardButton.setText("↓") 32 | self.forwardButton.setObjectName("forwardButton") 33 | self.horizontalLayout.addWidget(self.forwardButton) 34 | self.backwardButton = QToolButton(parent=SearchBar) 35 | self.backwardButton.setText("↑") 36 | self.backwardButton.setObjectName("backwardButton") 37 | self.horizontalLayout.addWidget(self.backwardButton) 38 | self.closeButton = QToolButton(parent=SearchBar) 39 | self.closeButton.setObjectName("closeButton") 40 | self.horizontalLayout.addWidget(self.closeButton) 41 | 42 | self.retranslateUi(SearchBar) 43 | QMetaObject.connectSlotsByName(SearchBar) 44 | 45 | def retranslateUi(self, SearchBar): 46 | SearchBar.setWindowTitle(_p("SearchBar", "Search")) 47 | self.lineEdit.setPlaceholderText(_p("SearchBar", "Search…")) 48 | self.forwardButton.setToolTip(_p("SearchBar", "Next Occurrence")) 49 | self.backwardButton.setToolTip(_p("SearchBar", "Previous Occurrence")) 50 | self.closeButton.setToolTip(_p("SearchBar", "Close Search Bar")) 51 | self.closeButton.setText(_p("SearchBar", "Done")) 52 | -------------------------------------------------------------------------------- /gitfourchette/forms/ui_unloadedrepoplaceholder.py: -------------------------------------------------------------------------------- 1 | # Form implementation generated from reading ui file 'unloadedrepoplaceholder.ui' 2 | # 3 | # Created by: PyQt6 UI code generator 6.8.0 4 | # 5 | # WARNING: Any manual changes made to this file will be lost when pyuic6 is 6 | # run again. Do not edit this file unless you know what you are doing. 7 | 8 | 9 | from gitfourchette.localization import * 10 | from gitfourchette.qt import * 11 | 12 | 13 | class Ui_UnloadedRepoPlaceholder(object): 14 | def setupUi(self, UnloadedRepoPlaceholder): 15 | UnloadedRepoPlaceholder.setObjectName("UnloadedRepoPlaceholder") 16 | UnloadedRepoPlaceholder.resize(670, 327) 17 | self.gridLayout = QGridLayout(UnloadedRepoPlaceholder) 18 | self.gridLayout.setObjectName("gridLayout") 19 | spacerItem = QSpacerItem(20, 109, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) 20 | self.gridLayout.addItem(spacerItem, 5, 1, 1, 1) 21 | spacerItem1 = QSpacerItem(141, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) 22 | self.gridLayout.addItem(spacerItem1, 4, 0, 1, 1) 23 | self.label = QLabel(parent=UnloadedRepoPlaceholder) 24 | self.label.setMinimumSize(QSize(300, 0)) 25 | self.label.setAlignment(Qt.AlignmentFlag.AlignCenter) 26 | self.label.setWordWrap(True) 27 | self.label.setObjectName("label") 28 | self.gridLayout.addWidget(self.label, 3, 1, 1, 1) 29 | self.nameLabel = QLabel(parent=UnloadedRepoPlaceholder) 30 | sizePolicy = QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred) 31 | sizePolicy.setHorizontalStretch(0) 32 | sizePolicy.setVerticalStretch(0) 33 | sizePolicy.setHeightForWidth(self.nameLabel.sizePolicy().hasHeightForWidth()) 34 | self.nameLabel.setSizePolicy(sizePolicy) 35 | self.nameLabel.setMaximumSize(QSize(300, 16777215)) 36 | self.nameLabel.setText("Long Long Long Long Long Long Long Long Long Long Name") 37 | self.nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) 38 | self.nameLabel.setObjectName("nameLabel") 39 | self.gridLayout.addWidget(self.nameLabel, 2, 1, 1, 1) 40 | self.loadButton = QPushButton(parent=UnloadedRepoPlaceholder) 41 | self.loadButton.setObjectName("loadButton") 42 | self.gridLayout.addWidget(self.loadButton, 4, 1, 1, 1) 43 | spacerItem2 = QSpacerItem(20, 109, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) 44 | self.gridLayout.addItem(spacerItem2, 0, 1, 1, 1) 45 | spacerItem3 = QSpacerItem(140, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) 46 | self.gridLayout.addItem(spacerItem3, 4, 2, 1, 1) 47 | self.icon = QLabel(parent=UnloadedRepoPlaceholder) 48 | self.icon.setText("ICON") 49 | self.icon.setAlignment(Qt.AlignmentFlag.AlignCenter) 50 | self.icon.setObjectName("icon") 51 | self.gridLayout.addWidget(self.icon, 1, 1, 1, 1) 52 | 53 | self.retranslateUi(UnloadedRepoPlaceholder) 54 | QMetaObject.connectSlotsByName(UnloadedRepoPlaceholder) 55 | 56 | def retranslateUi(self, UnloadedRepoPlaceholder): 57 | UnloadedRepoPlaceholder.setWindowTitle(_p("UnloadedRepoPlaceholder", "Unloaded repository")) 58 | self.label.setText(_p("UnloadedRepoPlaceholder", "Ready to load this repository.")) 59 | self.loadButton.setText(_p("UnloadedRepoPlaceholder", "Load")) 60 | -------------------------------------------------------------------------------- /gitfourchette/forms/unloadedrepoplaceholder.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2024 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from gitfourchette.forms.ui_unloadedrepoplaceholder import Ui_UnloadedRepoPlaceholder 8 | from gitfourchette.qt import * 9 | from gitfourchette.toolbox import * 10 | 11 | 12 | class UnloadedRepoPlaceholder(QWidget): 13 | def __init__(self, parent=None): 14 | super().__init__(parent) 15 | self.ui = Ui_UnloadedRepoPlaceholder() 16 | self.ui.setupUi(self) 17 | tweakWidgetFont(self.ui.nameLabel, bold=True) 18 | -------------------------------------------------------------------------------- /gitfourchette/forms/welcomewidget.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2024 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from gitfourchette.forms.ui_welcomewidget import Ui_WelcomeWidget 8 | from gitfourchette.qt import * 9 | from gitfourchette.toolbox import * 10 | 11 | 12 | class WelcomeWidget(QFrame): 13 | newRepo = Signal() 14 | openRepo = Signal() 15 | cloneRepo = Signal() 16 | 17 | def __init__(self, parent): 18 | super().__init__(parent) 19 | 20 | self.ui = Ui_WelcomeWidget() 21 | self.ui.setupUi(self) 22 | 23 | logoPixmap = QPixmap("assets:icons/gitfourchette") 24 | logoPixmap.setDevicePixelRatio(4) 25 | self.ui.logoLabel.setText(qAppName()) 26 | self.ui.logoLabel.setPixmap(logoPixmap) 27 | 28 | defaultFont = self.ui.welcomeLabel.font() 29 | fs1 = int(defaultFont.pointSizeF() * 1.3) 30 | fs2 = int(defaultFont.pointSizeF() * 1.8) 31 | appText = f"{qAppName()}" 32 | welcomeText = self.ui.welcomeLabel.text() 33 | welcomeText = f"" + welcomeText.format(app=appText) 34 | self.ui.welcomeLabel.setText(welcomeText) 35 | 36 | self.ui.newRepoButton.clicked.connect(self.newRepo) 37 | self.ui.openRepoButton.clicked.connect(self.openRepo) 38 | self.ui.cloneRepoButton.clicked.connect(self.cloneRepo) 39 | -------------------------------------------------------------------------------- /gitfourchette/globalshortcuts.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2025 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from gitfourchette.qt import * 8 | from gitfourchette.toolbox import MultiShortcut, makeMultiShortcut 9 | 10 | 11 | class GlobalShortcuts: 12 | NO_SHORTCUT: MultiShortcut = [] 13 | 14 | find: MultiShortcut = NO_SHORTCUT 15 | findNext: MultiShortcut = NO_SHORTCUT 16 | findPrevious: MultiShortcut = NO_SHORTCUT 17 | refresh: MultiShortcut = NO_SHORTCUT 18 | openRepoFolder: MultiShortcut = NO_SHORTCUT 19 | openTerminal: MultiShortcut = NO_SHORTCUT 20 | 21 | stageHotkeys = [Qt.Key.Key_Return, Qt.Key.Key_Enter] # Return: main keys; Enter: on keypad 22 | discardHotkeys = [Qt.Key.Key_Delete, Qt.Key.Key_Backspace] 23 | 24 | _initialized = False 25 | 26 | @classmethod 27 | def initialize(cls): 28 | if cls._initialized: 29 | return 30 | 31 | assert QApplication.instance(), "QApplication must have been created before instantiating QKeySequence" 32 | 33 | cls.find = makeMultiShortcut(QKeySequence.StandardKey.Find, "/") 34 | cls.findNext = makeMultiShortcut(QKeySequence.StandardKey.FindNext if not GNOME else "F3") 35 | cls.findPrevious = makeMultiShortcut(QKeySequence.StandardKey.FindPrevious if not GNOME else "Shift+F3") 36 | cls.refresh = makeMultiShortcut(QKeySequence.StandardKey.Refresh, "Ctrl+R", "F5") 37 | cls.openRepoFolder = makeMultiShortcut("Ctrl+Shift+O") 38 | cls.openTerminal = makeMultiShortcut("Ctrl+Alt+O") 39 | 40 | cls._initialized = True 41 | -------------------------------------------------------------------------------- /gitfourchette/graph/__init__.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2025 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from gitfourchette.graph.graph import ( 8 | Arc, 9 | ArcJunction, 10 | BatchRow, 11 | ChainHandle, 12 | Frame, 13 | Graph, 14 | KF_INTERVAL, 15 | PlaybackState, 16 | ) 17 | from gitfourchette.graph.graphtrickle import GraphTrickle 18 | from gitfourchette.graph.graphdiagram import GraphDiagram 19 | from gitfourchette.graph.graphbuilder import ( 20 | GraphBuildLoop, 21 | GraphSpliceLoop, 22 | MockCommit, 23 | MockOid, 24 | ) 25 | -------------------------------------------------------------------------------- /gitfourchette/graph/__main__.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2024 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | if __name__ == '__main__': 8 | from gitfourchette.graph import * 9 | from argparse import ArgumentParser 10 | 11 | parser = ArgumentParser(description="GitFourchette ASCII graph tool") 12 | parser.add_argument("definition", help="Graph definition (e.g.: \"u:z i:b m:a,b a:z b-c-z\")", nargs="+") 13 | parser.add_argument("-t", "--tips", nargs="*", default=[]) 14 | parser.add_argument("-x", "--hide", nargs="*", default=[]) 15 | parser.add_argument("-v", "--verbose", action="store_true") 16 | args = parser.parse_args() 17 | 18 | definition = " ".join(args.definition) 19 | sequence, heads = GraphDiagram.parseDefinition(definition) 20 | 21 | idSequence = [c.id for c in sequence] 22 | assert all(c in idSequence for c in args.hide), "one of the given hidden commits isn't in the graph" 23 | assert all(c in idSequence for c in args.tips), "one of the given tip commits isn't in the graph" 24 | 25 | builder = GraphBuildLoop(args.tips or heads, hideSeeds=args.hide).sendAll(sequence) 26 | 27 | if args.verbose: 28 | print("Hidden commits:", builder.hiddenCommits) 29 | 30 | diagram = GraphDiagram.diagram(builder.graph, hiddenCommits=builder.hiddenCommits, verbose=args.verbose) 31 | print(diagram) 32 | -------------------------------------------------------------------------------- /gitfourchette/graph/graphtrickle.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2024 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from collections.abc import Iterable 8 | 9 | from gitfourchette.graph.graph import Oid 10 | 11 | STOP = 0 12 | PIPE = 1 13 | SOURCE = 2 14 | 15 | 16 | class GraphTrickle: 17 | def __init__(self): 18 | self.frontier = {} 19 | self.flaggedSet = set() 20 | 21 | @property 22 | def done(self) -> bool: 23 | return all(v == STOP for v in self.frontier.values()) 24 | 25 | def newCommit(self, commit: Oid, parents: Iterable[Oid]): 26 | frontier = self.frontier 27 | flagged = frontier.pop(commit, STOP) 28 | 29 | if flagged != STOP: 30 | # Trickle through parents that are not explicitly flagged 31 | for p in parents: 32 | frontier.setdefault(p, PIPE) 33 | self.flaggedSet.add(commit) 34 | else: 35 | # Block trickling to parents (that aren't sources themselves) 36 | for p in parents: 37 | if frontier.get(p, STOP) != SOURCE: 38 | frontier[p] = STOP 39 | 40 | @staticmethod 41 | def newHiddenTrickle( 42 | allHeads: set[Oid], 43 | hideSeeds: set[Oid], 44 | forceHide: set[Oid] | None = None 45 | ): 46 | trickle = GraphTrickle() 47 | 48 | # Explicitly show all refs by default (block foreign trickle) 49 | for head in allHeads: 50 | trickle.frontier[head] = STOP 51 | 52 | # Explicitly hide tips (allow foreign trickle) 53 | for head in hideSeeds: 54 | trickle.frontier[head] = PIPE 55 | 56 | # Explicitly hide stash junk parents (beyond parent #0) 57 | # NOTE: Dropped this from the actual app but kept around in unit tests. 58 | if forceHide: 59 | for head in forceHide: 60 | trickle.frontier[head] = SOURCE 61 | 62 | assert trickle.testFrontierInputs() 63 | return trickle 64 | 65 | @staticmethod 66 | def newForeignTrickle( 67 | allHeads: set[Oid], 68 | localSeeds: set[Oid] 69 | ): 70 | trickle = GraphTrickle() 71 | 72 | # Start with all foreign heads 73 | for head in allHeads: 74 | trickle.frontier[head] = PIPE 75 | 76 | # Local heads block propagation of foreign trickle 77 | for head in localSeeds: 78 | trickle.frontier[head] = STOP 79 | 80 | assert trickle.testFrontierInputs() 81 | return trickle 82 | 83 | def testFrontierInputs(self): 84 | return all(isinstance(head, Oid) for head in self.frontier) 85 | -------------------------------------------------------------------------------- /gitfourchette/graphview/commitlogfilter.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2025 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from gitfourchette.graphview.commitlogmodel import CommitLogModel 8 | from gitfourchette.porcelain import * 9 | from gitfourchette.qt import * 10 | from gitfourchette.repomodel import UC_FAKEID 11 | from gitfourchette.toolbox import * 12 | 13 | 14 | class CommitLogFilter(QSortFilterProxyModel): 15 | hiddenIds: set[Oid] 16 | 17 | def __init__(self, parent): 18 | super().__init__(parent) 19 | self.hiddenIds = set() 20 | self.setDynamicSortFilter(True) 21 | 22 | @property 23 | def clModel(self) -> CommitLogModel: 24 | return self.sourceModel() 25 | 26 | @benchmark 27 | def setHiddenCommits(self, hiddenIds: set[Oid]): 28 | # Invalidating the filter can be costly, so avoid if possible 29 | if self.hiddenIds == hiddenIds: 30 | return 31 | # Duplicate the set so we don't prematurely bail from above 32 | # if hidden commits change in the same set object 33 | self.hiddenIds = set(hiddenIds) 34 | self.invalidateFilter() 35 | 36 | def filterAcceptsRow(self, sourceRow: int, sourceParent: QModelIndex) -> bool: 37 | try: 38 | commit = self.clModel._commitSequence[sourceRow] 39 | except IndexError: 40 | # Probably an extra special row 41 | return True 42 | if not commit: 43 | return True 44 | if commit.id == UC_FAKEID: 45 | return True 46 | return commit.id not in self.hiddenIds 47 | -------------------------------------------------------------------------------- /gitfourchette/localization.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2024 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | # In December 2024, GitFourchette migrated to gettext due to shortcomings 8 | # in Qt Linguist tooling for Python: 9 | # - In PyQt6, self.tr() incorrectly returns non-localized text when called 10 | # from a subclass of the class that defines the string; 11 | # - pylupdate6 forces a context in contextless tr() calls. 12 | # - pyside6-lupdate yields better results, but it isn't readily available on 13 | # some Linux distros, causing friction for contributors; 14 | # - pyside6-lupdate ignores plurals in translate() (explicit context); 15 | # - pyside6-lupdate -pluralsonly (for English) doesn't play well with Weblate. 16 | # 17 | # Benefits of gettext: 18 | # - It just works. No weird edge cases causing English text to pop up in an 19 | # otherwise complete localization; 20 | # - POEdit or xgettext/msgmerge are easier to install for contributors; 21 | # - Weblate has great support for gettext; 22 | # - Custom gettext functions allow for reduced visual noise in the code; 23 | # - English plurals are defined straight from the code. No need for "actual 24 | # English" translations of "developer English" strings such as "%n file(s)". 25 | 26 | from gettext import GNUTranslations 27 | from gettext import NullTranslations 28 | 29 | 30 | _translator = NullTranslations() 31 | 32 | def installGettextTranslator(path: str): 33 | global _translator 34 | try: 35 | with open(path, 'rb') as fp: 36 | _translator = GNUTranslations(fp) 37 | except OSError: 38 | _translator = NullTranslations() 39 | 40 | def _(message: str, *args, **kwargs) -> str: 41 | message = _translator.gettext(message) 42 | if args or kwargs: 43 | message = message.format(*args, **kwargs) 44 | return message 45 | 46 | def _n(singular: str, plural: str, n: int, *args, **kwargs) -> str: 47 | return _translator.ngettext(singular, plural, n).format(*args, **kwargs, n=n) 48 | 49 | def _np(context: str, singular: str, plural: str, n: int) -> str: 50 | return _translator.npgettext(context, singular, plural, n).format(n=n) 51 | 52 | def _p(context: str, message: str, *args, **kwargs) -> str: 53 | message = _translator.pgettext(context, message) 54 | if args or kwargs: 55 | message = message.format(*args, **kwargs) 56 | return message 57 | 58 | 59 | __all__ = [ 60 | "_", 61 | "_n", 62 | "_np", 63 | "_p", 64 | "installGettextTranslator", 65 | ] 66 | -------------------------------------------------------------------------------- /gitfourchette/repoprefs.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2025 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | """ 8 | Manage proprietary settings in a repository's .git/config and .git/gitfourchette.json. 9 | """ 10 | 11 | from dataclasses import dataclass, field 12 | 13 | from gitfourchette.appconsts import * 14 | from gitfourchette.forms.signatureform import SignatureOverride 15 | from gitfourchette.porcelain import * 16 | from gitfourchette.settings import RefSort 17 | from gitfourchette.prefsfile import PrefsFile 18 | 19 | KEY_PREFIX = "gitfourchette-" 20 | 21 | 22 | @dataclass 23 | class RepoPrefs(PrefsFile): 24 | _filename = f"{APP_SYSTEM_NAME}.json" 25 | _allowMakeDirs = False 26 | _parentDir = "" 27 | 28 | _repo: Repo 29 | draftCommitMessage: str = "" 30 | draftCommitSignature: Signature | None = None 31 | draftCommitSignatureOverride: SignatureOverride = SignatureOverride.Nothing 32 | draftAmendMessage: str = "" 33 | hidePatterns: set = field(default_factory=set) 34 | showPatterns: set = field(default_factory=set) 35 | collapseCache: set = field(default_factory=set) 36 | sortBranches: RefSort = RefSort.UseGlobalPref 37 | sortRemoteBranches: RefSort = RefSort.UseGlobalPref 38 | sortTags: RefSort = RefSort.UseGlobalPref 39 | refSortClearTimestamp: int = 0 40 | 41 | def getParentDir(self): 42 | return self._parentDir 43 | 44 | def hasDraftCommit(self): 45 | return self.draftCommitMessage or self.draftCommitSignatureOverride != SignatureOverride.Nothing 46 | 47 | def clearDraftCommit(self): 48 | self.draftCommitMessage = "" 49 | self.draftCommitSignature = None 50 | self.draftCommitSignatureOverride = SignatureOverride.Nothing 51 | self.setDirty() 52 | 53 | def clearDraftAmend(self): 54 | self.draftAmendMessage = "" 55 | self.setDirty() 56 | 57 | def getRemoteKeyFile(self, remote: str) -> str: 58 | return RepoPrefs.getRemoteKeyFileForRepo(self._repo, remote) 59 | 60 | def setRemoteKeyFile(self, remote: str, path: str): 61 | RepoPrefs.setRemoteKeyFileForRepo(self._repo, remote, path) 62 | 63 | @staticmethod 64 | def getRemoteKeyFileForRepo(repo: Repo, remote: str): 65 | return repo.get_config_value(("remote", remote, KEY_PREFIX+"keyfile")) 66 | 67 | @staticmethod 68 | def setRemoteKeyFileForRepo(repo: Repo, remote: str, path: str): 69 | repo.set_config_value(("remote", remote, KEY_PREFIX+"keyfile"), path) 70 | 71 | def clearRefSort(self): 72 | self.sortBranches = RefSort.UseGlobalPref 73 | self.sortRemoteBranches = RefSort.UseGlobalPref 74 | self.sortTags = RefSort.UseGlobalPref 75 | self.refSortClearTimestamp = 0 76 | self.setDirty() 77 | 78 | def getShadowUpstream(self, localBranchName: str): 79 | return self._repo.get_config_value(("branch", localBranchName, KEY_PREFIX+"shadow-remote")) 80 | 81 | def setShadowUpstream(self, localBranchName: str, upstreamName: str): 82 | self._repo.set_config_value(("branch", localBranchName, KEY_PREFIX+"shadow-remote"), upstreamName) 83 | -------------------------------------------------------------------------------- /gitfourchette/syntax/__init__.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2025 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from .colorscheme import ColorScheme, PygmentsPresets 8 | from .lexercache import LexerCache 9 | from .lexjob import LexJob 10 | from .lexjobcache import LexJobCache 11 | 12 | try: 13 | import pygments 14 | pygmentsVersion = pygments.__version__ 15 | syntaxHighlightingAvailable = True 16 | except ImportError: # pragma: no cover 17 | pygmentsVersion = "" 18 | syntaxHighlightingAvailable = False 19 | -------------------------------------------------------------------------------- /gitfourchette/syntax/lexjobcache.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2024 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | import logging 8 | 9 | from gitfourchette.syntax.lexjob import LexJob 10 | from gitfourchette.porcelain import Oid 11 | from gitfourchette.toolbox.gitutils import shortHash 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class LexJobCache: 17 | MaxBudget = 1_048_576 18 | """ 19 | Maximum total size, in bytes, of all the files being lexed. 20 | This figure sets an upper bound on the total footprint of lexed tokens 21 | (in the worst case scenario, there would be 1 token per source byte). 22 | """ 23 | 24 | cache: dict[Oid, LexJob] = {} 25 | totalFileSize = 0 26 | 27 | @classmethod 28 | def put(cls, job: LexJob): 29 | fileKey = job.fileKey 30 | 31 | assert not job.scheduler.isActive() 32 | assert fileKey not in cls.cache, "LexJob already cached" 33 | 34 | # If the new file is larger than cache capacity, just bail 35 | if job.fileSize > cls.MaxBudget: 36 | logger.debug("File too large to fit in cache") 37 | return 38 | 39 | # Make room in FIFO 40 | keys = list(cls.cache) 41 | while cls.totalFileSize + job.fileSize > cls.MaxBudget: 42 | oldestKey = keys.pop(0) 43 | cls.evict(oldestKey) 44 | 45 | cls.cache[fileKey] = job 46 | cls.totalFileSize += job.fileSize 47 | logger.debug(f"Put {shortHash(fileKey)} (tot: {cls.totalFileSize>>10}K)") 48 | 49 | @classmethod 50 | def get(cls, fileKey: Oid): 51 | job = cls.cache.pop(fileKey) 52 | cls.cache[fileKey] = job # Bump key 53 | logger.debug(f"Get {shortHash(fileKey)}") 54 | return job 55 | 56 | @classmethod 57 | def evict(cls, fileKey: Oid): 58 | job = cls.cache.pop(fileKey) 59 | cls.totalFileSize -= job.fileSize 60 | logger.debug(f"Del {shortHash(fileKey)} (tot: {cls.totalFileSize>>10:,}K)") 61 | return job 62 | 63 | @classmethod 64 | def clear(cls): 65 | cls.cache.clear() 66 | cls.totalFileSize = 0 67 | -------------------------------------------------------------------------------- /gitfourchette/tasks/__init__.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2025 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from gitfourchette.tasks.repotask import RepoTask, RepoTaskRunner, TaskPrereqs, TaskEffects 8 | from gitfourchette.tasks.repotask import RepoGoneError 9 | from gitfourchette.tasks.repotask import TaskInvoker 10 | 11 | from gitfourchette.tasks.branchtasks import ( 12 | DeleteBranch, 13 | DeleteBranchFolder, 14 | EditUpstreamBranch, 15 | FastForwardBranch, 16 | MergeBranch, 17 | NewBranchFromCommit, 18 | NewBranchFromHead, 19 | NewBranchFromRef, 20 | RecallCommit, 21 | RenameBranch, 22 | RenameBranchFolder, 23 | ResetHead, 24 | SwitchBranch, 25 | ) 26 | from gitfourchette.tasks.committasks import ( 27 | AmendCommit, 28 | CheckoutCommit, 29 | CherrypickCommit, 30 | DeleteTag, 31 | NewCommit, 32 | NewTag, 33 | RevertCommit, 34 | SetUpGitIdentity, 35 | ) 36 | from gitfourchette.tasks.exporttasks import ( 37 | ExportCommitAsPatch, 38 | ExportPatchCollection, 39 | ExportStashAsPatch, 40 | ExportWorkdirAsPatch, 41 | ) 42 | from gitfourchette.tasks.misctasks import ( 43 | EditRepoSettings, 44 | GetCommitInfo, 45 | NewIgnorePattern, 46 | ) 47 | from gitfourchette.tasks.jumptasks import ( 48 | Jump, 49 | JumpBack, 50 | JumpBackOrForward, 51 | JumpForward, 52 | JumpToHEAD, 53 | JumpToUncommittedChanges, 54 | RefreshRepo, 55 | ) 56 | from gitfourchette.tasks.loadtasks import PrimeRepo 57 | from gitfourchette.tasks.nettasks import ( 58 | DeleteRemoteBranch, 59 | RenameRemoteBranch, 60 | FetchRemotes, 61 | FetchRemoteBranch, 62 | PullBranch, 63 | PushBranch, 64 | PushRefspecs, 65 | UpdateSubmodule, 66 | UpdateSubmodulesRecursive, 67 | ) 68 | from gitfourchette.tasks.remotetasks import NewRemote, EditRemote, DeleteRemote 69 | 70 | from gitfourchette.tasks.indextasks import ( 71 | AbortMerge, 72 | AcceptMergeConflictResolution, 73 | ApplyPatch, 74 | ApplyPatchFile, 75 | ApplyPatchFileReverse, 76 | DiscardFiles, 77 | DiscardModeChanges, 78 | HardSolveConflicts, 79 | MarkConflictSolved, 80 | RevertPatch, 81 | ApplyPatchData, 82 | StageFiles, 83 | UnstageFiles, 84 | UnstageModeChanges, 85 | RestoreRevisionToWorkdir, 86 | ) 87 | 88 | from gitfourchette.tasks.stashtasks import ( 89 | ApplyStash, 90 | DropStash, 91 | NewStash, 92 | ) 93 | 94 | from gitfourchette.tasks.submoduletasks import ( 95 | AbsorbSubmodule, 96 | RegisterSubmodule, 97 | RemoveSubmodule, 98 | ) 99 | 100 | from gitfourchette.tasks.taskbook import TaskBook 101 | -------------------------------------------------------------------------------- /gitfourchette/toolbox/__init__.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2025 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | """ 8 | Library of widgets and utilities that aren't specifically tied to GitFourchette's core functionality. 9 | """ 10 | 11 | from .actiondef import ActionDef 12 | from .autohidemenubar import AutoHideMenuBar 13 | from .benchmark import Benchmark, benchmark 14 | from .calledfromqthread import calledFromQThread 15 | from .fittedtext import FittedText 16 | from .excutils import shortenTracebackPath, excStrings 17 | from .fontpicker import FontPicker 18 | from .gitutils import ( 19 | shortHash, dumpTempBlob, nameValidationMessage, 20 | AuthorDisplayStyle, abbreviatePerson, 21 | PatchPurpose, 22 | simplifyOctalFileMode, 23 | remoteUrlProtocol, 24 | splitRemoteUrl, 25 | stripRemoteUrlPath, 26 | guessRemoteUrlFromText, 27 | signatureQDateTime, 28 | signatureDateFormat, 29 | ) 30 | from .memoryindicator import MemoryIndicator 31 | from .messageboxes import ( 32 | MessageBoxIconName, excMessageBox, asyncMessageBox, 33 | showWarning, showInformation, askConfirmation, 34 | addULToMessageBox, 35 | NonCriticalOperation) 36 | from .iconbank import stockIcon 37 | from .pathutils import PathDisplayStyle, abbreviatePath, compactPath 38 | from .persistentfiledialog import PersistentFileDialog 39 | from .qbusyspinner import QBusySpinner 40 | from .qcomboboxwithpreview import QComboBoxWithPreview 41 | from .qelidedlabel import QElidedLabel 42 | from .qfaintseparator import QFaintSeparator 43 | from .qfilepickercheckbox import QFilePickerCheckBox 44 | from .qhintbutton import QHintButton 45 | from .qsignalblockercontext import QSignalBlockerContext 46 | from .qstatusbar2 import QStatusBar2 47 | from .qtabwidget2 import QTabWidget2, QTabBar2 48 | from .qtutils import ( 49 | enforceComboBoxMaxVisibleItems, 50 | isImageFormatSupported, 51 | onAppThread, 52 | setFontFeature, 53 | adjustedWidgetFontSize, 54 | tweakWidgetFont, 55 | formatWidgetText, 56 | formatWidgetTooltip, 57 | itemViewVisibleRowRange, 58 | isDarkTheme, 59 | mutedTextColorHex, 60 | mutedToolTipColorHex, 61 | appendShortcutToToolTipText, 62 | appendShortcutToToolTip, 63 | openFolder, showInFolder, 64 | DisableWidgetContext, 65 | DisableWidgetUpdatesContext, 66 | QScrollBackupContext, 67 | makeInternalLink, 68 | MultiShortcut, 69 | makeMultiShortcut, 70 | makeWidgetShortcut, 71 | CallbackAccumulator, 72 | lerp, 73 | mixColors, 74 | DocumentLinks, 75 | waitForSignal, 76 | findParentWidget, 77 | setTabOrder, 78 | QModelIndex_default, 79 | QPoint_zero, 80 | ) 81 | from .textutils import ( 82 | toLengthVariants, 83 | escape, escamp, paragraphs, messageSummary, elide, 84 | toRoomyUL, 85 | toTightUL, 86 | linkify, 87 | tagify, 88 | clipboardStatusMessage, 89 | hquo, hquoe, bquo, bquoe, lquo, lquoe, tquo, tquoe, 90 | btag, 91 | stripHtml, 92 | stripAccelerators, 93 | withUniqueSuffix, 94 | englishTitleCase, 95 | naturalSort, 96 | ) 97 | from .urltooltip import UrlToolTip 98 | from .validatormultiplexer import ValidatorMultiplexer 99 | -------------------------------------------------------------------------------- /gitfourchette/toolbox/benchmark.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2024 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | import logging 8 | import os 9 | import time 10 | 11 | BENCHMARK_LOGGING_LEVEL = 5 12 | 13 | logger = logging.getLogger(__name__) 14 | logging.addLevelName(BENCHMARK_LOGGING_LEVEL, "BENCHMARK") 15 | 16 | try: 17 | import psutil 18 | except ModuleNotFoundError: 19 | psutil = None 20 | 21 | 22 | def getRSS(): 23 | if psutil: 24 | return psutil.Process(os.getpid()).memory_info().rss 25 | else: 26 | return 0 27 | 28 | 29 | class Benchmark: 30 | """ Context manager that reports how long a piece of code takes to run. """ 31 | 32 | nesting: list[str] = [] 33 | 34 | def __init__(self, name: str): 35 | self.name = name 36 | self.phase = "" 37 | self.startTime = 0.0 38 | self.startBytes = 0 39 | 40 | def enter(self, phase=""): 41 | if self.startTime: 42 | self.exit() 43 | 44 | Benchmark.nesting.append(self.name) 45 | self.startBytes = getRSS() 46 | self.startTime = time.perf_counter() 47 | self.phase = phase 48 | 49 | def exit(self): 50 | ms = 1000 * (time.perf_counter() - self.startTime) 51 | kb = (getRSS() - self.startBytes) // 1024 52 | 53 | description = "/".join(Benchmark.nesting) 54 | if self.phase: 55 | description += f" ({self.phase})" 56 | logger.log(BENCHMARK_LOGGING_LEVEL, f"{ms:8.2f} ms {kb:6,d}K {description}") 57 | 58 | Benchmark.nesting.pop() 59 | self.startTime = 0.0 60 | self.phase = "" 61 | 62 | def __enter__(self): 63 | self.enter() 64 | return self 65 | 66 | def __exit__(self, exc_type=None, exc_value=None, traceback=None): 67 | self.exit() 68 | 69 | 70 | def benchmark(func): 71 | """ Function decorator that reports how long the function takes to run. """ 72 | def wrapper(*args, **kwargs): 73 | with Benchmark(func.__qualname__): 74 | return func(*args, **kwargs) 75 | return wrapper 76 | -------------------------------------------------------------------------------- /gitfourchette/toolbox/calledfromqthread.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2024 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | import functools 8 | import sys 9 | import threading 10 | 11 | 12 | def calledFromQThread(f): # pragma: no cover 13 | """ 14 | Add this decorator to functions that are called from within a QThread 15 | so pytest-cov can trace them correctly. 16 | """ 17 | # Adapted from https://github.com/nedbat/coveragepy/issues/686#issuecomment-634932753 18 | 19 | if "coverage" not in sys.modules: 20 | return f 21 | 22 | @functools.wraps(f) 23 | def wrapped(*args, **kwargs): 24 | sys.settrace(threading._trace_hook) 25 | return f(*args, **kwargs) 26 | 27 | return wrapped 28 | -------------------------------------------------------------------------------- /gitfourchette/toolbox/excutils.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2024 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | import os 8 | import re 9 | import traceback 10 | 11 | 12 | def shortenTracebackPath(line): 13 | return re.sub(r'^\s*File "([^"]+)", line (\d+)', 14 | lambda m: F'{os.path.basename(m.group(1))}:{m.group(2)}', 15 | line, count=1) 16 | 17 | 18 | def excStrings(exc): 19 | summary = traceback.format_exception_only(exc.__class__, exc) 20 | summary = ''.join(summary).strip() 21 | 22 | details = traceback.format_exception(exc.__class__, exc, exc.__traceback__) 23 | details = ''.join(details).strip() 24 | 25 | return summary, details 26 | -------------------------------------------------------------------------------- /gitfourchette/toolbox/iconbank.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2025 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from gitfourchette.qt import * 8 | from gitfourchette.toolbox.recolorsvgiconengine import RecolorSvgIconEngine 9 | 10 | 11 | _stockIconCache: dict[int, QIcon] = {} 12 | 13 | # Override some icon IDs depending on desktop environment 14 | _overrideIconIds = {} 15 | _overrideIconIdsReady = False 16 | 17 | 18 | def _iconOverrideTable(): 19 | overrides = {} 20 | 21 | assert QApplication.instance(), "need app instance for QIcon.themeName()" 22 | iconTheme = QIcon.themeName().casefold() 23 | 24 | # Use native warning icon in all contexts on Mac & Windows 25 | if MACOS or WINDOWS: 26 | overrides["achtung"] = "SP_MessageBoxWarning" 27 | 28 | # Override Ubuntu default theme's scary red icon for warnings 29 | if FREEDESKTOP and iconTheme.startswith("yaru"): 30 | overrides["SP_MessageBoxWarning"] = "warning-small-symbolic" 31 | 32 | return overrides 33 | 34 | 35 | def stockIcon(iconId: str, colorTable="") -> QIcon: 36 | # Special cases 37 | global _overrideIconIdsReady 38 | if not _overrideIconIdsReady: 39 | _overrideIconIds.clear() 40 | _overrideIconIds.update(_iconOverrideTable()) 41 | iconId = _overrideIconIds.get(iconId, iconId) 42 | 43 | # Compute cache key 44 | key = hash(iconId) ^ hash(colorTable) 45 | 46 | # Attempt to get cached icon 47 | try: 48 | return _stockIconCache[key] 49 | except KeyError: 50 | pass 51 | 52 | # Find path to icon file (if any) 53 | iconPath = "" 54 | for ext in ".svg", ".png": 55 | file = QFile(f"assets:icons/{iconId}{ext}") 56 | if file.exists(): 57 | iconPath = file.fileName() 58 | break 59 | 60 | # Create QIcon 61 | if iconPath.endswith(".svg"): 62 | # Dynamic SVG icon 63 | engine = RecolorSvgIconEngine(iconPath, colorTable) 64 | icon = QIcon(engine) 65 | elif iconPath: 66 | # Bitmap file 67 | icon = QIcon(iconPath) 68 | elif iconId.startswith("SP_"): 69 | # Qt standard pixmaps (with "SP_" prefix) 70 | entry = getattr(QStyle.StandardPixmap, iconId) 71 | icon = QApplication.style().standardIcon(entry) 72 | else: 73 | # Fall back to theme icon 74 | icon = QIcon.fromTheme(iconId) 75 | 76 | assert iconPath.endswith(".svg") or not colorTable, f"can't remap colors in non-SVG icon! {iconId}" 77 | 78 | # Cache icon 79 | _stockIconCache[key] = icon 80 | return icon 81 | -------------------------------------------------------------------------------- /gitfourchette/toolbox/memoryindicator.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2025 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | import gc 8 | import logging 9 | import time 10 | 11 | import pygit2 12 | 13 | from gitfourchette.qt import * 14 | from gitfourchette.toolbox.qtutils import setFontFeature 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | class MemoryIndicator(QPushButton): 20 | def __init__(self, parent): 21 | super().__init__(parent) 22 | 23 | self.setObjectName("MemoryIndicator") 24 | self.setText("Memory") 25 | 26 | # No border: don't let it thicken the status bar 27 | self.setStyleSheet("border: none; text-align: right; padding-right: 8px;") 28 | 29 | font: QFont = self.font() 30 | font.setPointSize(font.pointSize() * 85 // 100) 31 | setFontFeature(font, "tnum") # Tabular numbers 32 | self.setFont(font) 33 | 34 | width = 220 35 | 36 | self.setMinimumWidth(width) 37 | self.setMaximumWidth(width) 38 | self.clicked.connect(self.onMemoryIndicatorClicked) 39 | self.setToolTip("Force GC") 40 | self.setFocusPolicy(Qt.FocusPolicy.NoFocus) 41 | self.lastUpdate = 0.0 42 | 43 | def onMemoryIndicatorClicked(self): 44 | gc.collect() 45 | 46 | windows = '\n'.join(f'\t* {w.__class__.__name__} {w.objectName()}' for w in QApplication.topLevelWindows()) 47 | widgets = '\n'.join(f'\t* {w.__class__.__name__} {w.objectName()}' for w in QApplication.topLevelWidgets()) 48 | report = f"\nTop-Level Windows:\n{windows}\nTop-Level Widgets:\n{widgets}\n" 49 | logging.info(report) 50 | 51 | self.lastUpdate = 0.0 52 | self.updateMemoryIndicator() 53 | 54 | def paintEvent(self, e): 55 | self.updateMemoryIndicator() 56 | super().paintEvent(e) 57 | 58 | def updateMemoryIndicator(self): 59 | now = time.time() 60 | if now - self.lastUpdate < 0.03: 61 | return 62 | self.lastUpdate = time.time() 63 | 64 | numQObjects = sum(1 + len(tlw.findChildren(QObject)) # "+1" to account for tlw itself 65 | for tlw in QApplication.topLevelWidgets()) 66 | 67 | cacheMem, _dummy = pygit2.settings.cached_memory 68 | fds = QLocale().formattedDataSize(cacheMem, 0, QLocale.DataSizeFormat.DataSizeSIFormat) 69 | self.setText(f"git: {fds} qto: {numQObjects} pyo: {len(gc.get_objects())}") 70 | -------------------------------------------------------------------------------- /gitfourchette/toolbox/pathutils.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2025 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | import enum 8 | import os 9 | 10 | HOME = os.path.abspath(os.path.expanduser('~')) 11 | 12 | 13 | class PathDisplayStyle(enum.IntEnum): 14 | FullPaths = 1 15 | AbbreviateDirs = 2 16 | FileNameOnly = 3 17 | 18 | 19 | def compactPath(path: str) -> str: 20 | # Normalize path first, which also turns forward slashes to backslashes on Windows. 21 | path = os.path.abspath(path) 22 | if path.startswith(HOME): 23 | path = "~" + path[len(HOME):] 24 | return path 25 | 26 | 27 | def abbreviatePath(path: str, style: PathDisplayStyle = PathDisplayStyle.FullPaths) -> str: 28 | if style == PathDisplayStyle.AbbreviateDirs: 29 | splitLong = path.split('/') 30 | for i in range(len(splitLong) - 1): 31 | if splitLong[i][0] == '.': 32 | splitLong[i] = splitLong[i][:2] 33 | else: 34 | splitLong[i] = splitLong[i][0] 35 | return '/'.join(splitLong) 36 | elif style == PathDisplayStyle.FileNameOnly: 37 | return path.rsplit('/', 1)[-1] 38 | else: 39 | return path 40 | -------------------------------------------------------------------------------- /gitfourchette/toolbox/persistentfiledialog.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2024 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | import os 8 | 9 | from gitfourchette.qt import * 10 | 11 | 12 | class PersistentFileDialog: 13 | @staticmethod 14 | def getPath(key: str, fallbackPath: str = ""): 15 | from gitfourchette import settings 16 | return settings.history.fileDialogPaths.get(key, fallbackPath) 17 | 18 | @staticmethod 19 | def savePath(key, path): 20 | if path: 21 | from gitfourchette import settings 22 | settings.history.fileDialogPaths[key] = path 23 | settings.history.write() 24 | 25 | @staticmethod 26 | def install(qfd: QFileDialog, key: str): 27 | # Don't use native dialog in unit tests 28 | # (macOS native file dialog cannot be controlled from unit tests) 29 | from gitfourchette.settings import TEST_MODE 30 | qfd.setOption(QFileDialog.Option.DontUseNativeDialog, TEST_MODE) 31 | 32 | # Restore saved path 33 | savedPath = PersistentFileDialog.getPath(key) 34 | if savedPath: 35 | savedPath = os.path.dirname(savedPath) 36 | if os.path.exists(savedPath): 37 | qfd.setDirectory(savedPath) 38 | 39 | # Remember selected path 40 | qfd.fileSelected.connect(lambda path: PersistentFileDialog.savePath(key, path)) 41 | 42 | return qfd 43 | 44 | @staticmethod 45 | def saveFile(parent: QWidget, key: str, caption: str, initialFilename="", filter="", selectedFilter="", deleteOnClose=True): 46 | qfd = QFileDialog(parent, caption, initialFilename, filter) 47 | qfd.setAcceptMode(QFileDialog.AcceptMode.AcceptSave) 48 | qfd.setFileMode(QFileDialog.FileMode.AnyFile) 49 | if selectedFilter: 50 | qfd.selectNameFilter(selectedFilter) 51 | qfd.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, deleteOnClose) 52 | qfd.setWindowModality(Qt.WindowModality.WindowModal) 53 | PersistentFileDialog.install(qfd, key) 54 | return qfd 55 | 56 | @staticmethod 57 | def openFile(parent: QWidget, key: str, caption: str, fallbackPath="", filter="", selectedFilter="", deleteOnClose=True): 58 | qfd = QFileDialog(parent, caption, fallbackPath, filter) 59 | qfd.setAcceptMode(QFileDialog.AcceptMode.AcceptOpen) 60 | qfd.setFileMode(QFileDialog.FileMode.AnyFile) 61 | if selectedFilter: 62 | qfd.selectNameFilter(selectedFilter) 63 | qfd.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, deleteOnClose) 64 | qfd.setWindowModality(Qt.WindowModality.WindowModal) 65 | PersistentFileDialog.install(qfd, key) 66 | return qfd 67 | 68 | @staticmethod 69 | def openDirectory(parent: QWidget, key: str, caption: str, options=QFileDialog.Option.ShowDirsOnly, deleteOnClose=True): 70 | qfd = QFileDialog(parent, caption) 71 | qfd.setAcceptMode(QFileDialog.AcceptMode.AcceptOpen) 72 | qfd.setFileMode(QFileDialog.FileMode.Directory) 73 | qfd.setOptions(options) 74 | qfd.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, deleteOnClose) 75 | qfd.setWindowModality(Qt.WindowModality.WindowModal) 76 | PersistentFileDialog.install(qfd, key) 77 | return qfd 78 | -------------------------------------------------------------------------------- /gitfourchette/toolbox/qcomboboxwithpreview.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2024 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from gitfourchette.qt import * 8 | 9 | 10 | class QComboBoxWithPreview(QComboBox): 11 | dataPicked = Signal(object) 12 | 13 | class Role: 14 | Data = Qt.ItemDataRole.UserRole + 0 15 | Preview = Qt.ItemDataRole.UserRole + 1 16 | 17 | def __init__(self, parent: QWidget): 18 | super().__init__(parent) 19 | self.numPresets = 0 20 | self.captionWidth = 0 21 | self.previewWidth = 0 22 | delegate = QComboBoxWithPreviewDelegate(self) 23 | self.setItemDelegate(delegate) 24 | self.activated.connect(self.onActivated) 25 | 26 | def addItemWithPreview(self, caption: str, data: object, preview: str): 27 | i = self.count() 28 | self.addItem(caption) 29 | self.setItemData(i, data, QComboBoxWithPreview.Role.Data) 30 | self.setItemData(i, preview, QComboBoxWithPreview.Role.Preview) 31 | 32 | fontMetrics = self.fontMetrics() 33 | self.captionWidth = max(self.captionWidth, fontMetrics.horizontalAdvance(caption) + 20) 34 | self.previewWidth = max(self.previewWidth, fontMetrics.horizontalAdvance(preview) + 6) 35 | 36 | self.numPresets += 1 37 | 38 | def showPopup(self): 39 | # TODO: Where is QListView padding defined? 40 | self.view().setMinimumWidth(3 + self.captionWidth + self.previewWidth + 3) 41 | super().showPopup() 42 | 43 | def onActivated(self, index: int): 44 | # The signal may be sent for an index beyond the number of presets 45 | # when the user hits enter with a custom item. 46 | if index < 0 or index >= self.numPresets: 47 | return 48 | 49 | data = self.itemData(index, QComboBoxWithPreview.Role.Data) 50 | self.dataPicked.emit(data) 51 | 52 | if self.isEditable(): 53 | self.setEditText(str(data)) 54 | 55 | 56 | class QComboBoxWithPreviewDelegate(QStyledItemDelegate): 57 | def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex): 58 | super().paint(painter, option, index) 59 | painter.save() 60 | 61 | pw: QComboBoxWithPreview = self.parent() 62 | rect = QRect(option.rect) 63 | rect.setLeft(rect.left() + 3 + pw.captionWidth) 64 | 65 | isSelected = bool(option.state & QStyle.StateFlag.State_Selected) 66 | colorRole = QPalette.ColorRole.PlaceholderText if not isSelected else QPalette.ColorRole.HighlightedText 67 | 68 | font: QFont = painter.font() 69 | font.setItalic(True) 70 | 71 | preview = index.data(QComboBoxWithPreview.Role.Preview) 72 | 73 | painter.setFont(font) 74 | painter.setPen(option.palette.color(QPalette.ColorGroup.Normal, colorRole)) 75 | painter.drawText(rect, Qt.AlignmentFlag.AlignVCenter, str(preview)) 76 | painter.restore() 77 | -------------------------------------------------------------------------------- /gitfourchette/toolbox/qelidedlabel.py: -------------------------------------------------------------------------------- 1 | # Adapted from https://stackoverflow.com/a/68092991 2 | # New: per-line elision in multiline strings 3 | 4 | from gitfourchette.qt import * 5 | 6 | 7 | class QElidedLabel(QLabel): 8 | _elideMode = Qt.TextElideMode.ElideRight 9 | 10 | def elideMode(self): 11 | return self._elideMode 12 | 13 | def setElideMode(self, mode): 14 | if self._elideMode != mode and mode != Qt.TextElideMode.ElideNone: 15 | self._elideMode = mode 16 | self.updateGeometry() 17 | 18 | def minimumSizeHint(self): 19 | return self.sizeHint() 20 | 21 | def sizeHint(self): 22 | hint = self.fontMetrics().boundingRect(self.text()).size() 23 | cm = self.contentsMargins() 24 | margin = self.margin() * 2 25 | return QSize( 26 | min(100, hint.width()) + cm.left() + cm.right() + margin, 27 | min(self.fontMetrics().height(), hint.height()) + cm.top() + cm.bottom() + margin 28 | ) 29 | 30 | def paintEvent(self, event): 31 | qp = QPainter(self) 32 | opt = QStyleOptionFrame() 33 | self.initStyleOption(opt) 34 | self.style().drawControl(QStyle.ControlElement.CE_ShapedFrame, opt, qp, self) 35 | 36 | elideMode = self.elideMode() 37 | metrics = self.fontMetrics() 38 | margin = self.margin() 39 | m = int(metrics.horizontalAdvance('x') / 2 - margin) # int() for PyQt5 compat 40 | r = self.contentsRect().adjusted(margin + m, margin, -(margin + m), -margin) 41 | width = r.width() 42 | 43 | elidedText = "\n".join( 44 | metrics.elidedText(line, elideMode, width) 45 | for line in self.text().splitlines()) 46 | 47 | qp.drawText(r, self.alignment(), elidedText) 48 | -------------------------------------------------------------------------------- /gitfourchette/toolbox/qfaintseparator.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2025 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from gitfourchette.qt import * 8 | 9 | class QFaintSeparator(QFrame): 10 | def __init__(self, parent=None): 11 | super().__init__(parent) 12 | self.setFrameStyle(QFrame.Shape.HLine) 13 | self.setFrameShadow(QFrame.Shadow.Plain) 14 | self.setMaximumHeight(1) 15 | -------------------------------------------------------------------------------- /gitfourchette/toolbox/qhintbutton.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2025 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from gitfourchette.localization import * 8 | from gitfourchette.qt import * 9 | from gitfourchette.toolbox import stockIcon 10 | 11 | 12 | class QHintButton(QToolButton): 13 | def __init__(self, parent=None, toolTip="", iconKey="hint"): 14 | super().__init__(parent) 15 | self.setFocusPolicy(Qt.FocusPolicy.NoFocus) 16 | self.setAutoRaise(True) 17 | self.setText(_("Help")) 18 | self.setIcon(stockIcon(iconKey)) 19 | self.setToolTip(toolTip) 20 | self.setCursor(Qt.CursorShape.WhatsThisCursor) 21 | self.connectClicked() 22 | 23 | def connectClicked(self): 24 | self.clicked.connect(lambda _: QToolTip.showText(QCursor.pos(), self.toolTip(), self)) 25 | -------------------------------------------------------------------------------- /gitfourchette/toolbox/qsignalblockercontext.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2024 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | import warnings 8 | 9 | from gitfourchette.qt import * 10 | 11 | 12 | class QSignalBlockerContext: 13 | """ 14 | Context manager wrapper around QSignalBlocker. 15 | """ 16 | 17 | nestingLevels: dict[int, int] = {} # Map object id to nesting depth 18 | concurrentBlockers = 0 19 | 20 | def __init__(self, *objectsToBlock: QObject | QWidget): 21 | self.objectsToBlock = objectsToBlock 22 | 23 | def __enter__(self): 24 | self.concurrentBlockers += 1 25 | 26 | for o in self.objectsToBlock: 27 | key = id(o) 28 | self.nestingLevels[key] = self.nestingLevels.get(key, 0) + 1 29 | if self.nestingLevels[key] == 1: 30 | # Block signals if we're the first QSignalBlockerContext to refer to this object 31 | if o.signalsBlocked(): # pragma: no cover 32 | warnings.warn(f"QSignalBlockerContext: object signals already blocked! {o}") 33 | o.blockSignals(True) 34 | 35 | def __exit__(self, excType, excValue, excTraceback): 36 | for o in self.objectsToBlock: 37 | key = id(o) 38 | self.nestingLevels[key] -= 1 39 | assert self.nestingLevels[key] >= 0 40 | # Unblock signals if we were holding last remaining reference to this object 41 | if self.nestingLevels[key] == 0: 42 | o.blockSignals(False) 43 | del self.nestingLevels[key] 44 | 45 | self.concurrentBlockers -= 1 46 | assert self.concurrentBlockers >= 0 47 | assert self.concurrentBlockers != 0 or not self.concurrentBlockers 48 | -------------------------------------------------------------------------------- /gitfourchette/toolbox/qstatusbar2.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2024 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from gitfourchette.qt import * 8 | from gitfourchette.toolbox import * 9 | 10 | 11 | class QStatusBar2(QStatusBar): 12 | def __init__(self, parent: QWidget): 13 | super().__init__(parent) 14 | self.setObjectName("QStatusBar2") 15 | 16 | self.memoryIndicator = MemoryIndicator(self) 17 | 18 | self.setSizeGripEnabled(False) 19 | self.addPermanentWidget(self.memoryIndicator) 20 | # macOS: must reset stylesheet after addPermanentWidget for no-border thickness thing to take effect 21 | self.memoryIndicator.setStyleSheet(self.memoryIndicator.styleSheet()) 22 | 23 | self.busyMessageDelayer = QTimer(self) 24 | self.busyMessageDelayer.setSingleShot(True) 25 | self.busyMessageDelayer.setInterval(20) 26 | self.busyMessageDelayer.timeout.connect(self.commitBusyMessage) 27 | 28 | self.busyWidget = QWidget(self) 29 | self.busySpinner = QBusySpinner(self.busyWidget, centerOnParent=False) 30 | self.busySpinner.stop() 31 | self.busyLabel = QLabel(self.busyWidget) 32 | # Emojis such as the lightbulb may increase the label's height 33 | self.busyWidget.setMaximumHeight(self.fontMetrics().height()) 34 | 35 | layout = QHBoxLayout(self.busyWidget) 36 | layout.setContentsMargins(0, 0, 0, 0) 37 | layout.addWidget(self.busySpinner) 38 | layout.addWidget(self.busyLabel, 1) 39 | 40 | self.busyWidget.setVisible(False) 41 | 42 | def showMessage(self, text: str, msecs=0): 43 | if self.busyMessageDelayer.isActive(): 44 | # Commit the busy message now and kill the delayer. 45 | # It'll be immediately overridden by the temporary message. 46 | # We don't want it to happen the other way around. 47 | self.commitBusyMessage() 48 | 49 | super().showMessage(text, msecs) 50 | 51 | @property 52 | def isBusyMessageVisible(self): 53 | # Temporary messages take precedence over busySpinner/busyWidget, so 54 | # those widgets might be invisible even though a message is set. 55 | # So, busySpinner.isSpinning is a better way to check that a message 56 | # is currently set than .isVisible. 57 | return self.busySpinner.isSpinning() 58 | 59 | def showBusyMessage(self, text: str): 60 | self.busyLabel.setText(text) 61 | if not self.isBusyMessageVisible and not self.busyMessageDelayer.isActive(): 62 | self.busyMessageDelayer.start() 63 | 64 | def commitBusyMessage(self): 65 | self.busyMessageDelayer.stop() 66 | 67 | if not self.busySpinner.isSpinning(): 68 | self.busySpinner.start() 69 | self.busyWidget.setVisible(True) 70 | self.addWidget(self.busyWidget, 1) 71 | 72 | def clearMessage(self): 73 | self.busyMessageDelayer.stop() 74 | 75 | if self.isBusyMessageVisible: 76 | self.busySpinner.stop() 77 | self.removeWidget(self.busyWidget) 78 | self.busyWidget.setVisible(False) 79 | 80 | super().clearMessage() 81 | 82 | def enableMemoryIndicator(self, show: bool = False): 83 | self.memoryIndicator.setVisible(show) 84 | -------------------------------------------------------------------------------- /gitfourchette/toolbox/urltooltip.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2024 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | from gitfourchette.qt import * 8 | 9 | 10 | class UrlToolTip(QTimer): 11 | def __init__(self, parent): 12 | super().__init__(parent) 13 | self._pendingText = "" 14 | self.setInterval(QApplication.style().styleHint(QStyle.StyleHint.SH_ToolTip_WakeUpDelay)) 15 | self.setSingleShot(True) 16 | self.timeout.connect(lambda: QToolTip.showText(QCursor.pos(), self._pendingText)) 17 | 18 | def linkHovered(self, url: str): 19 | self._pendingText = url 20 | if QToolTip.isVisible(): 21 | self.stop() 22 | self.timeout.emit() 23 | else: 24 | self.start() 25 | 26 | def install(self): 27 | parentWidget = self.parent() 28 | assert isinstance(parentWidget, QWidget) 29 | for label in parentWidget.findChildren(QLabel): 30 | if label.openExternalLinks(): 31 | label.linkHovered.connect(self.linkHovered) 32 | -------------------------------------------------------------------------------- /gitfourchette/webhost.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2024 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | import dataclasses 8 | import urllib 9 | 10 | from gitfourchette.toolbox import * 11 | 12 | 13 | HTTPS_PORT = 443 14 | 15 | 16 | @dataclasses.dataclass 17 | class WebHost: 18 | name: str 19 | branchPrefix: str 20 | port: int = HTTPS_PORT 21 | 22 | @staticmethod 23 | def makeLink(remoteUrl: str, branch: str = ""): 24 | host, path = splitRemoteUrl(remoteUrl) 25 | 26 | if not host: 27 | return "", "" 28 | 29 | path = path.removesuffix(".git") 30 | 31 | try: 32 | hostInfo = WEB_HOSTS[host] 33 | hostName = hostInfo.name 34 | except KeyError: 35 | hostInfo = WEB_HOSTS["github.com"] # fall back to GitHub's scheme 36 | hostName = host 37 | 38 | port = "" if hostInfo.port == HTTPS_PORT else f":{hostInfo.port}" 39 | suffix = "" 40 | 41 | if branch: 42 | suffix = hostInfo.branchPrefix + urllib.parse.quote(branch, safe='/') 43 | 44 | return f"https://{host}{port}/{path}{suffix}", hostName 45 | 46 | 47 | WEB_HOSTS = { 48 | "github.com": WebHost("GitHub", "/tree/"), 49 | "gitlab.com": WebHost("GitLab", "/-/tree/"), 50 | "git.sr.ht": WebHost("Sourcehut", "/tree/"), 51 | "codeberg.org": WebHost("Codeberg", "/src/branch/"), 52 | "git.launchpad.net": WebHost("Launchpad", "/log?h="), 53 | "bitbucket.org": WebHost("Bitbucket", "/src/"), 54 | } 55 | -------------------------------------------------------------------------------- /pkg/appimage/.gitignore: -------------------------------------------------------------------------------- 1 | # requirements.txt contains non-portable absolute paths, 2 | # so it's generated on demand by the build script 3 | requirements.txt 4 | -------------------------------------------------------------------------------- /pkg/appimage/README.md: -------------------------------------------------------------------------------- 1 | This folder contains boilerplate to produce an AppImage with [python-appimage](https://github.com/niess/python-appimage). 2 | 3 | From the repo's root directory, run: 4 | 5 | ``` 6 | ./pkg/appimage/build-appimage.sh 7 | ``` 8 | 9 | This will produce an AppImage file in `build`. 10 | -------------------------------------------------------------------------------- /pkg/appimage/build-appimage.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | export QT_API=${QT_API:-pyqt6} 7 | export PYVER=${PYVER:-"3.13"} 8 | 9 | HERE="$(dirname "$(readlink -f -- "$0")" )" 10 | ROOT="$(readlink -f -- "$HERE/../..")" 11 | 12 | ARCH="$(uname -m)" 13 | 14 | cd "$ROOT" 15 | APPVER="$(python3 -c 'from gitfourchette.appconsts import APP_VERSION; print(APP_VERSION)')" 16 | echo "App version: $APPVER" 17 | 18 | mkdir -p "$ROOT/build" 19 | cd "$ROOT/build" 20 | 21 | # Freeze QT api 22 | "$ROOT/update_resources.py" --freeze $QT_API 23 | 24 | # Write requirements file so python_appimage knows what to include. 25 | # The path to gitfourchette's root dir must be absolute. 26 | echo -e "$ROOT[$QT_API,pygments]" > "$HERE/requirements.txt" 27 | 28 | # Create AppImage 29 | python3 -m python_appimage -v -a continuous build app -p $PYVER "$HERE" 30 | 31 | # Post-process the AppImage 32 | mv GitFourchette-$ARCH.AppImage FullFat.AppImage 33 | rm -rf squashfs-root # remove existing squashfs-root from previous run 34 | ./FullFat.AppImage --appimage-extract # extract contents to squashfs-root 35 | 36 | # Remove junk that we don't need 37 | pushd squashfs-root 38 | junklist=$(cat "$HERE/junklist.txt") 39 | rm -rfv $junklist 40 | popd 41 | 42 | # Repackage the AppImage 43 | ~/.local/bin/.appimagetool-continuous.appdir.$ARCH/AppRun --no-appstream squashfs-root 44 | chmod +x GitFourchette-$ARCH.AppImage 45 | mv -v GitFourchette{,-$APPVER}-$ARCH.AppImage 46 | -------------------------------------------------------------------------------- /pkg/appimage/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash -i 2 | {{ python-executable }} -u "${APPDIR}/opt/python{{ python-version }}/bin/gitfourchette" "$@" 3 | 4 | -------------------------------------------------------------------------------- /pkg/appimage/gitfourchette.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=GitFourchette 3 | Comment=The comfortable Git UI 4 | Comment[fr]=L’interface Git tout confort 5 | Exec=gitfourchette %F 6 | Icon=gitfourchette 7 | StartupNotify=true 8 | Type=Application 9 | Categories=Development;RevisionControl; 10 | Keywords=git; 11 | -------------------------------------------------------------------------------- /pkg/appimage/gitfourchette.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorio/gitfourchette/4a24e06dc38e8c4253a1d18215b6cb581cecfd4e/pkg/appimage/gitfourchette.png -------------------------------------------------------------------------------- /pkg/flatpak/org.gitfourchette.gitfourchette.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=GitFourchette 3 | Comment=The comfortable Git UI 4 | Comment[fr]=L’interface Git tout confort 5 | Exec=gitfourchette %F 6 | Icon=org.gitfourchette.gitfourchette 7 | StartupNotify=true 8 | Type=Application 9 | Categories=Development;RevisionControl; 10 | Keywords=git; 11 | -------------------------------------------------------------------------------- /pkg/flatpak/org.gitfourchette.gitfourchette.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorio/gitfourchette/4a24e06dc38e8c4253a1d18215b6cb581cecfd4e/pkg/flatpak/org.gitfourchette.gitfourchette.png -------------------------------------------------------------------------------- /pkg/flatpak/sync_changelog.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | 3 | from pathlib import Path 4 | import markdown 5 | import re 6 | 7 | def patchSection(path: Path, contents: str): 8 | def ensureNewline(s: str): 9 | return s + ("" if s.endswith("\n") else "\n") 10 | 11 | text = path.read_text("utf-8") 12 | contents = ensureNewline(contents) 13 | lines = contents.splitlines(keepends=True) 14 | assert len(lines) >= 2 15 | beginMarker = lines[0] 16 | endMarker = lines[-1] 17 | assert beginMarker 18 | assert endMarker 19 | 20 | beginPos = text.index(beginMarker) 21 | endPos = text.index(endMarker.rstrip()) 22 | 23 | newText = (text[: beginPos] + contents + text[endPos + len(endMarker) :]) 24 | path.write_text(newText, "utf-8") 25 | return newText 26 | 27 | thisDir = Path(__file__).parent 28 | changelogPath = thisDir / '../../CHANGELOG.md' 29 | metainfoPath = thisDir / 'org.gitfourchette.gitfourchette.metainfo.xml' 30 | 31 | changelogText = changelogPath.read_text('utf-8') 32 | changelogSoup = markdown.markdown(changelogText) 33 | 34 | releases = [] 35 | 36 | for soupLine in changelogSoup.splitlines(): 37 | # Flatpak linter doesn't recognize , they'll reject the build if the changelog contains one 38 | soupLine = re.sub(r"<(/?)strong>", r"<\1em>", soupLine) 39 | 40 | if soupLine.startswith(""): 42 | continue 43 | ma = re.match(r"

(\S+)\s+\((\d+-\d+-\d+)\)

", soupLine) 44 | assert ma, f"Line does not match pattern: {soupLine}" 45 | version = ma.group(1) 46 | date = ma.group(2) 47 | releases.append(f' ') 48 | releases.append(f' https://github.com/jorio/gitfourchette/releases/tag/v{version}') 49 | releases.append( ' ') 50 | releases.append( ' ') 51 | releases.append( ' ') 52 | else: 53 | releases.insert(-2, (' ' * 6) + soupLine) 54 | 55 | releases.insert(0, "") 56 | releases.append("") 57 | releases = [' ' + r for r in releases] 58 | 59 | patchSection(metainfoPath, "\n".join(releases)) 60 | print(f"Updated: {metainfoPath}") 61 | -------------------------------------------------------------------------------- /pkg/gitfourchette.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=GitFourchette 3 | Comment=The comfortable Git UI 4 | Comment[fr]=L’interface Git tout confort 5 | Exec=python3 -m gitfourchette %F 6 | Icon=gitfourchette 7 | StartupNotify=true 8 | Type=Application 9 | Categories=Development;RevisionControl; 10 | Keywords=git; 11 | -------------------------------------------------------------------------------- /pkg/pyinstaller/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorio/gitfourchette/4a24e06dc38e8c4253a1d18215b6cb581cecfd4e/pkg/pyinstaller/__init__.py -------------------------------------------------------------------------------- /pkg/pyinstaller/build-macos-app.sh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh 2 | 3 | set -e 4 | 5 | PYTHON=${PYTHON:-python3} 6 | 7 | here="$(dirname "$(realpath "$0")")" 8 | cd "$here/../.." 9 | 10 | $PYTHON -m PyInstaller pkg/pyinstaller/gitfourchette-macos.spec --noconfirm 11 | 12 | APP=dist/GitFourchette.app 13 | 14 | # If macOS sees this file, AND the system's preferred language matches the language of 15 | # "Edit" and "Help" menu titles, we'll automagically get stuff like a search field 16 | # in the Help menu, or dictation stuff in the Edit menu. 17 | # If this file is absent, the magic menu entries are only added if the menu names 18 | # are in English. 19 | touch $APP/Contents/Resources/empty.lproj 20 | 21 | # Remove PySide6 bloat 22 | # for component in QtNetwork QtOpenGL QtQml QtQmlModels QtQuick QtVirtualKeyboard 23 | # do 24 | # echo "Removing $component" 25 | # rm -fv $APP/Contents/Resources/$component 26 | # rm -fv $APP/Contents/Frameworks/$component 27 | # rm -rfv $APP/Contents/Frameworks/PySide6/Qt/lib/$component.framework 28 | # done 29 | 30 | # Remove stock Qt localizations for unsupported languages to save a few megs 31 | keeplang="qt.*_(en|fr)\.qm" 32 | for i in "$APP/Contents/Resources/PyQt6/Qt6/translations/"*.qm 33 | do 34 | if [[ "$(basename "$i")" =~ $keeplang ]] 35 | then echo "Keep: $i" 36 | else echo -n "Delete: " && rm -v "$i" 37 | fi 38 | done 39 | 40 | rm -v "$APP/Contents/Resources/assets/lang/"*.po* 41 | rm -v "$APP/Contents/Resources/assets/lang/README.md" 42 | 43 | # Remove framework bloat 44 | # Note: QtDBus.framework is included but somehow the app won't start without it. 45 | rm -v "$APP/Contents/Frameworks/QtNetwork" 46 | rm -v "$APP/Contents/Frameworks/QtPdf" 47 | rm -v "$APP/Contents/Resources/QtNetwork" 48 | rm -v "$APP/Contents/Resources/QtPdf" 49 | rm -rv "$APP/Contents/Frameworks/PyQt6/Qt6/lib/QtPdf.framework" 50 | rm -rv "$APP/Contents/Frameworks/PyQt6/Qt6/lib/QtNetwork.framework" 51 | rm -rv "$APP/Contents/Frameworks/PyQt6/Qt6/plugins/imageformats/libqpdf.dylib" 52 | -------------------------------------------------------------------------------- /pkg/pyinstaller/gitfourchette-linux.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | from pkg.pyinstaller import spec_helper 4 | from gitfourchette.appconsts import APP_VERSION 5 | 6 | QT_API = "pyqt6" 7 | spec_helper.writeBuildConstants(QT_API) 8 | 9 | a = Analysis( 10 | [spec_helper.ROOT / 'gitfourchette/__main__.py'], 11 | pathex=[], 12 | binaries=[], 13 | datas=[(spec_helper.ROOT / 'gitfourchette/assets', 'assets')], 14 | hiddenimports=['_cffi_backend'], 15 | hookspath=[], 16 | hooksconfig={}, 17 | runtime_hooks=[], 18 | excludes=spec_helper.getExcludeList(QT_API), 19 | noarchive=False, # True: keep pyc files 20 | ) 21 | 22 | pyz = PYZ(a.pure, a.zipped_data) 23 | 24 | exe = EXE( 25 | pyz, 26 | a.scripts, 27 | [], 28 | exclude_binaries=True, 29 | name='gitfourchette-bin', 30 | debug=False, 31 | bootloader_ignore_signals=False, 32 | strip=False, 33 | upx=False, 34 | console=True, 35 | disable_windowed_traceback=False, 36 | argv_emulation=False, 37 | target_arch=None, 38 | codesign_identity=None, 39 | entitlements_file=None, 40 | ) 41 | 42 | coll = COLLECT( 43 | exe, 44 | a.binaries, 45 | a.zipfiles, 46 | a.datas, 47 | strip=False, 48 | upx=False, 49 | name='GitFourchette', 50 | ) 51 | -------------------------------------------------------------------------------- /pkg/pyinstaller/gitfourchette-macos.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | from pkg.pyinstaller import spec_helper 4 | from gitfourchette.appconsts import APP_VERSION 5 | 6 | QT_API = "pyqt6" 7 | spec_helper.writeBuildConstants(QT_API) 8 | 9 | MAC_EXCLUDES = [ 10 | 'PyQt6.QtDBus', 11 | 'PyQt6.QtPdf', 12 | ] 13 | 14 | a = Analysis( 15 | [spec_helper.ROOT / 'gitfourchette/__main__.py'], 16 | pathex=[], 17 | binaries=[], 18 | datas=[(spec_helper.ROOT / 'gitfourchette/assets', 'assets')], 19 | hiddenimports=['_cffi_backend'], 20 | hookspath=[], 21 | hooksconfig={}, 22 | runtime_hooks=[], 23 | excludes=spec_helper.getExcludeList(QT_API) + MAC_EXCLUDES, 24 | noarchive=False, # True: keep pyc files 25 | ) 26 | 27 | pyz = PYZ(a.pure, a.zipped_data) 28 | 29 | exe = EXE( 30 | pyz, 31 | a.scripts, 32 | [], 33 | exclude_binaries=True, 34 | name='GitFourchette', 35 | debug=False, 36 | bootloader_ignore_signals=False, 37 | strip=False, 38 | upx=False, 39 | console=False, 40 | disable_windowed_traceback=False, 41 | argv_emulation=False, 42 | target_arch='arm64', #'universal2', 43 | codesign_identity=None, 44 | entitlements_file=None, 45 | ) 46 | 47 | coll = COLLECT( 48 | exe, 49 | a.binaries, 50 | a.zipfiles, 51 | a.datas, 52 | strip=False, 53 | upx=False, 54 | name='GitFourchette', 55 | ) 56 | 57 | app = BUNDLE( 58 | coll, 59 | name='GitFourchette.app', 60 | icon='gitfourchette.icns', 61 | bundle_identifier='org.gitfourchette.gitfourchette', 62 | version=APP_VERSION, 63 | info_plist={ 64 | "NSReadableCopyright": "© 2025 Iliyas Jorio", 65 | "LSApplicationCategoryType": "public.app-category.developer-tools", 66 | "CFBundleDocumentTypes": [ 67 | { 68 | "CFBundleTypeName": "folder", 69 | "CFBundleTypeRole": "Editor", 70 | "LSItemContentTypes": ["public.folder", "public.item"], 71 | } 72 | ] 73 | } 74 | ) 75 | -------------------------------------------------------------------------------- /pkg/pyinstaller/gitfourchette.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorio/gitfourchette/4a24e06dc38e8c4253a1d18215b6cb581cecfd4e/pkg/pyinstaller/gitfourchette.icns -------------------------------------------------------------------------------- /pkg/pyinstaller/spec_helper.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2024 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | import subprocess 8 | from pathlib import Path 9 | 10 | ROOT = Path().resolve() 11 | 12 | if not (ROOT/'gitfourchette/qt.py').is_file(): 13 | raise ValueError("Please cd to the root of the GitFourchette repo") 14 | 15 | EXCLUDES = [ 16 | 'psutil', 17 | 'cached_property', # optionally imported by pygit2 (this pulls in asyncio) 18 | 'qtpy', 19 | 'PySide6', 20 | 'PySide2', 21 | 'PyQt5', 22 | 'PyQt5.QtTest', 23 | 'PyQt5.QtMultimedia', 24 | 'PyQt6', 25 | 'PyQt6.QtPdf', 26 | 'PyQt6.QtTest', 27 | 'PyQt6.QtMultimedia', 28 | 'PySide6.QtMultimedia', 29 | 'PySide6.QtNetwork', 30 | 'PySide6.QtOpenGL', 31 | 'PySide6.QtQml', 32 | 'PySide6.QtQuick', 33 | 'PySide6.QtQuick3D', 34 | 'PySide6.QtQuickControls2', 35 | 'PySide6.QtQuickWidgets', 36 | 'PySide6.QtTest', 37 | ] 38 | 39 | 40 | def getExcludeList(api): 41 | excludes = EXCLUDES[:] 42 | api = api.lower() 43 | if api == 'pyside6': 44 | excludes.remove('PySide6') 45 | elif api == 'pyqt6': 46 | excludes.remove('PyQt6') 47 | elif api == 'pyqt5': 48 | excludes.remove('PyQt5') 49 | else: 50 | raise NotImplementedError(f"Unsupported Qt binding for Pyinstaller bundle: {api}") 51 | return excludes 52 | 53 | 54 | def writeBuildConstants(api): 55 | subprocess.run(['python3', ROOT/'update_resources.py', '--freeze', api]) 56 | -------------------------------------------------------------------------------- /run.desktop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env xdg-open 2 | # 3 | # Convenience desktop file to run the app from source. 4 | # For packaging, you should use pkg/gitfourchette.desktop instead. 5 | # 6 | # To get the icon to show up on Wayland: 7 | # mkdir -p ~/.local/share/icons 8 | # cp gitfourchette/assets/icons/gitfourchette.png ~/.local/share/icons/org.gitfourchette.gitfourchette.png 9 | 10 | [Desktop Entry] 11 | Exec=bash -c 'PYTHONPATH="$(dirname "%k")" python3 -m gitfourchette' 12 | Icon=org.gitfourchette.gitfourchette 13 | Name=GitFourchette (from source) 14 | Comment=The comfortable Git UI 15 | Comment[fr]=L’interface Git tout confort 16 | StartupNotify=true 17 | Type=Application 18 | Categories=Development;RevisionControl; 19 | Keywords=git; 20 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | PYTHON=${PYTHON:-python3} 6 | export PYTHONPATH="$(dirname "$(readlink -f -- "$0")" )" 7 | export QT_API=${QT_API:-pyqt6} 8 | 9 | $PYTHON -m gitfourchette "$@" 10 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | HERE="$(dirname "$(readlink -f -- "$0")" )" 6 | PYTHON=${PYTHON:-python3} 7 | export PYTEST_QT_API=${PYTEST_QT_API:-pyqt6} 8 | export QT_QPA_PLATFORM=offscreen 9 | 10 | RUNNER="$PYTHON -m pytest -n auto --dist worksteal" 11 | 12 | if [[ $1 = "--cov" ]]; then 13 | shift 14 | RUNNER+=" --cov=gitfourchette --cov-report=html" 15 | else 16 | echo "Coverage report disabled, pass --cov to enable" 17 | fi 18 | 19 | cd "$HERE" 20 | $RUNNER "$@" 21 | 22 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2024 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | import logging 8 | import os 9 | 10 | # Verbose logging by default in unit tests 11 | logging.basicConfig(level=logging.DEBUG) 12 | logging.captureWarnings(True) 13 | 14 | # Keep QT_API env var (used by our qt.py module) in sync with Qt binding used by pytest-qt 15 | if os.environ.get("PYTEST_QT_API") and os.environ.get("QT_API"): 16 | # PYTEST_QT_API takes precedence over QT_API 17 | os.environ["QT_API"] = os.environ["PYTEST_QT_API"] 18 | elif os.environ.get("QT_API"): 19 | os.environ["PYTEST_QT_API"] = os.environ["QT_API"] 20 | else: 21 | from pytestqt.qt_compat import qt_api 22 | qt_api.set_qt_api("") # Triggers api load 23 | os.environ["PYTEST_QT_API"] = qt_api.pytest_qt_api 24 | os.environ["QT_API"] = qt_api.pytest_qt_api 25 | 26 | # Force qtpy (if used) to honor QT_API 27 | os.environ["FORCE_QT_API"] = "1" 28 | 29 | from gitfourchette.qt import * # noqa: E402 - intentionally importing Qt at this specific point 30 | -------------------------------------------------------------------------------- /test/data/TestEmptyRepository.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorio/gitfourchette/4a24e06dc38e8c4253a1d18215b6cb581cecfd4e/test/data/TestEmptyRepository.zip -------------------------------------------------------------------------------- /test/data/TestGitRepository.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorio/gitfourchette/4a24e06dc38e8c4253a1d18215b6cb581cecfd4e/test/data/TestGitRepository.zip -------------------------------------------------------------------------------- /test/data/editor-shim.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | 5 | scratch = sys.argv[1] 6 | 7 | with open(scratch, "w") as file: 8 | file.write("\n".join(sys.argv[2:])) 9 | -------------------------------------------------------------------------------- /test/data/image1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorio/gitfourchette/4a24e06dc38e8c4253a1d18215b6cb581cecfd4e/test/data/image1.png -------------------------------------------------------------------------------- /test/data/image2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorio/gitfourchette/4a24e06dc38e8c4253a1d18215b6cb581cecfd4e/test/data/image2.png -------------------------------------------------------------------------------- /test/data/keys/missingpriv.pub: -------------------------------------------------------------------------------- 1 | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIP0Lzl5yCq50kdL0CccBv5jH0SbHgo3jypgbtHJfKmWV whatever 2 | -------------------------------------------------------------------------------- /test/data/keys/missingpub: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW 3 | QyNTUxOQAAACD9C85ecgqudJHS9AnHAb+Yx9Emx4KN48qYG7RyXypllQAAAJBtz7rjbc+6 4 | 4wAAAAtzc2gtZWQyNTUxOQAAACD9C85ecgqudJHS9AnHAb+Yx9Emx4KN48qYG7RyXypllQ 5 | AAAEAazND0FqJ4jxaCLV3mnooesOAJ7pIbwGbUZCo6mGD+lv0Lzl5yCq50kdL0CccBv5jH 6 | 0SbHgo3jypgbtHJfKmWVAAAACHdoYXRldmVyAQIDBAU= 7 | -----END OPENSSH PRIVATE KEY----- 8 | -------------------------------------------------------------------------------- /test/data/keys/pygit2_empty: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABCcT0eRuC 3 | NRvnDkorGSaQqaAAAAEAAAAAEAAAGXAAAAB3NzaC1yc2EAAAADAQABAAABgQC5bu0UuVdG 4 | eimAo2mu87uy/hWCbvie6N8H4a7ZjnSThpDIv0clcnbycCukDb/d8o9FRIPNNp2astBlWp 5 | nZ9vCHByP8z8ITO/pG5Mu0Wp0n1Hrja5UVdk4cBvvoEcr2sdNqj3wUUhqeAnNCYeeJuW9V 6 | j2JNV2wwJYPAZBDi6GJTM9FPlCJz+QA6dJLkUiIwngrL8bSPzXd+1mzHOrIcqjxKm8J+ed 7 | 3tVSlRq7YvefrACUOTI9ymXwYr8a/TiwA76nTHvNPYIhmzH8PEF65QsFTs/qk0nqSwmqKA 8 | vineapBMmrb3Vf/Hd/c0nEMgHt6gy/+dtlgPh1bTqaQaS/oeo5OtjMDK/w93SR9M3UZBX+ 9 | eeMUGfyhvYT4Nvo3TumKzaZ6peGu1T0MKprSwgRdI+v1q+58sKfzSK5QevTpKiX1+4Leo6 10 | BiNKKu6doD19fgNSD/dwfOWehxFtD8q/1J5k0QPgqslFkqyZJXRCzzowRYunSN+AaHVD3W 11 | o4AuqtfTiazPMAAAWQVrRkwWjO1Fcw7zebagqfBufB05nc08wL911ZPCVwqVSIepcEK/hM 12 | CJ/5/N+UILn9BXGe9qmOHPUuMa9UaLBSyzmlJ1s/NMGLzYWiv62SX1QNEXPegxwLasQvbL 13 | njjzdESGX+qUHxT4okNH52zi4DcBLX4HPL/TYQsKTNxCOclOljPDo+3IfHzx76yG5dAl5L 14 | C7ghLsd1zxpwZI+ag7NhNzZ4hBxX9JUenAfGyuOL+YCTp8JnU+dXJ3XaA3WAVGnvsZlAaq 15 | GJUGCdLlMiacO0eXNTm53xc92X9tPmetEVwhuD/Af7Vc4dOmH9Zu+7n9z9bLPrOowNr7ue 16 | w8YCqCg83iuQYmSSPj/JTvCzaoGDfW+yjALlb5RJUAIMJ51k0WyVIyqS0TE8+EINKETlj7 17 | iIx1Y5z54ZnldlqrD2vLImO2b401oOb7fJUEU9Ke5NPi93tsps8nYKhatcRYLnLq9gsFv9 18 | YlDCueoJJobg1k9TO+IwxraPgz3jl24zskSKT/tLFvsz0fQM5QWha2vB8kyZI067ojuNLb 19 | /mj5itgLIDmISa9cf98HhafeE8kGAtKEJR2rLwvb79EAhZ2ypt2I8LVur5hCM4cC9vSVyS 20 | dq/f4sgQpyQqSByMXeLEFYJSCDDc/PL3RC0Q9PqrQYZ1+pqj/6esV3ujLogMAHqEuW4EVw 21 | tMDUvjzfnC8MVUQpc5H4yonsWjGeGhH+VEkBSVISpABTSrYFN5kBodPD16wmRTbFF4tTQq 22 | Egmj5vUmxSY+a2EjDJREQBerMhj3W5sPhAB1QGVVn5kyFvmsjM4t06zzZj/R5muIcX0cT+ 23 | Th3N+xeYIuVi9kS5v7yOBlMk0KGq8QATSL/u+niO0e0neoT5Jv6E7EIafAFrn3Ji0rNave 24 | ObCqse3yZct0pbspM4f0c9mHaVbzmvwwtjmUFGdMJgse0UARXqvOlF9PUaN/AhqQlIyVjj 25 | ednPLrOz617XDSixiP+tKzKmqjZsBASZzpGwpHKii9/k7Q7aG5/Int8ulBS3H8C6ipMSxW 26 | EKSMJ4g6k33RY1EFL3dWtJYWhReAhY6kvIc3lmSeo7I9SQWXLupx0NUnkXeO63hLmJ9tjD 27 | CXeI0cwb2a6DWKLh6c2gQ5LPNb/8xzvYJfdop2Oxr+9L2NP7pIgvYr/cmgPtF5WkLT2Ypk 28 | z+KgwWxUKRiK/3G+dVe27u0Id7Yi596wnNGxJfZmlnbfioY4i+K9KcyS08LxlmgsIsQHiY 29 | Scv6SuamPdjiHdSwK/GuAcQUVwXQRA7DoV2uxOosAaUXWMiiSjJ3n1L8IVgp17OKxVN0Bd 30 | 5phre4VhYFoXGnq43xFAY3XQJctBqLPdb47RNi3JlhVK+Q1WKzK9OWbDoiseoNnMD5NXOt 31 | Wqf/vxD6AJEyO8sOT55l6hZAkNHIfFUGx4MNmLl12hJYSZgY9tx7aizz8RMT6GMBammQcU 32 | Q0pNDF1RBFOtxgb/QE+9/Vym4dMGnJrhhdbcYZbKngcsho4Qs39qMQvv0V23zAExreQH8U 33 | TBTZYyYkiPqdUiB2fNCW89QWksvBe3CXZAC0T0tdBcEYe5UPJRQ/K2FS6bJTYmxDkDWzHD 34 | 9iHbiu3Z8JGB9kHT6B5AgM+fYgEhpCgieDEHdF85cXtGSt8rjFFW6SMS70aLkgzFpYVeD0 35 | zgzumI6JRY3vSMpUY60NCz+FOmIxy7oIpv7nDf/6Ubvah/heUF/P6IQwOQXumVMK9/Khqx 36 | j5TxaRCZ7fXV7IXH1hjQgWSZkGNUHc+rEAZdPOYFXlQb/2+DkO8IE7SxSWwk8tDzS0L7+H 37 | hWXgIm7mjIB6HDNfRb2zPL7gOgm83qZfrhSdP76XqnuV1LvvZMIs2dC8lKFfLk6oayzUvQ 38 | z5AMR0EutUSCby7+DKyBmaYSq0s= 39 | -----END OPENSSH PRIVATE KEY----- 40 | -------------------------------------------------------------------------------- /test/data/keys/pygit2_empty.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC5bu0UuVdGeimAo2mu87uy/hWCbvie6N8H4a7ZjnSThpDIv0clcnbycCukDb/d8o9FRIPNNp2astBlWpnZ9vCHByP8z8ITO/pG5Mu0Wp0n1Hrja5UVdk4cBvvoEcr2sdNqj3wUUhqeAnNCYeeJuW9Vj2JNV2wwJYPAZBDi6GJTM9FPlCJz+QA6dJLkUiIwngrL8bSPzXd+1mzHOrIcqjxKm8J+ed3tVSlRq7YvefrACUOTI9ymXwYr8a/TiwA76nTHvNPYIhmzH8PEF65QsFTs/qk0nqSwmqKAvineapBMmrb3Vf/Hd/c0nEMgHt6gy/+dtlgPh1bTqaQaS/oeo5OtjMDK/w93SR9M3UZBX+eeMUGfyhvYT4Nvo3TumKzaZ6peGu1T0MKprSwgRdI+v1q+58sKfzSK5QevTpKiX1+4Leo6BiNKKu6doD19fgNSD/dwfOWehxFtD8q/1J5k0QPgqslFkqyZJXRCzzowRYunSN+AaHVD3Wo4AuqtfTiazPM= pygit2_empty 2 | -------------------------------------------------------------------------------- /test/data/keys/simple: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW 3 | QyNTUxOQAAACD9C85ecgqudJHS9AnHAb+Yx9Emx4KN48qYG7RyXypllQAAAJBtz7rjbc+6 4 | 4wAAAAtzc2gtZWQyNTUxOQAAACD9C85ecgqudJHS9AnHAb+Yx9Emx4KN48qYG7RyXypllQ 5 | AAAEAazND0FqJ4jxaCLV3mnooesOAJ7pIbwGbUZCo6mGD+lv0Lzl5yCq50kdL0CccBv5jH 6 | 0SbHgo3jypgbtHJfKmWVAAAACHdoYXRldmVyAQIDBAU= 7 | -----END OPENSSH PRIVATE KEY----- 8 | -------------------------------------------------------------------------------- /test/data/keys/simple.pub: -------------------------------------------------------------------------------- 1 | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIP0Lzl5yCq50kdL0CccBv5jH0SbHgo3jypgbtHJfKmWV whatever 2 | -------------------------------------------------------------------------------- /test/data/merge-shim.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | 5 | scratch = sys.argv[1] 6 | M, L, R, B = sys.argv[2:6] 7 | 8 | with open(scratch, "w") as file: 9 | file.write("\n".join(sys.argv[2:])) 10 | 11 | with open(M, "w") as file: 12 | file.write("merge complete!") 13 | -------------------------------------------------------------------------------- /test/data/pause.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import sys 5 | import time 6 | 7 | print(f"PID {os.getpid()} - parent {os.getppid()}") 8 | 9 | with open(sys.argv[1], "w") as file: 10 | file.write("about to sleep\n") 11 | file.flush() 12 | time.sleep(2) 13 | file.write("finished sleeping\n") 14 | -------------------------------------------------------------------------------- /test/data/submoroot.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorio/gitfourchette/4a24e06dc38e8c4253a1d18215b6cb581cecfd4e/test/data/submoroot.zip -------------------------------------------------------------------------------- /test/data/testrepoformerging.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorio/gitfourchette/4a24e06dc38e8c4253a1d18215b6cb581cecfd4e/test/data/testrepoformerging.zip -------------------------------------------------------------------------------- /test/reposcenario.py: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # Copyright (C) 2024 Iliyas Jorio. 3 | # This file is part of GitFourchette, distributed under the GNU GPL v3. 4 | # For full terms, see the included LICENSE file. 5 | # ----------------------------------------------------------------------------- 6 | 7 | import shutil 8 | 9 | from .util import * 10 | from gitfourchette.porcelain import * 11 | 12 | 13 | def fileWithStagedAndUnstagedChanges(path): 14 | with RepoContext(path) as repo: 15 | writeFile(F"{path}/a/a1.txt", "a1\nstaged change\n") 16 | repo.index.read() 17 | repo.index.add("a/a1.txt") 18 | repo.index.write() 19 | writeFile(F"{path}/a/a1.txt", "a1\nUNSTAGED CHANGE TO REVERT\nstaged change\n") 20 | assert repo.status() == {"a/a1.txt": FileStatus.INDEX_MODIFIED | FileStatus.WT_MODIFIED} 21 | 22 | 23 | def stagedNewEmptyFile(path): 24 | with RepoContext(path) as repo: 25 | writeFile(F"{path}/SomeNewFile.txt", "") 26 | repo.index.read() 27 | repo.index.add("SomeNewFile.txt") 28 | repo.index.write() 29 | assert repo.status() == {"SomeNewFile.txt": FileStatus.INDEX_NEW} 30 | 31 | 32 | def stashedChange(path): 33 | with RepoContext(path) as repo: 34 | writeFile(F"{path}/a/a1.txt", "a1\nPENDING CHANGE\n") 35 | repo.stash(TEST_SIGNATURE, "helloworld") 36 | assert repo.status() == {} 37 | 38 | 39 | def statelessConflictingChange(path): 40 | """ 41 | Cause a conflict via a stash in order to keep RepositoryState.NONE 42 | """ 43 | with RepoContext(path) as repo: 44 | writeFile(f"{path}/a/a1.txt", "a1\nPENDING CHANGE\n") 45 | repo.stash(TEST_SIGNATURE, "helloworld") 46 | writeFile(f"{path}/a/a1.txt", "a1\nCONFLICTING CHANGE\n") 47 | repo.index.add_all() 48 | repo.create_commit_on_head("conflicting thing", TEST_SIGNATURE, TEST_SIGNATURE) 49 | repo.stash_apply() 50 | assert repo.status() == {"a/a1.txt": FileStatus.CONFLICTED} 51 | 52 | 53 | def submodule(path, absorb=False): 54 | subPath = os.path.join(path, "submodir") 55 | shutil.copytree(path, subPath) 56 | 57 | # Make bare copy of submodule so that we can use it as a remote and test UpdateSubmodule 58 | makeBareCopy(subPath, "submo-localfs", preFetch=True, barePath=f"{path}/../submodule-bare-copy.git") 59 | 60 | with RepoContext(subPath) as subRepo: 61 | subRepo.remotes.delete("origin") # nuke origin remote to prevent net access in UpdateSubmodule 62 | subRepo.branches.local["master"].upstream = subRepo.branches.remote["submo-localfs/master"] 63 | subRemoteUrl = subRepo.remotes["submo-localfs"].url 64 | 65 | with RepoContext(path, write_index=True) as repo: 66 | # Give submodule a custom name that is different from the path to reveal edge cases 67 | repo.add_inner_repo_as_submodule("submodir", subRemoteUrl, absorb_git_dir=absorb, name="submoname") 68 | subAddCommit = repo.create_commit_on_head("Add Submodule for Test Purposes") 69 | 70 | if WINDOWS: 71 | subPath = subPath.replace("\\", "/") 72 | 73 | return subPath, subAddCommit 74 | --------------------------------------------------------------------------------