Merge commit '55be1ad1ca' into add_locale_ja_JP

This commit is contained in:
Sousi Omine 2025-04-02 00:37:03 +09:00
commit 50804fdbc7
288 changed files with 10124 additions and 6428 deletions

View file

@ -19,14 +19,25 @@ jobs:
os: macos-latest os: macos-latest
runtime: osx-arm64 runtime: osx-arm64
- name : Linux - name : Linux
os: ubuntu-20.04 os: ubuntu-latest
runtime: linux-x64 runtime: linux-x64
container: ubuntu:20.04
- name : Linux (arm64) - name : Linux (arm64)
os: ubuntu-20.04 os: ubuntu-latest
runtime: linux-arm64 runtime: linux-arm64
container: ubuntu:20.04
name: Build ${{ matrix.name }} name: Build ${{ matrix.name }}
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
container: ${{ matrix.container || '' }}
steps: steps:
- name: Install common CLI tools
if: ${{ startsWith(matrix.runtime, 'linux-') }}
run: |
export DEBIAN_FRONTEND=noninteractive
ln -fs /usr/share/zoneinfo/Etc/UTC /etc/localtime
apt-get update
apt-get install -y sudo
sudo apt-get install -y curl wget git unzip zip libicu66 tzdata clang
- name: Checkout sources - name: Checkout sources
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup .NET - name: Setup .NET
@ -47,7 +58,7 @@ jobs:
if: ${{ matrix.runtime == 'linux-arm64' }} if: ${{ matrix.runtime == 'linux-arm64' }}
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install clang llvm gcc-aarch64-linux-gnu zlib1g-dev:arm64 sudo apt-get install -y llvm gcc-aarch64-linux-gnu zlib1g-dev:arm64
- name: Build - name: Build
run: dotnet build -c Release run: dotnet build -c Release
- name: Publish - name: Publish

View file

@ -7,12 +7,12 @@ on:
required: true required: true
type: string type: string
jobs: jobs:
windows-portable: windows:
name: Package portable Windows app name: Package Windows
runs-on: ubuntu-latest runs-on: windows-2019
strategy: strategy:
matrix: matrix:
runtime: [win-x64, win-arm64] runtime: [ win-x64, win-arm64 ]
steps: steps:
- name: Checkout sources - name: Checkout sources
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -22,10 +22,11 @@ jobs:
name: sourcegit.${{ matrix.runtime }} name: sourcegit.${{ matrix.runtime }}
path: build/SourceGit path: build/SourceGit
- name: Package - name: Package
shell: bash
env: env:
VERSION: ${{ inputs.version }} VERSION: ${{ inputs.version }}
RUNTIME: ${{ matrix.runtime }} RUNTIME: ${{ matrix.runtime }}
run: ./build/scripts/package.windows-portable.sh run: ./build/scripts/package.windows.sh
- name: Upload package artifact - name: Upload package artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
@ -36,7 +37,7 @@ jobs:
with: with:
name: sourcegit.${{ matrix.runtime }} name: sourcegit.${{ matrix.runtime }}
osx-app: osx-app:
name: Package OSX app name: Package macOS
runs-on: macos-latest runs-on: macos-latest
strategy: strategy:
matrix: matrix:
@ -69,6 +70,7 @@ jobs:
linux: linux:
name: Package Linux name: Package Linux
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: ubuntu:20.04
strategy: strategy:
matrix: matrix:
runtime: [linux-x64, linux-arm64] runtime: [linux-x64, linux-arm64]
@ -77,9 +79,10 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Download package dependencies - name: Download package dependencies
run: | run: |
sudo add-apt-repository universe export DEBIAN_FRONTEND=noninteractive
sudo apt-get update ln -fs /usr/share/zoneinfo/Etc/UTC /etc/localtime
sudo apt-get install desktop-file-utils rpm libfuse2 apt-get update
apt-get install -y curl wget git dpkg-dev fakeroot tzdata zip unzip desktop-file-utils rpm libfuse2 file build-essential binutils
- name: Download build - name: Download build
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
@ -89,6 +92,7 @@ jobs:
env: env:
VERSION: ${{ inputs.version }} VERSION: ${{ inputs.version }}
RUNTIME: ${{ matrix.runtime }} RUNTIME: ${{ matrix.runtime }}
APPIMAGE_EXTRACT_AND_RUN: 1
run: | run: |
mkdir build/SourceGit mkdir build/SourceGit
tar -xf "build/sourcegit.${{ matrix.runtime }}.tar" -C build/SourceGit tar -xf "build/sourcegit.${{ matrix.runtime }}.tar" -C build/SourceGit

View file

