├── .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 |
24 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/gitfourchette.run.xml:
--------------------------------------------------------------------------------
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/runConfigurations/pytest.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
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 
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 | 
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 |
5 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/achtung@dark.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/back.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/colorscheme-chip.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/dark.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/error.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/forward.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/git-branch.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/git-change.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/git-checkout.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/git-cherrypick.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/git-commit-amend.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/git-commit.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/git-discard-lines.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/git-discard.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/git-fetch.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/git-folder.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/git-head-detached.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/git-head.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/git-merge.svg:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/git-pull.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/git-push.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/git-remote.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/git-settings.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/git-stage-lines.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/git-stage.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/git-stash-black.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/git-stash.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/git-submodule.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/git-tag.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/git-unstage-lines.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/git-unstage.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/git-workdir.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/gitfourchette.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jorio/gitfourchette/4a24e06dc38e8c4253a1d18215b6cb581cecfd4e/gitfourchette/assets/icons/gitfourchette.png
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/hint.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/light-dark-toggle.svg:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/light.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/linebg-chip-colorblind.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/linebg-chip-redgreen.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/magnifying-glass.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/maximize.svg:
--------------------------------------------------------------------------------
1 |
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 |
7 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/prefs-diff.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/prefs-external.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/prefs-general.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/prefs-graph.svg:
--------------------------------------------------------------------------------
1 |
16 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/prefs-imagediff.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/prefs-tabs.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/prefs-trash.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/prefs-usercommands.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/reveal.svg:
--------------------------------------------------------------------------------
1 |
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 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/status_a@dark.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/status_d.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/status_d@dark.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/status_m.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/status_m@dark.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/status_missing.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/status_r.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/status_r@dark.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/status_t.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/status_t@dark.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/status_u.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/status_u@dark.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/status_x.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/status_x@dark.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/terminal.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/urgent-tab.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/view-exclusive.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/view-hidden-indirect.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/view-hidden.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/gitfourchette/assets/icons/view-visible.svg:
--------------------------------------------------------------------------------
1 |
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 |
--------------------------------------------------------------------------------