@ -1,39 +0,0 @@
name: Publish to Buildkite
on:
workflow_call:
secrets:
BUILDKITE_TOKEN:
required: true
jobs:
publish:
name: Publish to Buildkite
runs-on: ubuntu-latest
strategy:
matrix:
runtime: [linux-x64, linux-arm64]
steps:
- name: Download package artifacts
uses: actions/download-artifact@v4
with:
name: package.${{ matrix.runtime }}
path: packages
- name: Publish DEB package
env:
BUILDKITE_TOKEN: ${{ secrets.BUILDKITE_TOKEN }}
run: |
FILE=$(echo packages/*.deb)
curl -X POST https://api.buildkite.com/v2/packages/organizations/sourcegit/registries/sourcegit-deb/packages \
-H "Authorization: Bearer $BUILDKITE_TOKEN" \
-F "file=@$FILE" \
--fail
- name: Publish RPM package
env:
BUILDKITE_TOKEN: ${{ secrets.BUILDKITE_TOKEN }}
run: |
FILE=$(echo packages/*.rpm)
curl -X POST https://api.buildkite.com/v2/packages/organizations/sourcegit/registries/sourcegit-rpm/packages \
-H "Authorization: Bearer $BUILDKITE_TOKEN" \
-F "file=@$FILE" \
--fail

View file

@ -24,12 +24,6 @@ jobs:
uses: ./.github/workflows/package.yml uses: ./.github/workflows/package.yml
with: with:
version: ${{ needs.version.outputs.version }} version: ${{ needs.version.outputs.version }}
publish-packages:
needs: [package, version]
name: Publish Packages
uses: ./.github/workflows/publish-packages.yml
secrets:
BUILDKITE_TOKEN: ${{ secrets.BUILDKITE_TOKEN }}
release: release:
needs: [package, version] needs: [package, version]
name: Release name: Release
@ -44,7 +38,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }} TAG: ${{ github.ref_name }}
VERSION: ${{ needs.version.outputs.version }} VERSION: ${{ needs.version.outputs.version }}
run: gh release create "$TAG" -t "Release $VERSION" --notes-from-tag run: gh release create "$TAG" -t "$VERSION" --notes-from-tag
- name: Download artifacts - name: Download artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:

View file

@ -1,6 +1,6 @@
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2024 sourcegit Copyright (c) 2025 sourcegit
Permission is hereby granted, free of charge, to any person obtaining a copy of Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in this software and associated documentation files (the "Software"), to deal in
@ -17,4 +17,4 @@ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -18,9 +18,9 @@
* Supports SSH access with each remote * Supports SSH access with each remote
* GIT commands with GUI * GIT commands with GUI
* Clone/Fetch/Pull/Push... * Clone/Fetch/Pull/Push...
* Merge/Rebase/Reset/Revert/Amend/Cherry-pick... * Merge/Rebase/Reset/Revert/Cherry-pick...
* Amend/Reword * Amend/Reword/Squash
* Interactive rebase (Basic) * Interactive rebase
* Branches * Branches
* Remotes * Remotes
* Tags * Tags
@ -40,6 +40,7 @@
* Git LFS * Git LFS
* Issue Link * Issue Link
* Workspace * Workspace
* Custom Action
* Using AI to generate commit message (C# port of [anjerodev/commitollama](https://github.com/anjerodev/commitollama)) * Using AI to generate commit message (C# port of [anjerodev/commitollama](https://github.com/anjerodev/commitollama))
> [!WARNING] > [!WARNING]
@ -47,7 +48,7 @@
## Translation Status ## Translation Status
[![en_US](https://img.shields.io/badge/en__US-100%25-brightgreen)](TRANSLATION.md) [![de__DE](https://img.shields.io/badge/de__DE-97.50%25-yellow)](TRANSLATION.md) [![es__ES](https://img.shields.io/badge/es__ES-97.78%25-yellow)](TRANSLATION.md) [![fr__FR](https://img.shields.io/badge/fr__FR-95.00%25-yellow)](TRANSLATION.md) [![it__IT](https://img.shields.io/badge/it__IT-95.56%25-yellow)](TRANSLATION.md) [![pt__BR](https://img.shields.io/badge/pt__BR-96.81%25-yellow)](TRANSLATION.md) [![ru__RU](https://img.shields.io/badge/ru__RU-97.92%25-yellow)](TRANSLATION.md) [![zh__CN](https://img.shields.io/badge/zh__CN-100.00%25-brightgreen)](TRANSLATION.md) [![zh__TW](https://img.shields.io/badge/zh__TW-100.00%25-brightgreen)](TRANSLATION.md) You can find the current translation status in [TRANSLATION.md](TRANSLATION.md)
## How to Use ## How to Use
@ -59,12 +60,13 @@ This software creates a folder `$"{System.Environment.SpecialFolder.ApplicationD
| OS | PATH | | OS | PATH |
|---------|-----------------------------------------------------| |---------|-----------------------------------------------------|
| Windows | `C:\Users\USER_NAME\AppData\Roaming\SourceGit` | | Windows | `%APPDATA%\SourceGit` |
| Linux | `${HOME}/.config/SourceGit` or `${HOME}/.sourcegit` | | Linux | `${HOME}/.config/SourceGit` or `${HOME}/.sourcegit` |
| macOS | `${HOME}/Library/Application Support/SourceGit` | | macOS | `${HOME}/Library/Application Support/SourceGit` |
> [!TIP] > [!TIP]
> You can open the app data dir from the main menu. > * You can open this data storage directory from the main menu `Open Data Storage Directory`.
> * You can create a `data` folder next to the `SourceGit` executable to force this app to store data (user settings, downloaded avatars and crash logs) into it (Portable-Mode). Only works on Windows.
For **Windows** users: For **Windows** users:
@ -75,12 +77,12 @@ For **Windows** users:
``` ```
> [!NOTE] > [!NOTE]
> `winget` will install this software as a commandline tool. You need run `SourceGit` from console or `Win+R` at the first time. Then you can add it to the taskbar. > `winget` will install this software as a commandline tool. You need run `SourceGit` from console or `Win+R` at the first time. Then you can add it to the taskbar.
* You can install the latest stable by `scoope` with follow commands: * You can install the latest stable by `scoop` with follow commands:
```shell ```shell
scoop bucket add extras scoop bucket add extras
scoop install sourcegit scoop install sourcegit
``` ```
* Portable versions can be found in [Releases](https://github.com/sourcegit-scm/sourcegit/releases/latest) * Pre-built binaries can be found in [Releases](https://github.com/sourcegit-scm/sourcegit/releases/latest)
For **macOS** users: For **macOS** users:
@ -98,49 +100,45 @@ For **macOS** users:
For **Linux** users: For **Linux** users:
* For Debian/Ubuntu based distributions, you can add the `sourcegit` repository by following: * Thanks [@aikawayataro](https://github.com/aikawayataro) for providing `rpm` and `deb` repositories, hosted on [Codeberg](https://codeberg.org/yataro/-/packages).
You may need to install curl and/or gpg first, if you're on a very minimal host:
`deb` how to:
```shell ```shell
apt update && apt install curl gpg -y curl https://codeberg.org/api/packages/yataro/debian/repository.key | sudo tee /etc/apt/keyrings/sourcegit.asc
echo "deb [signed-by=/etc/apt/keyrings/sourcegit.asc, arch=amd64,arm64] https://codeberg.org/api/packages/yataro/debian generic main" | sudo tee /etc/apt/sources.list.d/sourcegit.list
sudo apt update
sudo apt install sourcegit
``` ```
Install the registry signing key:
`rpm` how to:
```shell ```shell
curl -fsSL "https://packages.buildkite.com/sourcegit/sourcegit-deb/gpgkey" | gpg --dearmor -o /etc/apt/keyrings/sourcegit_sourcegit-deb-archive-keyring.gpg curl https://codeberg.org/api/packages/yataro/rpm.repo | sed -e 's/gpgcheck=1/gpgcheck=0/' > sourcegit.repo
# Fedora 41 and newer
sudo dnf config-manager addrepo --from-repofile=./sourcegit.repo
# Fedora 40 and earlier
sudo dnf config-manager --add-repo ./sourcegit.repo
sudo dnf install sourcegit
``` ```
Configure the source:
```shell If your distribution isn't using `dnf`, please refer to the documentation of your distribution on how to add an `rpm` repository.
echo -e "deb [signed-by=/etc/apt/keyrings/sourcegit_sourcegit-deb-archive-keyring.gpg] https://packages.buildkite.com/sourcegit/sourcegit-deb/any/ any main\ndeb-src [signed-by=/etc/apt/keyrings/sourcegit_sourcegit-deb-archive-keyring.gpg] https://packages.buildkite.com/sourcegit/sourcegit-deb/any/ any main" > /etc/apt/sources.list.d/buildkite-sourcegit-sourcegit-deb.list * `AppImage` files can be found on [AppImage hub](https://appimage.github.io/SourceGit/), `xdg-open` (`xdg-utils`) must be installed to support open native file manager.
``` * Make sure [git-credential-manager](https://github.com/git-ecosystem/git-credential-manager/releases) is installed on your Linux.
Update your local repository and install the package:
```shell
apt update && apt install sourcegit
```
* For RHEL/Fedora based distributions, you can add the `sourcegit` repository by following:
Configure the source:
```shell
sudo sh -c 'echo -e "[sourcegit-rpm]\nname=sourcegit-rpm\nbaseurl=https://packages.buildkite.com/sourcegit/sourcegit-rpm/rpm_any/rpm_any/\$basearch\nenabled=1\nrepo_gpgcheck=1\ngpgcheck=0\ngpgkey=https://packages.buildkite.com/sourcegit/sourcegit-rpm/gpgkey\npriority=1"' > /etc/yum.repos.d/sourcegit-rpm.repo
```
Install the package with this command:
```shell
sudo dnf install -y sourcegit
```
* `Appimage` files can be found on [AppimageHub](https://appimage.github.io/SourceGit/)
* `xdg-open` must be installed to support open native file manager.
* Make sure [git-credential-manager](https://github.com/git-ecosystem/git-credential-manager/releases) is installed on your linux.
* Maybe you need to set environment variable `AVALONIA_SCREEN_SCALE_FACTORS`. See https://github.com/AvaloniaUI/Avalonia/wiki/Configuring-X11-per-monitor-DPI. * Maybe you need to set environment variable `AVALONIA_SCREEN_SCALE_FACTORS`. See https://github.com/AvaloniaUI/Avalonia/wiki/Configuring-X11-per-monitor-DPI.
* If you can NOT type accented characters, such as `ê`, `ó`, try to set the environment variable `AVALONIA_IM_MODULE` to `none`. * If you can NOT type accented characters, such as `ê`, `ó`, try to set the environment variable `AVALONIA_IM_MODULE` to `none`.
## OpenAI ## OpenAI
This software supports using OpenAI or other AI service that has an OpenAI comaptible HTTP API to generate commit message. You need configurate the service in `Preference` window. This software supports using OpenAI or other AI service that has an OpenAI compatible HTTP API to generate commit message. You need configurate the service in `Preference` window.
For `OpenAI`: For `OpenAI`:
* `Server` must be `https://api.openai.com/v1/chat/completions` * `Server` must be `https://api.openai.com/v1`
For other AI service: For other AI service:
* The `Server` should fill in a URL equivalent to OpenAI's `https://api.openai.com/v1/chat/completions`. For example, when using `Ollama`, it should be `http://localhost:11434/v1/chat/completions` instead of `http://localhost:11434/api/generate` * The `Server` should fill in a URL equivalent to OpenAI's `https://api.openai.com/v1`. For example, when using `Ollama`, it should be `http://localhost:11434/v1` instead of `http://localhost:11434/api/generate`
* The `API Key` is optional that depends on the service * The `API Key` is optional that depends on the service
## External Tools ## External Tools
@ -159,7 +157,7 @@ This app supports open repository in external tools listed in the table below.
> [!NOTE] > [!NOTE]
> This app will try to find those tools based on some pre-defined or expected locations automatically. If you are using one portable version of these tools, it will not be detected by this app. > This app will try to find those tools based on some pre-defined or expected locations automatically. If you are using one portable version of these tools, it will not be detected by this app.
> To solve this problem you can add a file named `external_editors.json` in app data dir and provide the path directly. For example: > To solve this problem you can add a file named `external_editors.json` in app data storage directory and provide the path directly. For example:
```json ```json
{ {
"tools": { "tools": {
@ -189,6 +187,19 @@ This app supports open repository in external tools listed in the table below.
Everyone is welcome to submit a PR. Please make sure your PR is based on the latest `develop` branch and the target branch of PR is `develop`. Everyone is welcome to submit a PR. Please make sure your PR is based on the latest `develop` branch and the target branch of PR is `develop`.
In short, here are the commands to get started once [.NET tools are installed](https://dotnet.microsoft.com/en-us/download):
```sh
dotnet nuget add source https://api.nuget.org/v3/index.json -n nuget.org
dotnet restore
dotnet build
dotnet run --project src/SourceGit.csproj
```
Thanks to all the people who contribute. Thanks to all the people who contribute.
[![Contributors](https://contrib.rocks/image?repo=sourcegit-scm/sourcegit&columns=20)](https://github.com/sourcegit-scm/sourcegit/graphs/contributors) [![Contributors](https://contrib.rocks/image?repo=sourcegit-scm/sourcegit&columns=20)](https://github.com/sourcegit-scm/sourcegit/graphs/contributors)
## Third-Party Components
For detailed license information, see [THIRD-PARTY-LICENSES.md](THIRD-PARTY-LICENSES.md).

View file

@ -18,7 +18,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{
.github\workflows\package.yml = .github\workflows\package.yml .github\workflows\package.yml = .github\workflows\package.yml
.github\workflows\release.yml = .github\workflows\release.yml .github\workflows\release.yml = .github\workflows\release.yml
.github\workflows\localization-check.yml = .github\workflows\localization-check.yml .github\workflows\localization-check.yml = .github\workflows\localization-check.yml
.github\workflows\publish-packages.yml = .github\workflows\publish-packages.yml
EndProjectSection EndProjectSection
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{49A7C2D6-558C-4FAA-8F5D-EEE81497AED7}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{49A7C2D6-558C-4FAA-8F5D-EEE81497AED7}"
@ -61,6 +60,8 @@ EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DEBIAN", "DEBIAN", "{F101849D-BDB7-40D4-A516-751150C3CCFC}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DEBIAN", "DEBIAN", "{F101849D-BDB7-40D4-A516-751150C3CCFC}"
ProjectSection(SolutionItems) = preProject ProjectSection(SolutionItems) = preProject
build\resources\deb\DEBIAN\control = build\resources\deb\DEBIAN\control build\resources\deb\DEBIAN\control = build\resources\deb\DEBIAN\control
build\resources\deb\DEBIAN\preinst = build\resources\deb\DEBIAN\preinst
build\resources\deb\DEBIAN\prerm = build\resources\deb\DEBIAN\prerm
EndProjectSection EndProjectSection
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "rpm", "rpm", "{9BA0B044-0CC9-46F8-B551-204F149BF45D}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "rpm", "rpm", "{9BA0B044-0CC9-46F8-B551-204F149BF45D}"
@ -81,7 +82,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "scripts", "scripts", "{C54D
build\scripts\localization-check.js = build\scripts\localization-check.js build\scripts\localization-check.js = build\scripts\localization-check.js
build\scripts\package.linux.sh = build\scripts\package.linux.sh build\scripts\package.linux.sh = build\scripts\package.linux.sh
build\scripts\package.osx-app.sh = build\scripts\package.osx-app.sh build\scripts\package.osx-app.sh = build\scripts\package.osx-app.sh
build\scripts\package.windows-portable.sh = build\scripts\package.windows-portable.sh build\scripts\package.windows.sh = build\scripts\package.windows.sh
EndProjectSection EndProjectSection
EndProject EndProject
Global Global

86
THIRD-PARTY-LICENSES.md Normal file
View file

@ -0,0 +1,86 @@
# Third-Party Licenses
This project incorporates components from the following third parties:
## Packages
### AvaloniaUI
- **Source**: https://github.com/AvaloniaUI/Avalonia
- **Version**: 11.2.5
- **License**: MIT License
- **License Link**: https://github.com/AvaloniaUI/Avalonia/blob/master/licence.md
### AvaloniaEdit
- **Source**: https://github.com/AvaloniaUI/AvaloniaEdit
- **Version**: 11.2.0
- **License**: MIT License
- **License Link**: https://github.com/AvaloniaUI/AvaloniaEdit/blob/master/LICENSE
### LiveChartsCore.SkiaSharpView.Avalonia
- **Source**: https://github.com/beto-rodriguez/LiveCharts2
- **Version**: 2.0.0-rc5.4
- **License**: MIT License
- **License Link**: https://github.com/beto-rodriguez/LiveCharts2/blob/master/LICENSE
### TextMateSharp
- **Source**: https://github.com/danipen/TextMateSharp
- **Version**: 1.0.66
- **License**: MIT License
- **License Link**: https://github.com/danipen/TextMateSharp/blob/master/LICENSE.md
### OpenAI .NET SDK
- **Source**: https://github.com/openai/openai-dotnet
- **Version**: 2.2.0-beta2
- **License**: MIT License
- **License Link**: https://github.com/openai/openai-dotnet/blob/main/LICENSE
### Azure.AI.OpenAI
- **Source**: https://github.com/Azure/azure-sdk-for-net
- **Version**: 2.2.0-beta2
- **License**: MIT License
- **License Link**: https://github.com/Azure/azure-sdk-for-net/blob/main/LICENSE.txt
## Fonts
### JetBrainsMono
- **Source**: https://github.com/JetBrains/JetBrainsMono
- **Commit**: v2.304
- **License**: SIL Open Font License, Version 1.1
- **License Link**: https://github.com/JetBrains/JetBrainsMono/blob/v2.304/OFL.txt
## Grammar Files
### haxe-TmLanguage
- **Source**: https://github.com/vshaxe/haxe-TmLanguage
- **Commit**: ddad8b4c6d0781ac20be0481174ec1be772c5da5
- **License**: MIT License
- **License Link**: https://github.com/vshaxe/haxe-TmLanguage/blob/ddad8b4c6d0781ac20be0481174ec1be772c5da5/LICENSE.md
### coc-toml
- **Source**: https://github.com/kkiyama117/coc-toml
- **Commit**: aac3e0c65955c03314b2733041b19f903b7cc447
- **License**: MIT License
- **License Link**: https://github.com/kkiyama117/coc-toml/blob/aac3e0c65955c03314b2733041b19f903b7cc447/LICENSE
### eclipse-buildship
- **Source**: https://github.com/eclipse/buildship
- **Commit**: 6bb773e7692f913dec27105129ebe388de34e68b
- **License**: Eclipse Public License 1.0
- **License Link**: https://github.com/eclipse-buildship/buildship/blob/6bb773e7692f913dec27105129ebe388de34e68b/README.md
### vscode-jsp-lang
- **Source**: https://github.com/samuel-weinhardt/vscode-jsp-lang
- **Commit**: 0e89ecdb13650dbbe5a1e85b47b2e1530bf2f355
- **License**: MIT License
- **License Link**: https://github.com/samuel-weinhardt/vscode-jsp-lang/blob/0e89ecdb13650dbbe5a1e85b47b2e1530bf2f355/LICENSE

View file

@ -1,117 +1,91 @@
### de_DE.axaml: 97.50% # Translation Status
This document shows the translation status of each locale file in the repository.
## Details
### ![en_US](https://img.shields.io/badge/en__US-%E2%88%9A-brightgreen)
### ![de__DE](https://img.shields.io/badge/de__DE-98.65%25-yellow)
<details> <details>
<summary>Missing Keys</summary> <summary>Missing keys in de_DE.axaml</summary>
- Text.BranchCM.MergeMultiBranches - Text.BranchUpstreamInvalid
- Text.CommitCM.Merge - Text.Configure.CustomAction.WaitForExit
- Text.CommitCM.MergeMultiple - Text.Configure.IssueTracker.AddSampleAzure
- Text.CommitDetail.Files.Search - Text.CopyFullPath
- Text.Diff.UseBlockNavigation - Text.Diff.First
- Text.FileCM.ResolveUsing - Text.Diff.Last
- Text.Hotkeys.Global.Clone - Text.Preferences.AI.Streaming
- Text.InProgress.CherryPick.Head - Text.Preferences.Appearance.EditorTabWidth
- Text.InProgress.Merge.Operating - Text.Preferences.General.ShowTagsInGraph
- Text.InProgress.Rebase.StoppedAt - Text.StashCM.SaveAsPatch
- Text.InProgress.Revert.Head
- Text.Merge.Source
- Text.MergeMultiple
- Text.MergeMultiple.CommitChanges
- Text.MergeMultiple.Strategy
- Text.MergeMultiple.Targets
- Text.Repository.Skip
- Text.WorkingCopy.CommitToEdit
</details> </details>
### es_ES.axaml: 97.78% ### ![es__ES](https://img.shields.io/badge/es__ES-99.87%25-yellow)
<details> <details>
<summary>Missing Keys</summary> <summary>Missing keys in es_ES.axaml</summary>
- Text.BranchCM.MergeMultiBranches - Text.CopyFullPath
- Text.CommitCM.Merge
- Text.CommitCM.MergeMultiple
- Text.Diff.UseBlockNavigation
- Text.FileCM.ResolveUsing
- Text.Hotkeys.Global.Clone
- Text.InProgress.CherryPick.Head
- Text.InProgress.Merge.Operating
- Text.InProgress.Rebase.StoppedAt
- Text.InProgress.Revert.Head
- Text.Merge.Source
- Text.MergeMultiple
- Text.MergeMultiple.CommitChanges
- Text.MergeMultiple.Strategy
- Text.MergeMultiple.Targets
- Text.Repository.Skip
</details> </details>
### fr_FR.axaml: 95.00% ### ![fr__FR](https://img.shields.io/badge/fr__FR-99.87%25-yellow)
<details> <details>
<summary>Missing Keys</summary> <summary>Missing keys in fr_FR.axaml</summary>
- Text.BranchCM.MergeMultiBranches - Text.CopyFullPath
- Text.CherryPick.AppendSourceToMessage
- Text.CherryPick.Mainline.Tips
- Text.CommitCM.CherryPickMultiple
- Text.CommitCM.Merge
- Text.CommitCM.MergeMultiple
- Text.CommitDetail.Files.Search
- Text.Diff.UseBlockNavigation
- Text.Fetch.Force
- Text.FileCM.ResolveUsing
- Text.Hotkeys.Global.Clone
- Text.InProgress.CherryPick.Head
- Text.InProgress.Merge.Operating
- Text.InProgress.Rebase.StoppedAt
- Text.InProgress.Revert.Head
- Text.Merge.Source
- Text.MergeMultiple
- Text.MergeMultiple.CommitChanges
- Text.MergeMultiple.Strategy
- Text.MergeMultiple.Targets
- Text.Preference.Appearance.FontSize
- Text.Preference.Appearance.FontSize.Default
- Text.Preference.Appearance.FontSize.Editor
- Text.Preference.General.ShowChildren
- Text.Repository.CustomActions
- Text.Repository.FilterCommits
- Text.Repository.FilterCommits.Default
- Text.Repository.FilterCommits.Exclude
- Text.Repository.FilterCommits.Include
- Text.Repository.HistoriesOrder
- Text.Repository.HistoriesOrder.ByDate
- Text.Repository.HistoriesOrder.Topo
- Text.Repository.Skip
- Text.ScanRepositories
- Text.SHALinkCM.NavigateTo
- Text.WorkingCopy.CommitToEdit
</details> </details>
### it_IT.axaml: 95.56% ### ![it__IT](https://img.shields.io/badge/it__IT-99.73%25-yellow)
<details> <details>
<summary>Missing Keys</summary> <summary>Missing keys in it_IT.axaml</summary>
- Text.CopyFullPath
- Text.Preferences.General.ShowTagsInGraph
</details>
### ![pt__BR](https://img.shields.io/badge/pt__BR-90.98%25-yellow)
<details>
<summary>Missing keys in pt_BR.axaml</summary>
- Text.AIAssistant.Regen
- Text.AIAssistant.Use
- Text.ApplyStash
- Text.ApplyStash.DropAfterApply
- Text.ApplyStash.RestoreIndex
- Text.ApplyStash.Stash
- Text.BranchCM.CustomAction
- Text.BranchCM.MergeMultiBranches - Text.BranchCM.MergeMultiBranches
- Text.BranchUpstreamInvalid
- Text.Clone.RecurseSubmodules
- Text.CommitCM.Merge - Text.CommitCM.Merge
- Text.CommitCM.MergeMultiple - Text.CommitCM.MergeMultiple
- Text.CommitDetail.Files.Search - Text.CommitDetail.Files.Search
- Text.CommitDetail.Info.Children - Text.CommitDetail.Info.Children
- Text.Configure.IssueTracker.AddSampleGitLabMergeRequest - Text.Configure.CustomAction.Scope.Branch
- Text.Configure.OpenAI.Preferred - Text.Configure.CustomAction.WaitForExit
- Text.Configure.OpenAI.Preferred.Tip - Text.Configure.IssueTracker.AddSampleGiteeIssue
- Text.Configure.IssueTracker.AddSampleGiteePullRequest
- Text.CopyFullPath
- Text.CreateBranch.Name.WarnSpace
- Text.DeleteRepositoryNode.Path
- Text.DeleteRepositoryNode.TipForGroup
- Text.DeleteRepositoryNode.TipForRepository
- Text.Diff.First
- Text.Diff.Last
- Text.Diff.UseBlockNavigation - Text.Diff.UseBlockNavigation
- Text.Fetch.Force - Text.Fetch.Force
- Text.FileCM.ResolveUsing - Text.FileCM.ResolveUsing
- Text.Hotkeys.Global.Clone
- Text.InProgress.CherryPick.Head - Text.InProgress.CherryPick.Head
- Text.InProgress.Merge.Operating - Text.InProgress.Merge.Operating
- Text.InProgress.Rebase.StoppedAt - Text.InProgress.Rebase.StoppedAt
@ -121,93 +95,40 @@
- Text.MergeMultiple.CommitChanges - Text.MergeMultiple.CommitChanges
- Text.MergeMultiple.Strategy - Text.MergeMultiple.Strategy
- Text.MergeMultiple.Targets - Text.MergeMultiple.Targets
- Text.Preference.General.ShowChildren - Text.Preferences.AI.Streaming
- Text.Preferences.Appearance.EditorTabWidth
- Text.Preferences.General.DateFormat
- Text.Preferences.General.ShowChildren
- Text.Preferences.General.ShowTagsInGraph
- Text.Preferences.Git.SSLVerify
- Text.Repository.FilterCommits - Text.Repository.FilterCommits
- Text.Repository.FilterCommits.Default - Text.Repository.HistoriesLayout
- Text.Repository.FilterCommits.Exclude - Text.Repository.HistoriesLayout.Horizontal
- Text.Repository.FilterCommits.Include - Text.Repository.HistoriesLayout.Vertical
- Text.Repository.HistoriesOrder - Text.Repository.HistoriesOrder
- Text.Repository.HistoriesOrder.ByDate - Text.Repository.Notifications.Clear
- Text.Repository.HistoriesOrder.Topo - Text.Repository.OnlyHighlightCurrentBranchInHistories
- Text.Repository.Skip - Text.Repository.Skip
- Text.SHALinkCM.CopySHA - Text.Repository.Tags.OrderByCreatorDate
- Text.Repository.Tags.OrderByNameAsc
- Text.Repository.Tags.OrderByNameDes
- Text.Repository.Tags.Sort
- Text.Repository.UseRelativeTimeInHistories
- Text.SetUpstream
- Text.SetUpstream.Local
- Text.SetUpstream.Unset
- Text.SetUpstream.Upstream
- Text.SHALinkCM.NavigateTo - Text.SHALinkCM.NavigateTo
- Text.Stash.AutoRestore
- Text.Stash.AutoRestore.Tip
- Text.StashCM.SaveAsPatch
- Text.WorkingCopy.CommitToEdit - Text.WorkingCopy.CommitToEdit
- Text.WorkingCopy.SignOff
</details> </details>
### pt_BR.axaml: 96.81% ### ![ru__RU](https://img.shields.io/badge/ru__RU-%E2%88%9A-brightgreen)
### ![zh__CN](https://img.shields.io/badge/zh__CN-%E2%88%9A-brightgreen)
<details> ### ![zh__TW](https://img.shields.io/badge/zh__TW-%E2%88%9A-brightgreen)
<summary>Missing Keys</summary>
- Text.BranchCM.MergeMultiBranches
- Text.CommitCM.Merge
- Text.CommitCM.MergeMultiple
- Text.CommitDetail.Files.Search
- Text.CommitDetail.Info.Children
- Text.Diff.UseBlockNavigation
- Text.Fetch.Force
- Text.FileCM.ResolveUsing
- Text.Hotkeys.Global.Clone
- Text.InProgress.CherryPick.Head
- Text.InProgress.Merge.Operating
- Text.InProgress.Rebase.StoppedAt
- Text.InProgress.Revert.Head
- Text.Merge.Source
- Text.MergeMultiple
- Text.MergeMultiple.CommitChanges
- Text.MergeMultiple.Strategy
- Text.MergeMultiple.Targets
- Text.Preference.General.ShowChildren
- Text.Repository.FilterCommits
- Text.Repository.Skip
- Text.SHALinkCM.NavigateTo
- Text.WorkingCopy.CommitToEdit
</details>
### ru_RU.axaml: 97.92%
<details>
<summary>Missing Keys</summary>
- Text.BranchCM.MergeMultiBranches
- Text.CommitCM.Merge
- Text.CommitCM.MergeMultiple
- Text.FileCM.ResolveUsing
- Text.Hotkeys.Global.Clone
- Text.InProgress.CherryPick.Head
- Text.InProgress.Merge.Operating
- Text.InProgress.Rebase.StoppedAt
- Text.InProgress.Revert.Head
- Text.Merge.Source
- Text.MergeMultiple
- Text.MergeMultiple.CommitChanges
- Text.MergeMultiple.Strategy
- Text.MergeMultiple.Targets
- Text.Repository.Skip
</details>
### zh_CN.axaml: 100.00%
<details>
<summary>Missing Keys</summary>
</details>
### zh_TW.axaml: 100.00%
<details>
<summary>Missing Keys</summary>
</details>

View file

@ -1 +1 @@
8.42 2025.11

View file

@ -1,7 +1,8 @@
Package: sourcegit Package: sourcegit
Version: 8.23 Version: 2025.10
Priority: optional Priority: optional
Depends: libx11-6, libice6, libsm6 Depends: libx11-6, libice6, libsm6, libicu | libicu76 | libicu74 | libicu72 | libicu71 | libicu70 | libicu69 | libicu68 | libicu67 | libicu66 | libicu65 | libicu63 | libicu60 | libicu57 | libicu55 | libicu52, xdg-utils
Architecture: amd64 Architecture: amd64
Installed-Size: 60440
Maintainer: longshuang@msn.cn Maintainer: longshuang@msn.cn
Description: Open-source & Free Git GUI Client Description: Open-source & Free Git GUI Client

View file

@ -0,0 +1,32 @@
#!/bin/sh
set -e
# summary of how this script can be called:
# * <new-preinst> `install'
# * <new-preinst> `install' <old-version>
# * <new-preinst> `upgrade' <old-version>
# * <old-preinst> `abort-upgrade' <new-version>
# for details, see http://www.debian.org/doc/debian-policy/
case "$1" in
install|upgrade)
# Check if SourceGit is running and stop it
if pgrep -f '/opt/sourcegit/sourcegit' > /dev/null; then
echo "Stopping running SourceGit instance..."
pkill -f '/opt/sourcegit/sourcegit' || true
# Give the process a moment to terminate
sleep 1
fi
;;
abort-upgrade)
;;
*)
echo "preinst called with unknown argument \`$1'" >&2
exit 1
;;
esac
exit 0

View file

@ -0,0 +1,35 @@
#!/bin/sh
set -e
# summary of how this script can be called:
# * <prerm> `remove'
# * <old-prerm> `upgrade' <new-version>
# * <new-prerm> `failed-upgrade' <old-version>
# * <conflictor's-prerm> `remove' `in-favour' <package> <new-version>
# * <deconfigured's-prerm> `deconfigure' `in-favour'
# <package-being-installed> <version> `removing'
# <conflicting-package> <version>
# for details, see http://www.debian.org/doc/debian-policy/ or
# the debian-policy package
case "$1" in
remove|upgrade|deconfigure)
if pgrep -f '/opt/sourcegit/sourcegit' > /dev/null; then
echo "Stopping running SourceGit instance..."
pkill -f '/opt/sourcegit/sourcegit' || true
# Give the process a moment to terminate
sleep 1
fi
;;
failed-upgrade)
;;
*)
echo "prerm called with unknown argument \`$1'" >&2
exit 1
;;
esac
exit 0

View file

@ -8,6 +8,7 @@ Source: https://github.com/sourcegit-scm/sourcegit/archive/refs/tags/v%_version.
Requires: libX11.so.6()(%{__isa_bits}bit) Requires: libX11.so.6()(%{__isa_bits}bit)
Requires: libSM.so.6()(%{__isa_bits}bit) Requires: libSM.so.6()(%{__isa_bits}bit)
Requires: libicu Requires: libicu
Requires: xdg-utils
%define _build_id_links none %define _build_id_links none

View file

@ -6,7 +6,6 @@ const repoRoot = path.join(__dirname, '../../');
const localesDir = path.join(repoRoot, 'src/Resources/Locales'); const localesDir = path.join(repoRoot, 'src/Resources/Locales');
const enUSFile = path.join(localesDir, 'en_US.axaml'); const enUSFile = path.join(localesDir, 'en_US.axaml');
const outputFile = path.join(repoRoot, 'TRANSLATION.md'); const outputFile = path.join(repoRoot, 'TRANSLATION.md');
const readmeFile = path.join(repoRoot, 'README.md');
const parser = new xml2js.Parser(); const parser = new xml2js.Parser();
@ -18,42 +17,36 @@ async function parseXml(filePath) {
async function calculateTranslationRate() { async function calculateTranslationRate() {
const enUSData = await parseXml(enUSFile); const enUSData = await parseXml(enUSFile);
const enUSKeys = new Set(enUSData.ResourceDictionary['x:String'].map(item => item.$['x:Key'])); const enUSKeys = new Set(enUSData.ResourceDictionary['x:String'].map(item => item.$['x:Key']));
const translationRates = [];
const badges = [];
const files = (await fs.readdir(localesDir)).filter(file => file !== 'en_US.axaml' && file.endsWith('.axaml')); const files = (await fs.readdir(localesDir)).filter(file => file !== 'en_US.axaml' && file.endsWith('.axaml'));
// Add en_US badge first const lines = [];
badges.push(`[![en_US](https://img.shields.io/badge/en__US-100%25-brightgreen)](TRANSLATION.md)`);
lines.push('# Translation Status');
lines.push('This document shows the translation status of each locale file in the repository.');
lines.push(`## Details`);
lines.push(`### ![en_US](https://img.shields.io/badge/en__US-%E2%88%9A-brightgreen)`);
for (const file of files) { for (const file of files) {
const locale = file.replace('.axaml', '').replace('_', '__');
const filePath = path.join(localesDir, file); const filePath = path.join(localesDir, file);
const localeData = await parseXml(filePath); const localeData = await parseXml(filePath);
const localeKeys = new Set(localeData.ResourceDictionary['x:String'].map(item => item.$['x:Key'])); const localeKeys = new Set(localeData.ResourceDictionary['x:String'].map(item => item.$['x:Key']));
const missingKeys = [...enUSKeys].filter(key => !localeKeys.has(key)); const missingKeys = [...enUSKeys].filter(key => !localeKeys.has(key));
const translationRate = ((enUSKeys.size - missingKeys.length) / enUSKeys.size) * 100;
translationRates.push(`### ${file}: ${translationRate.toFixed(2)}%\n`); if (missingKeys.length > 0) {
translationRates.push(`<details>\n<summary>Missing Keys</summary>\n\n${missingKeys.map(key => `- ${key}`).join('\n')}\n\n</details>`); const progress = ((enUSKeys.size - missingKeys.length) / enUSKeys.size) * 100;
const badgeColor = progress >= 75 ? 'yellow' : 'red';
// Add badges lines.push(`### ![${locale}](https://img.shields.io/badge/${locale}-${progress.toFixed(2)}%25-${badgeColor})`);
const locale = file.replace('.axaml', '').replace('_', '__'); lines.push(`<details>\n<summary>Missing keys in ${file}</summary>\n\n${missingKeys.map(key => `- ${key}`).join('\n')}\n\n</details>`)
const badgeColor = translationRate === 100 ? 'brightgreen' : translationRate >= 75 ? 'yellow' : 'red'; } else {
badges.push(`[![${locale}](https://img.shields.io/badge/${locale}-${translationRate.toFixed(2)}%25-${badgeColor})](TRANSLATION.md)`); lines.push(`### ![${locale}](https://img.shields.io/badge/${locale}-%E2%88%9A-brightgreen)`);
}
} }
console.log(translationRates.join('\n\n')); const content = lines.join('\n\n');
console.log(content);
await fs.writeFile(outputFile, translationRates.join('\n\n') + '\n', 'utf8'); await fs.writeFile(outputFile, content, 'utf8');
// Update README.md
let readmeContent = await fs.readFile(readmeFile, 'utf8');
const badgeSection = `## Translation Status\n\n${badges.join(' ')}`;
console.log(badgeSection);
readmeContent = readmeContent.replace(/## Translation Status\n\n.*\n\n/, badgeSection + '\n\n');
await fs.writeFile(readmeFile, readmeContent, 'utf8');
} }
calculateTranslationRate().catch(err => console.error(err)); calculateTranslationRate().catch(err => console.error(err));

View file

@ -56,8 +56,15 @@ cp -f SourceGit/* resources/deb/opt/sourcegit
ln -rsf resources/deb/opt/sourcegit/sourcegit resources/deb/usr/bin ln -rsf resources/deb/opt/sourcegit/sourcegit resources/deb/usr/bin
cp -r resources/_common/applications resources/deb/usr/share cp -r resources/_common/applications resources/deb/usr/share
cp -r resources/_common/icons resources/deb/usr/share cp -r resources/_common/icons resources/deb/usr/share
sed -i -e "s/^Version:.*/Version: $VERSION/" -e "s/^Architecture:.*/Architecture: $arch/" resources/deb/DEBIAN/control # Calculate installed size in KB
dpkg-deb --root-owner-group --build resources/deb "sourcegit_$VERSION-1_$arch.deb" installed_size=$(du -sk resources/deb | cut -f1)
# Update the control file
sed -i -e "s/^Version:.*/Version: $VERSION/" \
-e "s/^Architecture:.*/Architecture: $arch/" \
-e "s/^Installed-Size:.*/Installed-Size: $installed_size/" \
resources/deb/DEBIAN/control
# Build deb package with gzip compression
dpkg-deb -Zgzip --root-owner-group --build resources/deb "sourcegit_$VERSION-1_$arch.deb"
rpmbuild -bb --target="$target" resources/rpm/SPECS/build.spec --define "_topdir $(pwd)/resources/rpm" --define "_version $VERSION" rpmbuild -bb --target="$target" resources/rpm/SPECS/build.spec --define "_topdir $(pwd)/resources/rpm" --define "_version $VERSION"
mv "resources/rpm/RPMS/$target/sourcegit-$VERSION-1.$target.rpm" ./ mv "resources/rpm/RPMS/$target/sourcegit-$VERSION-1.$target.rpm" ./

View file

@ -1,12 +0,0 @@
#!/usr/bin/env bash
set -e
set -o
set -u
set pipefail
cd build
rm -rf SourceGit/*.pdb
zip "sourcegit_$VERSION.$RUNTIME.zip" -r SourceGit

View file

@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -e
set -o
set -u
set pipefail
cd build
rm -rf SourceGit/*.pdb
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || "$OSTYPE" == "win32" ]]; then
powershell -Command "Compress-Archive -Path SourceGit -DestinationPath \"sourcegit_$VERSION.$RUNTIME.zip\" -Force"
else
zip "sourcegit_$VERSION.$RUNTIME.zip" -r SourceGit
fi

View file

@ -25,12 +25,34 @@ namespace SourceGit
private Action<object> _action = null; private Action<object> _action = null;
} }
public static readonly Command OpenPreferenceCommand = new Command(_ => OpenDialog(new Views.Preference())); public static bool IsCheckForUpdateCommandVisible
{
get
{
#if DISABLE_UPDATE_DETECTION
return false;
#else
return true;
#endif
}
}
public static readonly Command OpenPreferencesCommand = new Command(_ => OpenDialog(new Views.Preferences()));
public static readonly Command OpenHotkeysCommand = new Command(_ => OpenDialog(new Views.Hotkeys())); public static readonly Command OpenHotkeysCommand = new Command(_ => OpenDialog(new Views.Hotkeys()));
public static readonly Command OpenAppDataDirCommand = new Command(_ => Native.OS.OpenInFileManager(Native.OS.DataDir)); public static readonly Command OpenAppDataDirCommand = new Command(_ => Native.OS.OpenInFileManager(Native.OS.DataDir));
public static readonly Command OpenAboutCommand = new Command(_ => OpenDialog(new Views.About())); public static readonly Command OpenAboutCommand = new Command(_ => OpenDialog(new Views.About()));
public static readonly Command CheckForUpdateCommand = new Command(_ => Check4Update(true)); public static readonly Command CheckForUpdateCommand = new Command(_ => (Current as App)?.Check4Update(true));
public static readonly Command QuitCommand = new Command(_ => Quit(0)); public static readonly Command QuitCommand = new Command(_ => Quit(0));
public static readonly Command CopyTextBlockCommand = new Command(p => CopyTextBlock(p as TextBlock)); public static readonly Command CopyTextBlockCommand = new Command(p =>
{
var textBlock = p as TextBlock;
if (textBlock == null)
return;
if (textBlock.Inlines is { Count: > 0 } inlines)
CopyText(inlines.Text);
else if (!string.IsNullOrEmpty(textBlock.Text))
CopyText(textBlock.Text);
});
} }
} }

View file

@ -46,11 +46,9 @@ namespace SourceGit
[JsonSerializable(typeof(Models.ExternalToolPaths))] [JsonSerializable(typeof(Models.ExternalToolPaths))]
[JsonSerializable(typeof(Models.InteractiveRebaseJobCollection))] [JsonSerializable(typeof(Models.InteractiveRebaseJobCollection))]
[JsonSerializable(typeof(Models.JetBrainsState))] [JsonSerializable(typeof(Models.JetBrainsState))]
[JsonSerializable(typeof(Models.OpenAIChatRequest))]
[JsonSerializable(typeof(Models.OpenAIChatResponse))]
[JsonSerializable(typeof(Models.ThemeOverrides))] [JsonSerializable(typeof(Models.ThemeOverrides))]
[JsonSerializable(typeof(Models.Version))] [JsonSerializable(typeof(Models.Version))]
[JsonSerializable(typeof(Models.RepositorySettings))] [JsonSerializable(typeof(Models.RepositorySettings))]
[JsonSerializable(typeof(ViewModels.Preference))] [JsonSerializable(typeof(ViewModels.Preferences))]
internal partial class JsonCodeGen : JsonSerializerContext { } internal partial class JsonCodeGen : JsonSerializerContext { }
} }

View file

@ -34,9 +34,9 @@
<NativeMenu> <NativeMenu>
<NativeMenuItem Header="{DynamicResource Text.About.Menu}" Command="{x:Static s:App.OpenAboutCommand}"/> <NativeMenuItem Header="{DynamicResource Text.About.Menu}" Command="{x:Static s:App.OpenAboutCommand}"/>
<NativeMenuItem Header="{DynamicResource Text.Hotkeys}" Command="{x:Static s:App.OpenHotkeysCommand}"/> <NativeMenuItem Header="{DynamicResource Text.Hotkeys}" Command="{x:Static s:App.OpenHotkeysCommand}"/>
<NativeMenuItem Header="{DynamicResource Text.SelfUpdate}" Command="{x:Static s:App.CheckForUpdateCommand}"/> <NativeMenuItem Header="{DynamicResource Text.SelfUpdate}" Command="{x:Static s:App.CheckForUpdateCommand}" IsVisible="{x:Static s:App.IsCheckForUpdateCommandVisible}"/>
<NativeMenuItemSeparator/> <NativeMenuItemSeparator/>
<NativeMenuItem Header="{DynamicResource Text.Preference}" Command="{x:Static s:App.OpenPreferenceCommand}" Gesture="⌘+,"/> <NativeMenuItem Header="{DynamicResource Text.Preferences}" Command="{x:Static s:App.OpenPreferencesCommand}" Gesture="⌘+,"/>
<NativeMenuItem Header="{DynamicResource Text.OpenAppDataDir}" Command="{x:Static s:App.OpenAppDataDirCommand}"/> <NativeMenuItem Header="{DynamicResource Text.OpenAppDataDir}" Command="{x:Static s:App.OpenAppDataDirCommand}"/>
<NativeMenuItemSeparator/> <NativeMenuItemSeparator/>
<NativeMenuItem Header="{DynamicResource Text.Quit}" Command="{x:Static s:App.QuitCommand}" Gesture="⌘+Q"/> <NativeMenuItem Header="{DynamicResource Text.Quit}" Command="{x:Static s:App.QuitCommand}" Gesture="⌘+Q"/>

View file

@ -1,10 +1,12 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.IO; using System.IO;
using System.Net.Http; using System.Net.Http;
using System.Reflection; using System.Reflection;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia; using Avalonia;
@ -22,6 +24,7 @@ namespace SourceGit
{ {
public partial class App : Application public partial class App : Application
{ {
#region App Entry Point
[STAThread] [STAThread]
public static void Main(string[] args) public static void Main(string[] args)
{ {
@ -34,15 +37,14 @@ namespace SourceGit
TaskScheduler.UnobservedTaskException += (_, e) => TaskScheduler.UnobservedTaskException += (_, e) =>
{ {
LogException(e.Exception);
e.SetObserved(); e.SetObserved();
}; };
try try
{ {
if (TryLaunchedAsRebaseTodoEditor(args, out int exitTodo)) if (TryLaunchAsRebaseTodoEditor(args, out int exitTodo))
Environment.Exit(exitTodo); Environment.Exit(exitTodo);
else if (TryLaunchedAsRebaseMessageEditor(args, out int exitMessage)) else if (TryLaunchAsRebaseMessageEditor(args, out int exitMessage))
Environment.Exit(exitMessage); Environment.Exit(exitMessage);
else else
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
@ -75,34 +77,33 @@ namespace SourceGit
return builder; return builder;
} }
public override void Initialize() private static void LogException(Exception ex)
{ {
AvaloniaXamlLoader.Load(this); if (ex == null)
return;
var pref = ViewModels.Preference.Instance; var builder = new StringBuilder();
pref.PropertyChanged += (_, _) => pref.Save(); builder.Append($"Crash::: {ex.GetType().FullName}: {ex.Message}\n\n");
builder.Append("----------------------------\n");
builder.Append($"Version: {Assembly.GetExecutingAssembly().GetName().Version}\n");
builder.Append($"OS: {Environment.OSVersion}\n");
builder.Append($"Framework: {AppDomain.CurrentDomain.SetupInformation.TargetFrameworkName}\n");
builder.Append($"Source: {ex.Source}\n");
builder.Append($"Thread Name: {Thread.CurrentThread.Name ?? "Unnamed"}\n");
builder.Append($"User: {Environment.UserName}\n");
builder.Append($"App Start Time: {Process.GetCurrentProcess().StartTime}\n");
builder.Append($"Exception Time: {DateTime.Now}\n");
builder.Append($"Memory Usage: {Process.GetCurrentProcess().PrivateMemorySize64 / 1024 / 1024} MB\n");
builder.Append($"---------------------------\n\n");
builder.Append(ex);
SetLocale(pref.Locale); var time = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss");
SetTheme(pref.Theme, pref.ThemeOverrides); var file = Path.Combine(Native.OS.DataDir, $"crash_{time}.log");
SetFonts(pref.DefaultFontFamily, pref.MonospaceFontFamily, pref.OnlyUseMonoFontInEditor); File.WriteAllText(file, builder.ToString());
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
BindingPlugins.DataValidators.RemoveAt(0);
if (TryLaunchedAsCoreEditor(desktop))
return;
if (TryLaunchedAsAskpass(desktop))
return;
TryLaunchedAsNormal(desktop);
}
} }
#endregion
#region Utility Functions
public static void OpenDialog(Window window) public static void OpenDialog(Window window)
{ {
if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner }) if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
@ -204,6 +205,9 @@ namespace SourceGit
app._fontsOverrides = null; app._fontsOverrides = null;
} }
defaultFont = app.FixFontFamilyName(defaultFont);
monospaceFont = app.FixFontFamilyName(monospaceFont);
var resDic = new ResourceDictionary(); var resDic = new ResourceDictionary();
if (!string.IsNullOrEmpty(defaultFont)) if (!string.IsNullOrEmpty(defaultFont))
resDic.Add("Fonts.Default", new FontFamily(defaultFont)); resDic.Add("Fonts.Default", new FontFamily(defaultFont));
@ -304,21 +308,6 @@ namespace SourceGit
return Current is App app ? app._launcher : null; return Current is App app ? app._launcher : null;
} }
public static ViewModels.Repository FindOpenedRepository(string repoPath)
{
if (Current is App app && app._launcher != null)
{
foreach (var page in app._launcher.Pages)
{
var id = page.Node.Id.Replace("\\", "/");
if (id == repoPath && page.Data is ViewModels.Repository repo)
return repo;
}
}
return null;
}
public static void Quit(int exitCode) public static void Quit(int exitCode)
{ {
if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
@ -331,94 +320,39 @@ namespace SourceGit
Environment.Exit(exitCode); Environment.Exit(exitCode);
} }
} }
#endregion
private static void CopyTextBlock(TextBlock textBlock) #region Overrides
public override void Initialize()
{ {
if (textBlock == null) AvaloniaXamlLoader.Load(this);
return;
if (textBlock.Inlines is { Count: > 0 } inlines) var pref = ViewModels.Preferences.Instance;
CopyText(inlines.Text); pref.PropertyChanged += (_, _) => pref.Save();
else if (!string.IsNullOrEmpty(textBlock.Text))
CopyText(textBlock.Text); SetLocale(pref.Locale);
SetTheme(pref.Theme, pref.ThemeOverrides);
SetFonts(pref.DefaultFontFamily, pref.MonospaceFontFamily, pref.OnlyUseMonoFontInEditor);
} }
private static void LogException(Exception ex) public override void OnFrameworkInitializationCompleted()
{ {
if (ex == null) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
return;
var builder = new StringBuilder();
builder.Append($"Crash::: {ex.GetType().FullName}: {ex.Message}\n\n");
builder.Append("----------------------------\n");
builder.Append($"Version: {Assembly.GetExecutingAssembly().GetName().Version}\n");
builder.Append($"OS: {Environment.OSVersion.ToString()}\n");
builder.Append($"Framework: {AppDomain.CurrentDomain.SetupInformation.TargetFrameworkName}\n");
builder.Append($"Source: {ex.Source}\n");
builder.Append($"---------------------------\n\n");
builder.Append(ex.StackTrace);
while (ex.InnerException != null)
{ {
ex = ex.InnerException; BindingPlugins.DataValidators.RemoveAt(0);
builder.Append($"\n\nInnerException::: {ex.GetType().FullName}: {ex.Message}\n");
builder.Append(ex.StackTrace); if (TryLaunchAsCoreEditor(desktop))
return;
if (TryLaunchAsAskpass(desktop))
return;
TryLaunchAsNormal(desktop);
} }
var time = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss");
var file = Path.Combine(Native.OS.DataDir, $"crash_{time}.log");
File.WriteAllText(file, builder.ToString());
} }
#endregion
private static void Check4Update(bool manually = false) private static bool TryLaunchAsRebaseTodoEditor(string[] args, out int exitCode)
{
Task.Run(async () =>
{
try
{
// Fetch lastest release information.
var client = new HttpClient() { Timeout = TimeSpan.FromSeconds(5) };
var data = await client.GetStringAsync("https://sourcegit-scm.github.io/data/version.json");
// Parse json into Models.Version.
var ver = JsonSerializer.Deserialize(data, JsonCodeGen.Default.Version);
if (ver == null)
return;
// Check if already up-to-date.
if (!ver.IsNewVersion)
{
if (manually)
ShowSelfUpdateResult(new Models.AlreadyUpToDate());
return;
}
// Should not check ignored tag if this is called manually.
if (!manually)
{
var pref = ViewModels.Preference.Instance;
if (ver.TagName == pref.IgnoreUpdateTag)
return;
}
ShowSelfUpdateResult(ver);
}
catch (Exception e)
{
if (manually)
ShowSelfUpdateResult(e);
}
});
}
private static void ShowSelfUpdateResult(object data)
{
Dispatcher.UIThread.Post(() =>
{
OpenDialog(new Views.SelfUpdate() { DataContext = new ViewModels.SelfUpdate() { Data = data } });
});
}
private static bool TryLaunchedAsRebaseTodoEditor(string[] args, out int exitCode)
{ {
exitCode = -1; exitCode = -1;
@ -471,7 +405,7 @@ namespace SourceGit
return true; return true;
} }
private static bool TryLaunchedAsRebaseMessageEditor(string[] args, out int exitCode) private static bool TryLaunchAsRebaseMessageEditor(string[] args, out int exitCode)
{ {
exitCode = -1; exitCode = -1;
@ -505,7 +439,7 @@ namespace SourceGit
return true; return true;
} }
private bool TryLaunchedAsCoreEditor(IClassicDesktopStyleApplicationLifetime desktop) private bool TryLaunchAsCoreEditor(IClassicDesktopStyleApplicationLifetime desktop)
{ {
var args = desktop.Args; var args = desktop.Args;
if (args == null || args.Length <= 1 || !args[0].Equals("--core-editor", StringComparison.Ordinal)) if (args == null || args.Length <= 1 || !args[0].Equals("--core-editor", StringComparison.Ordinal))
@ -513,14 +447,18 @@ namespace SourceGit
var file = args[1]; var file = args[1];
if (!File.Exists(file)) if (!File.Exists(file))
{
desktop.Shutdown(-1); desktop.Shutdown(-1);
else return true;
desktop.MainWindow = new Views.StandaloneCommitMessageEditor(file); }
var editor = new Views.StandaloneCommitMessageEditor();
editor.SetFile(file);
desktop.MainWindow = editor;
return true; return true;
} }
private bool TryLaunchedAsAskpass(IClassicDesktopStyleApplicationLifetime desktop) private bool TryLaunchAsAskpass(IClassicDesktopStyleApplicationLifetime desktop)
{ {
var launchAsAskpass = Environment.GetEnvironmentVariable("SOURCEGIT_LAUNCH_AS_ASKPASS"); var launchAsAskpass = Environment.GetEnvironmentVariable("SOURCEGIT_LAUNCH_AS_ASKPASS");
if (launchAsAskpass is not "TRUE") if (launchAsAskpass is not "TRUE")
@ -529,14 +467,16 @@ namespace SourceGit
var args = desktop.Args; var args = desktop.Args;
if (args?.Length > 0) if (args?.Length > 0)
{ {
desktop.MainWindow = new Views.Askpass(args[0]); var askpass = new Views.Askpass();
askpass.TxtDescription.Text = args[0];
desktop.MainWindow = askpass;
return true; return true;
} }
return false; return false;
} }
private void TryLaunchedAsNormal(IClassicDesktopStyleApplicationLifetime desktop) private void TryLaunchAsNormal(IClassicDesktopStyleApplicationLifetime desktop)
{ {
Native.OS.SetupEnternalTools(); Native.OS.SetupEnternalTools();
Models.AvatarManager.Instance.Start(); Models.AvatarManager.Instance.Start();
@ -548,9 +488,96 @@ namespace SourceGit
_launcher = new ViewModels.Launcher(startupRepo); _launcher = new ViewModels.Launcher(startupRepo);
desktop.MainWindow = new Views.Launcher() { DataContext = _launcher }; desktop.MainWindow = new Views.Launcher() { DataContext = _launcher };
var pref = ViewModels.Preference.Instance; #if !DISABLE_UPDATE_DETECTION
var pref = ViewModels.Preferences.Instance;
if (pref.ShouldCheck4UpdateOnStartup()) if (pref.ShouldCheck4UpdateOnStartup())
Check4Update(); Check4Update();
#endif
}
private void Check4Update(bool manually = false)
{
Task.Run(async () =>
{
try
{
// Fetch latest release information.
var client = new HttpClient() { Timeout = TimeSpan.FromSeconds(5) };
var data = await client.GetStringAsync("https://sourcegit-scm.github.io/data/version.json");
// Parse JSON into Models.Version.
var ver = JsonSerializer.Deserialize(data, JsonCodeGen.Default.Version);
if (ver == null)
return;
// Check if already up-to-date.
if (!ver.IsNewVersion)
{
if (manually)
ShowSelfUpdateResult(new Models.AlreadyUpToDate());
return;
}
// Should not check ignored tag if this is called manually.
if (!manually)
{
var pref = ViewModels.Preferences.Instance;
if (ver.TagName == pref.IgnoreUpdateTag)
return;
}
ShowSelfUpdateResult(ver);
}
catch (Exception e)
{
if (manually)
ShowSelfUpdateResult(new Models.SelfUpdateFailed(e));
}
});
}
private void ShowSelfUpdateResult(object data)
{
Dispatcher.UIThread.Post(() =>
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
{
var dialog = new Views.SelfUpdate() { DataContext = new ViewModels.SelfUpdate() { Data = data } };
dialog.ShowDialog(owner);
}
});
}
private string FixFontFamilyName(string input)
{
if (string.IsNullOrEmpty(input))
return string.Empty;
var parts = input.Split(',');
var trimmed = new List<string>();
foreach (var part in parts)
{
var t = part.Trim();
if (string.IsNullOrEmpty(t))
continue;
// Collapse multiple spaces into single space
var prevChar = '\0';
var sb = new StringBuilder();
foreach (var c in t)
{
if (c == ' ' && prevChar == ' ')
continue;
sb.Append(c);
prevChar = c;
}
trimmed.Add(sb.ToString());
}
return trimmed.Count > 0 ? string.Join(',', trimmed) : string.Empty;
} }
private ViewModels.Launcher _launcher = null; private ViewModels.Launcher _launcher = null;

View file

@ -12,7 +12,7 @@ namespace SourceGit.Commands
Args = includeUntracked ? "add ." : "add -u ."; Args = includeUntracked ? "add ." : "add -u .";
} }
public Add(string repo, List<Models.Change> changes) public Add(string repo, List<string> changes)
{ {
WorkingDirectory = repo; WorkingDirectory = repo;
Context = repo; Context = repo;
@ -22,10 +22,17 @@ namespace SourceGit.Commands
foreach (var c in changes) foreach (var c in changes)
{ {
builder.Append(" \""); builder.Append(" \"");
builder.Append(c.Path); builder.Append(c);
builder.Append("\""); builder.Append("\"");
} }
Args = builder.ToString(); Args = builder.ToString();
} }
public Add(string repo, string pathspecFromFile)
{
WorkingDirectory = repo;
Context = repo;
Args = $"add --pathspec-from-file=\"{pathspecFromFile}\"";
}
} }
} }

View file

@ -65,7 +65,7 @@ namespace SourceGit.Commands
var commit = match.Groups[1].Value; var commit = match.Groups[1].Value;
var author = match.Groups[2].Value; var author = match.Groups[2].Value;
var timestamp = int.Parse(match.Groups[3].Value); var timestamp = int.Parse(match.Groups[3].Value);
var when = DateTime.UnixEpoch.AddSeconds(timestamp).ToLocalTime().ToString("yyyy/MM/dd"); var when = DateTime.UnixEpoch.AddSeconds(timestamp).ToLocalTime().ToString(_dateFormat);
var info = new Models.BlameLineInfo() var info = new Models.BlameLineInfo()
{ {
@ -87,6 +87,7 @@ namespace SourceGit.Commands
private readonly Models.BlameData _result = new Models.BlameData(); private readonly Models.BlameData _result = new Models.BlameData();
private readonly StringBuilder _content = new StringBuilder(); private readonly StringBuilder _content = new StringBuilder();
private readonly string _dateFormat = Models.DateTimeFormat.Actived.DateOnly;
private string _lastSHA = string.Empty; private string _lastSHA = string.Empty;
private bool _needUnifyCommitSHA = false; private bool _needUnifyCommitSHA = false;
private int _minSHALen = 64; private int _minSHALen = 64;

View file

@ -54,21 +54,14 @@
public static bool DeleteRemote(string repo, string remote, string name) public static bool DeleteRemote(string repo, string remote, string name)
{ {
bool exists = new Remote(repo).HasBranch(remote, name);
if (exists)
return new Push(repo, remote, $"refs/heads/{name}", true).Exec();
var cmd = new Command(); var cmd = new Command();
cmd.WorkingDirectory = repo; cmd.WorkingDirectory = repo;
cmd.Context = repo; cmd.Context = repo;
cmd.Args = $"branch -D -r {remote}/{name}";
bool exists = new Remote(repo).HasBranch(remote, name);
if (exists)
{
cmd.SSHKey = new Config(repo).Get($"remote.{remote}.sshkey");
cmd.Args = $"push {remote} --delete {name}";
}
else
{
cmd.Args = $"branch -D -r {remote}/{name}";
}
return cmd.Exec(); return cmd.Exec();
} }
} }

View file

@ -12,7 +12,7 @@ namespace SourceGit.Commands
WorkingDirectory = path; WorkingDirectory = path;
TraitErrorAsOutput = true; TraitErrorAsOutput = true;
SSHKey = sshKey; SSHKey = sshKey;
Args = "clone --progress --verbose --recurse-submodules "; Args = "clone --progress --verbose ";
if (!string.IsNullOrEmpty(extraArgs)) if (!string.IsNullOrEmpty(extraArgs))
Args += $"{extraArgs} "; Args += $"{extraArgs} ";

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading;
using Avalonia.Threading; using Avalonia.Threading;
@ -10,11 +11,6 @@ namespace SourceGit.Commands
{ {
public partial class Command public partial class Command
{ {
public class CancelToken
{
public bool Requested { get; set; } = false;
}
public class ReadToEndResult public class ReadToEndResult
{ {
public bool IsSuccess { get; set; } = false; public bool IsSuccess { get; set; } = false;
@ -30,7 +26,7 @@ namespace SourceGit.Commands
} }
public string Context { get; set; } = string.Empty; public string Context { get; set; } = string.Empty;
public CancelToken Cancel { get; set; } = null; public CancellationToken CancellationToken { get; set; } = CancellationToken.None;
public string WorkingDirectory { get; set; } = null; public string WorkingDirectory { get; set; } = null;
public EditorType Editor { get; set; } = EditorType.CoreEditor; // Only used in Exec() mode public EditorType Editor { get; set; } = EditorType.CoreEditor; // Only used in Exec() mode
public string SSHKey { get; set; } = string.Empty; public string SSHKey { get; set; } = string.Empty;
@ -43,36 +39,15 @@ namespace SourceGit.Commands
var start = CreateGitStartInfo(); var start = CreateGitStartInfo();
var errs = new List<string>(); var errs = new List<string>();
var proc = new Process() { StartInfo = start }; var proc = new Process() { StartInfo = start };
var isCancelled = false;
proc.OutputDataReceived += (_, e) => proc.OutputDataReceived += (_, e) =>
{ {
if (Cancel != null && Cancel.Requested)
{
isCancelled = true;
proc.CancelErrorRead();
proc.CancelOutputRead();
if (!proc.HasExited)
proc.Kill(true);
return;
}
if (e.Data != null) if (e.Data != null)
OnReadline(e.Data); OnReadline(e.Data);
}; };
proc.ErrorDataReceived += (_, e) => proc.ErrorDataReceived += (_, e) =>
{ {
if (Cancel != null && Cancel.Requested)
{
isCancelled = true;
proc.CancelErrorRead();
proc.CancelOutputRead();
if (!proc.HasExited)
proc.Kill(true);
return;
}
if (string.IsNullOrEmpty(e.Data)) if (string.IsNullOrEmpty(e.Data))
{ {
errs.Add(string.Empty); errs.Add(string.Empty);
@ -97,9 +72,25 @@ namespace SourceGit.Commands
errs.Add(e.Data); errs.Add(e.Data);
}; };
var dummy = null as Process;
var dummyProcLock = new object();
try try
{ {
proc.Start(); proc.Start();
// It not safe, please only use `CancellationToken` in readonly commands.
if (CancellationToken.CanBeCanceled)
{
dummy = proc;
CancellationToken.Register(() =>
{
lock (dummyProcLock)
{
if (dummy is { HasExited: false })
dummy.Kill();
}
});
}
} }
catch (Exception e) catch (Exception e)
{ {
@ -113,15 +104,23 @@ namespace SourceGit.Commands
proc.BeginErrorReadLine(); proc.BeginErrorReadLine();
proc.WaitForExit(); proc.WaitForExit();
if (dummy != null)
{
lock (dummyProcLock)
{
dummy = null;
}
}
int exitCode = proc.ExitCode; int exitCode = proc.ExitCode;
proc.Close(); proc.Close();
if (!isCancelled && exitCode != 0) if (!CancellationToken.IsCancellationRequested && exitCode != 0)
{ {
if (RaiseError) if (RaiseError)
{ {
var errMsg = string.Join("\n", errs); var errMsg = string.Join("\n", errs).Trim();
if (!string.IsNullOrWhiteSpace(errMsg)) if (!string.IsNullOrEmpty(errMsg))
Dispatcher.UIThread.Post(() => App.RaiseException(Context, errMsg)); Dispatcher.UIThread.Post(() => App.RaiseException(Context, errMsg));
} }
@ -192,13 +191,12 @@ namespace SourceGit.Commands
if (!start.Environment.ContainsKey("GIT_SSH_COMMAND") && !string.IsNullOrEmpty(SSHKey)) if (!start.Environment.ContainsKey("GIT_SSH_COMMAND") && !string.IsNullOrEmpty(SSHKey))
start.Environment.Add("GIT_SSH_COMMAND", $"ssh -i '{SSHKey}'"); start.Environment.Add("GIT_SSH_COMMAND", $"ssh -i '{SSHKey}'");
// Force using en_US.UTF-8 locale to avoid GCM crash // Force using en_US.UTF-8 locale
if (OperatingSystem.IsLinux()) if (OperatingSystem.IsLinux())
start.Environment.Add("LANG", "en_US.UTF-8"); {
start.Environment.Add("LANG", "C");
// Fix macOS `PATH` env start.Environment.Add("LC_ALL", "C");
if (OperatingSystem.IsMacOS() && !string.IsNullOrEmpty(Native.OS.CustomPathEnv)) }
start.Environment.Add("PATH", Native.OS.CustomPathEnv);
// Force using this app as git editor. // Force using this app as git editor.
switch (Editor) switch (Editor)

View file

@ -6,7 +6,7 @@ namespace SourceGit.Commands
{ {
public partial class CompareRevisions : Command public partial class CompareRevisions : Command
{ {
[GeneratedRegex(@"^(\s?[\w\?]{1,4})\s+(.+)$")] [GeneratedRegex(@"^([MADRC])\s+(.+)$")]
private static partial Regex REG_FORMAT(); private static partial Regex REG_FORMAT();
public CompareRevisions(string repo, string start, string end) public CompareRevisions(string repo, string start, string end)
@ -18,6 +18,15 @@ namespace SourceGit.Commands
Args = $"diff --name-status {based} {end}"; Args = $"diff --name-status {based} {end}";
} }
public CompareRevisions(string repo, string start, string end, string path)
{
WorkingDirectory = repo;
Context = repo;
var based = string.IsNullOrEmpty(start) ? "-R" : start;
Args = $"diff --name-status {based} {end} -- \"{path}\"";
}
public List<Models.Change> Result() public List<Models.Change> Result()
{ {
Exec(); Exec();

View file

@ -29,7 +29,7 @@ namespace SourceGit.Commands
var rs = new Dictionary<string, string>(); var rs = new Dictionary<string, string>();
if (output.IsSuccess) if (output.IsSuccess)
{ {
var lines = output.StdOut.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); var lines = output.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines) foreach (var line in lines)
{ {
var idx = line.IndexOf('=', StringComparison.Ordinal); var idx = line.IndexOf('=', StringComparison.Ordinal);

View file

@ -8,7 +8,7 @@ namespace SourceGit.Commands
{ {
WorkingDirectory = repo; WorkingDirectory = repo;
Context = repo; Context = repo;
Args = "status -uno --ignore-submodules=dirty --porcelain"; Args = "--no-optional-locks status -uno --ignore-submodules=dirty --porcelain";
} }
public int Result() public int Result()
@ -16,7 +16,7 @@ namespace SourceGit.Commands
var rs = ReadToEnd(); var rs = ReadToEnd();
if (rs.IsSuccess) if (rs.IsSuccess)
{ {
var lines = rs.StdOut.Split('\n', StringSplitOptions.RemoveEmptyEntries); var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
return lines.Length; return lines.Length;
} }

View file

@ -68,6 +68,18 @@ namespace SourceGit.Commands
return; return;
} }
if (line.StartsWith("deleted file mode ", StringComparison.Ordinal))
{
_result.OldMode = line.Substring(18);
return;
}
if (line.StartsWith("new file mode ", StringComparison.Ordinal))
{
_result.NewMode = line.Substring(14);
return;
}
if (_result.IsBinary) if (_result.IsBinary)
return; return;

View file

@ -8,7 +8,26 @@ namespace SourceGit.Commands
{ {
public static class ExecuteCustomAction public static class ExecuteCustomAction
{ {
public static void Run(string repo, string file, string args, Action<string> outputHandler) public static void Run(string repo, string file, string args)
{
var start = new ProcessStartInfo();
start.FileName = file;
start.Arguments = args;
start.UseShellExecute = false;
start.CreateNoWindow = true;
start.WorkingDirectory = repo;
try
{
Process.Start(start);
}
catch (Exception e)
{
Dispatcher.UIThread.Invoke(() => App.RaiseException(repo, e.Message));
}
}
public static void RunAndWait(string repo, string file, string args, Action<string> outputHandler)
{ {
var start = new ProcessStartInfo(); var start = new ProcessStartInfo();
start.FileName = file; start.FileName = file;
@ -21,14 +40,6 @@ namespace SourceGit.Commands
start.StandardErrorEncoding = Encoding.UTF8; start.StandardErrorEncoding = Encoding.UTF8;
start.WorkingDirectory = repo; start.WorkingDirectory = repo;
// Force using en_US.UTF-8 locale to avoid GCM crash
if (OperatingSystem.IsLinux())
start.Environment.Add("LANG", "en_US.UTF-8");
// Fix macOS `PATH` env
if (OperatingSystem.IsMacOS() && !string.IsNullOrEmpty(Native.OS.CustomPathEnv))
start.Environment.Add("PATH", Native.OS.CustomPathEnv);
var proc = new Process() { StartInfo = start }; var proc = new Process() { StartInfo = start };
var builder = new StringBuilder(); var builder = new StringBuilder();
@ -53,26 +64,21 @@ namespace SourceGit.Commands
proc.BeginOutputReadLine(); proc.BeginOutputReadLine();
proc.BeginErrorReadLine(); proc.BeginErrorReadLine();
proc.WaitForExit(); proc.WaitForExit();
var exitCode = proc.ExitCode;
if (exitCode != 0)
{
var errMsg = builder.ToString().Trim();
if (!string.IsNullOrEmpty(errMsg))
Dispatcher.UIThread.Invoke(() => App.RaiseException(repo, errMsg));
}
} }
catch (Exception e) catch (Exception e)
{ {
Dispatcher.UIThread.Invoke(() => Dispatcher.UIThread.Invoke(() => App.RaiseException(repo, e.Message));
{
App.RaiseException(repo, e.Message);
});
} }
var exitCode = proc.ExitCode;
proc.Close(); proc.Close();
if (exitCode != 0)
{
var errMsg = builder.ToString();
Dispatcher.UIThread.Invoke(() =>
{
App.RaiseException(repo, errMsg);
});
}
} }
} }
} }

View file

@ -4,7 +4,7 @@ namespace SourceGit.Commands
{ {
public class Fetch : Command public class Fetch : Command
{ {
public Fetch(string repo, string remote, bool noTags, bool prune, bool force, Action<string> outputHandler) public Fetch(string repo, string remote, bool noTags, bool force, Action<string> outputHandler)
{ {
_outputHandler = outputHandler; _outputHandler = outputHandler;
WorkingDirectory = repo; WorkingDirectory = repo;
@ -21,9 +21,6 @@ namespace SourceGit.Commands
if (force) if (force)
Args += "--force "; Args += "--force ";
if (prune)
Args += "--prune ";
Args += remote; Args += remote;
} }

View file

@ -3,6 +3,8 @@ using System.Collections.Generic;
using System.Text; using System.Text;
using System.Threading; using System.Threading;
using Avalonia.Threading;
namespace SourceGit.Commands namespace SourceGit.Commands
{ {
/// <summary> /// <summary>
@ -20,82 +22,78 @@ namespace SourceGit.Commands
} }
} }
public GenerateCommitMessage(Models.OpenAIService service, string repo, List<Models.Change> changes, CancellationToken cancelToken, Action<string> onProgress) public GenerateCommitMessage(Models.OpenAIService service, string repo, List<Models.Change> changes, CancellationToken cancelToken, Action<string> onResponse)
{ {
_service = service; _service = service;
_repo = repo; _repo = repo;
_changes = changes; _changes = changes;
_cancelToken = cancelToken; _cancelToken = cancelToken;
_onProgress = onProgress; _onResponse = onResponse;
} }
public string Result() public void Exec()
{ {
try try
{ {
var summarybuilder = new StringBuilder(); _onResponse?.Invoke("Waiting for pre-file analyzing to completed...\n\n");
var bodyBuilder = new StringBuilder();
var responseBuilder = new StringBuilder();
var summaryBuilder = new StringBuilder();
foreach (var change in _changes) foreach (var change in _changes)
{ {
if (_cancelToken.IsCancellationRequested) if (_cancelToken.IsCancellationRequested)
return ""; return;
_onProgress?.Invoke($"Analyzing {change.Path}..."); responseBuilder.Append("- ");
summaryBuilder.Append("- ");
var summary = GenerateChangeSummary(change); var rs = new GetDiffContent(_repo, new Models.DiffOption(change, false)).ReadToEnd();
summarybuilder.Append("- "); if (rs.IsSuccess)
summarybuilder.Append(summary); {
summarybuilder.Append("(file: "); _service.Chat(
summarybuilder.Append(change.Path); _service.AnalyzeDiffPrompt,
summarybuilder.Append(")"); $"Here is the `git diff` output: {rs.StdOut}",
summarybuilder.AppendLine(); _cancelToken,
update =>
{
responseBuilder.Append(update);
summaryBuilder.Append(update);
bodyBuilder.Append("- "); _onResponse?.Invoke($"Waiting for pre-file analyzing to completed...\n\n{responseBuilder}");
bodyBuilder.Append(summary); });
bodyBuilder.AppendLine(); }
responseBuilder.Append("\n");
summaryBuilder.Append("(file: ");
summaryBuilder.Append(change.Path);
summaryBuilder.Append(")\n");
} }
if (_cancelToken.IsCancellationRequested) if (_cancelToken.IsCancellationRequested)
return ""; return;
_onProgress?.Invoke($"Generating commit message..."); var responseBody = responseBuilder.ToString();
var subjectBuilder = new StringBuilder();
var body = bodyBuilder.ToString(); _service.Chat(
var subject = GenerateSubject(summarybuilder.ToString()); _service.GenerateSubjectPrompt,
return string.Format("{0}\n\n{1}", subject, body); $"Here are the summaries changes:\n{summaryBuilder}",
_cancelToken,
update =>
{
subjectBuilder.Append(update);
_onResponse?.Invoke($"{subjectBuilder}\n\n{responseBody}");
});
} }
catch (Exception e) catch (Exception e)
{ {
App.RaiseException(_repo, $"Failed to generate commit message: {e}"); Dispatcher.UIThread.Post(() => App.RaiseException(_repo, $"Failed to generate commit message: {e}"));
return "";
} }
} }
private string GenerateChangeSummary(Models.Change change)
{
var rs = new GetDiffContent(_repo, new Models.DiffOption(change, false)).ReadToEnd();
var diff = rs.IsSuccess ? rs.StdOut : "unknown change";
var rsp = _service.Chat(_service.AnalyzeDiffPrompt, $"Here is the `git diff` output: {diff}", _cancelToken);
if (rsp != null && rsp.Choices.Count > 0)
return rsp.Choices[0].Message.Content;
return string.Empty;
}
private string GenerateSubject(string summary)
{
var rsp = _service.Chat(_service.GenerateSubjectPrompt, $"Here are the summaries changes:\n{summary}", _cancelToken);
if (rsp != null && rsp.Choices.Count > 0)
return rsp.Choices[0].Message.Content;
return string.Empty;
}
private Models.OpenAIService _service; private Models.OpenAIService _service;
private string _repo; private string _repo;
private List<Models.Change> _changes; private List<Models.Change> _changes;
private CancellationToken _cancelToken; private CancellationToken _cancelToken;
private Action<string> _onProgress; private Action<string> _onResponse;
} }
} }

View file

@ -0,0 +1,24 @@
using System.IO;
namespace SourceGit.Commands
{
public class IsBareRepository : Command
{
public IsBareRepository(string path)
{
WorkingDirectory = path;
Args = "rev-parse --is-bare-repository";
}
public bool Result()
{
if (!Directory.Exists(Path.Combine(WorkingDirectory, "refs")) ||
!Directory.Exists(Path.Combine(WorkingDirectory, "objects")) ||
!File.Exists(Path.Combine(WorkingDirectory, "HEAD")))
return false;
var rs = ReadToEnd();
return rs.IsSuccess && rs.StdOut.Trim() == "true";
}
}
}

View file

@ -0,0 +1,17 @@
namespace SourceGit.Commands
{
public class IsCommitSHA : Command
{
public IsCommitSHA(string repo, string hash)
{
WorkingDirectory = repo;
Args = $"cat-file -t {hash}";
}
public bool Result()
{
var rs = ReadToEnd();
return rs.IsSuccess && rs.StdOut.Trim().Equals("commit");
}
}
}

View file

@ -7,7 +7,7 @@ namespace SourceGit.Commands
{ {
public partial class LFS public partial class LFS
{ {
[GeneratedRegex(@"^(.+)\s+(\w+)\s+\w+:(\d+)$")] [GeneratedRegex(@"^(.+)\s+([\w.]+)\s+\w+:(\d+)$")]
private static partial Regex REG_LOCK(); private static partial Regex REG_LOCK();
class SubCmd : Command class SubCmd : Command
@ -82,7 +82,7 @@ namespace SourceGit.Commands
var rs = cmd.ReadToEnd(); var rs = cmd.ReadToEnd();
if (rs.IsSuccess) if (rs.IsSuccess)
{ {
var lines = rs.StdOut.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines) foreach (var line in lines)
{ {
var match = REG_LOCK().Match(line); var match = REG_LOCK().Match(line);

View file

@ -4,21 +4,20 @@ namespace SourceGit.Commands
{ {
public class Pull : Command public class Pull : Command
{ {
public Pull(string repo, string remote, string branch, bool useRebase, bool noTags, bool prune, Action<string> outputHandler) public Pull(string repo, string remote, string branch, bool useRebase, bool noTags, Action<string> outputHandler)
{ {
_outputHandler = outputHandler; _outputHandler = outputHandler;
WorkingDirectory = repo; WorkingDirectory = repo;
Context = repo; Context = repo;
TraitErrorAsOutput = true; TraitErrorAsOutput = true;
SSHKey = new Config(repo).Get($"remote.{remote}.sshkey"); SSHKey = new Config(repo).Get($"remote.{remote}.sshkey");
Args = "pull --verbose --progress --tags "; Args = "pull --verbose --progress ";
if (useRebase) if (useRebase)
Args += "--rebase "; Args += "--rebase=true ";
if (noTags) if (noTags)
Args += "--no-tags "; Args += "--no-tags ";
if (prune)
Args += "--prune ";
Args += $"{remote} {branch}"; Args += $"{remote} {branch}";
} }

View file

@ -26,7 +26,7 @@ namespace SourceGit.Commands
Args += $"{remote} {local}:{remoteBranch}"; Args += $"{remote} {local}:{remoteBranch}";
} }
public Push(string repo, string remote, string tag, bool isDelete) public Push(string repo, string remote, string refname, bool isDelete)
{ {
WorkingDirectory = repo; WorkingDirectory = repo;
Context = repo; Context = repo;
@ -36,7 +36,7 @@ namespace SourceGit.Commands
if (isDelete) if (isDelete)
Args += "--delete "; Args += "--delete ";
Args += $"{remote} refs/tags/{tag}"; Args += $"{remote} {refname}";
} }
protected override void OnReadline(string line) protected override void OnReadline(string line)

View file

@ -24,12 +24,23 @@ namespace SourceGit.Commands
if (!rs.IsSuccess) if (!rs.IsSuccess)
return branches; return branches;
var lines = rs.StdOut.Split('\n', StringSplitOptions.RemoveEmptyEntries); var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
var remoteBranches = new HashSet<string>();
foreach (var line in lines) foreach (var line in lines)
{ {
var b = ParseLine(line); var b = ParseLine(line);
if (b != null) if (b != null)
{
branches.Add(b); branches.Add(b);
if (!b.IsLocal)
remoteBranches.Add(b.FullName);
}
}
foreach (var b in branches)
{
if (b.IsLocal && !string.IsNullOrEmpty(b.Upstream))
b.IsUpsteamGone = !remoteBranches.Contains(b.Upstream);
} }
return branches; return branches;
@ -75,6 +86,7 @@ namespace SourceGit.Commands
branch.Head = parts[1]; branch.Head = parts[1];
branch.IsCurrent = parts[2] == "*"; branch.IsCurrent = parts[2] == "*";
branch.Upstream = parts[3]; branch.Upstream = parts[3];
branch.IsUpsteamGone = false;
if (branch.IsLocal && !string.IsNullOrEmpty(parts[4]) && !parts[4].Equals("=", StringComparison.Ordinal)) if (branch.IsLocal && !string.IsNullOrEmpty(parts[4]) && !parts[4].Equals("=", StringComparison.Ordinal))
branch.TrackStatus = new QueryTrackStatus(WorkingDirectory, branch.Name, branch.Upstream).Result(); branch.TrackStatus = new QueryTrackStatus(WorkingDirectory, branch.Name, branch.Upstream).Result();

View file

@ -9,10 +9,10 @@ namespace SourceGit.Commands
WorkingDirectory = repo; WorkingDirectory = repo;
Context = repo; Context = repo;
_commit = commit; _commit = commit;
Args = $"rev-list -{max} --parents --branches --remotes ^{commit}"; Args = $"rev-list -{max} --parents --branches --remotes --ancestry-path ^{commit}";
} }
public IEnumerable<string> Result() public List<string> Result()
{ {
Exec(); Exec();
return _lines; return _lines;

View file

@ -6,7 +6,7 @@ namespace SourceGit.Commands
{ {
WorkingDirectory = repo; WorkingDirectory = repo;
Context = repo; Context = repo;
Args = $"show --no-show-signature --pretty=format:%B -s {sha}"; Args = $"show --no-show-signature --format=%B -s {sha}";
} }
public string Result() public string Result()

View file

@ -7,7 +7,7 @@
WorkingDirectory = repo; WorkingDirectory = repo;
Context = repo; Context = repo;
const string baseArgs = "show --no-show-signature --pretty=format:\"%G?%n%GS%n%GK\" -s"; const string baseArgs = "show --no-show-signature --format=%G?%n%GS%n%GK -s";
const string fakeSignersFileArg = "-c gpg.ssh.allowedSignersFile=/dev/null"; const string fakeSignersFileArg = "-c gpg.ssh.allowedSignersFile=/dev/null";
Args = $"{(useFakeSignersFile ? fakeSignersFileArg : string.Empty)} {baseArgs} {sha}"; Args = $"{(useFakeSignersFile ? fakeSignersFileArg : string.Empty)} {baseArgs} {sha}";
} }
@ -18,7 +18,7 @@
if (!rs.IsSuccess) if (!rs.IsSuccess)
return null; return null;
var raw = rs.StdOut.Trim(); var raw = rs.StdOut.Trim().ReplaceLineEndings("\n");
if (raw.Length <= 1) if (raw.Length <= 1)
return null; return null;
@ -29,7 +29,6 @@
Signer = lines[1], Signer = lines[1],
Key = lines[2] Key = lines[2]
}; };
} }
} }
} }

View file

@ -6,13 +6,11 @@ namespace SourceGit.Commands
{ {
public class QueryCommits : Command public class QueryCommits : Command
{ {
public QueryCommits(string repo, bool useTopoOrder, string limits, bool needFindHead = true) public QueryCommits(string repo, string limits, bool needFindHead = true)
{ {
var order = useTopoOrder ? "--topo-order" : "--date-order";
WorkingDirectory = repo; WorkingDirectory = repo;
Context = repo; Context = repo;
Args = $"log {order} --no-show-signature --decorate=full --pretty=format:%H%n%P%n%D%n%aN±%aE%n%at%n%cN±%cE%n%ct%n%s {limits}"; Args = $"log --no-show-signature --decorate=full --format=%H%n%P%n%D%n%aN±%aE%n%at%n%cN±%cE%n%ct%n%s {limits}";
_findFirstMerged = needFindHead; _findFirstMerged = needFindHead;
} }
@ -20,9 +18,13 @@ namespace SourceGit.Commands
{ {
string search = onlyCurrentBranch ? string.Empty : "--branches --remotes "; string search = onlyCurrentBranch ? string.Empty : "--branches --remotes ";
if (method == Models.CommitSearchMethod.ByUser) if (method == Models.CommitSearchMethod.ByAuthor)
{ {
search += $"-i --author=\"{filter}\" --committer=\"{filter}\""; search += $"-i --author=\"{filter}\"";
}
else if (method == Models.CommitSearchMethod.ByCommitter)
{
search += $"-i --committer=\"{filter}\"";
} }
else if (method == Models.CommitSearchMethod.ByFile) else if (method == Models.CommitSearchMethod.ByFile)
{ {
@ -33,7 +35,7 @@ namespace SourceGit.Commands
var argsBuilder = new StringBuilder(); var argsBuilder = new StringBuilder();
argsBuilder.Append(search); argsBuilder.Append(search);
var words = filter.Split(new[] { ' ', '\t', '\r' }, StringSplitOptions.RemoveEmptyEntries); var words = filter.Split([' ', '\t', '\r'], StringSplitOptions.RemoveEmptyEntries);
foreach (var word in words) foreach (var word in words)
{ {
var escaped = word.Trim().Replace("\"", "\\\"", StringComparison.Ordinal); var escaped = word.Trim().Replace("\"", "\\\"", StringComparison.Ordinal);
@ -46,7 +48,7 @@ namespace SourceGit.Commands
WorkingDirectory = repo; WorkingDirectory = repo;
Context = repo; Context = repo;
Args = $"log -1000 --date-order --no-show-signature --decorate=full --pretty=format:%H%n%P%n%D%n%aN±%aE%n%at%n%cN±%cE%n%ct%n%s " + search; Args = $"log -1000 --date-order --no-show-signature --decorate=full --format=%H%n%P%n%D%n%aN±%aE%n%at%n%cN±%cE%n%ct%n%s " + search;
_findFirstMerged = false; _findFirstMerged = false;
} }
@ -122,7 +124,7 @@ namespace SourceGit.Commands
Args = $"log --since=\"{_commits[^1].CommitterTimeStr}\" --format=\"%H\""; Args = $"log --since=\"{_commits[^1].CommitterTimeStr}\" --format=\"%H\"";
var rs = ReadToEnd(); var rs = ReadToEnd();
var shas = rs.StdOut.Split('\n', StringSplitOptions.RemoveEmptyEntries); var shas = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
if (shas.Length == 0) if (shas.Length == 0)
return; return;

View file

@ -3,18 +3,18 @@ using System.Collections.Generic;
namespace SourceGit.Commands namespace SourceGit.Commands
{ {
public class QueryCommitsWithFullMessage : Command public class QueryCommitsForInteractiveRebase : Command
{ {
public QueryCommitsWithFullMessage(string repo, string args) public QueryCommitsForInteractiveRebase(string repo, string on)
{ {
_boundary = $"----- BOUNDARY OF COMMIT {Guid.NewGuid()} -----"; _boundary = $"----- BOUNDARY OF COMMIT {Guid.NewGuid()} -----";
WorkingDirectory = repo; WorkingDirectory = repo;
Context = repo; Context = repo;
Args = $"log --date-order --no-show-signature --decorate=full --pretty=format:\"%H%n%P%n%D%n%aN±%aE%n%at%n%cN±%cE%n%ct%n%B%n{_boundary}\" {args}"; Args = $"log --date-order --no-show-signature --decorate=full --format=\"%H%n%P%n%D%n%aN±%aE%n%at%n%cN±%cE%n%ct%n%B%n{_boundary}\" {on}..HEAD";
} }
public List<Models.CommitWithMessage> Result() public List<Models.InteractiveCommit> Result()
{ {
var rs = ReadToEnd(); var rs = ReadToEnd();
if (!rs.IsSuccess) if (!rs.IsSuccess)
@ -29,7 +29,7 @@ namespace SourceGit.Commands
switch (nextPartIdx) switch (nextPartIdx)
{ {
case 0: case 0:
_current = new Models.CommitWithMessage(); _current = new Models.InteractiveCommit();
_current.Commit.SHA = line; _current.Commit.SHA = line;
_commits.Add(_current); _commits.Add(_current);
break; break;
@ -52,7 +52,7 @@ namespace SourceGit.Commands
_current.Commit.CommitterTime = ulong.Parse(line); _current.Commit.CommitterTime = ulong.Parse(line);
break; break;
default: default:
var boundary = rs.StdOut.IndexOf(_boundary, end + 1); var boundary = rs.StdOut.IndexOf(_boundary, end + 1, StringComparison.Ordinal);
if (boundary > end) if (boundary > end)
{ {
_current.Message = rs.StdOut.Substring(start, boundary - start - 1); _current.Message = rs.StdOut.Substring(start, boundary - start - 1);
@ -88,8 +88,8 @@ namespace SourceGit.Commands
_current.Commit.Parents.AddRange(data.Split(separator: ' ', options: StringSplitOptions.RemoveEmptyEntries)); _current.Commit.Parents.AddRange(data.Split(separator: ' ', options: StringSplitOptions.RemoveEmptyEntries));
} }
private List<Models.CommitWithMessage> _commits = new List<Models.CommitWithMessage>(); private List<Models.InteractiveCommit> _commits = [];
private Models.CommitWithMessage _current = null; private Models.InteractiveCommit _current = null;
private string _boundary = ""; private string _boundary = "";
} }
} }

View file

@ -0,0 +1,26 @@
using System.IO;
namespace SourceGit.Commands
{
public class QueryGitCommonDir : Command
{
public QueryGitCommonDir(string workDir)
{
WorkingDirectory = workDir;
Args = "rev-parse --git-common-dir";
RaiseError = false;
}
public string Result()
{
var rs = ReadToEnd().StdOut;
if (string.IsNullOrEmpty(rs))
return null;
rs = rs.Trim();
if (Path.IsPathRooted(rs))
return rs;
return Path.GetFullPath(Path.Combine(WorkingDirectory, rs));
}
}
}

View file

@ -13,7 +13,7 @@ namespace SourceGit.Commands
{ {
WorkingDirectory = repo; WorkingDirectory = repo;
Context = repo; Context = repo;
Args = $"status -u{UNTRACKED[includeUntracked ? 1 : 0]} --ignore-submodules=dirty --porcelain"; Args = $"--no-optional-locks status -u{UNTRACKED[includeUntracked ? 1 : 0]} --ignore-submodules=dirty --porcelain";
} }
public List<Models.Change> Result() public List<Models.Change> Result()

View file

@ -20,9 +20,12 @@ namespace SourceGit.Commands
if (!output.IsSuccess) if (!output.IsSuccess)
return rs; return rs;
var lines = output.StdOut.Split('\n'); var lines = output.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines) foreach (var line in lines)
{ {
if (line.EndsWith("/HEAD", StringComparison.Ordinal))
continue;
if (line.StartsWith("refs/heads/", StringComparison.Ordinal)) if (line.StartsWith("refs/heads/", StringComparison.Ordinal))
rs.Add(new() { Name = line.Substring("refs/heads/".Length), Type = Models.DecoratorType.LocalBranchHead }); rs.Add(new() { Name = line.Substring("refs/heads/".Length), Type = Models.DecoratorType.LocalBranchHead });
else if (line.StartsWith("refs/remotes/", StringComparison.Ordinal)) else if (line.StartsWith("refs/remotes/", StringComparison.Ordinal))

View file

@ -0,0 +1,21 @@
namespace SourceGit.Commands
{
public class QueryRevisionByRefName : Command
{
public QueryRevisionByRefName(string repo, string refname)
{
WorkingDirectory = repo;
Context = repo;
Args = $"rev-parse {refname}";
}
public string Result()
{
var rs = ReadToEnd();
if (rs.IsSuccess && !string.IsNullOrEmpty(rs.StdOut))
return rs.StdOut.Trim();
return null;
}
}
}

View file

@ -1,4 +1,6 @@
namespace SourceGit.Commands using System.Collections.Generic;
namespace SourceGit.Commands
{ {
public class QueryRevisionFileNames : Command public class QueryRevisionFileNames : Command
{ {
@ -9,13 +11,17 @@
Args = $"ls-tree -r -z --name-only {revision}"; Args = $"ls-tree -r -z --name-only {revision}";
} }
public string[] Result() public List<string> Result()
{ {
var rs = ReadToEnd(); var rs = ReadToEnd();
if (rs.IsSuccess) if (!rs.IsSuccess)
return rs.StdOut.Split('\0', System.StringSplitOptions.RemoveEmptyEntries); return [];
return []; var lines = rs.StdOut.Split('\0', System.StringSplitOptions.RemoveEmptyEntries);
var outs = new List<string>();
foreach (var line in lines)
outs.Add(line);
return outs;
} }
} }
} }

View file

@ -8,7 +8,7 @@ namespace SourceGit.Commands
{ {
WorkingDirectory = repo; WorkingDirectory = repo;
Context = repo; Context = repo;
Args = $"show --no-show-signature --decorate=full --pretty=format:%H%n%P%n%D%n%aN±%aE%n%at%n%cN±%cE%n%ct%n%s -s {sha}"; Args = $"show --no-show-signature --decorate=full --format=%H%n%P%n%D%n%aN±%aE%n%at%n%cN±%cE%n%ct%n%s -s {sha}";
} }
public Models.Commit Result() public Models.Commit Result()

View file

@ -24,7 +24,7 @@ namespace SourceGit.Commands
if (rs.IsSuccess) if (rs.IsSuccess)
{ {
var changes = new List<Models.Change>(); var changes = new List<Models.Change>();
var lines = rs.StdOut.Split('\n', StringSplitOptions.RemoveEmptyEntries); var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines) foreach (var line in lines)
{ {
var match = REG_FORMAT2().Match(line); var match = REG_FORMAT2().Match(line);

View file

@ -1,60 +1,68 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
namespace SourceGit.Commands namespace SourceGit.Commands
{ {
/// <summary>
/// Query stash changes. Requires git >= 2.32.0
/// </summary>
public partial class QueryStashChanges : Command public partial class QueryStashChanges : Command
{ {
[GeneratedRegex(@"^(\s?[\w\?]{1,4})\s+(.+)$")] [GeneratedRegex(@"^([MADRC])\s+(.+)$")]
private static partial Regex REG_FORMAT(); private static partial Regex REG_FORMAT();
public QueryStashChanges(string repo, string sha) public QueryStashChanges(string repo, string stash)
{ {
WorkingDirectory = repo; WorkingDirectory = repo;
Context = repo; Context = repo;
Args = $"diff --name-status --pretty=format: {sha}^ {sha}"; Args = $"stash show -u --name-status \"{stash}\"";
} }
public List<Models.Change> Result() public List<Models.Change> Result()
{ {
Exec(); var rs = ReadToEnd();
return _changes; if (!rs.IsSuccess)
} return [];
protected override void OnReadline(string line) var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
{ var outs = new List<Models.Change>();
var match = REG_FORMAT().Match(line); foreach (var line in lines)
if (!match.Success)
return;
var change = new Models.Change() { Path = match.Groups[2].Value };
var status = match.Groups[1].Value;
switch (status[0])
{ {
case 'M': var match = REG_FORMAT().Match(line);
change.Set(Models.ChangeState.Modified); if (!match.Success)
_changes.Add(change); continue;
break;
case 'A':
change.Set(Models.ChangeState.Added);
_changes.Add(change);
break;
case 'D':
change.Set(Models.ChangeState.Deleted);
_changes.Add(change);
break;
case 'R':
change.Set(Models.ChangeState.Renamed);
_changes.Add(change);
break;
case 'C':
change.Set(Models.ChangeState.Copied);
_changes.Add(change);
break;
}
}
private readonly List<Models.Change> _changes = new List<Models.Change>(); var change = new Models.Change() { Path = match.Groups[2].Value };
var status = match.Groups[1].Value;
switch (status[0])
{
case 'M':
change.Set(Models.ChangeState.Modified);
outs.Add(change);
break;
case 'A':
change.Set(Models.ChangeState.Added);
outs.Add(change);
break;
case 'D':
change.Set(Models.ChangeState.Deleted);
outs.Add(change);
break;
case 'R':
change.Set(Models.ChangeState.Renamed);
outs.Add(change);
break;
case 'C':
change.Set(Models.ChangeState.Copied);
outs.Add(change);
break;
}
}
outs.Sort((l, r) => string.Compare(l.Path, r.Path, StringComparison.Ordinal));
return outs;
}
} }
} }

View file

@ -1,4 +1,5 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
namespace SourceGit.Commands namespace SourceGit.Commands
{ {
@ -8,7 +9,7 @@ namespace SourceGit.Commands
{ {
WorkingDirectory = repo; WorkingDirectory = repo;
Context = repo; Context = repo;
Args = "stash list --pretty=format:%H%n%ct%n%gd%n%s"; Args = "stash list --format=%H%n%P%n%ct%n%gd%n%s";
} }
public List<Models.Stash> Result() public List<Models.Stash> Result()
@ -26,21 +27,32 @@ namespace SourceGit.Commands
_stashes.Add(_current); _stashes.Add(_current);
break; break;
case 1: case 1:
_current.Time = ulong.Parse(line); ParseParent(line);
break; break;
case 2: case 2:
_current.Name = line; _current.Time = ulong.Parse(line);
break; break;
case 3: case 3:
_current.Name = line;
break;
case 4:
_current.Message = line; _current.Message = line;
break; break;
} }
_nextLineIdx++; _nextLineIdx++;
if (_nextLineIdx > 3) if (_nextLineIdx > 4)
_nextLineIdx = 0; _nextLineIdx = 0;
} }
private void ParseParent(string data)
{
if (data.Length < 8)
return;
_current.Parents.AddRange(data.Split(separator: ' ', options: StringSplitOptions.RemoveEmptyEntries));
}
private readonly List<Models.Stash> _stashes = new List<Models.Stash>(); private readonly List<Models.Stash> _stashes = new List<Models.Stash>();
private Models.Stash _current = null; private Models.Stash _current = null;
private int _nextLineIdx = 0; private int _nextLineIdx = 0;

View file

@ -24,11 +24,9 @@ namespace SourceGit.Commands
{ {
var submodules = new List<Models.Submodule>(); var submodules = new List<Models.Submodule>();
var rs = ReadToEnd(); var rs = ReadToEnd();
if (!rs.IsSuccess)
return submodules;
var builder = new StringBuilder(); var builder = new StringBuilder();
var lines = rs.StdOut.Split('\n', System.StringSplitOptions.RemoveEmptyEntries); var lines = rs.StdOut.Split(['\r', '\n'], System.StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines) foreach (var line in lines)
{ {
var match = REG_FORMAT1().Match(line); var match = REG_FORMAT1().Match(line);
@ -51,13 +49,13 @@ namespace SourceGit.Commands
if (submodules.Count > 0) if (submodules.Count > 0)
{ {
Args = $"status -uno --porcelain -- {builder}"; Args = $"--no-optional-locks status -uno --porcelain -- {builder}";
rs = ReadToEnd(); rs = ReadToEnd();
if (!rs.IsSuccess) if (!rs.IsSuccess)
return submodules; return submodules;
var dirty = new HashSet<string>(); var dirty = new HashSet<string>();
lines = rs.StdOut.Split('\n', System.StringSplitOptions.RemoveEmptyEntries); lines = rs.StdOut.Split(['\r', '\n'], System.StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines) foreach (var line in lines)
{ {
var match = REG_FORMAT_STATUS().Match(line); var match = REG_FORMAT_STATUS().Match(line);

View file

@ -11,7 +11,7 @@ namespace SourceGit.Commands
Context = repo; Context = repo;
WorkingDirectory = repo; WorkingDirectory = repo;
Args = $"tag -l --sort=-creatordate --format=\"{_boundary}%(refname)%00%(objectname)%00%(*objectname)%00%(contents:subject)%0a%0a%(contents:body)\""; Args = $"tag -l --format=\"{_boundary}%(refname)%00%(objectname)%00%(*objectname)%00%(creatordate:unix)%00%(contents:subject)%0a%0a%(contents:body)\"";
} }
public List<Models.Tag> Result() public List<Models.Tag> Result()
@ -25,14 +25,15 @@ namespace SourceGit.Commands
foreach (var record in records) foreach (var record in records)
{ {
var subs = record.Split('\0', StringSplitOptions.None); var subs = record.Split('\0', StringSplitOptions.None);
if (subs.Length != 4) if (subs.Length != 5)
continue; continue;
var message = subs[3].Trim(); var message = subs[4].Trim();
tags.Add(new Models.Tag() tags.Add(new Models.Tag()
{ {
Name = subs[0].Substring(10), Name = subs[0].Substring(10),
SHA = string.IsNullOrEmpty(subs[2]) ? subs[1] : subs[2], SHA = string.IsNullOrEmpty(subs[2]) ? subs[1] : subs[2],
CreatorDate = ulong.Parse(subs[3]),
Message = string.IsNullOrEmpty(message) ? null : message, Message = string.IsNullOrEmpty(message) ? null : message,
}); });
} }

View file

@ -19,7 +19,7 @@ namespace SourceGit.Commands
if (!rs.IsSuccess) if (!rs.IsSuccess)
return status; return status;
var lines = rs.StdOut.Split(new char[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines) foreach (var line in lines)
{ {
if (line[0] == '>') if (line[0] == '>')

View file

@ -37,6 +37,19 @@ namespace SourceGit.Commands
return true; return true;
} }
public static bool ProcessStashChanges(string repo, List<Models.DiffOption> opts, string saveTo)
{
using (var sw = File.Create(saveTo))
{
foreach (var opt in opts)
{
if (!ProcessSingleChange(repo, opt, sw))
return false;
}
}
return true;
}
private static bool ProcessSingleChange(string repo, Models.DiffOption opt, FileStream writer) private static bool ProcessSingleChange(string repo, Models.DiffOption opt, FileStream writer)
{ {
var starter = new ProcessStartInfo(); var starter = new ProcessStartInfo();

View file

@ -11,72 +11,84 @@ namespace SourceGit.Commands
Context = repo; Context = repo;
} }
public bool Push(string message) public bool Push(string message, bool includeUntracked = true, bool keepIndex = false)
{
Args = $"stash push -m \"{message}\"";
return Exec();
}
public bool Push(List<Models.Change> changes, string message, bool onlyStaged, bool keepIndex)
{ {
var builder = new StringBuilder(); var builder = new StringBuilder();
builder.Append("stash push "); builder.Append("stash push ");
if (onlyStaged) if (includeUntracked)
builder.Append("--staged "); builder.Append("--include-untracked ");
if (keepIndex)
builder.Append("--keep-index ");
builder.Append("-m \"");
builder.Append(message);
builder.Append("\"");
Args = builder.ToString();
return Exec();
}
public bool Push(string message, List<Models.Change> changes, bool keepIndex)
{
var builder = new StringBuilder();
builder.Append("stash push --include-untracked ");
if (keepIndex) if (keepIndex)
builder.Append("--keep-index "); builder.Append("--keep-index ");
builder.Append("-m \""); builder.Append("-m \"");
builder.Append(message); builder.Append(message);
builder.Append("\" -- "); builder.Append("\" -- ");
if (onlyStaged) foreach (var c in changes)
{ builder.Append($"\"{c.Path}\" ");
foreach (var c in changes)
builder.Append($"\"{c.Path}\" ");
}
else
{
var needAdd = new List<Models.Change>();
foreach (var c in changes)
{
builder.Append($"\"{c.Path}\" ");
if (c.WorkTree == Models.ChangeState.Added || c.WorkTree == Models.ChangeState.Untracked)
{
needAdd.Add(c);
if (needAdd.Count > 10)
{
new Add(WorkingDirectory, needAdd).Exec();
needAdd.Clear();
}
}
}
if (needAdd.Count > 0)
{
new Add(WorkingDirectory, needAdd).Exec();
needAdd.Clear();
}
}
Args = builder.ToString(); Args = builder.ToString();
return Exec(); return Exec();
} }
public bool Apply(string name) public bool Push(string message, string pathspecFromFile, bool keepIndex)
{ {
Args = $"stash apply -q {name}"; var builder = new StringBuilder();
builder.Append("stash push --include-untracked --pathspec-from-file=\"");
builder.Append(pathspecFromFile);
builder.Append("\" ");
if (keepIndex)
builder.Append("--keep-index ");
builder.Append("-m \"");
builder.Append(message);
builder.Append("\"");
Args = builder.ToString();
return Exec();
}
public bool PushOnlyStaged(string message, bool keepIndex)
{
var builder = new StringBuilder();
builder.Append("stash push --staged ");
if (keepIndex)
builder.Append("--keep-index ");
builder.Append("-m \"");
builder.Append(message);
builder.Append("\"");
Args = builder.ToString();
return Exec();
}
public bool Apply(string name, bool restoreIndex)
{
var opts = restoreIndex ? "--index" : string.Empty;
Args = $"stash apply -q {opts} \"{name}\"";
return Exec(); return Exec();
} }
public bool Pop(string name) public bool Pop(string name)
{ {
Args = $"stash pop -q {name}"; Args = $"stash pop -q --index \"{name}\"";
return Exec(); return Exec();
} }
public bool Drop(string name) public bool Drop(string name)
{ {
Args = $"stash drop -q {name}"; Args = $"stash drop -q \"{name}\"";
return Exec(); return Exec();
} }

View file

@ -8,7 +8,7 @@ namespace SourceGit.Commands
{ {
WorkingDirectory = repo; WorkingDirectory = repo;
Context = repo; Context = repo;
Args = $"log --date-order --branches --remotes -{max} --pretty=format:\"%ct$%aN\""; Args = $"log --date-order --branches --remotes -{max} --format=%ct$%aN";
} }
public Models.Statistics Result() public Models.Statistics Result()

View file

@ -48,9 +48,7 @@ namespace SourceGit.Commands
if (remotes != null) if (remotes != null)
{ {
foreach (var r in remotes) foreach (var r in remotes)
{ new Push(repo, r.Name, $"refs/tags/{name}", true).Exec();
new Push(repo, r.Name, name, true).Exec();
}
} }
return true; return true;

View file

@ -1,19 +0,0 @@
namespace SourceGit.Commands
{
public class Version : Command
{
public Version()
{
Args = "--version";
RaiseError = false;
}
public string Query()
{
var rs = ReadToEnd();
if (!rs.IsSuccess || string.IsNullOrWhiteSpace(rs.StdOut))
return string.Empty;
return rs.StdOut.Trim().Substring("git version ".Length);
}
}
}

View file

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
namespace SourceGit.Commands namespace SourceGit.Commands
{ {
@ -20,12 +21,13 @@ namespace SourceGit.Commands
var last = null as Models.Worktree; var last = null as Models.Worktree;
if (rs.IsSuccess) if (rs.IsSuccess)
{ {
var lines = rs.StdOut.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines) foreach (var line in lines)
{ {
if (line.StartsWith("worktree ", StringComparison.Ordinal)) if (line.StartsWith("worktree ", StringComparison.Ordinal))
{ {
last = new Models.Worktree() { FullPath = line.Substring(9).Trim() }; last = new Models.Worktree() { FullPath = line.Substring(9).Trim() };
last.RelativePath = Path.GetRelativePath(WorkingDirectory, last.FullPath);
worktrees.Add(last); worktrees.Add(last);
} }
else if (line.StartsWith("bare", StringComparison.Ordinal)) else if (line.StartsWith("bare", StringComparison.Ordinal))
@ -73,6 +75,8 @@ namespace SourceGit.Commands
if (!string.IsNullOrEmpty(tracking)) if (!string.IsNullOrEmpty(tracking))
Args += tracking; Args += tracking;
else if (!string.IsNullOrEmpty(name) && !createNew)
Args += name;
_outputHandler = outputHandler; _outputHandler = outputHandler;
return Exec(); return Exec();

View file

@ -1,4 +1,5 @@
using Avalonia.Data.Converters; using Avalonia.Data.Converters;
using Avalonia.Media;
namespace SourceGit.Converters namespace SourceGit.Converters
{ {
@ -6,5 +7,8 @@ namespace SourceGit.Converters
{ {
public static readonly FuncValueConverter<bool, double> ToPageTabWidth = public static readonly FuncValueConverter<bool, double> ToPageTabWidth =
new FuncValueConverter<bool, double>(x => x ? 200 : double.NaN); new FuncValueConverter<bool, double>(x => x ? 200 : double.NaN);
public static readonly FuncValueConverter<bool, FontWeight> IsBoldToFontWeight =
new FuncValueConverter<bool, FontWeight>(x => x ? FontWeight.Bold : FontWeight.Normal);
} }
} }

View file

@ -23,10 +23,10 @@ namespace SourceGit.Converters
new FuncValueConverter<int, bool>(v => v != 1); new FuncValueConverter<int, bool>(v => v != 1);
public static readonly FuncValueConverter<int, bool> IsSubjectLengthBad = public static readonly FuncValueConverter<int, bool> IsSubjectLengthBad =
new FuncValueConverter<int, bool>(v => v > ViewModels.Preference.Instance.SubjectGuideLength); new FuncValueConverter<int, bool>(v => v > ViewModels.Preferences.Instance.SubjectGuideLength);
public static readonly FuncValueConverter<int, bool> IsSubjectLengthGood = public static readonly FuncValueConverter<int, bool> IsSubjectLengthGood =
new FuncValueConverter<int, bool>(v => v <= ViewModels.Preference.Instance.SubjectGuideLength); new FuncValueConverter<int, bool>(v => v <= ViewModels.Preferences.Instance.SubjectGuideLength);
public static readonly FuncValueConverter<int, Thickness> ToTreeMargin = public static readonly FuncValueConverter<int, Thickness> ToTreeMargin =
new FuncValueConverter<int, Thickness>(v => new Thickness(v * 16, 0, 0, 0)); new FuncValueConverter<int, Thickness>(v => new Thickness(v * 16, 0, 0, 0));

View file

@ -1,13 +1,12 @@
using System; using System;
using System.Globalization; using System.Globalization;
using System.Text.RegularExpressions;
using Avalonia.Data.Converters; using Avalonia.Data.Converters;
using Avalonia.Styling; using Avalonia.Styling;
namespace SourceGit.Converters namespace SourceGit.Converters
{ {
public static partial class StringConverters public static class StringConverters
{ {
public class ToLocaleConverter : IValueConverter public class ToLocaleConverter : IValueConverter
{ {
@ -68,22 +67,6 @@ namespace SourceGit.Converters
public static readonly FuncValueConverter<string, string> ToShortSHA = public static readonly FuncValueConverter<string, string> ToShortSHA =
new FuncValueConverter<string, string>(v => v == null ? string.Empty : (v.Length > 10 ? v.Substring(0, 10) : v)); new FuncValueConverter<string, string>(v => v == null ? string.Empty : (v.Length > 10 ? v.Substring(0, 10) : v));
public static readonly FuncValueConverter<string, bool> UnderRecommendGitVersion =
new(v =>
{
var match = REG_GIT_VERSION().Match(v ?? "");
if (match.Success)
{
var major = int.Parse(match.Groups[1].Value);
var minor = int.Parse(match.Groups[2].Value);
var build = int.Parse(match.Groups[3].Value);
return new Version(major, minor, build) < MINIMAL_GIT_VERSION;
}
return true;
});
public static readonly FuncValueConverter<string, string> TrimRefsPrefix = public static readonly FuncValueConverter<string, string> TrimRefsPrefix =
new FuncValueConverter<string, string>(v => new FuncValueConverter<string, string>(v =>
{ {
@ -96,9 +79,7 @@ namespace SourceGit.Converters
return v; return v;
}); });
[GeneratedRegex(@"^[\s\w]*(\d+)\.(\d+)[\.\-](\d+).*$")] public static readonly FuncValueConverter<string, bool> ContainsSpaces =
private static partial Regex REG_GIT_VERSION(); new FuncValueConverter<string, bool>(v => v != null && v.Contains(' '));
private static readonly Version MINIMAL_GIT_VERSION = new Version(2, 23, 0);
} }
} }

View file

@ -2,14 +2,22 @@
{ {
public class ApplyWhiteSpaceMode public class ApplyWhiteSpaceMode
{ {
public static readonly ApplyWhiteSpaceMode[] Supported =
[
new ApplyWhiteSpaceMode("No Warn", "Turns off the trailing whitespace warning", "nowarn"),
new ApplyWhiteSpaceMode("Warn", "Outputs warnings for a few such errors, but applies", "warn"),
new ApplyWhiteSpaceMode("Error", "Raise errors and refuses to apply the patch", "error"),
new ApplyWhiteSpaceMode("Error All", "Similar to 'error', but shows more", "error-all"),
];
public string Name { get; set; } public string Name { get; set; }
public string Desc { get; set; } public string Desc { get; set; }
public string Arg { get; set; } public string Arg { get; set; }
public ApplyWhiteSpaceMode(string n, string d, string a) public ApplyWhiteSpaceMode(string n, string d, string a)
{ {
Name = App.Text(n); Name = n;
Desc = App.Text(d); Desc = d;
Arg = a; Arg = a;
} }
} }

View file

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
@ -35,7 +35,7 @@ namespace SourceGit.Models
private static AvatarManager _instance = null; private static AvatarManager _instance = null;
[GeneratedRegex(@"^(?:(\d+)\+)?(.+?)@users\.noreply\.github\.com$")] [GeneratedRegex(@"^(?:(\d+)\+)?(.+?)@.+\.github\.com$")]
private static partial Regex REG_GITHUB_USER_EMAIL(); private static partial Regex REG_GITHUB_USER_EMAIL();
private object _synclock = new object(); private object _synclock = new object();
@ -43,6 +43,7 @@ namespace SourceGit.Models
private List<IAvatarHost> _avatars = new List<IAvatarHost>(); private List<IAvatarHost> _avatars = new List<IAvatarHost>();
private Dictionary<string, Bitmap> _resources = new Dictionary<string, Bitmap>(); private Dictionary<string, Bitmap> _resources = new Dictionary<string, Bitmap>();
private HashSet<string> _requesting = new HashSet<string>(); private HashSet<string> _requesting = new HashSet<string>();
private HashSet<string> _defaultAvatars = new HashSet<string>();
public void Start() public void Start()
{ {
@ -50,8 +51,8 @@ namespace SourceGit.Models
if (!Directory.Exists(_storePath)) if (!Directory.Exists(_storePath))
Directory.CreateDirectory(_storePath); Directory.CreateDirectory(_storePath);
var icon = AssetLoader.Open(new Uri($"avares://SourceGit/Resources/Images/github.png", UriKind.RelativeOrAbsolute)); LoadDefaultAvatar("noreply@github.com", "github.png");
_resources.Add("noreply@github.com", new Bitmap(icon)); LoadDefaultAvatar("unrealbot@epicgames.com", "unreal.png");
Task.Run(() => Task.Run(() =>
{ {
@ -140,7 +141,7 @@ namespace SourceGit.Models
{ {
if (forceRefetch) if (forceRefetch)
{ {
if (email.Equals("noreply@github.com", StringComparison.Ordinal)) if (_defaultAvatars.Contains(email))
return null; return null;
if (_resources.ContainsKey(email)) if (_resources.ContainsKey(email))
@ -185,11 +186,18 @@ namespace SourceGit.Models
return null; return null;
} }
private void LoadDefaultAvatar(string key, string img)
{
var icon = AssetLoader.Open(new Uri($"avares://SourceGit/Resources/Images/{img}", UriKind.RelativeOrAbsolute));
_resources.Add(key, new Bitmap(icon));
_defaultAvatars.Add(key);
}
private string GetEmailHash(string email) private string GetEmailHash(string email)
{ {
var lowered = email.ToLower(CultureInfo.CurrentCulture).Trim(); var lowered = email.ToLower(CultureInfo.CurrentCulture).Trim();
var hash = MD5.Create().ComputeHash(Encoding.Default.GetBytes(lowered)); var hash = MD5.HashData(Encoding.Default.GetBytes(lowered).AsSpan());
var builder = new StringBuilder(); var builder = new StringBuilder(hash.Length * 2);
foreach (var c in hash) foreach (var c in hash)
builder.Append(c.ToString("x2")); builder.Append(c.ToString("x2"));
return builder.ToString(); return builder.ToString();

View file

@ -34,6 +34,7 @@ namespace SourceGit.Models
public string Upstream { get; set; } public string Upstream { get; set; }
public BranchTrackStatus TrackStatus { get; set; } public BranchTrackStatus TrackStatus { get; set; }
public string Remote { get; set; } public string Remote { get; set; }
public bool IsUpsteamGone { get; set; }
public string FriendlyName => IsLocal ? Name : $"{Remote}/{Name}"; public string FriendlyName => IsLocal ? Name : $"{Remote}/{Name}";
} }

View file

@ -8,7 +8,9 @@ namespace SourceGit.Models
{ {
public enum CommitSearchMethod public enum CommitSearchMethod
{ {
ByUser, BySHA = 0,
ByAuthor,
ByCommitter,
ByMessage, ByMessage,
ByFile, ByFile,
} }
@ -31,9 +33,10 @@ namespace SourceGit.Models
public List<Decorator> Decorators { get; set; } = new List<Decorator>(); public List<Decorator> Decorators { get; set; } = new List<Decorator>();
public bool HasDecorators => Decorators.Count > 0; public bool HasDecorators => Decorators.Count > 0;
public string AuthorTimeStr => DateTime.UnixEpoch.AddSeconds(AuthorTime).ToLocalTime().ToString("yyyy/MM/dd HH:mm:ss"); public string AuthorTimeStr => DateTime.UnixEpoch.AddSeconds(AuthorTime).ToLocalTime().ToString(DateTimeFormat.Actived.DateTime);
public string CommitterTimeStr => DateTime.UnixEpoch.AddSeconds(CommitterTime).ToLocalTime().ToString("yyyy/MM/dd HH:mm:ss"); public string CommitterTimeStr => DateTime.UnixEpoch.AddSeconds(CommitterTime).ToLocalTime().ToString(DateTimeFormat.Actived.DateTime);
public string AuthorTimeShortStr => DateTime.UnixEpoch.AddSeconds(AuthorTime).ToLocalTime().ToString("yyyy/MM/dd"); public string AuthorTimeShortStr => DateTime.UnixEpoch.AddSeconds(AuthorTime).ToLocalTime().ToString(DateTimeFormat.Actived.DateOnly);
public string CommitterTimeShortStr => DateTime.UnixEpoch.AddSeconds(CommitterTime).ToLocalTime().ToString(DateTimeFormat.Actived.DateOnly);
public bool IsMerged { get; set; } = false; public bool IsMerged { get; set; } = false;
public bool IsCommitterVisible => !Author.Equals(Committer) || AuthorTime != CommitterTime; public bool IsCommitterVisible => !Author.Equals(Committer) || AuthorTime != CommitterTime;
@ -111,9 +114,9 @@ namespace SourceGit.Models
} }
} }
public class CommitWithMessage public class CommitFullMessage
{ {
public Commit Commit { get; set; } = new Commit(); public string Message { get; set; } = string.Empty;
public string Message { get; set; } = ""; public List<Hyperlink> Links { get; set; } = [];
} }
} }

View file

@ -25,10 +25,11 @@ namespace SourceGit.Models
s_penCount = colors.Count; s_penCount = colors.Count;
} }
public class Path(int color) public class Path(int color, bool isMerged)
{ {
public List<Point> Points { get; } = []; public List<Point> Points { get; } = [];
public int Color { get; } = color; public int Color { get; } = color;
public bool IsMerged { get; } = isMerged;
} }
public class Link public class Link
@ -37,6 +38,7 @@ namespace SourceGit.Models
public Point Control; public Point Control;
public Point End; public Point End;
public int Color; public int Color;
public bool IsMerged;
} }
public enum DotType public enum DotType
@ -51,6 +53,7 @@ namespace SourceGit.Models
public DotType Type; public DotType Type;
public Point Center; public Point Center;
public int Color; public int Color;
public bool IsMerged;
} }
public List<Path> Paths { get; } = []; public List<Path> Paths { get; } = [];
@ -68,7 +71,7 @@ namespace SourceGit.Models
var unsolved = new List<PathHelper>(); var unsolved = new List<PathHelper>();
var ended = new List<PathHelper>(); var ended = new List<PathHelper>();
var offsetY = -halfHeight; var offsetY = -halfHeight;
var colorIdx = 0; var colorPicker = new ColorPicker();
foreach (var commit in commits) foreach (var commit in commits)
{ {
@ -108,7 +111,6 @@ namespace SourceGit.Models
} }
isMerged = isMerged || l.IsMerged; isMerged = isMerged || l.IsMerged;
major.IsMerged = isMerged;
} }
else else
{ {
@ -119,28 +121,35 @@ namespace SourceGit.Models
// Remove ended curves from unsolved // Remove ended curves from unsolved
foreach (var l in ended) foreach (var l in ended)
{
colorPicker.Recycle(l.Path.Color);
unsolved.Remove(l); unsolved.Remove(l);
}
ended.Clear(); ended.Clear();
// Create new curve for branch head // If no path found, create new curve for branch head
// Otherwise, create new curve for new merged commit
if (major == null) if (major == null)
{ {
offsetX += unitWidth; offsetX += unitWidth;
if (commit.Parents.Count > 0) if (commit.Parents.Count > 0)
{ {
major = new PathHelper(commit.Parents[0], isMerged, colorIdx, new Point(offsetX, offsetY)); major = new PathHelper(commit.Parents[0], isMerged, colorPicker.Next(), new Point(offsetX, offsetY));
unsolved.Add(major); unsolved.Add(major);
temp.Paths.Add(major.Path); temp.Paths.Add(major.Path);
} }
}
colorIdx = (colorIdx + 1) % s_penCount; else if (isMerged && !major.IsMerged && commit.Parents.Count > 0)
{
major.ReplaceMerged();
temp.Paths.Add(major.Path);
} }
// Calculate link position of this commit. // Calculate link position of this commit.
var position = new Point(major?.LastX ?? offsetX, offsetY); var position = new Point(major?.LastX ?? offsetX, offsetY);
var dotColor = major?.Path.Color ?? 0; var dotColor = major?.Path.Color ?? 0;
var anchor = new Dot() { Center = position, Color = dotColor }; var anchor = new Dot() { Center = position, Color = dotColor, IsMerged = isMerged };
if (commit.IsCurrentHead) if (commit.IsCurrentHead)
anchor.Type = DotType.Head; anchor.Type = DotType.Head;
else if (commit.Parents.Count > 1) else if (commit.Parents.Count > 1)
@ -158,16 +167,20 @@ namespace SourceGit.Models
var parent = unsolved.Find(x => x.Next.Equals(parentHash, StringComparison.Ordinal)); var parent = unsolved.Find(x => x.Next.Equals(parentHash, StringComparison.Ordinal));
if (parent != null) if (parent != null)
{ {
// Try to change the merge state of linked graph if (isMerged && !parent.IsMerged)
if (isMerged) {
parent.IsMerged = true; parent.Goto(parent.LastX, offsetY + halfHeight, halfHeight);
parent.ReplaceMerged();
temp.Paths.Add(parent.Path);
}
temp.Links.Add(new Link temp.Links.Add(new Link
{ {
Start = position, Start = position,
End = new Point(parent.LastX, offsetY + halfHeight), End = new Point(parent.LastX, offsetY + halfHeight),
Control = new Point(parent.LastX, position.Y), Control = new Point(parent.LastX, position.Y),
Color = parent.Path.Color Color = parent.Path.Color,
IsMerged = isMerged,
}); });
} }
else else
@ -175,10 +188,9 @@ namespace SourceGit.Models
offsetX += unitWidth; offsetX += unitWidth;
// Create new curve for parent commit that not includes before // Create new curve for parent commit that not includes before
var l = new PathHelper(parentHash, isMerged, colorIdx, position, new Point(offsetX, position.Y + halfHeight)); var l = new PathHelper(parentHash, isMerged, colorPicker.Next(), position, new Point(offsetX, position.Y + halfHeight));
unsolved.Add(l); unsolved.Add(l);
temp.Paths.Add(l.Path); temp.Paths.Add(l.Path);
colorIdx = (colorIdx + 1) % s_penCount;
} }
} }
} }
@ -205,32 +217,53 @@ namespace SourceGit.Models
return temp; return temp;
} }
private class ColorPicker
{
public int Next()
{
if (_colorsQueue.Count == 0)
{
for (var i = 0; i < s_penCount; i++)
_colorsQueue.Enqueue(i);
}
return _colorsQueue.Dequeue();
}
public void Recycle(int idx)
{
if (!_colorsQueue.Contains(idx))
_colorsQueue.Enqueue(idx);
}
private Queue<int> _colorsQueue = new Queue<int>();
}
private class PathHelper private class PathHelper
{ {
public Path Path { get; } public Path Path { get; private set; }
public string Next { get; set; } public string Next { get; set; }
public bool IsMerged { get; set; }
public double LastX { get; private set; } public double LastX { get; private set; }
public bool IsMerged => Path.IsMerged;
public PathHelper(string next, bool isMerged, int color, Point start) public PathHelper(string next, bool isMerged, int color, Point start)
{ {
Next = next; Next = next;
IsMerged = isMerged;
LastX = start.X; LastX = start.X;
_lastY = start.Y; _lastY = start.Y;
Path = new Path(color); Path = new Path(color, isMerged);
Path.Points.Add(start); Path.Points.Add(start);
} }
public PathHelper(string next, bool isMerged, int color, Point start, Point to) public PathHelper(string next, bool isMerged, int color, Point start, Point to)
{ {
Next = next; Next = next;
IsMerged = isMerged;
LastX = to.X; LastX = to.X;
_lastY = to.Y; _lastY = to.Y;
Path = new Path(color); Path = new Path(color, isMerged);
Path.Points.Add(start); Path.Points.Add(start);
Path.Points.Add(to); Path.Points.Add(to);
} }
@ -310,6 +343,19 @@ namespace SourceGit.Models
_lastY = y; _lastY = y;
} }
/// <summary>
/// End the current path and create a new from the end.
/// </summary>
public void ReplaceMerged()
{
var color = Path.Color;
Add(LastX, _lastY);
Path = new Path(color, true);
Path.Points.Add(new Point(LastX, _lastY));
_endY = 0;
}
private void Add(double x, double y) private void Add(double x, double y)
{ {
if (_endY < y) if (_endY < y)
@ -327,7 +373,6 @@ namespace SourceGit.Models
private static readonly List<Color> s_defaultPenColors = [ private static readonly List<Color> s_defaultPenColors = [
Colors.Orange, Colors.Orange,
Colors.ForestGreen, Colors.ForestGreen,
Colors.Gray,
Colors.Turquoise, Colors.Turquoise,
Colors.Olive, Colors.Olive,
Colors.Magenta, Colors.Magenta,

View file

@ -6,6 +6,7 @@ namespace SourceGit.Models
{ {
Repository, Repository,
Commit, Commit,
Branch,
} }
public class CustomAction : ObservableObject public class CustomAction : ObservableObject
@ -34,9 +35,16 @@ namespace SourceGit.Models
set => SetProperty(ref _arguments, value); set => SetProperty(ref _arguments, value);
} }
public bool WaitForExit
{
get => _waitForExit;
set => SetProperty(ref _waitForExit, value);
}
private string _name = string.Empty; private string _name = string.Empty;
private CustomActionScope _scope = CustomActionScope.Repository; private CustomActionScope _scope = CustomActionScope.Repository;
private string _executable = string.Empty; private string _executable = string.Empty;
private string _arguments = string.Empty; private string _arguments = string.Empty;
private bool _waitForExit = true;
} }
} }

View file

@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
namespace SourceGit.Models
{
public class DateTimeFormat
{
public string DateOnly { get; set; }
public string DateTime { get; set; }
public string Example
{
get => _example.ToString(DateTime);
}
public DateTimeFormat(string dateOnly, string dateTime)
{
DateOnly = dateOnly;
DateTime = dateTime;
}
public static int ActiveIndex
{
get;
set;
} = 0;
public static DateTimeFormat Actived
{
get => Supported[ActiveIndex];
}
public static readonly List<DateTimeFormat> Supported = new List<DateTimeFormat>
{
new DateTimeFormat("yyyy/MM/dd", "yyyy/MM/dd HH:mm:ss"),
new DateTimeFormat("yyyy.MM.dd", "yyyy.MM.dd HH:mm:ss"),
new DateTimeFormat("yyyy-MM-dd", "yyyy-MM-dd HH:mm:ss"),
new DateTimeFormat("MM/dd/yyyy", "MM/dd/yyyy HH:mm:ss"),
new DateTimeFormat("MM.dd.yyyy", "MM.dd.yyyy HH:mm:ss"),
new DateTimeFormat("MM-dd-yyyy", "MM-dd-yyyy HH:mm:ss"),
new DateTimeFormat("dd/MM/yyyy", "dd/MM/yyyy HH:mm:ss"),
new DateTimeFormat("dd.MM.yyyy", "dd.MM.yyyy HH:mm:ss"),
new DateTimeFormat("dd-MM-yyyy", "dd-MM-yyyy HH:mm:ss"),
new DateTimeFormat("MMM d yyyy", "MMM d yyyy HH:mm:ss"),
new DateTimeFormat("d MMM yyyy", "d MMM yyyy HH:mm:ss"),
};
private static readonly DateTime _example = new DateTime(2025, 1, 31, 8, 0, 0, DateTimeKind.Local);
}
}

View file

@ -1,9 +0,0 @@
namespace SourceGit.Models
{
public enum DealWithLocalChanges
{
DoNothing,
StashAndReaply,
Discard,
}
}

View file

@ -681,6 +681,18 @@ namespace SourceGit.Models
public TextDiff TextDiff { get; set; } = null; public TextDiff TextDiff { get; set; } = null;
public LFSDiff LFSDiff { get; set; } = null; public LFSDiff LFSDiff { get; set; } = null;
public string FileModeChange => string.IsNullOrEmpty(OldMode) ? string.Empty : $"{OldMode} → {NewMode}"; public string FileModeChange
{
get
{
if (string.IsNullOrEmpty(OldMode) && string.IsNullOrEmpty(NewMode))
return string.Empty;
var oldDisplay = string.IsNullOrEmpty(OldMode) ? "0" : OldMode;
var newDisplay = string.IsNullOrEmpty(NewMode) ? "0" : NewMode;
return $"{oldDisplay} → {newDisplay}";
}
}
} }
} }

View file

@ -39,7 +39,7 @@ namespace SourceGit.Models
new ExternalMerger(4, "tortoise_merge", "Tortoise Merge", "TortoiseMerge.exe;TortoiseGitMerge.exe", "-base:\"$BASE\" -theirs:\"$REMOTE\" -mine:\"$LOCAL\" -merged:\"$MERGED\"", "-base:\"$LOCAL\" -theirs:\"$REMOTE\""), new ExternalMerger(4, "tortoise_merge", "Tortoise Merge", "TortoiseMerge.exe;TortoiseGitMerge.exe", "-base:\"$BASE\" -theirs:\"$REMOTE\" -mine:\"$LOCAL\" -merged:\"$MERGED\"", "-base:\"$LOCAL\" -theirs:\"$REMOTE\""),
new ExternalMerger(5, "kdiff3", "KDiff3", "kdiff3.exe", "\"$REMOTE\" -b \"$BASE\" \"$LOCAL\" -o \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), new ExternalMerger(5, "kdiff3", "KDiff3", "kdiff3.exe", "\"$REMOTE\" -b \"$BASE\" \"$LOCAL\" -o \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""),
new ExternalMerger(6, "beyond_compare", "Beyond Compare", "BComp.exe", "\"$REMOTE\" \"$LOCAL\" \"$BASE\" \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), new ExternalMerger(6, "beyond_compare", "Beyond Compare", "BComp.exe", "\"$REMOTE\" \"$LOCAL\" \"$BASE\" \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""),
new ExternalMerger(7, "win_merge", "WinMerge", "WinMergeU.exe", "\"$MERGED\"", "-u -e \"$LOCAL\" \"$REMOTE\""), new ExternalMerger(7, "win_merge", "WinMerge", "WinMergeU.exe", "\"$MERGED\"", "-u -e -sw \"$LOCAL\" \"$REMOTE\""),
new ExternalMerger(8, "codium", "VSCodium", "VSCodium.exe", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), new ExternalMerger(8, "codium", "VSCodium", "VSCodium.exe", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""),
new ExternalMerger(9, "p4merge", "P4Merge", "p4merge.exe", "-tw 4 \"$BASE\" \"$LOCAL\" \"$REMOTE\" \"$MERGED\"", "-tw 4 \"$LOCAL\" \"$REMOTE\""), new ExternalMerger(9, "p4merge", "P4Merge", "p4merge.exe", "-tw 4 \"$BASE\" \"$LOCAL\" \"$REMOTE\" \"$MERGED\"", "-tw 4 \"$LOCAL\" \"$REMOTE\""),
}; };

30
src/Models/GitVersions.cs Normal file
View file

@ -0,0 +1,30 @@
namespace SourceGit.Models
{
public static class GitVersions
{
/// <summary>
/// The minimal version of Git that required by this app.
/// </summary>
public static readonly System.Version MINIMAL = new System.Version(2, 23, 0);
/// <summary>
/// The minimal version of Git that supports the `add` command with the `--pathspec-from-file` option.
/// </summary>
public static readonly System.Version ADD_WITH_PATHSPECFILE = new System.Version(2, 25, 0);
/// <summary>
/// The minimal version of Git that supports the `stash push` command with the `--pathspec-from-file` option.
/// </summary>
public static readonly System.Version STASH_PUSH_WITH_PATHSPECFILE = new System.Version(2, 26, 0);
/// <summary>
/// The minimal version of Git that supports the `stash push` command with the `--staged` option.
/// </summary>
public static readonly System.Version STASH_PUSH_ONLY_STAGED = new System.Version(2, 35, 0);
/// <summary>
/// The minimal version of Git that supports the `stash show` command with the `-u` option.
/// </summary>
public static readonly System.Version STASH_SHOW_WITH_UNTRACKED = new System.Version(2, 32, 0);
}
}

View file

@ -2,9 +2,6 @@
{ {
public interface IRepository public interface IRepository
{ {
string FullPath { get; set; }
string GitDir { get; set; }
void RefreshBranches(); void RefreshBranches();
void RefreshWorktrees(); void RefreshWorktrees();
void RefreshTags(); void RefreshTags();

View file

@ -12,6 +12,12 @@ namespace SourceGit.Models
Drop, Drop,
} }
public class InteractiveCommit
{
public Commit Commit { get; set; } = new Commit();
public string Message { get; set; } = string.Empty;
}
public class InteractiveRebaseJob public class InteractiveRebaseJob
{ {
public string SHA { get; set; } = string.Empty; public string SHA { get; set; } = string.Empty;

View file

@ -5,8 +5,9 @@
public static readonly MergeMode[] Supported = public static readonly MergeMode[] Supported =
[ [
new MergeMode("Default", "Fast-forward if possible", ""), new MergeMode("Default", "Fast-forward if possible", ""),
new MergeMode("Fast-forward", "Refuse to merge when fast-forward is not possible", "--ff-only"),
new MergeMode("No Fast-forward", "Always create a merge commit", "--no-ff"), new MergeMode("No Fast-forward", "Always create a merge commit", "--no-ff"),
new MergeMode("Squash", "Use '--squash'", "--squash"), new MergeMode("Squash", "Squash merge", "--squash"),
new MergeMode("Don't commit", "Merge without commit", "--no-ff --no-commit"), new MergeMode("Don't commit", "Merge without commit", "--no-ff --no-commit"),
]; ];

View file

@ -1,79 +1,99 @@
using System; using System;
using System.ClientModel;
using System.Collections.Generic; using System.Collections.Generic;
using System.Net.Http;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.RegularExpressions;
using System.Text.Json.Serialization;
using System.Threading; using System.Threading;
using Azure.AI.OpenAI;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using OpenAI;
using OpenAI.Chat;
namespace SourceGit.Models namespace SourceGit.Models
{ {
public class OpenAIChatMessage public partial class OpenAIResponse
{ {
[JsonPropertyName("role")] public OpenAIResponse(Action<string> onUpdate)
public string Role
{ {
get; _onUpdate = onUpdate;
set;
} }
[JsonPropertyName("content")] public void Append(string text)
public string Content
{ {
get; var buffer = text;
set;
}
}
public class OpenAIChatChoice if (_thinkTail.Length > 0)
{ {
[JsonPropertyName("index")] _thinkTail.Append(buffer);
public int Index buffer = _thinkTail.ToString();
{ _thinkTail.Clear();
get; }
set;
buffer = REG_COT().Replace(buffer, "");
var startIdx = buffer.IndexOf('<', StringComparison.Ordinal);
if (startIdx >= 0)
{
if (startIdx > 0)
OnReceive(buffer.Substring(0, startIdx));
var endIdx = buffer.IndexOf(">", startIdx + 1, StringComparison.Ordinal);
if (endIdx <= startIdx)
{
if (buffer.Length - startIdx <= 15)
_thinkTail.Append(buffer.Substring(startIdx));
else
OnReceive(buffer.Substring(startIdx));
}
else if (endIdx < startIdx + 15)
{
var tag = buffer.Substring(startIdx + 1, endIdx - startIdx - 1);
if (_thinkTags.Contains(tag))
_thinkTail.Append(buffer.Substring(startIdx));
else
OnReceive(buffer.Substring(startIdx));
}
else
{
OnReceive(buffer.Substring(startIdx));
}
}
else
{
OnReceive(buffer);
}
} }
[JsonPropertyName("message")] public void End()
public OpenAIChatMessage Message
{ {
get; if (_thinkTail.Length > 0)
set; {
} OnReceive(_thinkTail.ToString());
} _thinkTail.Clear();
}
public class OpenAIChatResponse
{
[JsonPropertyName("choices")]
public List<OpenAIChatChoice> Choices
{
get;
set;
} = [];
}
public class OpenAIChatRequest
{
[JsonPropertyName("model")]
public string Model
{
get;
set;
} }
[JsonPropertyName("messages")] private void OnReceive(string text)
public List<OpenAIChatMessage> Messages
{ {
get; if (!_hasTrimmedStart)
set; {
} = []; text = text.TrimStart();
if (string.IsNullOrEmpty(text))
return;
public void AddMessage(string role, string content) _hasTrimmedStart = true;
{ }
Messages.Add(new OpenAIChatMessage { Role = role, Content = content });
_onUpdate.Invoke(text);
} }
[GeneratedRegex(@"<(think|thought|thinking|thought_chain)>.*?</\1>", RegexOptions.Singleline)]
private static partial Regex REG_COT();
private Action<string> _onUpdate = null;
private StringBuilder _thinkTail = new StringBuilder();
private HashSet<string> _thinkTags = ["think", "thought", "thinking", "thought_chain"];
private bool _hasTrimmedStart = false;
} }
public class OpenAIService : ObservableObject public class OpenAIService : ObservableObject
@ -87,7 +107,15 @@ namespace SourceGit.Models
public string Server public string Server
{ {
get => _server; get => _server;
set => SetProperty(ref _server, value); set
{
// migrate old server value
if (!string.IsNullOrEmpty(value) && value.EndsWith("/chat/completions", StringComparison.Ordinal))
{
value = value.Substring(0, value.Length - "/chat/completions".Length);
}
SetProperty(ref _server, value);
}
} }
public string ApiKey public string ApiKey
@ -102,6 +130,12 @@ namespace SourceGit.Models
set => SetProperty(ref _model, value); set => SetProperty(ref _model, value);
} }
public bool Streaming
{
get => _streaming;
set => SetProperty(ref _streaming, value);
}
public string AnalyzeDiffPrompt public string AnalyzeDiffPrompt
{ {
get => _analyzeDiffPrompt; get => _analyzeDiffPrompt;
@ -147,45 +181,54 @@ namespace SourceGit.Models
"""; """;
} }
public OpenAIChatResponse Chat(string prompt, string question, CancellationToken cancellation) public void Chat(string prompt, string question, CancellationToken cancellation, Action<string> onUpdate)
{ {
var chat = new OpenAIChatRequest() { Model = Model }; var server = new Uri(_server);
chat.AddMessage("user", prompt); var key = new ApiKeyCredential(_apiKey);
chat.AddMessage("user", question); var client = null as ChatClient;
if (_server.Contains("openai.azure.com/", StringComparison.Ordinal))
var client = new HttpClient() { Timeout = TimeSpan.FromSeconds(60) };
if (!string.IsNullOrEmpty(ApiKey))
{ {
if (Server.Contains("openai.azure.com/", StringComparison.Ordinal)) var azure = new AzureOpenAIClient(server, key);
client.DefaultRequestHeaders.Add("api-key", ApiKey); client = azure.GetChatClient(_model);
else }
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {ApiKey}"); else
{
var openai = new OpenAIClient(key, new() { Endpoint = server });
client = openai.GetChatClient(_model);
} }
var req = new StringContent(JsonSerializer.Serialize(chat, JsonCodeGen.Default.OpenAIChatRequest), Encoding.UTF8, "application/json"); var messages = new List<ChatMessage>();
messages.Add(_model.Equals("o1-mini", StringComparison.Ordinal) ? new UserChatMessage(prompt) : new SystemChatMessage(prompt));
messages.Add(new UserChatMessage(question));
try try
{ {
var task = client.PostAsync(Server, req, cancellation); var rsp = new OpenAIResponse(onUpdate);
task.Wait(cancellation);
var rsp = task.Result; if (_streaming)
var reader = rsp.Content.ReadAsStringAsync(cancellation);
reader.Wait(cancellation);
var body = reader.Result;
if (!rsp.IsSuccessStatusCode)
{ {
throw new Exception($"AI service returns error code {rsp.StatusCode}. Body: {body ?? string.Empty}"); var updates = client.CompleteChatStreaming(messages, null, cancellation);
foreach (var update in updates)
{
if (update.ContentUpdate.Count > 0)
rsp.Append(update.ContentUpdate[0].Text);
}
}
else
{
var completion = client.CompleteChat(messages, null, cancellation);
if (completion.Value.Content.Count > 0)
rsp.Append(completion.Value.Content[0].Text);
} }
return JsonSerializer.Deserialize(reader.Result, JsonCodeGen.Default.OpenAIChatResponse); rsp.End();
} }
catch catch
{ {
if (cancellation.IsCancellationRequested) if (!cancellation.IsCancellationRequested)
return null; throw;
throw;
} }
} }
@ -193,6 +236,7 @@ namespace SourceGit.Models
private string _server; private string _server;
private string _apiKey; private string _apiKey;
private string _model; private string _model;
private bool _streaming = true;
private string _analyzeDiffPrompt; private string _analyzeDiffPrompt;
private string _generateSubjectPrompt; private string _generateSubjectPrompt;
} }

View file

@ -8,12 +8,12 @@ namespace SourceGit.Models
{ {
[GeneratedRegex(@"^https?://([-a-zA-Z0-9:%._\+~#=]+@)?[-a-zA-Z0-9:%._\+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6})?(:[0-9]{1,5})?\b(/[-a-zA-Z0-9()@:%_\+.~#?&=]+)+(\.git)?$")] [GeneratedRegex(@"^https?://([-a-zA-Z0-9:%._\+~#=]+@)?[-a-zA-Z0-9:%._\+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6})?(:[0-9]{1,5})?\b(/[-a-zA-Z0-9()@:%_\+.~#?&=]+)+(\.git)?$")]
private static partial Regex REG_HTTPS(); private static partial Regex REG_HTTPS();
[GeneratedRegex(@"^[\w\-]+@[\w\.\-]+(\:[0-9]+)?:[\w\-/~%]+/[\w\-\.%]+(\.git)?$")] [GeneratedRegex(@"^[\w\-]+@[\w\.\-]+(\:[0-9]+)?:([a-zA-z0-9~%][\w\-\./~%]*)?[a-zA-Z0-9](\.git)?$")]
private static partial Regex REG_SSH1(); private static partial Regex REG_SSH1();
[GeneratedRegex(@"^ssh://([\w\-]+@)?[\w\.\-]+(\:[0-9]+)?/[\w\-/~]+/[\w\-\.]+(\.git)?$")] [GeneratedRegex(@"^ssh://([\w\-]+@)?[\w\.\-]+(\:[0-9]+)?/([a-zA-z0-9~%][\w\-\./~%]*)?[a-zA-Z0-9](\.git)?$")]
private static partial Regex REG_SSH2(); private static partial Regex REG_SSH2();
[GeneratedRegex(@"^git@([\w\.\-]+):([\w\-/~]+/[\w\-\.]+)\.git$")] [GeneratedRegex(@"^git@([\w\.\-]+):([\w\-/~%]+/[\w\-\.%]+)\.git$")]
private static partial Regex REG_TO_VISIT_URL_CAPTURE(); private static partial Regex REG_TO_VISIT_URL_CAPTURE();
private static readonly Regex[] URL_FORMATS = [ private static readonly Regex[] URL_FORMATS = [

View file

@ -14,13 +14,43 @@ namespace SourceGit.Models
set; set;
} = string.Empty; } = string.Empty;
public DealWithLocalChanges DealWithLocalChangesOnCheckoutBranch public bool EnableReflog
{ {
get; get;
set; set;
} = DealWithLocalChanges.DoNothing; } = false;
public bool EnablePruneOnFetch public bool EnableFirstParentInHistories
{
get;
set;
} = false;
public bool EnableTopoOrderInHistories
{
get;
set;
} = false;
public bool OnlyHighlighCurrentBranchInHistories
{
get;
set;
} = false;
public TagSortMode TagSortMode
{
get;
set;
} = TagSortMode.CreatorDate;
public bool IncludeUntrackedInLocalChanges
{
get;
set;
} = true;
public bool EnableForceOnFetch
{ {
get; get;
set; set;
@ -32,12 +62,6 @@ namespace SourceGit.Models
set; set;
} = false; } = false;
public DealWithLocalChanges DealWithLocalChangesOnPull
{
get;
set;
} = DealWithLocalChanges.DoNothing;
public bool PreferRebaseInsteadOfMerge public bool PreferRebaseInsteadOfMerge
{ {
get; get;
@ -68,11 +92,17 @@ namespace SourceGit.Models
set; set;
} = false; } = false;
public DealWithLocalChanges DealWithLocalChangesOnCreateBranch public bool PushToRemoteWhenCreateTag
{ {
get; get;
set; set;
} = DealWithLocalChanges.DoNothing; } = true;
public bool PushToRemoteWhenDeleteTag
{
get;
set;
} = false;
public bool CheckoutBranchOnCreateBranch public bool CheckoutBranchOnCreateBranch
{ {
@ -84,31 +114,31 @@ namespace SourceGit.Models
{ {
get; get;
set; set;
} = new AvaloniaList<Filter>(); } = [];
public AvaloniaList<CommitTemplate> CommitTemplates public AvaloniaList<CommitTemplate> CommitTemplates
{ {
get; get;
set; set;
} = new AvaloniaList<CommitTemplate>(); } = [];
public AvaloniaList<string> CommitMessages public AvaloniaList<string> CommitMessages
{ {
get; get;
set; set;
} = new AvaloniaList<string>(); } = [];
public AvaloniaList<IssueTrackerRule> IssueTrackerRules public AvaloniaList<IssueTrackerRule> IssueTrackerRules
{ {
get; get;
set; set;
} = new AvaloniaList<IssueTrackerRule>(); } = [];
public AvaloniaList<CustomAction> CustomActions public AvaloniaList<CustomAction> CustomActions
{ {
get; get;
set; set;
} = new AvaloniaList<CustomAction>(); } = [];
public bool EnableAutoFetch public bool EnableAutoFetch
{ {
@ -146,12 +176,54 @@ namespace SourceGit.Models
set; set;
} = false; } = false;
public bool AutoRestoreAfterStash
{
get;
set;
} = false;
public string PreferedOpenAIService public string PreferedOpenAIService
{ {
get; get;
set; set;
} = "---"; } = "---";
public bool IsLocalBranchesExpandedInSideBar
{
get;
set;
} = true;
public bool IsRemotesExpandedInSideBar
{
get;
set;
} = false;
public bool IsTagsExpandedInSideBar
{
get;
set;
} = false;
public bool IsSubmodulesExpandedInSideBar
{
get;
set;
} = false;
public bool IsWorktreeExpandedInSideBar
{
get;
set;
} = false;
public List<string> ExpandedBranchNodesInSideBar
{
get;
set;
} = [];
public Dictionary<string, FilterMode> CollectHistoriesFilters() public Dictionary<string, FilterMode> CollectHistoriesFilters()
{ {
var map = new Dictionary<string, FilterMode>(); var map = new Dictionary<string, FilterMode>();
@ -378,65 +450,13 @@ namespace SourceGit.Models
CommitMessages.Insert(0, message); CommitMessages.Insert(0, message);
} }
public IssueTrackerRule AddNewIssueTracker() public IssueTrackerRule AddIssueTracker(string name, string regex, string url)
{ {
var rule = new IssueTrackerRule() var rule = new IssueTrackerRule()
{ {
Name = "New Issue Tracker", Name = name,
RegexString = "#(\\d+)", RegexString = regex,
URLTemplate = "https://xxx/$1", URLTemplate = url,
};
IssueTrackerRules.Add(rule);
return rule;
}
public IssueTrackerRule AddGithubIssueTracker(string repoURL)
{
var rule = new IssueTrackerRule()
{
Name = "Github ISSUE",
RegexString = "#(\\d+)",
URLTemplate = string.IsNullOrEmpty(repoURL) ? "https://github.com/username/repository/issues/$1" : $"{repoURL}/issues/$1",
};
IssueTrackerRules.Add(rule);
return rule;
}
public IssueTrackerRule AddJiraIssueTracker()
{
var rule = new IssueTrackerRule()
{
Name = "Jira Tracker",
RegexString = "PROJ-(\\d+)",
URLTemplate = "https://jira.yourcompany.com/browse/PROJ-$1",
};
IssueTrackerRules.Add(rule);
return rule;
}
public IssueTrackerRule AddGitLabIssueTracker(string repoURL)
{
var rule = new IssueTrackerRule()
{
Name = "GitLab ISSUE",
RegexString = "#(\\d+)",
URLTemplate = string.IsNullOrEmpty(repoURL) ? "https://gitlab.com/username/repository/-/issues/$1" : $"{repoURL}/-/issues/$1",
};
IssueTrackerRules.Add(rule);
return rule;
}
public IssueTrackerRule AddGitLabMergeRequestTracker(string repoURL)
{
var rule = new IssueTrackerRule()
{
Name = "GitLab MR",
RegexString = "!(\\d+)",
URLTemplate = string.IsNullOrEmpty(repoURL) ? "https://gitlab.com/username/repository/-/merge_requests/$1" : $"{repoURL}/-/merge_requests/$1",
}; };
IssueTrackerRules.Add(rule); IssueTrackerRules.Add(rule);
@ -451,11 +471,7 @@ namespace SourceGit.Models
public CustomAction AddNewCustomAction() public CustomAction AddNewCustomAction()
{ {
var act = new CustomAction() var act = new CustomAction() { Name = "Unnamed Action" };
{
Name = "Unnamed Custom Action",
};
CustomActions.Add(act); CustomActions.Add(act);
return act; return act;
} }

View file

@ -29,6 +29,6 @@ namespace SourceGit.Models
public class RevisionSubmodule public class RevisionSubmodule
{ {
public Commit Commit { get; set; } = null; public Commit Commit { get; set; } = null;
public string FullMessage { get; set; } = string.Empty; public CommitFullMessage FullMessage { get; set; } = null;
} }
} }

View file

@ -1,4 +1,5 @@
using System.Reflection; using System;
using System.Reflection;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace SourceGit.Models namespace SourceGit.Models
@ -32,5 +33,24 @@ namespace SourceGit.Models
} }
} }
public class AlreadyUpToDate { } public class AlreadyUpToDate
{
}
public class SelfUpdateFailed
{
public string Reason
{
get;
private set;
}
public SelfUpdateFailed(Exception e)
{
if (e.InnerException is { } inner)
Reason = inner.Message;
else
Reason = e.Message;
}
}
} }

View file

@ -41,6 +41,8 @@ namespace SourceGit.Models
{ {
new ShellOrTerminal("mac-terminal", "Terminal", ""), new ShellOrTerminal("mac-terminal", "Terminal", ""),
new ShellOrTerminal("iterm2", "iTerm", ""), new ShellOrTerminal("iterm2", "iTerm", ""),
new ShellOrTerminal("warp", "Warp", ""),
new ShellOrTerminal("ghostty", "Ghostty", "")
}; };
} }
else else
@ -55,6 +57,7 @@ namespace SourceGit.Models
new ShellOrTerminal("mate-terminal", "MATE Terminal", "mate-terminal"), new ShellOrTerminal("mate-terminal", "MATE Terminal", "mate-terminal"),
new ShellOrTerminal("foot", "Foot", "foot"), new ShellOrTerminal("foot", "Foot", "foot"),
new ShellOrTerminal("wezterm", "WezTerm", "wezterm"), new ShellOrTerminal("wezterm", "WezTerm", "wezterm"),
new ShellOrTerminal("ptyxis", "Ptyxis", "ptyxis"),
new ShellOrTerminal("custom", "Custom", ""), new ShellOrTerminal("custom", "Custom", ""),
}; };
} }

View file

@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
namespace SourceGit.Models namespace SourceGit.Models
{ {
@ -6,9 +7,10 @@ namespace SourceGit.Models
{ {
public string Name { get; set; } = ""; public string Name { get; set; } = "";
public string SHA { get; set; } = ""; public string SHA { get; set; } = "";
public List<string> Parents { get; set; } = [];
public ulong Time { get; set; } = 0; public ulong Time { get; set; } = 0;
public string Message { get; set; } = ""; public string Message { get; set; } = "";
public string TimeStr => DateTime.UnixEpoch.AddSeconds(Time).ToLocalTime().ToString("yyyy/MM/dd HH:mm:ss"); public string TimeStr => DateTime.UnixEpoch.AddSeconds(Time).ToLocalTime().ToString(DateTimeFormat.Actived.DateTime);
} }
} }

View file

@ -2,10 +2,18 @@
namespace SourceGit.Models namespace SourceGit.Models
{ {
public enum TagSortMode
{
CreatorDate = 0,
NameInAscending,
NameInDescending,
}
public class Tag : ObservableObject public class Tag : ObservableObject
{ {
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public string SHA { get; set; } = string.Empty; public string SHA { get; set; } = string.Empty;
public ulong CreatorDate { get; set; } = 0;
public string Message { get; set; } = string.Empty; public string Message { get; set; } = string.Empty;
public FilterMode FilterMode public FilterMode FilterMode

View file

@ -313,7 +313,7 @@ namespace SourceGit.Models
private static bool IsNameChar(char c) private static bool IsNameChar(char c)
{ {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'); return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_';
} }
// (?) notice or log if variable is not found // (?) notice or log if variable is not found

View file

@ -21,10 +21,11 @@ namespace SourceGit.Models
{ {
private static readonly ExtraGrammar[] s_extraGrammars = private static readonly ExtraGrammar[] s_extraGrammars =
[ [
new ExtraGrammar("source.toml", ".toml", "toml.json"), new ExtraGrammar("source.toml", [".toml"], "toml.json"),
new ExtraGrammar("source.kotlin", ".kotlin", "kotlin.json"), new ExtraGrammar("source.kotlin", [".kotlin", ".kt", ".kts"], "kotlin.json"),
new ExtraGrammar("source.hx", ".hx", "haxe.json"), new ExtraGrammar("source.hx", [".hx"], "haxe.json"),
new ExtraGrammar("source.hxml", ".hxml", "hxml.json"), new ExtraGrammar("source.hxml", [".hxml"], "hxml.json"),
new ExtraGrammar("text.html.jsp", [".jsp", ".jspf", ".tag"], "jsp.json"),
]; ];
public static string GetScope(string file, RegistryOptions reg) public static string GetScope(string file, RegistryOptions reg)
@ -36,13 +37,14 @@ namespace SourceGit.Models
extension = ".xml"; extension = ".xml";
else if (extension == ".command") else if (extension == ".command")
extension = ".sh"; extension = ".sh";
else if (extension == ".kt" || extension == ".kts")
extension = ".kotlin";
foreach (var grammar in s_extraGrammars) foreach (var grammar in s_extraGrammars)
{ {
if (grammar.Extension.Equals(extension, StringComparison.OrdinalIgnoreCase)) foreach (var ext in grammar.Extensions)
return grammar.Scope; {
if (ext.Equals(extension, StringComparison.OrdinalIgnoreCase))
return grammar.Scope;
}
} }
return reg.GetScopeByExtension(extension); return reg.GetScopeByExtension(extension);
@ -71,10 +73,10 @@ namespace SourceGit.Models
return reg.GetGrammar(scopeName); return reg.GetGrammar(scopeName);
} }
private record ExtraGrammar(string Scope, string Extension, string File) private record ExtraGrammar(string Scope, List<string> Extensions, string File)
{ {
public readonly string Scope = Scope; public readonly string Scope = Scope;
public readonly string Extension = Extension; public readonly List<string> Extensions = Extensions;
public readonly string File = File; public readonly string File = File;
} }
} }

View file

@ -43,6 +43,11 @@ namespace SourceGit.Models
return _caches.GetOrAdd(data, key => new User(key)); return _caches.GetOrAdd(data, key => new User(key));
} }
public override string ToString()
{
return $"{Name} <{Email}>";
}
private static ConcurrentDictionary<string, User> _caches = new ConcurrentDictionary<string, User>(); private static ConcurrentDictionary<string, User> _caches = new ConcurrentDictionary<string, User>();
private readonly int _hash; private readonly int _hash;
} }

View file

@ -8,12 +8,12 @@ namespace SourceGit.Models
{ {
public class Watcher : IDisposable public class Watcher : IDisposable
{ {
public Watcher(IRepository repo) public Watcher(IRepository repo, string fullpath, string gitDir)
{ {
_repo = repo; _repo = repo;
_wcWatcher = new FileSystemWatcher(); _wcWatcher = new FileSystemWatcher();
_wcWatcher.Path = _repo.FullPath; _wcWatcher.Path = fullpath;
_wcWatcher.Filter = "*"; _wcWatcher.Filter = "*";
_wcWatcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.DirectoryName | NotifyFilters.FileName | NotifyFilters.Size | NotifyFilters.CreationTime; _wcWatcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.DirectoryName | NotifyFilters.FileName | NotifyFilters.Size | NotifyFilters.CreationTime;
_wcWatcher.IncludeSubdirectories = true; _wcWatcher.IncludeSubdirectories = true;
@ -23,15 +23,8 @@ namespace SourceGit.Models
_wcWatcher.Deleted += OnWorkingCopyChanged; _wcWatcher.Deleted += OnWorkingCopyChanged;
_wcWatcher.EnableRaisingEvents = true; _wcWatcher.EnableRaisingEvents = true;
// If this repository is a worktree repository, just watch the main repository's gitdir.
var gitDirNormalized = _repo.GitDir.Replace("\\", "/");
var worktreeIdx = gitDirNormalized.IndexOf(".git/worktrees/", StringComparison.Ordinal);
var repoWatchDir = _repo.GitDir;
if (worktreeIdx > 0)
repoWatchDir = _repo.GitDir.Substring(0, worktreeIdx + 4);
_repoWatcher = new FileSystemWatcher(); _repoWatcher = new FileSystemWatcher();
_repoWatcher.Path = repoWatchDir; _repoWatcher.Path = gitDir;
_repoWatcher.Filter = "*"; _repoWatcher.Filter = "*";
_repoWatcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.DirectoryName | NotifyFilters.FileName; _repoWatcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.DirectoryName | NotifyFilters.FileName;
_repoWatcher.IncludeSubdirectories = true; _repoWatcher.IncludeSubdirectories = true;
@ -72,6 +65,11 @@ namespace SourceGit.Models
_updateBranch = DateTime.Now.ToFileTime() - 1; _updateBranch = DateTime.Now.ToFileTime() - 1;
} }
public void MarkTagDirtyManually()
{
_updateTags = DateTime.Now.ToFileTime() - 1;
}
public void MarkWorkingCopyDirtyManually() public void MarkWorkingCopyDirtyManually()
{ {
_updateWC = DateTime.Now.ToFileTime() - 1; _updateWC = DateTime.Now.ToFileTime() - 1;
@ -109,6 +107,7 @@ namespace SourceGit.Models
{ {
_updateBranch = 0; _updateBranch = 0;
_updateWC = 0; _updateWC = 0;
_updateSubmodules = 0;
if (_updateTags > 0) if (_updateTags > 0)
{ {
@ -119,6 +118,7 @@ namespace SourceGit.Models
Task.Run(_repo.RefreshBranches); Task.Run(_repo.RefreshBranches);
Task.Run(_repo.RefreshCommits); Task.Run(_repo.RefreshCommits);
Task.Run(_repo.RefreshWorkingCopyChanges); Task.Run(_repo.RefreshWorkingCopyChanges);
Task.Run(_repo.RefreshSubmodules);
Task.Run(_repo.RefreshWorktrees); Task.Run(_repo.RefreshWorktrees);
} }
@ -131,20 +131,20 @@ namespace SourceGit.Models
if (_updateSubmodules > 0 && now > _updateSubmodules) if (_updateSubmodules > 0 && now > _updateSubmodules)
{ {
_updateSubmodules = 0; _updateSubmodules = 0;
_repo.RefreshSubmodules(); Task.Run(_repo.RefreshSubmodules);
} }
if (_updateStashes > 0 && now > _updateStashes) if (_updateStashes > 0 && now > _updateStashes)
{ {
_updateStashes = 0; _updateStashes = 0;
_repo.RefreshStashes(); Task.Run(_repo.RefreshStashes);
} }
if (_updateTags > 0 && now > _updateTags) if (_updateTags > 0 && now > _updateTags)
{ {
_updateTags = 0; _updateTags = 0;
_repo.RefreshTags(); Task.Run(_repo.RefreshTags);
_repo.RefreshCommits(); Task.Run(_repo.RefreshCommits);
} }
} }
@ -173,12 +173,6 @@ namespace SourceGit.Models
(name.StartsWith("worktrees/", StringComparison.Ordinal) && name.EndsWith("/HEAD", StringComparison.Ordinal))) (name.StartsWith("worktrees/", StringComparison.Ordinal) && name.EndsWith("/HEAD", StringComparison.Ordinal)))
{ {
_updateBranch = DateTime.Now.AddSeconds(.5).ToFileTime(); _updateBranch = DateTime.Now.AddSeconds(.5).ToFileTime();
lock (_submodules)
{
if (_submodules.Count > 0)
_updateSubmodules = DateTime.Now.AddSeconds(1).ToFileTime();
}
} }
else if (name.StartsWith("objects/", StringComparison.Ordinal) || name.Equals("index", StringComparison.Ordinal)) else if (name.StartsWith("objects/", StringComparison.Ordinal) || name.Equals("index", StringComparison.Ordinal))
{ {
@ -195,7 +189,7 @@ namespace SourceGit.Models
if (name == ".git" || name.StartsWith(".git/", StringComparison.Ordinal)) if (name == ".git" || name.StartsWith(".git/", StringComparison.Ordinal))
return; return;
lock (_submodules) lock (_lockSubmodule)
{ {
foreach (var submodule in _submodules) foreach (var submodule in _submodules)
{ {

View file

@ -6,6 +6,7 @@ namespace SourceGit.Models
{ {
public string Branch { get; set; } = string.Empty; public string Branch { get; set; } = string.Empty;
public string FullPath { get; set; } = string.Empty; public string FullPath { get; set; } = string.Empty;
public string RelativePath { get; set; } = string.Empty;
public string Head { get; set; } = string.Empty; public string Head { get; set; } = string.Empty;
public bool IsBare { get; set; } = false; public bool IsBare { get; set; } = false;
public bool IsDetached { get; set; } = false; public bool IsDetached { get; set; } = false;
@ -21,15 +22,15 @@ namespace SourceGit.Models
get get
{ {
if (IsDetached) if (IsDetached)
return $"(deteched HEAD at {Head.Substring(10)})"; return $"deteched HEAD at {Head.Substring(10)}";
if (Branch.StartsWith("refs/heads/", System.StringComparison.Ordinal)) if (Branch.StartsWith("refs/heads/", System.StringComparison.Ordinal))
return $"({Branch.Substring(11)})"; return Branch.Substring(11);
if (Branch.StartsWith("refs/remotes/", System.StringComparison.Ordinal)) if (Branch.StartsWith("refs/remotes/", System.StringComparison.Ordinal))
return $"({Branch.Substring(13)})"; return Branch.Substring(13);
return $"({Branch})"; return Branch;
} }
} }

View file

@ -65,13 +65,16 @@ namespace SourceGit.Native
{ {
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var cwd = string.IsNullOrEmpty(workdir) ? home : workdir; var cwd = string.IsNullOrEmpty(workdir) ? home : workdir;
var terminal = OS.ShellOrTerminal;
var startInfo = new ProcessStartInfo(); var startInfo = new ProcessStartInfo();
startInfo.WorkingDirectory = cwd; startInfo.WorkingDirectory = cwd;
startInfo.FileName = OS.ShellOrTerminal; startInfo.FileName = terminal;
if (OS.ShellOrTerminal.EndsWith("wezterm", StringComparison.OrdinalIgnoreCase)) if (terminal.EndsWith("wezterm", StringComparison.OrdinalIgnoreCase))
startInfo.Arguments = $"start --cwd \"{cwd}\""; startInfo.Arguments = $"start --cwd \"{cwd}\"";
else if (terminal.EndsWith("ptyxis", StringComparison.OrdinalIgnoreCase))
startInfo.Arguments = $"--new-window --working-directory=\"{cwd}\"";
try try
{ {

View file

@ -18,14 +18,33 @@ namespace SourceGit.Native
DisableDefaultApplicationMenuItems = true, DisableDefaultApplicationMenuItems = true,
}); });
// Fix `PATH` env on macOS.
var path = Environment.GetEnvironmentVariable("PATH");
if (string.IsNullOrEmpty(path))
path = "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
else if (!path.Contains("/opt/homebrew/", StringComparison.Ordinal))
path = "/opt/homebrew/bin:/opt/homebrew/sbin:" + path;
var customPathFile = Path.Combine(OS.DataDir, "PATH"); var customPathFile = Path.Combine(OS.DataDir, "PATH");
if (File.Exists(customPathFile)) if (File.Exists(customPathFile))
OS.CustomPathEnv = File.ReadAllText(customPathFile).Trim(); {
var env = File.ReadAllText(customPathFile).Trim();
if (!string.IsNullOrEmpty(env))
path = env;
}
Environment.SetEnvironmentVariable("PATH", path);
} }
public string FindGitExecutable() public string FindGitExecutable()
{ {
return File.Exists("/usr/bin/git") ? "/usr/bin/git" : string.Empty; var gitPathVariants = new List<string>() {
"/usr/bin/git", "/usr/local/bin/git", "/opt/homebrew/bin/git", "/opt/homebrew/opt/git/bin/git"
};
foreach (var path in gitPathVariants)
if (File.Exists(path))
return path;
return string.Empty;
} }
public string FindTerminal(Models.ShellOrTerminal shell) public string FindTerminal(Models.ShellOrTerminal shell)
@ -36,6 +55,10 @@ namespace SourceGit.Native
return "Terminal"; return "Terminal";
case "iterm2": case "iterm2":
return "iTerm"; return "iTerm";
case "warp":
return "Warp";
case "ghostty":
return "Ghostty";
} }
return string.Empty; return string.Empty;

View file

@ -1,12 +1,15 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.IO; using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using Avalonia; using Avalonia;
namespace SourceGit.Native namespace SourceGit.Native
{ {
public static class OS public static partial class OS
{ {
public interface IBackend public interface IBackend
{ {
@ -22,11 +25,48 @@ namespace SourceGit.Native
void OpenWithDefaultEditor(string file); void OpenWithDefaultEditor(string file);
} }
public static string DataDir { get; private set; } = string.Empty; public static string DataDir
public static string GitExecutable { get; set; } = string.Empty; {
public static string ShellOrTerminal { get; set; } = string.Empty; get;
public static List<Models.ExternalTool> ExternalTools { get; set; } = []; private set;
public static string CustomPathEnv { get; set; } = string.Empty; } = string.Empty;
public static string GitExecutable
{
get => _gitExecutable;
set
{
if (_gitExecutable != value)
{
_gitExecutable = value;
UpdateGitVersion();
}
}
}
public static string GitVersionString
{
get;
private set;
} = string.Empty;
public static Version GitVersion
{
get;
private set;
} = new Version(0, 0, 0);
public static string ShellOrTerminal
{
get;
set;
} = string.Empty;
public static List<Models.ExternalTool> ExternalTools
{
get;
set;
} = [];
static OS() static OS()
{ {
@ -55,6 +95,17 @@ namespace SourceGit.Native
public static void SetupDataDir() public static void SetupDataDir()
{ {
if (OperatingSystem.IsWindows())
{
var execFile = Process.GetCurrentProcess().MainModule!.FileName;
var portableDir = Path.Combine(Path.GetDirectoryName(execFile), "data");
if (Directory.Exists(portableDir))
{
DataDir = portableDir;
return;
}
}
var osAppDataDir = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); var osAppDataDir = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
if (string.IsNullOrEmpty(osAppDataDir)) if (string.IsNullOrEmpty(osAppDataDir))
DataDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".sourcegit"); DataDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".sourcegit");
@ -111,6 +162,68 @@ namespace SourceGit.Native
_backend.OpenWithDefaultEditor(file); _backend.OpenWithDefaultEditor(file);
} }
public static string GetAbsPath(string root, string sub)
{
var fullpath = Path.Combine(root, sub);
if (OperatingSystem.IsWindows())
return fullpath.Replace('/', '\\');
return fullpath;
}
private static void UpdateGitVersion()
{
if (string.IsNullOrEmpty(_gitExecutable) || !File.Exists(_gitExecutable))
{
GitVersionString = string.Empty;
GitVersion = new Version(0, 0, 0);
return;
}
var start = new ProcessStartInfo();
start.FileName = _gitExecutable;
start.Arguments = "--version";
start.UseShellExecute = false;
start.CreateNoWindow = true;
start.RedirectStandardOutput = true;
start.RedirectStandardError = true;
start.StandardOutputEncoding = Encoding.UTF8;
start.StandardErrorEncoding = Encoding.UTF8;
var proc = new Process() { StartInfo = start };
try
{
proc.Start();
var rs = proc.StandardOutput.ReadToEnd();
proc.WaitForExit();
if (proc.ExitCode == 0 && !string.IsNullOrWhiteSpace(rs))
{
GitVersionString = rs.Trim();
var match = REG_GIT_VERSION().Match(GitVersionString);
if (match.Success)
{
var major = int.Parse(match.Groups[1].Value);
var minor = int.Parse(match.Groups[2].Value);
var build = int.Parse(match.Groups[3].Value);
GitVersion = new Version(major, minor, build);
GitVersionString = GitVersionString.Substring(11).Trim();
}
}
}
catch
{
// Ignore errors
}
proc.Close();
}
[GeneratedRegex(@"^git version[\s\w]*(\d+)\.(\d+)[\.\-](\d+).*$")]
private static partial Regex REG_GIT_VERSION();
private static IBackend _backend = null; private static IBackend _backend = null;
private static string _gitExecutable = string.Empty;
} }
} }

View file

@ -8,6 +8,7 @@ using System.Text;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Threading;
namespace SourceGit.Native namespace SourceGit.Native
{ {
@ -26,9 +27,21 @@ namespace SourceGit.Native
internal string szCSDVersion; internal string szCSDVersion;
} }
[DllImport("ntdll")] [StructLayout(LayoutKind.Sequential)]
internal struct MARGINS
{
public int cxLeftWidth;
public int cxRightWidth;
public int cyTopHeight;
public int cyBottomHeight;
}
[DllImport("ntdll.dll")]
private static extern int RtlGetVersion(ref RTL_OSVERSIONINFOEX lpVersionInformation); private static extern int RtlGetVersion(ref RTL_OSVERSIONINFOEX lpVersionInformation);
[DllImport("dwmapi.dll")]
private static extern int DwmExtendFrameIntoClientArea(IntPtr hwnd, ref MARGINS margins);
[DllImport("shlwapi.dll", CharSet = CharSet.Unicode, SetLastError = false)] [DllImport("shlwapi.dll", CharSet = CharSet.Unicode, SetLastError = false)]
private static extern bool PathFindOnPath([In, Out] StringBuilder pszFile, [In] string[] ppszOtherDirs); private static extern bool PathFindOnPath([In, Out] StringBuilder pszFile, [In] string[] ppszOtherDirs);
@ -140,7 +153,7 @@ namespace SourceGit.Native
public void OpenBrowser(string url) public void OpenBrowser(string url)
{ {
var info = new ProcessStartInfo("cmd", $"/c start {url}"); var info = new ProcessStartInfo("cmd", $"/c start \"\" \"{url}\"");
info.CreateNoWindow = true; info.CreateNoWindow = true;
Process.Start(info); Process.Start(info);
} }
@ -202,10 +215,17 @@ namespace SourceGit.Native
private void FixWindowFrameOnWin10(Window w) private void FixWindowFrameOnWin10(Window w)
{ {
if (w.WindowState == WindowState.Maximized || w.WindowState == WindowState.FullScreen) // Schedule the DWM frame extension to run in the next render frame
w.SystemDecorations = SystemDecorations.Full; // to ensure proper timing with the window initialization sequence
else if (w.WindowState == WindowState.Normal) Dispatcher.UIThread.InvokeAsync(() =>
w.SystemDecorations = SystemDecorations.BorderOnly; {
var platformHandle = w.TryGetPlatformHandle();
if (platformHandle == null)
return;
var margins = new MARGINS { cxLeftWidth = 1, cxRightWidth = 1, cyTopHeight = 1, cyBottomHeight = 1 };
DwmExtendFrameIntoClientArea(platformHandle.Handle, ref margins);
}, DispatcherPriority.Render);
} }
#region EXTERNAL_EDITOR_FINDER #region EXTERNAL_EDITOR_FINDER

View file

@ -1,7 +1,9 @@
{ {
"information_for_contributors": [ "information_for_contributors": [
"This file has been copied from https://github.com/vshaxe/haxe-TmLanguage/blob/ddad8b4c6d0781ac20be0481174ec1be772c5da5/haxe.tmLanguage", "This file has been copied from https://github.com/vshaxe/haxe-TmLanguage/blob/ddad8b4c6d0781ac20be0481174ec1be772c5da5/haxe.tmLanguage",
"and converted to JSON using https://marketplace.visualstudio.com/items?itemName=pedro-w.tmlanguage" "and converted to JSON using https://marketplace.visualstudio.com/items?itemName=pedro-w.tmlanguage",
"The original file was licensed under the MIT License",
"https://github.com/vshaxe/haxe-TmLanguage/blob/ddad8b4c6d0781ac20be0481174ec1be772c5da5/LICENSE.md"
], ],
"fileTypes": [ "fileTypes": [
"hx", "hx",
@ -2485,4 +2487,4 @@
"name": "variable.other.hx" "name": "variable.other.hx"
} }
} }
} }

View file

@ -1,7 +1,9 @@
{ {
"information_for_contributors": [ "information_for_contributors": [
"This file has been copied from https://github.com/vshaxe/haxe-TmLanguage/blob/ddad8b4c6d0781ac20be0481174ec1be772c5da5/hxml.tmLanguage", "This file has been copied from https://github.com/vshaxe/haxe-TmLanguage/blob/ddad8b4c6d0781ac20be0481174ec1be772c5da5/hxml.tmLanguage",
"and converted to JSON using https://marketplace.visualstudio.com/items?itemName=pedro-w.tmlanguage" "and converted to JSON using https://marketplace.visualstudio.com/items?itemName=pedro-w.tmlanguage",
"The original file was licensed under the MIT License",
"https://github.com/vshaxe/haxe-TmLanguage/blob/ddad8b4c6d0781ac20be0481174ec1be772c5da5/LICENSE.md"
], ],
"fileTypes": [ "fileTypes": [
"hxml" "hxml"
@ -67,4 +69,4 @@
], ],
"scopeName": "source.hxml", "scopeName": "source.hxml",
"uuid": "CB1B853A-C4C8-42C3-BA70-1B1605BE51C1" "uuid": "CB1B853A-C4C8-42C3-BA70-1B1605BE51C1"
} }

View file

@ -0,0 +1,100 @@
{
"information_for_contributors": [
"This file has been copied from https://github.com/samuel-weinhardt/vscode-jsp-lang/blob/0e89ecdb13650dbbe5a1e85b47b2e1530bf2f355/syntaxes/jsp.tmLanguage.json",
"The original file was licensed under the MIT License",
"https://github.com/samuel-weinhardt/vscode-jsp-lang/blob/0e89ecdb13650dbbe5a1e85b47b2e1530bf2f355/LICENSE"
],
"$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
"name": "Jakarta Server Pages",
"fileTypes": ["jsp", "jspf", "tag"],
"scopeName": "text.html.jsp",
"patterns": [
{ "include": "#comment" },
{ "include": "#directive" },
{ "include": "#expression" },
{ "include": "text.html.derivative" }
],
"injections": {
"L:text.html.jsp -comment -meta.tag.directive.jsp -meta.tag.scriptlet.jsp": {
"patterns": [
{ "include": "#scriptlet" }
],
"comment": "allow scriptlets anywhere except comments and nested"
},
"L:meta.attribute (string.quoted.single.html | string.quoted.double.html) -string.template.expression.jsp": {
"patterns": [
{ "include": "#expression" },
{ "include": "text.html.derivative" }
],
"comment": "allow expressions and tags within HTML attributes (not nested)"
}
},
"repository": {
"comment": {
"name": "comment.block.jsp",
"begin": "<%--",
"end": "--%>"
},
"directive": {
"name": "meta.tag.directive.jsp",
"begin": "(<)(%@)",
"end": "(%)(>)",
"beginCaptures": {
"1": { "name": "punctuation.definition.tag.jsp" },
"2": { "name": "entity.name.tag.jsp" }
},
"endCaptures": {
"1": { "name": "entity.name.tag.jsp" },
"2": { "name": "punctuation.definition.tag.jsp" }
},
"patterns": [
{
"match": "\\b(attribute|include|page|tag|taglib|variable)\\b(?!\\s*=)",
"name": "keyword.control.directive.jsp"
},
{ "include": "text.html.basic#attribute" }
]
},
"scriptlet": {
"name": "meta.tag.scriptlet.jsp",
"contentName": "meta.embedded.block.java",
"begin": "(<)(%[\\s!=])",
"end": "(%)(>)",
"beginCaptures": {
"1": { "name": "punctuation.definition.tag.jsp" },
"2": { "name": "entity.name.tag.jsp" }
},
"endCaptures": {
"1": { "name": "entity.name.tag.jsp" },
"2": { "name": "punctuation.definition.tag.jsp" }
},
"patterns": [
{
"match": "\\{(?=\\s*(%>|$))",
"comment": "consume trailing curly brackets for fragmented scriptlets"
},
{ "include": "source.java" }
]
},
"expression": {
"name": "string.template.expression.jsp",
"contentName": "meta.embedded.block.java",
"begin": "[$#]\\{",
"end": "\\}",
"beginCaptures": {
"0": { "name": "punctuation.definition.template-expression.begin.jsp" }
},
"endCaptures": {
"0": { "name": "punctuation.definition.template-expression.end.jsp" }
},
"patterns": [
{ "include": "#escape" },
{ "include": "source.java" }
]
},
"escape": {
"match": "\\\\.",
"name": "constant.character.escape.jsp"
}
}
}

Some files were not shown because too many files have changed in this diff Show more