diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json deleted file mode 100644 index 67d50ad4..00000000 --- a/.config/dotnet-tools.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "version": 1, - "isRoot": true, - "tools": { - } -} \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index dedc5722..22c741b9 100644 --- a/.editorconfig +++ b/.editorconfig @@ -206,6 +206,9 @@ dotnet_diagnostic.CA1854.severity = warning #CA2211:Non-constant fields should not be visible dotnet_diagnostic.CA2211.severity = error +# IDE0005: remove used namespace using +dotnet_diagnostic.IDE0005.severity = error + # Wrapping preferences csharp_wrap_before_ternary_opsigns = false @@ -292,3 +295,12 @@ indent_size = 2 end_of_line = lf [*.{cmd,bat}] end_of_line = crlf + +# Package manifests +[{*.spec,control}] +end_of_line = lf + +# YAML files +[*.{yml,yaml}] +indent_size = 2 +end_of_line = lf diff --git a/.gitattributes b/.gitattributes index 69139978..bd1dfea9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,78 +1,14 @@ -# Auto detect text files and perform LF normalization -# https://www.davidlaing.com/2012/09/19/customise-your-gitattributes-to-become-a-git-ninja/ * text=auto - -# -# The above will handle all files NOT found below -# - -# Documents -*.bibtex text diff=bibtex -*.doc diff=astextplain -*.DOC diff=astextplain -*.docx diff=astextplain -*.DOCX diff=astextplain -*.dot diff=astextplain -*.DOT diff=astextplain -*.pdf diff=astextplain -*.PDF diff=astextplain -*.rtf diff=astextplain -*.RTF diff=astextplain *.md text -*.tex text diff=tex -*.adoc text -*.textile text -*.mustache text -*.csv text -*.tab text -*.tsv text -*.txt text -*.sql text - -# Graphics *.png binary -*.jpg binary -*.jpeg binary -*.gif binary -*.tif binary -*.tiff binary *.ico binary -# SVG treated as an asset (binary) by default. -*.svg text -# If you want to treat it as binary, -# use the following line instead. -# *.svg binary -*.eps binary - -# Scripts -*.bash text eol=lf -*.fish text eol=lf *.sh text eol=lf -# These are explicitly windows files and should use crlf +*.spec text eol=lf +control text eol=lf *.bat text eol=crlf *.cmd text eol=crlf *.ps1 text eol=crlf - -# Serialisation *.json text -*.toml text -*.xml text -*.yaml text -*.yml text - -# Archives -*.7z binary -*.gz binary -*.tar binary -*.tgz binary -*.zip binary - -# Text files where line endings should be preserved -*.patch -text - -# -# Exclude files from exporting -# .gitattributes export-ignore -.gitignore export-ignore \ No newline at end of file +.gitignore export-ignore diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..12792cf6 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,79 @@ +name: Build +on: + workflow_call: +jobs: + build: + strategy: + matrix: + include: + - name : Windows x64 + os: windows-2019 + runtime: win-x64 + - name : Windows ARM64 + os: windows-2019 + runtime: win-arm64 + - name : macOS (Intel) + os: macos-13 + runtime: osx-x64 + - name : macOS (Apple Silicon) + os: macos-latest + runtime: osx-arm64 + - name : Linux + os: ubuntu-latest + runtime: linux-x64 + container: ubuntu:20.04 + - name : Linux (arm64) + os: ubuntu-latest + runtime: linux-arm64 + container: ubuntu:20.04 + name: Build ${{ matrix.name }} + runs-on: ${{ matrix.os }} + container: ${{ matrix.container || '' }} + 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 + uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + - name: Configure arm64 packages + if: ${{ matrix.runtime == 'linux-arm64' }} + run: | + sudo dpkg --add-architecture arm64 + echo 'deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports/ focal main restricted + deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports/ focal-updates main restricted + deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports/ focal-backports main restricted' \ + | sudo tee /etc/apt/sources.list.d/arm64.list + sudo sed -i -e 's/^deb http/deb [arch=amd64] http/g' /etc/apt/sources.list + sudo sed -i -e 's/^deb mirror/deb [arch=amd64] mirror/g' /etc/apt/sources.list + - name: Install cross-compiling dependencies + if: ${{ matrix.runtime == 'linux-arm64' }} + run: | + sudo apt-get update + sudo apt-get install -y llvm gcc-aarch64-linux-gnu + - name: Build + run: dotnet build -c Release + - name: Publish + run: dotnet publish src/SourceGit.csproj -c Release -o publish -r ${{ matrix.runtime }} + - name: Rename executable file + if: ${{ startsWith(matrix.runtime, 'linux-') }} + run: mv publish/SourceGit publish/sourcegit + - name: Tar artifact + if: ${{ startsWith(matrix.runtime, 'linux-') || startsWith(matrix.runtime, 'osx-') }} + run: | + tar -cvf "sourcegit.${{ matrix.runtime }}.tar" -C publish . + rm -r publish/* + mv "sourcegit.${{ matrix.runtime }}.tar" publish + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: sourcegit.${{ matrix.runtime }} + path: publish/* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index babb2f79..50e02dc9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,42 +1,29 @@ name: Continuous Integration on: push: - branches: - - master + branches: [develop] pull_request: - branches: [master] + branches: [develop] workflow_dispatch: + workflow_call: jobs: build: name: Build - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - platform: linux-x64 - - os: windows-latest - platform: win-x64 - - os: macos-latest - platform: osx-x64 - - os: macos-latest - platform: osx-arm64 - runs-on: ${{ matrix.os }} + uses: ./.github/workflows/build.yml + version: + name: Prepare version string + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} steps: - name: Checkout sources uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 8.0.x - - name: Build - run: dotnet build -c Release - - name: Publish - run: dotnet publish src/SourceGit/SourceGit.csproj -c Release -o publish -r ${{ matrix.platform }} -p:PublishAot=true -p:PublishTrimmed=true -p:TrimMode=link --self-contained - - name: Upload Artifact - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.platform }} - path: publish \ No newline at end of file + - name: Output version string + id: version + run: echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT" + package: + needs: [build, version] + name: Package + uses: ./.github/workflows/package.yml + with: + version: ${{ needs.version.outputs.version }} diff --git a/.github/workflows/localization-check.yml b/.github/workflows/localization-check.yml new file mode 100644 index 00000000..8dcd61c8 --- /dev/null +++ b/.github/workflows/localization-check.yml @@ -0,0 +1,41 @@ +name: Localization Check +on: + push: + branches: [ develop ] + paths: + - 'src/Resources/Locales/**' + workflow_dispatch: + workflow_call: + +jobs: + localization-check: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + + - name: Install dependencies + run: npm install fs-extra@11.2.0 path@0.12.7 xml2js@0.6.2 + + - name: Run localization check + run: node build/scripts/localization-check.js + + - name: Commit changes + run: | + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' + if [ -n "$(git status --porcelain)" ]; then + git add TRANSLATION.md src/Resources/Locales/*.axaml + git commit -m 'doc: Update translation status and sort locale files' + git push + else + echo "No changes to commit" + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml new file mode 100644 index 00000000..2dfc97fd --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,111 @@ +name: Package +on: + workflow_call: + inputs: + version: + description: SourceGit package version + required: true + type: string +jobs: + windows: + name: Package Windows + runs-on: windows-2019 + strategy: + matrix: + runtime: [ win-x64, win-arm64 ] + steps: + - name: Checkout sources + uses: actions/checkout@v4 + - name: Download build + uses: actions/download-artifact@v4 + with: + name: sourcegit.${{ matrix.runtime }} + path: build/SourceGit + - name: Package + shell: bash + env: + VERSION: ${{ inputs.version }} + RUNTIME: ${{ matrix.runtime }} + run: ./build/scripts/package.windows.sh + - name: Upload package artifact + uses: actions/upload-artifact@v4 + with: + name: package.${{ matrix.runtime }} + path: build/sourcegit_*.zip + - name: Delete temp artifacts + uses: geekyeggo/delete-artifact@v5 + with: + name: sourcegit.${{ matrix.runtime }} + osx-app: + name: Package macOS + runs-on: macos-latest + strategy: + matrix: + runtime: [osx-x64, osx-arm64] + steps: + - name: Checkout sources + uses: actions/checkout@v4 + - name: Download build + uses: actions/download-artifact@v4 + with: + name: sourcegit.${{ matrix.runtime }} + path: build + - name: Package + env: + VERSION: ${{ inputs.version }} + RUNTIME: ${{ matrix.runtime }} + run: | + mkdir build/SourceGit + tar -xf "build/sourcegit.${{ matrix.runtime }}.tar" -C build/SourceGit + ./build/scripts/package.osx-app.sh + - name: Upload package artifact + uses: actions/upload-artifact@v4 + with: + name: package.${{ matrix.runtime }} + path: build/sourcegit_*.zip + - name: Delete temp artifacts + uses: geekyeggo/delete-artifact@v5 + with: + name: sourcegit.${{ matrix.runtime }} + linux: + name: Package Linux + runs-on: ubuntu-latest + container: ubuntu:20.04 + strategy: + matrix: + runtime: [linux-x64, linux-arm64] + steps: + - name: Checkout sources + uses: actions/checkout@v4 + - name: Download package dependencies + run: | + export DEBIAN_FRONTEND=noninteractive + ln -fs /usr/share/zoneinfo/Etc/UTC /etc/localtime + 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 + uses: actions/download-artifact@v4 + with: + name: sourcegit.${{ matrix.runtime }} + path: build + - name: Package + env: + VERSION: ${{ inputs.version }} + RUNTIME: ${{ matrix.runtime }} + APPIMAGE_EXTRACT_AND_RUN: 1 + run: | + mkdir build/SourceGit + tar -xf "build/sourcegit.${{ matrix.runtime }}.tar" -C build/SourceGit + ./build/scripts/package.linux.sh + - name: Upload package artifacts + uses: actions/upload-artifact@v4 + with: + name: package.${{ matrix.runtime }} + path: | + build/sourcegit-*.AppImage + build/sourcegit_*.deb + build/sourcegit-*.rpm + - name: Delete temp artifacts + uses: geekyeggo/delete-artifact@v5 + with: + name: sourcegit.${{ matrix.runtime }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..e61e608b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,52 @@ +name: Release +on: + push: + tags: + - v* +jobs: + build: + name: Build + uses: ./.github/workflows/build.yml + version: + name: Prepare version string + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: Output version string + id: version + env: + TAG: ${{ github.ref_name }} + run: echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + package: + needs: [build, version] + name: Package + uses: ./.github/workflows/package.yml + with: + version: ${{ needs.version.outputs.version }} + release: + needs: [package, version] + name: Release + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout sources + uses: actions/checkout@v4 + - name: Create release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + VERSION: ${{ needs.version.outputs.version }} + run: gh release create "$TAG" -t "$VERSION" --notes-from-tag + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + pattern: package.* + path: packages + merge-multiple: true + - name: Upload assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + run: gh release upload "$TAG" packages/* diff --git a/.gitignore b/.gitignore index 05b24d8a..e686a534 100644 --- a/.gitignore +++ b/.gitignore @@ -1,425 +1,13 @@ -# User-specific files -*.rsuser -*.suo -*.user -*.userosscache -*.sln.docstates - -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Mono auto generated files -mono_crash.* - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -[Ww][Ii][Nn]32/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ -[Ll]ogs/ - -# Visual Studio 2015/2017 cache/options directory .vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ +.vscode/ +.idea/ -# Visual Studio 2017 auto generated files -Generated\ Files/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUnit -*.VisualState.xml -TestResult.xml -nunit-*.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# Benchmark Results -BenchmarkDotNet.Artifacts/ - -# .NET Core -project.lock.json -project.fragment.lock.json -artifacts/ - -# ASP.NET Scaffolding -ScaffoldingReadMe.txt - -# StyleCop -StyleCopReport.xml - -# Files built by Visual Studio -*_i.c -*_p.c -*_h.h -*.ilk -*.meta -*.obj -*.iobj -*.pch -*.pdb -*.ipdb -*.pgc -*.pgd -*.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*_wpftmp.csproj -*.log -*.tlog -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# Visual Studio Trace Files -*.e2e - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper -*.DotSettings.user - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# AxoCover is a Code Coverage Tool -.axoCover/* -!.axoCover/settings.json - -# Coverlet is a free, cross platform Code Coverage Tool -coverage*.json -coverage*.xml -coverage*.info - -# Visual Studio code coverage results -*.coverage -*.coveragexml - -# NCrunch -_NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# Note: Comment the next line if you want to checkin your web deploy settings, -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# NuGet Symbol Packages -*.snupkg -# The packages folder can be ignored because of Package Restore -**/[Pp]ackages/* -# except build/, which is used as an MSBuild target. -!**/[Pp]ackages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/[Pp]ackages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt -*.appx -*.appxbundle -*.appxupload - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!?*.[Cc]ache/ - -# Others -ClientBin/ -~$* -*~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -orleans.codegen.cs - -# Including strong name files can present a security risk -# (https://github.com/github/gitignore/pull/2483#issue-259490424) -#*.snk - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm -ServiceFabricBackup/ -*.rptproj.bak - -# SQL Server files -*.mdf -*.ldf -*.ndf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings -*.rptproj.rsuser -*- [Bb]ackup.rdl -*- [Bb]ackup ([0-9]).rdl -*- [Bb]ackup ([0-9][0-9]).rdl - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat -node_modules/ - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio 6 auto-generated project file (contains which files were open etc.) -*.vbp - -# Visual Studio 6 workspace and project file (working project files containing files to include in project) -*.dsw -*.dsp - -# Visual Studio 6 technical files - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# CodeRush personal settings -.cr/personal - -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc - -# Cake - Uncomment if you are using it -# tools/** -# !tools/packages.config - -# Nuke Build - Uncomment if you are using it -.nuke/temp - -# Tabs Studio -*.tss - -# Telerik's JustMock configuration file -*.jmconfig - -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs - -# OpenCover UI analysis results -OpenCover/ - -# Azure Stream Analytics local run output -ASALocalRun/ - -# MSBuild Binary and Structured Log -*.binlog - -# NVidia Nsight GPU debugger configuration file -*.nvuser - -# MFractors (Xamarin productivity tool) working folder -.mfractor/ - -# Local History for Visual Studio -.localhistory/ - -# Visual Studio History (VSHistory) files -.vshistory/ - -# BeatPulse healthcheck temp database -healthchecksdb - -# Backup folder for Package Reference Convert tool in Visual Studio 2017 -MigrationBackup/ - -# Ionide (cross platform F# VS Code tools) working folder -.ionide/ - -# Fody - auto-generated XML schema -FodyWeavers.xsd - -# VS Code files for those working on multiple tools -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json +*.sln.docstates +*.user +*.suo *.code-workspace -# Local History for Visual Studio Code -.history/ - -# Windows Installer files from build outputs -*.cab -*.msi -*.msix -*.msm -*.msp - -# JetBrains Rider -*.sln.iml - -### Linux ### - -# temporary files which can be created if a process still has a handle open of a deleted file -.fuse_hidden* - -# KDE directory preferences -.directory - -# Linux trash folder which might appear on any partition or disk -.Trash-* - -# .nfs files are created when an open file is removed but is still being accessed -.nfs* - -### macOS ### -# General .DS_Store -.AppleDouble -.LSOverride - -# Icon must end with two \r -Icon - -# Thumbnails -._* - -# Files that might appear in the root of a volume .DocumentRevisions-V100 .fseventsd .Spotlight-V100 @@ -428,182 +16,26 @@ Icon .VolumeIcon.icns .com.apple.timemachine.donotpresent -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -### macOS Patch ### -# iCloud generated files -*.icloud - -### Rider ### -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -.idea/ - -# User-specific stuff -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/**/usage.statistics.xml -.idea/**/dictionaries -.idea/**/shelf - -# AWS User-specific -.idea/**/aws.xml - -# Generated files -.idea/**/contentModel.xml - -# Sensitive or high-churn files -.idea/**/dataSources/ -.idea/**/dataSources.ids -.idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml -.idea/**/dbnavigator.xml - -# Gradle -.idea/**/gradle.xml -.idea/**/libraries - -# Gradle and Maven with auto-import -# When using Gradle or Maven with auto-import, you should exclude module files, -# since they will be recreated, and may cause churn. Uncomment if using -# auto-import. -# .idea/artifacts -# .idea/compiler.xml -# .idea/jarRepositories.xml -# .idea/modules.xml -# .idea/*.iml -# .idea/modules -# *.iml -# *.ipr - -# CMake -cmake-build-*/ - -# Mongo Explorer plugin -.idea/**/mongoSettings.xml - -# File-based project format -*.iws - -# IntelliJ -out/ - -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# JIRA plugin -atlassian-ide-plugin.xml - -# Cursive Clojure plugin -.idea/replstate.xml - -# SonarLint plugin -.idea/sonarlint/ - -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties - -# Editor-based Rest Client -.idea/httpRequests - -# Android studio 3.1+ serialized cache file -.idea/caches/build_file_checksums.ser - -### VisualStudioCode ### -!.vscode/*.code-snippets - -# Local History for Visual Studio Code - -# Built Visual Studio Code Extensions -*.vsix - -### VisualStudioCode Patch ### -# Ignore all local history of files -.history -.ionide - -### Windows ### -# Windows thumbnail cache files Thumbs.db Thumbs.db:encryptable ehthumbs.db ehthumbs_vista.db -# Dump file -*.stackdump - -# Folder config file -[Dd]esktop.ini - -# Recycle Bin used on file shares -$RECYCLE.BIN/ - -# Windows Installer files - -# Windows shortcuts -*.lnk - -### Specifics ### - -# Specials -*.zip -archives +bin/ +obj/ +# ignore ci node files +node_modules/ +package.json package-lock.json -*.private.env.json -**/**/[Dd]ata/*.json -**/**/[Dd]ata/*.csv -# SpecFlow -*.feature.cs - -# Azurite -*azurite*.json - -# Build Folders -[Pp]ublish -[Oo]utput -[Ss]cripts -[Tt]ests/[Rr]esults - -# LibraryManager -**/lib - -# BuildBundlerMinifier -*.min.* -*.map - -# Sass Output -**/css - -# SQLite files -*.db -*.sqlite3 -*.sqlite -*.db-journal -*.sqlite3-journal -*.sqlite-journal -*.db-shm -*.db-wal - -## SourceGit ### - -# Output folders. -[Bb]uild/[Ss]ource[Gg]it/ -[Bb]uild/[Ss]ource[Gg]it.app/ - -# Files -SourceGit.win-x64.zip -SourceGit.linux-x64.tar.gz -SourceGit.osx-x64.zip -SourceGit.osx-arm64.zip \ No newline at end of file +build/resources/ +build/SourceGit/ +build/SourceGit.app/ +build/*.zip +build/*.tar.gz +build/*.deb +build/*.rpm +build/*.AppImage +SourceGit.app/ +build.command +src/Properties/launchSettings.json diff --git a/Directory.Build.props b/Directory.Build.props deleted file mode 100644 index fa499273..00000000 --- a/Directory.Build.props +++ /dev/null @@ -1,5 +0,0 @@ - - - true - - \ No newline at end of file diff --git a/LICENSE b/LICENSE index dceab2d8..442ce085 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ 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 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 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 -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 21236fe0..f9ba3072 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,207 @@ -# SourceGit +# SourceGit - Opensource Git GUI client. -Opensource Git GUI client. +[](https://github.com/sourcegit-scm/sourcegit/stargazers) +[](https://github.com/sourcegit-scm/sourcegit/forks) +[](LICENSE) +[](https://github.com/sourcegit-scm/sourcegit/releases/latest) +[](https://github.com/sourcegit-scm/sourcegit/releases) ## Highlights * Supports Windows/macOS/Linux * Opensource/Free * Fast -* English/简体中文 +* Deutsch/English/Español/Français/Italiano/Português/Русский/Українська/简体中文/繁體中文/日本語/தமிழ் (Tamil) * Built-in light/dark themes +* Customize theme * Visual commit graph * Supports SSH access with each remote * GIT commands with GUI * Clone/Fetch/Pull/Push... + * Merge/Rebase/Reset/Revert/Cherry-pick... + * Amend/Reword/Squash + * Interactive rebase * Branches * Remotes * Tags * Stashes * Submodules + * Worktrees * Archive * Diff * Save as patch/apply * File histories * Blame * Revision Diffs -* GitFlow support + * Branch Diff + * Image Diff - Side-By-Side/Swipe/Blend +* Git command logs +* Search commits +* GitFlow +* Git LFS +* Bisect +* Issue Link +* Workspace +* Custom Action +* Using AI to generate commit message (C# port of [anjerodev/commitollama](https://github.com/anjerodev/commitollama)) -> **Linux** only tested on **Ubuntu 22.04** on **X11**. +> [!WARNING] +> **Linux** only tested on **Debian 12** on both **X11** & **Wayland**. -## How to use +## Translation Status -**To use this tool, you need to install Git first.** +You can find the current translation status in [TRANSLATION.md](https://github.com/sourcegit-scm/sourcegit/blob/develop/TRANSLATION.md) -You can download the latest stable from [Releases](https://github.com/sourcegit-scm/sourcegit/releases/latest) or download workflow artifacts from [Github Actions](https://github.com/sourcegit-scm/sourcegit/actions) to try this app based on each commits. +## How to Use + +**To use this tool, you need to install Git(>=2.25.1) first.** + +You can download the latest stable from [Releases](https://github.com/sourcegit-scm/sourcegit/releases/latest) or download workflow artifacts from [GitHub Actions](https://github.com/sourcegit-scm/sourcegit/actions) to try this app based on latest commits. + +This software creates a folder `$"{System.Environment.SpecialFolder.ApplicationData}/SourceGit"`, which is platform-dependent, to store user settings, downloaded avatars and crash logs. + +| OS | PATH | +|---------|-----------------------------------------------------| +| Windows | `%APPDATA%\SourceGit` | +| Linux | `${HOME}/.config/SourceGit` or `${HOME}/.sourcegit` | +| macOS | `${HOME}/Library/Application Support/SourceGit` | + +> [!TIP] +> * 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: -**MSYS Git is NOT supported**. Please use official [Git for Windows](https://git-scm.com/download/win) instead. +* **MSYS Git is NOT supported**. Please use official [Git for Windows](https://git-scm.com/download/win) instead. +* You can install the latest stable from `winget` with follow commands: + ```shell + winget install SourceGit + ``` +> [!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. +* You can install the latest stable by `scoop` with follow commands: + ```shell + scoop bucket add extras + scoop install sourcegit + ``` +* Pre-built binaries can be found in [Releases](https://github.com/sourcegit-scm/sourcegit/releases/latest) For **macOS** users: -* Download `SourceGit.osx-x64.zip` or `SourceGit.osx-arm64.zip` from Releases. `x64` for Intel and `arm64` for Apple Silicon. -* Move `SourceGit.app` to `Applications` folder. -* Make sure your mac trusts all software from anywhere. For more information, search `spctl --master-disable`. +* Thanks [@ybeapps](https://github.com/ybeapps) for making `SourceGit` available on `Homebrew`. You can simply install it with following command: + ```shell + brew tap ybeapps/homebrew-sourcegit + brew install --cask --no-quarantine sourcegit + ``` +* If you want to install `SourceGit.app` from GitHub Release manually, you need run following command to make sure it works: + ```shell + sudo xattr -cr /Applications/SourceGit.app + ``` * Make sure [git-credential-manager](https://github.com/git-ecosystem/git-credential-manager/releases) is installed on your mac. -* You may need to run `sudo xattr -cr /Applications/SourceGit.app` to make sure the software works. +* You can run `echo $PATH > ~/Library/Application\ Support/SourceGit/PATH` to generate a custom PATH env file to introduce `PATH` env to SourceGit. For **Linux** users: -* `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, and it requires `ttf-mscorefonts-installer` installed. -* Maybe you need to set environment variable `AVALONIA_SCREEN_SCALE_FACTORS`. See https://github.com/AvaloniaUI/Avalonia/wiki/Configuring-X11-per-monitor-DPI. -* Modify `SourceGit.desktop.template` (replace SOURCEGIT_LOCAL_FOLDER with real path) and move it into `~/.local/share/applications`. +* Thanks [@aikawayataro](https://github.com/aikawayataro) for providing `rpm` and `deb` repositories, hosted on [Codeberg](https://codeberg.org/yataro/-/packages). -## Screen Shots + `deb` how to: + ```shell + 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 + ``` + + `rpm` how to: + ```shell + 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 + ``` + + If your distribution isn't using `dnf`, please refer to the documentation of your distribution on how to add an `rpm` repository. +* `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. +* 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`. + +## OpenAI + +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`: + +* `Server` must be `https://api.openai.com/v1` + +For other AI service: + +* 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 + +## External Tools + +This app supports open repository in external tools listed in the table below. + +| Tool | Windows | macOS | Linux | +|-------------------------------|---------|-------|-------| +| Visual Studio Code | YES | YES | YES | +| Visual Studio Code - Insiders | YES | YES | YES | +| VSCodium | YES | YES | YES | +| Fleet | YES | YES | YES | +| Sublime Text | YES | YES | YES | +| Zed | NO | YES | YES | +| Visual Studio | YES | NO | NO | + +> [!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. +> 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 +{ + "tools": { + "Visual Studio Code": "D:\\VSCode\\Code.exe" + } +} +``` + +> [!NOTE] +> This app also supports a lot of `JetBrains` IDEs, installing `JetBrains Toolbox` will help this app to find them. + +## Screenshots * Dark Theme - +  * Light Theme - +  + +* Custom + + You can find custom themes from [sourcegit-theme](https://github.com/sourcegit-scm/sourcegit-theme.git). And welcome to share your own themes. ## Contributing +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. - - - +[](https://github.com/sourcegit-scm/sourcegit/graphs/contributors) + +## Third-Party Components + +For detailed license information, see [THIRD-PARTY-LICENSES.md](THIRD-PARTY-LICENSES.md). diff --git a/SourceGit.sln b/SourceGit.sln index 39177369..624322f8 100644 --- a/SourceGit.sln +++ b/SourceGit.sln @@ -1,49 +1,88 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 +VisualStudioVersion = 17.9.34714.143 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SourceGit", "src\SourceGit\SourceGit.csproj", "{CD98D9AA-079A-4A79-9212-850EB97CF2ED}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SourceGit", "src\SourceGit.csproj", "{2091C34D-4A17-4375-BEF3-4D60BE8113E4}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C2487DDC-93D3-485F-943D-5DDF23B92A57}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{773082AC-D9C8-4186-8521-4B6A7BEE6158}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".files", ".files", "{2850A14C-4E9A-42EF-9559-C83610D4FBDE}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "resources", "resources", "{FD384607-ED99-47B7-AF31-FB245841BC92}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{F45A9D95-AF25-42D8-BBAC-8259C9EEE820}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{67B6D05F-A000-40BA-ADB4-C9065F880D7B}" + ProjectSection(SolutionItems) = preProject + .github\workflows\build.yml = .github\workflows\build.yml + .github\workflows\ci.yml = .github\workflows\ci.yml + .github\workflows\package.yml = .github\workflows\package.yml + .github\workflows\release.yml = .github\workflows\release.yml + .github\workflows\localization-check.yml = .github\workflows\localization-check.yml + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{49A7C2D6-558C-4FAA-8F5D-EEE81497AED7}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "files", "files", "{3AB707DB-A02C-4AFC-BF12-D7DF2B333BAC}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig + .gitattributes = .gitattributes .gitignore = .gitignore + global.json = global.json LICENSE = LICENSE README.md = README.md - .gitattributes = .gitattributes - Directory.Build.props = Directory.Build.props - global.json = global.json + VERSION = VERSION EndProjectSection EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{39979501-8501-43A7-AD23-F0BCF02CADB3}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".config", ".config", "{B7D4C4BA-BD7E-4B36-A0E2-3B226F419B8F}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "app", "app", "{ABC98884-F023-4EF4-A9C9-5DE9452BE955}" ProjectSection(SolutionItems) = preProject - .config\dotnet-tools.json = .config\dotnet-tools.json + build\resources\app\App.icns = build\resources\app\App.icns + build\resources\app\App.plist = build\resources\app\App.plist EndProjectSection EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".build", ".build", "{E6B9E250-EA2D-4665-831D-A547F707718D}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_common", "_common", "{04FD74B1-FBDB-496E-A48F-3D59D71FF952}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "usr", "usr", "{76639799-54BC-45E8-BD90-F45F63ACD11D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "share", "share", "{A3ABAA7C-EE14-4448-B466-6E69C1347E7D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "applications", "applications", "{2AF28D3B-14A8-46A8-B828-157FAAB1B06F}" ProjectSection(SolutionItems) = preProject - build\build.linux.sh = build\build.linux.sh - build\build.osx.command = build\build.osx.command - build\build.windows.bat = build\build.windows.bat + build\resources\_common\usr\share\applications\sourcegit.desktop = build\resources\_common\usr\share\applications\sourcegit.desktop EndProjectSection EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "resources", "resources", "{EAB376FC-F69F-4EDB-8E04-D3F326A9F015}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "icons", "icons", "{7166EC6C-17F5-4B5E-B38E-1E53C81EACF6}" ProjectSection(SolutionItems) = preProject - build\resources\App.icns = build\resources\App.icns - build\resources\App.plist = build\resources\App.plist - build\resources\SourceGit.desktop.template = build\resources\SourceGit.desktop.template + build\resources\_common\usr\share\icons\sourcegit.png = build\resources\_common\usr\share\icons\sourcegit.png EndProjectSection EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{8E67D51A-1C97-4B57-A0C4-C7916A6E983D}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deb", "deb", "{9C2F0CDA-B56E-44A5-94B6-F3EA7AC20CDC}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{8F83FE02-B6BB-4F96-8532-ACC5B8E3C33F}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DEBIAN", "DEBIAN", "{F101849D-BDB7-40D4-A516-751150C3CCFC}" ProjectSection(SolutionItems) = preProject - .github\workflows\ci.yml = .github\workflows\ci.yml + 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 +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "rpm", "rpm", "{9BA0B044-0CC9-46F8-B551-204F149BF45D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SPECS", "SPECS", "{7802CD7A-591B-4EDD-96F8-9BF3F61692E4}" + ProjectSection(SolutionItems) = preProject + build\resources\rpm\SPECS\build.spec = build\resources\rpm\SPECS\build.spec + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "appimage", "appimage", "{5D125DD9-B48A-491F-B2FB-D7830D74C4DC}" + ProjectSection(SolutionItems) = preProject + build\resources\appimage\sourcegit.appdata.xml = build\resources\appimage\sourcegit.appdata.xml + build\resources\appimage\sourcegit.png = build\resources\appimage\sourcegit.png + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "scripts", "scripts", "{C54D4001-9940-477C-A0B6-E795ED0A3209}" + ProjectSection(SolutionItems) = preProject + build\scripts\localization-check.js = build\scripts\localization-check.js + 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.windows.sh = build\scripts\package.windows.sh EndProjectSection EndProject Global @@ -51,18 +90,33 @@ Global Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2091C34D-4A17-4375-BEF3-4D60BE8113E4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2091C34D-4A17-4375-BEF3-4D60BE8113E4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2091C34D-4A17-4375-BEF3-4D60BE8113E4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2091C34D-4A17-4375-BEF3-4D60BE8113E4}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {CD98D9AA-079A-4A79-9212-850EB97CF2ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {CD98D9AA-079A-4A79-9212-850EB97CF2ED}.Debug|Any CPU.Build.0 = Debug|Any CPU - {CD98D9AA-079A-4A79-9212-850EB97CF2ED}.Release|Any CPU.ActiveCfg = Release|Any CPU - {CD98D9AA-079A-4A79-9212-850EB97CF2ED}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection GlobalSection(NestedProjects) = preSolution - {CD98D9AA-079A-4A79-9212-850EB97CF2ED} = {C2487DDC-93D3-485F-943D-5DDF23B92A57} - {EAB376FC-F69F-4EDB-8E04-D3F326A9F015} = {E6B9E250-EA2D-4665-831D-A547F707718D} - {8F83FE02-B6BB-4F96-8532-ACC5B8E3C33F} = {8E67D51A-1C97-4B57-A0C4-C7916A6E983D} + {2091C34D-4A17-4375-BEF3-4D60BE8113E4} = {49A7C2D6-558C-4FAA-8F5D-EEE81497AED7} + {FD384607-ED99-47B7-AF31-FB245841BC92} = {773082AC-D9C8-4186-8521-4B6A7BEE6158} + {67B6D05F-A000-40BA-ADB4-C9065F880D7B} = {F45A9D95-AF25-42D8-BBAC-8259C9EEE820} + {ABC98884-F023-4EF4-A9C9-5DE9452BE955} = {FD384607-ED99-47B7-AF31-FB245841BC92} + {04FD74B1-FBDB-496E-A48F-3D59D71FF952} = {FD384607-ED99-47B7-AF31-FB245841BC92} + {76639799-54BC-45E8-BD90-F45F63ACD11D} = {04FD74B1-FBDB-496E-A48F-3D59D71FF952} + {A3ABAA7C-EE14-4448-B466-6E69C1347E7D} = {76639799-54BC-45E8-BD90-F45F63ACD11D} + {2AF28D3B-14A8-46A8-B828-157FAAB1B06F} = {A3ABAA7C-EE14-4448-B466-6E69C1347E7D} + {7166EC6C-17F5-4B5E-B38E-1E53C81EACF6} = {A3ABAA7C-EE14-4448-B466-6E69C1347E7D} + {9C2F0CDA-B56E-44A5-94B6-F3EA7AC20CDC} = {FD384607-ED99-47B7-AF31-FB245841BC92} + {F101849D-BDB7-40D4-A516-751150C3CCFC} = {9C2F0CDA-B56E-44A5-94B6-F3EA7AC20CDC} + {9BA0B044-0CC9-46F8-B551-204F149BF45D} = {FD384607-ED99-47B7-AF31-FB245841BC92} + {7802CD7A-591B-4EDD-96F8-9BF3F61692E4} = {9BA0B044-0CC9-46F8-B551-204F149BF45D} + {5D125DD9-B48A-491F-B2FB-D7830D74C4DC} = {FD384607-ED99-47B7-AF31-FB245841BC92} + {C54D4001-9940-477C-A0B6-E795ED0A3209} = {773082AC-D9C8-4186-8521-4B6A7BEE6158} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {7FF1B9C6-B5BF-4A50-949F-4B407A0E31C9} EndGlobalSection EndGlobal diff --git a/THIRD-PARTY-LICENSES.md b/THIRD-PARTY-LICENSES.md new file mode 100644 index 00000000..2338263c --- /dev/null +++ b/THIRD-PARTY-LICENSES.md @@ -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 diff --git a/TRANSLATION.md b/TRANSLATION.md new file mode 100644 index 00000000..ba51b82c --- /dev/null +++ b/TRANSLATION.md @@ -0,0 +1,496 @@ +# Translation Status + +This document shows the translation status of each locale file in the repository. + +## Details + +###  + +###  + + +Missing keys in de_DE.axaml + +- Text.Avatar.Load +- Text.BranchCM.ResetToSelectedCommit +- Text.CommitDetail.Changes.Count +- Text.CreateBranch.OverwriteExisting +- Text.DeinitSubmodule +- Text.DeinitSubmodule.Force +- Text.DeinitSubmodule.Path +- Text.Diff.Submodule.Deleted +- Text.GitFlow.FinishWithPush +- Text.GitFlow.FinishWithSquash +- Text.Hotkeys.Global.SwitchWorkspace +- Text.Hotkeys.Global.SwitchTab +- Text.Hotkeys.TextEditor.OpenExternalMergeTool +- Text.Launcher.Workspaces +- Text.Launcher.Pages +- Text.Pull.RecurseSubmodules +- Text.Repository.ClearStashes +- Text.Repository.ShowSubmodulesAsTree +- Text.ResetWithoutCheckout +- Text.ResetWithoutCheckout.MoveTo +- Text.ResetWithoutCheckout.Target +- Text.Submodule.Deinit +- Text.Submodule.Status +- Text.Submodule.Status.Modified +- Text.Submodule.Status.NotInited +- Text.Submodule.Status.RevisionChanged +- Text.Submodule.Status.Unmerged +- Text.Submodule.URL +- Text.WorkingCopy.ResetAuthor + + + +###  + + +Missing keys in es_ES.axaml + +- Text.Avatar.Load + + + +###  + + +Missing keys in fr_FR.axaml + +- Text.Avatar.Load +- Text.Bisect +- Text.Bisect.Abort +- Text.Bisect.Bad +- Text.Bisect.Detecting +- Text.Bisect.Good +- Text.Bisect.Skip +- Text.Bisect.WaitingForRange +- Text.BranchCM.ResetToSelectedCommit +- Text.Checkout.RecurseSubmodules +- Text.CommitCM.CopyAuthor +- Text.CommitCM.CopyCommitter +- Text.CommitCM.CopySubject +- Text.CommitDetail.Changes.Count +- Text.CommitMessageTextBox.SubjectCount +- Text.Configure.Git.PreferredMergeMode +- Text.ConfirmEmptyCommit.Continue +- Text.ConfirmEmptyCommit.NoLocalChanges +- Text.ConfirmEmptyCommit.StageAllThenCommit +- Text.ConfirmEmptyCommit.WithLocalChanges +- Text.CreateBranch.OverwriteExisting +- Text.DeinitSubmodule +- Text.DeinitSubmodule.Force +- Text.DeinitSubmodule.Path +- Text.Diff.Submodule.Deleted +- Text.GitFlow.FinishWithPush +- Text.GitFlow.FinishWithSquash +- Text.Hotkeys.Global.SwitchWorkspace +- Text.Hotkeys.Global.SwitchTab +- Text.Hotkeys.TextEditor.OpenExternalMergeTool +- Text.Launcher.Workspaces +- Text.Launcher.Pages +- Text.Preferences.Git.IgnoreCRAtEOLInDiff +- Text.Pull.RecurseSubmodules +- Text.Repository.BranchSort +- Text.Repository.BranchSort.ByCommitterDate +- Text.Repository.BranchSort.ByName +- Text.Repository.ClearStashes +- Text.Repository.Search.ByContent +- Text.Repository.ShowSubmodulesAsTree +- Text.Repository.ViewLogs +- Text.Repository.Visit +- Text.ResetWithoutCheckout +- Text.ResetWithoutCheckout.MoveTo +- Text.ResetWithoutCheckout.Target +- Text.Submodule.Deinit +- Text.Submodule.Status +- Text.Submodule.Status.Modified +- Text.Submodule.Status.NotInited +- Text.Submodule.Status.RevisionChanged +- Text.Submodule.Status.Unmerged +- Text.Submodule.URL +- Text.ViewLogs +- Text.ViewLogs.Clear +- Text.ViewLogs.CopyLog +- Text.ViewLogs.Delete +- Text.WorkingCopy.ConfirmCommitWithFilter +- Text.WorkingCopy.Conflicts.OpenExternalMergeTool +- Text.WorkingCopy.Conflicts.OpenExternalMergeToolAllConflicts +- Text.WorkingCopy.Conflicts.UseMine +- Text.WorkingCopy.Conflicts.UseTheirs +- Text.WorkingCopy.ResetAuthor + + + +###  + + +Missing keys in it_IT.axaml + +- Text.Avatar.Load +- Text.BranchCM.ResetToSelectedCommit +- Text.CommitDetail.Changes.Count +- Text.CreateBranch.OverwriteExisting +- Text.DeinitSubmodule +- Text.DeinitSubmodule.Force +- Text.DeinitSubmodule.Path +- Text.Diff.Submodule.Deleted +- Text.Hotkeys.Global.SwitchWorkspace +- Text.Hotkeys.Global.SwitchTab +- Text.Launcher.Workspaces +- Text.Launcher.Pages +- Text.Pull.RecurseSubmodules +- Text.Repository.ClearStashes +- Text.ResetWithoutCheckout +- Text.ResetWithoutCheckout.MoveTo +- Text.ResetWithoutCheckout.Target +- Text.Submodule.Deinit +- Text.WorkingCopy.ResetAuthor + + + +###  + + +Missing keys in ja_JP.axaml + +- Text.Avatar.Load +- Text.Bisect +- Text.Bisect.Abort +- Text.Bisect.Bad +- Text.Bisect.Detecting +- Text.Bisect.Good +- Text.Bisect.Skip +- Text.Bisect.WaitingForRange +- Text.BranchCM.CompareWithCurrent +- Text.BranchCM.ResetToSelectedCommit +- Text.Checkout.RecurseSubmodules +- Text.CommitCM.CopyAuthor +- Text.CommitCM.CopyCommitter +- Text.CommitCM.CopySubject +- Text.CommitDetail.Changes.Count +- Text.CommitMessageTextBox.SubjectCount +- Text.Configure.Git.PreferredMergeMode +- Text.ConfirmEmptyCommit.Continue +- Text.ConfirmEmptyCommit.NoLocalChanges +- Text.ConfirmEmptyCommit.StageAllThenCommit +- Text.ConfirmEmptyCommit.WithLocalChanges +- Text.CreateBranch.OverwriteExisting +- Text.DeinitSubmodule +- Text.DeinitSubmodule.Force +- Text.DeinitSubmodule.Path +- Text.Diff.Submodule.Deleted +- Text.GitFlow.FinishWithPush +- Text.GitFlow.FinishWithSquash +- Text.Hotkeys.Global.SwitchWorkspace +- Text.Hotkeys.Global.SwitchTab +- Text.Hotkeys.TextEditor.OpenExternalMergeTool +- Text.Launcher.Workspaces +- Text.Launcher.Pages +- Text.Preferences.Git.IgnoreCRAtEOLInDiff +- Text.Pull.RecurseSubmodules +- Text.Repository.BranchSort +- Text.Repository.BranchSort.ByCommitterDate +- Text.Repository.BranchSort.ByName +- Text.Repository.ClearStashes +- Text.Repository.FilterCommits +- Text.Repository.Search.ByContent +- Text.Repository.ShowSubmodulesAsTree +- Text.Repository.ViewLogs +- Text.Repository.Visit +- Text.ResetWithoutCheckout +- Text.ResetWithoutCheckout.MoveTo +- Text.ResetWithoutCheckout.Target +- Text.Submodule.Deinit +- Text.Submodule.Status +- Text.Submodule.Status.Modified +- Text.Submodule.Status.NotInited +- Text.Submodule.Status.RevisionChanged +- Text.Submodule.Status.Unmerged +- Text.Submodule.URL +- Text.ViewLogs +- Text.ViewLogs.Clear +- Text.ViewLogs.CopyLog +- Text.ViewLogs.Delete +- Text.WorkingCopy.ConfirmCommitWithFilter +- Text.WorkingCopy.Conflicts.OpenExternalMergeTool +- Text.WorkingCopy.Conflicts.OpenExternalMergeToolAllConflicts +- Text.WorkingCopy.Conflicts.UseMine +- Text.WorkingCopy.Conflicts.UseTheirs +- Text.WorkingCopy.ResetAuthor + + + +###  + + +Missing keys in pt_BR.axaml + +- Text.AIAssistant.Regen +- Text.AIAssistant.Use +- Text.ApplyStash +- Text.ApplyStash.DropAfterApply +- Text.ApplyStash.RestoreIndex +- Text.ApplyStash.Stash +- Text.Avatar.Load +- Text.Bisect +- Text.Bisect.Abort +- Text.Bisect.Bad +- Text.Bisect.Detecting +- Text.Bisect.Good +- Text.Bisect.Skip +- Text.Bisect.WaitingForRange +- Text.BranchCM.CustomAction +- Text.BranchCM.MergeMultiBranches +- Text.BranchCM.ResetToSelectedCommit +- Text.BranchUpstreamInvalid +- Text.Checkout.RecurseSubmodules +- Text.Clone.RecurseSubmodules +- Text.CommitCM.CopyAuthor +- Text.CommitCM.CopyCommitter +- Text.CommitCM.CopySubject +- Text.CommitCM.Merge +- Text.CommitCM.MergeMultiple +- Text.CommitDetail.Changes.Count +- Text.CommitDetail.Files.Search +- Text.CommitDetail.Info.Children +- Text.CommitMessageTextBox.SubjectCount +- Text.Configure.CustomAction.Scope.Branch +- Text.Configure.CustomAction.WaitForExit +- Text.Configure.Git.PreferredMergeMode +- Text.Configure.IssueTracker.AddSampleGiteeIssue +- Text.Configure.IssueTracker.AddSampleGiteePullRequest +- Text.ConfirmEmptyCommit.Continue +- Text.ConfirmEmptyCommit.NoLocalChanges +- Text.ConfirmEmptyCommit.StageAllThenCommit +- Text.ConfirmEmptyCommit.WithLocalChanges +- Text.CopyFullPath +- Text.CreateBranch.Name.WarnSpace +- Text.CreateBranch.OverwriteExisting +- Text.DeinitSubmodule +- Text.DeinitSubmodule.Force +- Text.DeinitSubmodule.Path +- Text.DeleteRepositoryNode.Path +- Text.DeleteRepositoryNode.TipForGroup +- Text.DeleteRepositoryNode.TipForRepository +- Text.Diff.First +- Text.Diff.Last +- Text.Diff.Submodule.Deleted +- Text.Diff.UseBlockNavigation +- Text.Fetch.Force +- Text.FileCM.ResolveUsing +- Text.GitFlow.FinishWithPush +- Text.GitFlow.FinishWithSquash +- Text.Hotkeys.Global.Clone +- Text.Hotkeys.Global.SwitchWorkspace +- Text.Hotkeys.Global.SwitchTab +- Text.Hotkeys.TextEditor.OpenExternalMergeTool +- Text.InProgress.CherryPick.Head +- Text.InProgress.Merge.Operating +- Text.InProgress.Rebase.StoppedAt +- Text.InProgress.Revert.Head +- Text.Launcher.Workspaces +- Text.Launcher.Pages +- Text.Merge.Source +- Text.MergeMultiple +- Text.MergeMultiple.CommitChanges +- Text.MergeMultiple.Strategy +- Text.MergeMultiple.Targets +- Text.Preferences.AI.Streaming +- Text.Preferences.Appearance.EditorTabWidth +- Text.Preferences.General.DateFormat +- Text.Preferences.General.ShowChildren +- Text.Preferences.General.ShowTagsInGraph +- Text.Preferences.Git.IgnoreCRAtEOLInDiff +- Text.Preferences.Git.SSLVerify +- Text.Pull.RecurseSubmodules +- Text.Repository.BranchSort +- Text.Repository.BranchSort.ByCommitterDate +- Text.Repository.BranchSort.ByName +- Text.Repository.ClearStashes +- Text.Repository.FilterCommits +- Text.Repository.HistoriesLayout +- Text.Repository.HistoriesLayout.Horizontal +- Text.Repository.HistoriesLayout.Vertical +- Text.Repository.HistoriesOrder +- Text.Repository.Notifications.Clear +- Text.Repository.OnlyHighlightCurrentBranchInHistories +- Text.Repository.Search.ByContent +- Text.Repository.ShowSubmodulesAsTree +- Text.Repository.Skip +- Text.Repository.Tags.OrderByCreatorDate +- Text.Repository.Tags.OrderByName +- Text.Repository.Tags.Sort +- Text.Repository.UseRelativeTimeInHistories +- Text.Repository.ViewLogs +- Text.Repository.Visit +- Text.ResetWithoutCheckout +- Text.ResetWithoutCheckout.MoveTo +- Text.ResetWithoutCheckout.Target +- Text.SetUpstream +- Text.SetUpstream.Local +- Text.SetUpstream.Unset +- Text.SetUpstream.Upstream +- Text.SHALinkCM.NavigateTo +- Text.Stash.AutoRestore +- Text.Stash.AutoRestore.Tip +- Text.StashCM.SaveAsPatch +- Text.Submodule.Deinit +- Text.Submodule.Status +- Text.Submodule.Status.Modified +- Text.Submodule.Status.NotInited +- Text.Submodule.Status.RevisionChanged +- Text.Submodule.Status.Unmerged +- Text.Submodule.URL +- Text.ViewLogs +- Text.ViewLogs.Clear +- Text.ViewLogs.CopyLog +- Text.ViewLogs.Delete +- Text.WorkingCopy.CommitToEdit +- Text.WorkingCopy.ConfirmCommitWithFilter +- Text.WorkingCopy.Conflicts.OpenExternalMergeTool +- Text.WorkingCopy.Conflicts.OpenExternalMergeToolAllConflicts +- Text.WorkingCopy.Conflicts.UseMine +- Text.WorkingCopy.Conflicts.UseTheirs +- Text.WorkingCopy.ResetAuthor +- Text.WorkingCopy.SignOff + + + +###  + +###  + + +Missing keys in ta_IN.axaml + +- Text.Avatar.Load +- Text.Bisect +- Text.Bisect.Abort +- Text.Bisect.Bad +- Text.Bisect.Detecting +- Text.Bisect.Good +- Text.Bisect.Skip +- Text.Bisect.WaitingForRange +- Text.BranchCM.CompareWithCurrent +- Text.BranchCM.ResetToSelectedCommit +- Text.Checkout.RecurseSubmodules +- Text.CommitCM.CopyAuthor +- Text.CommitCM.CopyCommitter +- Text.CommitCM.CopySubject +- Text.CommitDetail.Changes.Count +- Text.CommitMessageTextBox.SubjectCount +- Text.Configure.Git.PreferredMergeMode +- Text.ConfirmEmptyCommit.Continue +- Text.ConfirmEmptyCommit.NoLocalChanges +- Text.ConfirmEmptyCommit.StageAllThenCommit +- Text.ConfirmEmptyCommit.WithLocalChanges +- Text.CreateBranch.OverwriteExisting +- Text.DeinitSubmodule +- Text.DeinitSubmodule.Force +- Text.DeinitSubmodule.Path +- Text.Diff.Submodule.Deleted +- Text.GitFlow.FinishWithPush +- Text.GitFlow.FinishWithSquash +- Text.Hotkeys.Global.SwitchWorkspace +- Text.Hotkeys.Global.SwitchTab +- Text.Hotkeys.TextEditor.OpenExternalMergeTool +- Text.Launcher.Workspaces +- Text.Launcher.Pages +- Text.Preferences.Git.IgnoreCRAtEOLInDiff +- Text.Pull.RecurseSubmodules +- Text.Repository.BranchSort +- Text.Repository.BranchSort.ByCommitterDate +- Text.Repository.BranchSort.ByName +- Text.Repository.ClearStashes +- Text.Repository.Search.ByContent +- Text.Repository.ShowSubmodulesAsTree +- Text.Repository.ViewLogs +- Text.Repository.Visit +- Text.ResetWithoutCheckout +- Text.ResetWithoutCheckout.MoveTo +- Text.ResetWithoutCheckout.Target +- Text.Submodule.Deinit +- Text.Submodule.Status +- Text.Submodule.Status.Modified +- Text.Submodule.Status.NotInited +- Text.Submodule.Status.RevisionChanged +- Text.Submodule.Status.Unmerged +- Text.Submodule.URL +- Text.UpdateSubmodules.Target +- Text.ViewLogs +- Text.ViewLogs.Clear +- Text.ViewLogs.CopyLog +- Text.ViewLogs.Delete +- Text.WorkingCopy.Conflicts.OpenExternalMergeTool +- Text.WorkingCopy.Conflicts.OpenExternalMergeToolAllConflicts +- Text.WorkingCopy.Conflicts.UseMine +- Text.WorkingCopy.Conflicts.UseTheirs +- Text.WorkingCopy.ResetAuthor + + + +###  + + +Missing keys in uk_UA.axaml + +- Text.Avatar.Load +- Text.Bisect +- Text.Bisect.Abort +- Text.Bisect.Bad +- Text.Bisect.Detecting +- Text.Bisect.Good +- Text.Bisect.Skip +- Text.Bisect.WaitingForRange +- Text.BranchCM.ResetToSelectedCommit +- Text.Checkout.RecurseSubmodules +- Text.CommitCM.CopyAuthor +- Text.CommitCM.CopyCommitter +- Text.CommitCM.CopySubject +- Text.CommitDetail.Changes.Count +- Text.CommitMessageTextBox.SubjectCount +- Text.ConfigureWorkspace.Name +- Text.CreateBranch.OverwriteExisting +- Text.DeinitSubmodule +- Text.DeinitSubmodule.Force +- Text.DeinitSubmodule.Path +- Text.Diff.Submodule.Deleted +- Text.GitFlow.FinishWithPush +- Text.GitFlow.FinishWithSquash +- Text.Hotkeys.Global.SwitchWorkspace +- Text.Hotkeys.Global.SwitchTab +- Text.Hotkeys.TextEditor.OpenExternalMergeTool +- Text.Launcher.Workspaces +- Text.Launcher.Pages +- Text.Preferences.Git.IgnoreCRAtEOLInDiff +- Text.Pull.RecurseSubmodules +- Text.Repository.BranchSort +- Text.Repository.BranchSort.ByCommitterDate +- Text.Repository.BranchSort.ByName +- Text.Repository.ClearStashes +- Text.Repository.Search.ByContent +- Text.Repository.ShowSubmodulesAsTree +- Text.Repository.ViewLogs +- Text.Repository.Visit +- Text.ResetWithoutCheckout +- Text.ResetWithoutCheckout.MoveTo +- Text.ResetWithoutCheckout.Target +- Text.Submodule.Deinit +- Text.Submodule.Status +- Text.Submodule.Status.Modified +- Text.Submodule.Status.NotInited +- Text.Submodule.Status.RevisionChanged +- Text.Submodule.Status.Unmerged +- Text.Submodule.URL +- Text.ViewLogs +- Text.ViewLogs.Clear +- Text.ViewLogs.CopyLog +- Text.ViewLogs.Delete +- Text.WorkingCopy.ResetAuthor + + + +###  + +###  \ No newline at end of file diff --git a/VERSION b/VERSION new file mode 100644 index 00000000..b89504d0 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +2025.21 \ No newline at end of file diff --git a/build/README.md b/build/README.md new file mode 100644 index 00000000..17305edf --- /dev/null +++ b/build/README.md @@ -0,0 +1,15 @@ +# build + +> [!WARNING] +> The files under the `build` folder is used for `Github Action` only, **NOT** for end users. + +## How to build this project manually + +1. Make sure [.NET SDK 9](https://dotnet.microsoft.com/en-us/download) is installed on your machine. +2. Clone this project +3. Run the follow command under the project root dir +```sh +dotnet publish -c Release -r $RUNTIME_IDENTIFIER -o $DESTINATION_FOLDER src/SourceGit.csproj +``` +> [!NOTE] +> Please replace the `$RUNTIME_IDENTIFIER` with one of `win-x64`,`win-arm64`,`linux-x64`,`linux-arm64`,`osx-x64`,`osx-arm64`, and replace the `$DESTINATION_FOLDER` with the real path that will store the output executable files. diff --git a/build/build.linux.sh b/build/build.linux.sh deleted file mode 100644 index 491dd503..00000000 --- a/build/build.linux.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/sh - -rm -rf SourceGit -dotnet publish ../src/SourceGit/SourceGit.csproj -c Release -r linux-x64 -o SourceGit -p:PublishAot=true -p:PublishTrimmed=true -p:TrimMode=link --self-contained -cp resources/SourceGit.desktop.template SourceGit/SourceGit.desktop.template -cp resources/App.icns SourceGit/SourceGit.icns -tar -zcvf SourceGit.linux-x64.tar.gz --exclude="*/en" --exclude="*/zh" --exclude="*/*.dbg" SourceGit -rm -rf SourceGit diff --git a/build/build.osx.command b/build/build.osx.command deleted file mode 100644 index 91a3d2f6..00000000 --- a/build/build.osx.command +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/sh - -rm -rf SourceGit.app - -mkdir -p SourceGit.app/Contents/Resources -cp resources/App.plist SourceGit.app/Contents/Info.plist -cp resources/App.icns SourceGit.app/Contents/Resources/App.icns - -mkdir -p SourceGit.app/Contents/MacOS -dotnet publish ../src/SourceGit/SourceGit.csproj -c Release -r osx-arm64 -o SourceGit.app/Contents/MacOS -p:PublishAot=true -p:PublishTrimmed=true -p:TrimMode=link --self-contained -zip SourceGit.osx-arm64.zip -r SourceGit.app -x "*/en/*" -x "*/zh/*" -x "*/*\.dsym/*" - -rm -rf SourceGit.app/Contents/MacOS - -mkdir -p SourceGit.app/Contents/MacOS -dotnet publish ../src/SourceGit/SourceGit.csproj -c Release -r osx-x64 -o SourceGit.app/Contents/MacOS -p:PublishAot=true -p:PublishTrimmed=true -p:TrimMode=link --self-contained -zip SourceGit.osx-x64.zip -r SourceGit.app -x "*/en/*" -x "*/zh/*" -x "*/*\.dsym/*" diff --git a/build/build.windows.bat b/build/build.windows.bat deleted file mode 100644 index 9198dbd2..00000000 --- a/build/build.windows.bat +++ /dev/null @@ -1,5 +0,0 @@ - -rmdir /S /Q SourceGit -dotnet publish ..\src\SourceGit\SourceGit.csproj -c Release -r win-x64 -o SourceGit -p:PublishAot=true -p:PublishTrimmed=true -p:TrimMode=link --self-contained -resources\7z.exe a SourceGit.win-x64.zip SourceGit "-xr!en/" "-xr!zh/" "-xr!*.pdb" -rmdir /S /Q SourceGit \ No newline at end of file diff --git a/build/resources/7z.exe b/build/resources/7z.exe deleted file mode 100644 index a739ca0f..00000000 Binary files a/build/resources/7z.exe and /dev/null differ diff --git a/build/resources/App.plist b/build/resources/App.plist deleted file mode 100644 index 07ac6e58..00000000 --- a/build/resources/App.plist +++ /dev/null @@ -1,26 +0,0 @@ - - - - - CFBundleIconFile - App.icns - CFBundleIdentifier - com.sourcegit-scm.sourcegit - CFBundleName - SourceGit - CFBundleVersion - 8.6.0 - LSMinimumSystemVersion - 10.12 - CFBundleExecutable - SourceGit - CFBundleInfoDictionaryVersion - 6.0 - CFBundlePackageType - APPL - CFBundleShortVersionString - 8.6 - NSHighResolutionCapable - - - \ No newline at end of file diff --git a/build/resources/SourceGit.desktop.template b/build/resources/SourceGit.desktop.template deleted file mode 100644 index ec4b7c41..00000000 --- a/build/resources/SourceGit.desktop.template +++ /dev/null @@ -1,7 +0,0 @@ -[Desktop Entry] -Name=SourceGit -Comment=Free & OpenSource Git Client -Exec=SOURCEGIT_LOCAL_FOLDER/SourceGit -Icon=SOURCEGIT_LOCAL_FOLDER/SourceGit.icns -Type=Application -Terminal=false \ No newline at end of file diff --git a/build/resources/_common/applications/sourcegit.desktop b/build/resources/_common/applications/sourcegit.desktop new file mode 100644 index 00000000..bcf9c813 --- /dev/null +++ b/build/resources/_common/applications/sourcegit.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Name=SourceGit +Comment=Open-source & Free Git GUI Client +Exec=/opt/sourcegit/sourcegit +Icon=/usr/share/icons/sourcegit.png +Terminal=false +Type=Application +Categories=Development +MimeType=inode/directory; diff --git a/build/resources/_common/icons/sourcegit.png b/build/resources/_common/icons/sourcegit.png new file mode 100644 index 00000000..8cdcd3a8 Binary files /dev/null and b/build/resources/_common/icons/sourcegit.png differ diff --git a/build/resources/App.icns b/build/resources/app/App.icns similarity index 100% rename from build/resources/App.icns rename to build/resources/app/App.icns diff --git a/build/resources/app/App.plist b/build/resources/app/App.plist new file mode 100644 index 00000000..ba6f40a2 --- /dev/null +++ b/build/resources/app/App.plist @@ -0,0 +1,26 @@ + + + + + CFBundleIconFile + App.icns + CFBundleIdentifier + com.sourcegit-scm.sourcegit + CFBundleName + SourceGit + CFBundleVersion + SOURCE_GIT_VERSION.0 + LSMinimumSystemVersion + 11.0 + CFBundleExecutable + SourceGit + CFBundleInfoDictionaryVersion + 6.0 + CFBundlePackageType + APPL + CFBundleShortVersionString + SOURCE_GIT_VERSION + NSHighResolutionCapable + + + diff --git a/build/resources/appimage/sourcegit.appdata.xml b/build/resources/appimage/sourcegit.appdata.xml new file mode 100644 index 00000000..012c82d3 --- /dev/null +++ b/build/resources/appimage/sourcegit.appdata.xml @@ -0,0 +1,16 @@ + + + com.sourcegit_scm.SourceGit + MIT + MIT + SourceGit + Open-source GUI client for git users + + Open-source GUI client for git users + + https://github.com/sourcegit-scm/sourcegit + com.sourcegit_scm.SourceGit.desktop + + com.sourcegit_scm.SourceGit.desktop + + diff --git a/build/resources/appimage/sourcegit.png b/build/resources/appimage/sourcegit.png new file mode 100644 index 00000000..8cdcd3a8 Binary files /dev/null and b/build/resources/appimage/sourcegit.png differ diff --git a/build/resources/deb/DEBIAN/control b/build/resources/deb/DEBIAN/control new file mode 100755 index 00000000..71786b43 --- /dev/null +++ b/build/resources/deb/DEBIAN/control @@ -0,0 +1,8 @@ +Package: sourcegit +Version: 2025.10 +Priority: optional +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 +Installed-Size: 60440 +Maintainer: longshuang@msn.cn +Description: Open-source & Free Git GUI Client diff --git a/build/resources/deb/DEBIAN/preinst b/build/resources/deb/DEBIAN/preinst new file mode 100755 index 00000000..a93f8090 --- /dev/null +++ b/build/resources/deb/DEBIAN/preinst @@ -0,0 +1,32 @@ +#!/bin/sh + +set -e + +# summary of how this script can be called: +# * `install' +# * `install' +# * `upgrade' +# * `abort-upgrade' +# 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 diff --git a/build/resources/deb/DEBIAN/prerm b/build/resources/deb/DEBIAN/prerm new file mode 100755 index 00000000..c2c9e4f0 --- /dev/null +++ b/build/resources/deb/DEBIAN/prerm @@ -0,0 +1,35 @@ +#!/bin/sh + +set -e + +# summary of how this script can be called: +# * `remove' +# * `upgrade' +# * `failed-upgrade' +# * `remove' `in-favour' +# * `deconfigure' `in-favour' +# `removing' +# +# 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 diff --git a/build/resources/rpm/SPECS/build.spec b/build/resources/rpm/SPECS/build.spec new file mode 100644 index 00000000..2a684837 --- /dev/null +++ b/build/resources/rpm/SPECS/build.spec @@ -0,0 +1,38 @@ +Name: sourcegit +Version: %_version +Release: 1 +Summary: Open-source & Free Git Gui Client +License: MIT +URL: https://sourcegit-scm.github.io/ +Source: https://github.com/sourcegit-scm/sourcegit/archive/refs/tags/v%_version.tar.gz +Requires: libX11.so.6()(%{__isa_bits}bit) +Requires: libSM.so.6()(%{__isa_bits}bit) +Requires: libicu +Requires: xdg-utils + +%define _build_id_links none + +%description +Open-source & Free Git Gui Client + +%install +mkdir -p %{buildroot}/opt/sourcegit +mkdir -p %{buildroot}/%{_bindir} +mkdir -p %{buildroot}/usr/share/applications +mkdir -p %{buildroot}/usr/share/icons +cp -f ../../../SourceGit/* %{buildroot}/opt/sourcegit/ +ln -rsf %{buildroot}/opt/sourcegit/sourcegit %{buildroot}/%{_bindir} +cp -r ../../_common/applications %{buildroot}/%{_datadir} +cp -r ../../_common/icons %{buildroot}/%{_datadir} +chmod 755 -R %{buildroot}/opt/sourcegit +chmod 755 %{buildroot}/%{_datadir}/applications/sourcegit.desktop + +%files +%dir /opt/sourcegit/ +/opt/sourcegit/* +/usr/share/applications/sourcegit.desktop +/usr/share/icons/* +%{_bindir}/sourcegit + +%changelog +# skip diff --git a/build/scripts/localization-check.js b/build/scripts/localization-check.js new file mode 100644 index 00000000..8d636b5b --- /dev/null +++ b/build/scripts/localization-check.js @@ -0,0 +1,83 @@ +const fs = require('fs-extra'); +const path = require('path'); +const xml2js = require('xml2js'); + +const repoRoot = path.join(__dirname, '../../'); +const localesDir = path.join(repoRoot, 'src/Resources/Locales'); +const enUSFile = path.join(localesDir, 'en_US.axaml'); +const outputFile = path.join(repoRoot, 'TRANSLATION.md'); + +const parser = new xml2js.Parser(); + +async function parseXml(filePath) { + const data = await fs.readFile(filePath); + return parser.parseStringPromise(data); +} + +async function filterAndSortTranslations(localeData, enUSKeys, enUSData) { + const strings = localeData.ResourceDictionary['x:String']; + // Remove keys that don't exist in English file + const filtered = strings.filter(item => enUSKeys.has(item.$['x:Key'])); + + // Sort based on the key order in English file + const enUSKeysArray = enUSData.ResourceDictionary['x:String'].map(item => item.$['x:Key']); + filtered.sort((a, b) => { + const aIndex = enUSKeysArray.indexOf(a.$['x:Key']); + const bIndex = enUSKeysArray.indexOf(b.$['x:Key']); + return aIndex - bIndex; + }); + + return filtered; +} + +async function calculateTranslationRate() { + const enUSData = await parseXml(enUSFile); + const enUSKeys = new Set(enUSData.ResourceDictionary['x:String'].map(item => item.$['x:Key'])); + const files = (await fs.readdir(localesDir)).filter(file => file !== 'en_US.axaml' && file.endsWith('.axaml')); + + const lines = []; + + lines.push('# Translation Status'); + lines.push('This document shows the translation status of each locale file in the repository.'); + lines.push(`## Details`); + lines.push(`### `); + + for (const file of files) { + const locale = file.replace('.axaml', '').replace('_', '__'); + const filePath = path.join(localesDir, file); + const localeData = await parseXml(filePath); + const localeKeys = new Set(localeData.ResourceDictionary['x:String'].map(item => item.$['x:Key'])); + const missingKeys = [...enUSKeys].filter(key => !localeKeys.has(key)); + + // Sort and clean up extra translations + const sortedAndCleaned = await filterAndSortTranslations(localeData, enUSKeys, enUSData); + localeData.ResourceDictionary['x:String'] = sortedAndCleaned; + + // Save the updated file + const builder = new xml2js.Builder({ + headless: true, + renderOpts: { pretty: true, indent: ' ' } + }); + let xmlStr = builder.buildObject(localeData); + + // Add an empty line before the first x:String + xmlStr = xmlStr.replace(' 0) { + const progress = ((enUSKeys.size - missingKeys.length) / enUSKeys.size) * 100; + const badgeColor = progress >= 75 ? 'yellow' : 'red'; + + lines.push(`### }%25-${badgeColor})`); + lines.push(`\nMissing keys in ${file}\n\n${missingKeys.map(key => `- ${key}`).join('\n')}\n\n`) + } else { + lines.push(`### `); + } + } + + const content = lines.join('\n\n'); + console.log(content); + await fs.writeFile(outputFile, content, 'utf8'); +} + +calculateTranslationRate().catch(err => console.error(err)); diff --git a/build/scripts/package.linux.sh b/build/scripts/package.linux.sh new file mode 100755 index 00000000..1b4adbdc --- /dev/null +++ b/build/scripts/package.linux.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash + +set -e +set -o +set -u +set pipefail + +arch= +appimage_arch= +target= +case "$RUNTIME" in + linux-x64) + arch=amd64 + appimage_arch=x86_64 + target=x86_64;; + linux-arm64) + arch=arm64 + appimage_arch=arm_aarch64 + target=aarch64;; + *) + echo "Unknown runtime $RUNTIME" + exit 1;; +esac + +APPIMAGETOOL_URL=https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage + +cd build + +if [[ ! -f "appimagetool" ]]; then + curl -o appimagetool -L "$APPIMAGETOOL_URL" + chmod +x appimagetool +fi + +rm -f SourceGit/*.dbg + +mkdir -p SourceGit.AppDir/opt +mkdir -p SourceGit.AppDir/usr/share/metainfo +mkdir -p SourceGit.AppDir/usr/share/applications + +cp -r SourceGit SourceGit.AppDir/opt/sourcegit +desktop-file-install resources/_common/applications/sourcegit.desktop --dir SourceGit.AppDir/usr/share/applications \ + --set-icon com.sourcegit_scm.SourceGit --set-key=Exec --set-value=AppRun +mv SourceGit.AppDir/usr/share/applications/{sourcegit,com.sourcegit_scm.SourceGit}.desktop +cp resources/appimage/sourcegit.png SourceGit.AppDir/com.sourcegit_scm.SourceGit.png +ln -rsf SourceGit.AppDir/opt/sourcegit/sourcegit SourceGit.AppDir/AppRun +ln -rsf SourceGit.AppDir/usr/share/applications/com.sourcegit_scm.SourceGit.desktop SourceGit.AppDir +cp resources/appimage/sourcegit.appdata.xml SourceGit.AppDir/usr/share/metainfo/com.sourcegit_scm.SourceGit.appdata.xml + +ARCH="$appimage_arch" ./appimagetool -v SourceGit.AppDir "sourcegit-$VERSION.linux.$arch.AppImage" + +mkdir -p resources/deb/opt/sourcegit/ +mkdir -p resources/deb/usr/bin +mkdir -p resources/deb/usr/share/applications +mkdir -p resources/deb/usr/share/icons +cp -f SourceGit/* resources/deb/opt/sourcegit +ln -rsf resources/deb/opt/sourcegit/sourcegit resources/deb/usr/bin +cp -r resources/_common/applications resources/deb/usr/share +cp -r resources/_common/icons resources/deb/usr/share +# Calculate installed size in KB +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" +mv "resources/rpm/RPMS/$target/sourcegit-$VERSION-1.$target.rpm" ./ diff --git a/build/scripts/package.osx-app.sh b/build/scripts/package.osx-app.sh new file mode 100755 index 00000000..2d43e24a --- /dev/null +++ b/build/scripts/package.osx-app.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +set -e +set -o +set -u +set pipefail + +cd build + +mkdir -p SourceGit.app/Contents/Resources +mv SourceGit SourceGit.app/Contents/MacOS +cp resources/app/App.icns SourceGit.app/Contents/Resources/App.icns +sed "s/SOURCE_GIT_VERSION/$VERSION/g" resources/app/App.plist > SourceGit.app/Contents/Info.plist +rm -rf SourceGit.app/Contents/MacOS/SourceGit.dsym + +zip "sourcegit_$VERSION.$RUNTIME.zip" -r SourceGit.app diff --git a/build/scripts/package.windows.sh b/build/scripts/package.windows.sh new file mode 100755 index 00000000..c22a9d35 --- /dev/null +++ b/build/scripts/package.windows.sh @@ -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 diff --git a/global.json b/global.json index b5b37b60..a27a2b82 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.0", + "version": "9.0.0", "rollForward": "latestMajor", "allowPrerelease": false } diff --git a/screenshots/theme_dark.png b/screenshots/theme_dark.png index 4a9646dc..85e18481 100644 Binary files a/screenshots/theme_dark.png and b/screenshots/theme_dark.png differ diff --git a/screenshots/theme_light.png b/screenshots/theme_light.png index 669b4d18..2e8cf6fc 100644 Binary files a/screenshots/theme_light.png and b/screenshots/theme_light.png differ diff --git a/src/App.Commands.cs b/src/App.Commands.cs new file mode 100644 index 00000000..22e9fb51 --- /dev/null +++ b/src/App.Commands.cs @@ -0,0 +1,58 @@ +using System; +using System.Windows.Input; +using Avalonia.Controls; + +namespace SourceGit +{ + public partial class App + { + public class Command : ICommand + { + public event EventHandler CanExecuteChanged + { + add { } + remove { } + } + + public Command(Action action) + { + _action = action; + } + + public bool CanExecute(object parameter) => _action != null; + public void Execute(object parameter) => _action?.Invoke(parameter); + + private Action _action = null; + } + + public static bool IsCheckForUpdateCommandVisible + { + get + { +#if DISABLE_UPDATE_DETECTION + return false; +#else + return true; +#endif + } + } + + public static readonly Command OpenPreferencesCommand = new Command(_ => ShowWindow(new Views.Preferences(), false)); + public static readonly Command OpenHotkeysCommand = new Command(_ => ShowWindow(new Views.Hotkeys(), false)); + public static readonly Command OpenAppDataDirCommand = new Command(_ => Native.OS.OpenInFileManager(Native.OS.DataDir)); + public static readonly Command OpenAboutCommand = new Command(_ => ShowWindow(new Views.About(), false)); + 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 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); + }); + } +} diff --git a/src/App.JsonCodeGen.cs b/src/App.JsonCodeGen.cs new file mode 100644 index 00000000..9cad0792 --- /dev/null +++ b/src/App.JsonCodeGen.cs @@ -0,0 +1,54 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +using Avalonia.Controls; +using Avalonia.Media; + +namespace SourceGit +{ + public class ColorConverter : JsonConverter + { + public override Color Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return Color.Parse(reader.GetString()); + } + + public override void Write(Utf8JsonWriter writer, Color value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } + } + + public class GridLengthConverter : JsonConverter + { + public override GridLength Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var size = reader.GetDouble(); + return new GridLength(size, GridUnitType.Pixel); + } + + public override void Write(Utf8JsonWriter writer, GridLength value, JsonSerializerOptions options) + { + writer.WriteNumberValue(value.Value); + } + } + + [JsonSourceGenerationOptions( + WriteIndented = true, + IgnoreReadOnlyFields = true, + IgnoreReadOnlyProperties = true, + Converters = [ + typeof(ColorConverter), + typeof(GridLengthConverter), + ] + )] + [JsonSerializable(typeof(Models.ExternalToolPaths))] + [JsonSerializable(typeof(Models.InteractiveRebaseJobCollection))] + [JsonSerializable(typeof(Models.JetBrainsState))] + [JsonSerializable(typeof(Models.ThemeOverrides))] + [JsonSerializable(typeof(Models.Version))] + [JsonSerializable(typeof(Models.RepositorySettings))] + [JsonSerializable(typeof(ViewModels.Preferences))] + internal partial class JsonCodeGen : JsonSerializerContext { } +} diff --git a/src/App.axaml b/src/App.axaml new file mode 100644 index 00000000..186022d5 --- /dev/null +++ b/src/App.axaml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.axaml.cs b/src/App.axaml.cs new file mode 100644 index 00000000..b5868ca1 --- /dev/null +++ b/src/App.axaml.cs @@ -0,0 +1,708 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Data.Core.Plugins; +using Avalonia.Markup.Xaml; +using Avalonia.Media; +using Avalonia.Media.Fonts; +using Avalonia.Platform.Storage; +using Avalonia.Styling; +using Avalonia.Threading; + +namespace SourceGit +{ + public partial class App : Application + { + #region App Entry Point + [STAThread] + public static void Main(string[] args) + { + Native.OS.SetupDataDir(); + + AppDomain.CurrentDomain.UnhandledException += (_, e) => + { + LogException(e.ExceptionObject as Exception); + }; + + TaskScheduler.UnobservedTaskException += (_, e) => + { + e.SetObserved(); + }; + + try + { + if (TryLaunchAsRebaseTodoEditor(args, out int exitTodo)) + Environment.Exit(exitTodo); + else if (TryLaunchAsRebaseMessageEditor(args, out int exitMessage)) + Environment.Exit(exitMessage); + else + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + } + catch (Exception ex) + { + LogException(ex); + } + } + + public static AppBuilder BuildAvaloniaApp() + { + var builder = AppBuilder.Configure(); + builder.UsePlatformDetect(); + builder.LogToTrace(); + builder.WithInterFont(); + builder.With(new FontManagerOptions() + { + DefaultFamilyName = "fonts:Inter#Inter" + }); + builder.ConfigureFonts(manager => + { + var monospace = new EmbeddedFontCollection( + new Uri("fonts:SourceGit", UriKind.Absolute), + new Uri("avares://SourceGit/Resources/Fonts", UriKind.Absolute)); + manager.AddFontCollection(monospace); + }); + + Native.OS.SetupApp(builder); + return builder; + } + + public static void LogException(Exception ex) + { + if (ex == null) + 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}\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); + + 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 + + #region Utility Functions + public static void ShowWindow(object data, bool showAsDialog) + { + var impl = (Views.ChromelessWindow target, bool isDialog) => + { + if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner }) + { + if (isDialog) + target.ShowDialog(owner); + else + target.Show(owner); + } + else + { + target.Show(); + } + }; + + if (data is Views.ChromelessWindow window) + { + impl(window, showAsDialog); + return; + } + + var dataTypeName = data.GetType().FullName; + if (string.IsNullOrEmpty(dataTypeName) || !dataTypeName.Contains(".ViewModels.", StringComparison.Ordinal)) + return; + + var viewTypeName = dataTypeName.Replace(".ViewModels.", ".Views."); + var viewType = Type.GetType(viewTypeName); + if (viewType == null || !viewType.IsSubclassOf(typeof(Views.ChromelessWindow))) + return; + + window = Activator.CreateInstance(viewType) as Views.ChromelessWindow; + if (window != null) + { + window.DataContext = data; + impl(window, showAsDialog); + } + } + + public static void RaiseException(string context, string message) + { + if (Current is App app && app._launcher != null) + app._launcher.DispatchNotification(context, message, true); + } + + public static void SendNotification(string context, string message) + { + if (Current is App app && app._launcher != null) + app._launcher.DispatchNotification(context, message, false); + } + + public static void SetLocale(string localeKey) + { + var app = Current as App; + if (app == null) + return; + + var targetLocale = app.Resources[localeKey] as ResourceDictionary; + if (targetLocale == null || targetLocale == app._activeLocale) + return; + + if (app._activeLocale != null) + app.Resources.MergedDictionaries.Remove(app._activeLocale); + + app.Resources.MergedDictionaries.Add(targetLocale); + app._activeLocale = targetLocale; + } + + public static void SetTheme(string theme, string themeOverridesFile) + { + var app = Current as App; + if (app == null) + return; + + if (theme.Equals("Light", StringComparison.OrdinalIgnoreCase)) + app.RequestedThemeVariant = ThemeVariant.Light; + else if (theme.Equals("Dark", StringComparison.OrdinalIgnoreCase)) + app.RequestedThemeVariant = ThemeVariant.Dark; + else + app.RequestedThemeVariant = ThemeVariant.Default; + + if (app._themeOverrides != null) + { + app.Resources.MergedDictionaries.Remove(app._themeOverrides); + app._themeOverrides = null; + } + + if (!string.IsNullOrEmpty(themeOverridesFile) && File.Exists(themeOverridesFile)) + { + try + { + var resDic = new ResourceDictionary(); + var overrides = JsonSerializer.Deserialize(File.ReadAllText(themeOverridesFile), JsonCodeGen.Default.ThemeOverrides); + foreach (var kv in overrides.BasicColors) + { + if (kv.Key.Equals("SystemAccentColor", StringComparison.Ordinal)) + resDic["SystemAccentColor"] = kv.Value; + else + resDic[$"Color.{kv.Key}"] = kv.Value; + } + + if (overrides.GraphColors.Count > 0) + Models.CommitGraph.SetPens(overrides.GraphColors, overrides.GraphPenThickness); + else + Models.CommitGraph.SetDefaultPens(overrides.GraphPenThickness); + + Models.Commit.OpacityForNotMerged = overrides.OpacityForNotMergedCommits; + + app.Resources.MergedDictionaries.Add(resDic); + app._themeOverrides = resDic; + } + catch + { + // ignore + } + } + else + { + Models.CommitGraph.SetDefaultPens(); + } + } + + public static void SetFonts(string defaultFont, string monospaceFont, bool onlyUseMonospaceFontInEditor) + { + var app = Current as App; + if (app == null) + return; + + if (app._fontsOverrides != null) + { + app.Resources.MergedDictionaries.Remove(app._fontsOverrides); + app._fontsOverrides = null; + } + + defaultFont = app.FixFontFamilyName(defaultFont); + monospaceFont = app.FixFontFamilyName(monospaceFont); + + var resDic = new ResourceDictionary(); + if (!string.IsNullOrEmpty(defaultFont)) + resDic.Add("Fonts.Default", new FontFamily(defaultFont)); + + if (string.IsNullOrEmpty(monospaceFont)) + { + if (!string.IsNullOrEmpty(defaultFont)) + { + monospaceFont = $"fonts:SourceGit#JetBrains Mono,{defaultFont}"; + resDic.Add("Fonts.Monospace", new FontFamily(monospaceFont)); + } + } + else + { + if (!string.IsNullOrEmpty(defaultFont) && !monospaceFont.Contains(defaultFont, StringComparison.Ordinal)) + monospaceFont = $"{monospaceFont},{defaultFont}"; + + resDic.Add("Fonts.Monospace", new FontFamily(monospaceFont)); + } + + if (onlyUseMonospaceFontInEditor) + { + if (string.IsNullOrEmpty(defaultFont)) + resDic.Add("Fonts.Primary", new FontFamily("fonts:Inter#Inter")); + else + resDic.Add("Fonts.Primary", new FontFamily(defaultFont)); + } + else + { + if (!string.IsNullOrEmpty(monospaceFont)) + resDic.Add("Fonts.Primary", new FontFamily(monospaceFont)); + } + + if (resDic.Count > 0) + { + app.Resources.MergedDictionaries.Add(resDic); + app._fontsOverrides = resDic; + } + } + + public static async void CopyText(string data) + { + if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + if (desktop.MainWindow?.Clipboard is { } clipboard) + await clipboard.SetTextAsync(data ?? ""); + } + } + + public static async Task GetClipboardTextAsync() + { + if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + if (desktop.MainWindow?.Clipboard is { } clipboard) + { + return await clipboard.GetTextAsync(); + } + } + return default; + } + + public static string Text(string key, params object[] args) + { + var fmt = Current?.FindResource($"Text.{key}") as string; + if (string.IsNullOrWhiteSpace(fmt)) + return $"Text.{key}"; + + if (args == null || args.Length == 0) + return fmt; + + return string.Format(fmt, args); + } + + public static Avalonia.Controls.Shapes.Path CreateMenuIcon(string key) + { + var icon = new Avalonia.Controls.Shapes.Path(); + icon.Width = 12; + icon.Height = 12; + icon.Stretch = Stretch.Uniform; + + var geo = Current?.FindResource(key) as StreamGeometry; + if (geo != null) + icon.Data = geo; + + return icon; + } + + public static IStorageProvider GetStorageProvider() + { + if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + return desktop.MainWindow?.StorageProvider; + + return null; + } + + public static ViewModels.Launcher GetLauncher() + { + return Current is App app ? app._launcher : null; + } + + public static void Quit(int exitCode) + { + if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow?.Close(); + desktop.Shutdown(exitCode); + } + else + { + Environment.Exit(exitCode); + } + } + #endregion + + #region Overrides + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + + var pref = ViewModels.Preferences.Instance; + pref.PropertyChanged += (_, _) => pref.Save(); + + SetLocale(pref.Locale); + SetTheme(pref.Theme, pref.ThemeOverrides); + SetFonts(pref.DefaultFontFamily, pref.MonospaceFontFamily, pref.OnlyUseMonoFontInEditor); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + BindingPlugins.DataValidators.RemoveAt(0); + + // Disable tooltip if window is not active. + ToolTip.ToolTipOpeningEvent.AddClassHandler((c, e) => + { + var topLevel = TopLevel.GetTopLevel(c); + if (topLevel is not Window { IsActive: true }) + e.Cancel = true; + }); + + if (TryLaunchAsCoreEditor(desktop)) + return; + + if (TryLaunchAsAskpass(desktop)) + return; + + _ipcChannel = new Models.IpcChannel(); + if (!_ipcChannel.IsFirstInstance) + { + var arg = desktop.Args is { Length: > 0 } ? desktop.Args[0].Trim() : string.Empty; + if (!string.IsNullOrEmpty(arg)) + { + if (arg.StartsWith('"') && arg.EndsWith('"')) + arg = arg.Substring(1, arg.Length - 2).Trim(); + + if (arg.Length > 0 && !Path.IsPathFullyQualified(arg)) + arg = Path.GetFullPath(arg); + } + + _ipcChannel.SendToFirstInstance(arg); + Environment.Exit(0); + } + else + { + _ipcChannel.MessageReceived += TryOpenRepository; + desktop.Exit += (_, _) => _ipcChannel.Dispose(); + TryLaunchAsNormal(desktop); + } + } + } + #endregion + + private static bool TryLaunchAsRebaseTodoEditor(string[] args, out int exitCode) + { + exitCode = -1; + + if (args.Length <= 1 || !args[0].Equals("--rebase-todo-editor", StringComparison.Ordinal)) + return false; + + var file = args[1]; + var filename = Path.GetFileName(file); + if (!filename.Equals("git-rebase-todo", StringComparison.OrdinalIgnoreCase)) + return true; + + var dirInfo = new DirectoryInfo(Path.GetDirectoryName(file)!); + if (!dirInfo.Exists || !dirInfo.Name.Equals("rebase-merge", StringComparison.Ordinal)) + return true; + + var jobsFile = Path.Combine(dirInfo.Parent!.FullName, "sourcegit_rebase_jobs.json"); + if (!File.Exists(jobsFile)) + return true; + + var collection = JsonSerializer.Deserialize(File.ReadAllText(jobsFile), JsonCodeGen.Default.InteractiveRebaseJobCollection); + var lines = new List(); + foreach (var job in collection.Jobs) + { + switch (job.Action) + { + case Models.InteractiveRebaseAction.Pick: + lines.Add($"p {job.SHA}"); + break; + case Models.InteractiveRebaseAction.Edit: + lines.Add($"e {job.SHA}"); + break; + case Models.InteractiveRebaseAction.Reword: + lines.Add($"r {job.SHA}"); + break; + case Models.InteractiveRebaseAction.Squash: + lines.Add($"s {job.SHA}"); + break; + case Models.InteractiveRebaseAction.Fixup: + lines.Add($"f {job.SHA}"); + break; + default: + lines.Add($"d {job.SHA}"); + break; + } + } + + File.WriteAllLines(file, lines); + + exitCode = 0; + return true; + } + + private static bool TryLaunchAsRebaseMessageEditor(string[] args, out int exitCode) + { + exitCode = -1; + + if (args.Length <= 1 || !args[0].Equals("--rebase-message-editor", StringComparison.Ordinal)) + return false; + + exitCode = 0; + + var file = args[1]; + var filename = Path.GetFileName(file); + if (!filename.Equals("COMMIT_EDITMSG", StringComparison.OrdinalIgnoreCase)) + return true; + + var gitDir = Path.GetDirectoryName(file)!; + var origHeadFile = Path.Combine(gitDir, "rebase-merge", "orig-head"); + var ontoFile = Path.Combine(gitDir, "rebase-merge", "onto"); + var doneFile = Path.Combine(gitDir, "rebase-merge", "done"); + var jobsFile = Path.Combine(gitDir, "sourcegit_rebase_jobs.json"); + if (!File.Exists(ontoFile) || !File.Exists(origHeadFile) || !File.Exists(doneFile) || !File.Exists(jobsFile)) + return true; + + var origHead = File.ReadAllText(origHeadFile).Trim(); + var onto = File.ReadAllText(ontoFile).Trim(); + var collection = JsonSerializer.Deserialize(File.ReadAllText(jobsFile), JsonCodeGen.Default.InteractiveRebaseJobCollection); + if (!collection.Onto.Equals(onto) || !collection.OrigHead.Equals(origHead)) + return true; + + var done = File.ReadAllText(doneFile).Trim().Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + if (done.Length == 0) + return true; + + var current = done[^1].Trim(); + var match = REG_REBASE_TODO().Match(current); + if (!match.Success) + return true; + + var sha = match.Groups[1].Value; + foreach (var job in collection.Jobs) + { + if (job.SHA.StartsWith(sha)) + { + File.WriteAllText(file, job.Message); + break; + } + } + + return true; + } + + private bool TryLaunchAsCoreEditor(IClassicDesktopStyleApplicationLifetime desktop) + { + var args = desktop.Args; + if (args == null || args.Length <= 1 || !args[0].Equals("--core-editor", StringComparison.Ordinal)) + return false; + + var file = args[1]; + if (!File.Exists(file)) + { + desktop.Shutdown(-1); + return true; + } + + var editor = new Views.StandaloneCommitMessageEditor(); + editor.SetFile(file); + desktop.MainWindow = editor; + return true; + } + + private bool TryLaunchAsAskpass(IClassicDesktopStyleApplicationLifetime desktop) + { + var launchAsAskpass = Environment.GetEnvironmentVariable("SOURCEGIT_LAUNCH_AS_ASKPASS"); + if (launchAsAskpass is not "TRUE") + return false; + + var args = desktop.Args; + if (args?.Length > 0) + { + var askpass = new Views.Askpass(); + askpass.TxtDescription.Text = args[0]; + desktop.MainWindow = askpass; + return true; + } + + return false; + } + + private void TryLaunchAsNormal(IClassicDesktopStyleApplicationLifetime desktop) + { + Native.OS.SetupExternalTools(); + Models.AvatarManager.Instance.Start(); + + string startupRepo = null; + if (desktop.Args != null && desktop.Args.Length == 1 && Directory.Exists(desktop.Args[0])) + startupRepo = desktop.Args[0]; + + var pref = ViewModels.Preferences.Instance; + pref.SetCanModify(); + + _launcher = new ViewModels.Launcher(startupRepo); + desktop.MainWindow = new Views.Launcher() { DataContext = _launcher }; + desktop.ShutdownMode = ShutdownMode.OnMainWindowClose; + +#if !DISABLE_UPDATE_DETECTION + if (pref.ShouldCheck4UpdateOnStartup()) + Check4Update(); +#endif + } + + private void TryOpenRepository(string repo) + { + if (!string.IsNullOrEmpty(repo) && Directory.Exists(repo)) + { + var test = new Commands.QueryRepositoryRootPath(repo).ReadToEnd(); + if (test.IsSuccess && !string.IsNullOrEmpty(test.StdOut)) + { + Dispatcher.UIThread.Invoke(() => + { + var node = ViewModels.Preferences.Instance.FindOrAddNodeByRepositoryPath(test.StdOut.Trim(), null, false); + ViewModels.Welcome.Instance.Refresh(); + _launcher?.OpenRepositoryInTab(node, null); + + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: Views.Launcher wnd }) + wnd.BringToTop(); + }); + + return; + } + } + + Dispatcher.UIThread.Invoke(() => + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: Views.Launcher launcher }) + launcher.BringToTop(); + }); + } + + 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(() => + { + ShowWindow(new ViewModels.SelfUpdate() { Data = data }, true); + }); + } + + private string FixFontFamilyName(string input) + { + if (string.IsNullOrEmpty(input)) + return string.Empty; + + var parts = input.Split(','); + var trimmed = new List(); + + 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; + } + + var name = sb.ToString(); + var idx = name.IndexOf('#'); + if (idx >= 0) + { + if (!name.Equals("fonts:Inter#Inter", StringComparison.Ordinal) && + !name.Equals("fonts:SourceGit#JetBrains Mono", StringComparison.Ordinal)) + continue; + } + + trimmed.Add(name); + } + + return trimmed.Count > 0 ? string.Join(',', trimmed) : string.Empty; + } + + [GeneratedRegex(@"^[a-z]+\s+([a-fA-F0-9]{4,40})(\s+.*)?$")] + private static partial Regex REG_REBASE_TODO(); + + private Models.IpcChannel _ipcChannel = null; + private ViewModels.Launcher _launcher = null; + private ResourceDictionary _activeLocale = null; + private ResourceDictionary _themeOverrides = null; + private ResourceDictionary _fontsOverrides = null; + } +} diff --git a/src/SourceGit/App.ico b/src/App.ico similarity index 100% rename from src/SourceGit/App.ico rename to src/App.ico diff --git a/src/SourceGit/App.manifest b/src/App.manifest similarity index 96% rename from src/SourceGit/App.manifest rename to src/App.manifest index b3bc3bdf..11a2ff11 100644 --- a/src/SourceGit/App.manifest +++ b/src/App.manifest @@ -1,7 +1,7 @@ diff --git a/src/Commands/Add.cs b/src/Commands/Add.cs new file mode 100644 index 00000000..210eb4b2 --- /dev/null +++ b/src/Commands/Add.cs @@ -0,0 +1,26 @@ +namespace SourceGit.Commands +{ + public class Add : Command + { + public Add(string repo, bool includeUntracked) + { + WorkingDirectory = repo; + Context = repo; + Args = includeUntracked ? "add ." : "add -u ."; + } + + public Add(string repo, Models.Change change) + { + WorkingDirectory = repo; + Context = repo; + Args = $"add -- \"{change.Path}\""; + } + + public Add(string repo, string pathspecFromFile) + { + WorkingDirectory = repo; + Context = repo; + Args = $"add --pathspec-from-file=\"{pathspecFromFile}\""; + } + } +} diff --git a/src/SourceGit/Commands/Apply.cs b/src/Commands/Apply.cs similarity index 100% rename from src/SourceGit/Commands/Apply.cs rename to src/Commands/Apply.cs diff --git a/src/Commands/Archive.cs b/src/Commands/Archive.cs new file mode 100644 index 00000000..5e0919f7 --- /dev/null +++ b/src/Commands/Archive.cs @@ -0,0 +1,12 @@ +namespace SourceGit.Commands +{ + public class Archive : Command + { + public Archive(string repo, string revision, string saveTo) + { + WorkingDirectory = repo; + Context = repo; + Args = $"archive --format=zip --verbose --output=\"{saveTo}\" {revision}"; + } + } +} diff --git a/src/Commands/AssumeUnchanged.cs b/src/Commands/AssumeUnchanged.cs new file mode 100644 index 00000000..28f78280 --- /dev/null +++ b/src/Commands/AssumeUnchanged.cs @@ -0,0 +1,14 @@ +namespace SourceGit.Commands +{ + public class AssumeUnchanged : Command + { + public AssumeUnchanged(string repo, string file, bool bAdd) + { + var mode = bAdd ? "--assume-unchanged" : "--no-assume-unchanged"; + + WorkingDirectory = repo; + Context = repo; + Args = $"update-index {mode} -- \"{file}\""; + } + } +} diff --git a/src/Commands/Bisect.cs b/src/Commands/Bisect.cs new file mode 100644 index 00000000..a3bf1a97 --- /dev/null +++ b/src/Commands/Bisect.cs @@ -0,0 +1,13 @@ +namespace SourceGit.Commands +{ + public class Bisect : Command + { + public Bisect(string repo, string subcmd) + { + WorkingDirectory = repo; + Context = repo; + RaiseError = false; + Args = $"bisect {subcmd}"; + } + } +} diff --git a/src/SourceGit/Commands/Blame.cs b/src/Commands/Blame.cs similarity index 81% rename from src/SourceGit/Commands/Blame.cs rename to src/Commands/Blame.cs index 5d047d8c..4fa8b317 100644 --- a/src/SourceGit/Commands/Blame.cs +++ b/src/Commands/Blame.cs @@ -6,10 +6,8 @@ namespace SourceGit.Commands { public partial class Blame : Command { - [GeneratedRegex(@"^\^?([0-9a-f]+)\s+.*\((.*)\s+(\d+)\s+[\-\+]?\d+\s+\d+\) (.*)")] private static partial Regex REG_FORMAT(); - private static readonly DateTime UTC_START = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).ToLocalTime(); public Blame(string repo, string file, string revision) { @@ -23,10 +21,17 @@ namespace SourceGit.Commands public Models.BlameData Result() { - var succ = Exec(); - if (!succ) + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return _result; + + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) { - return new Models.BlameData(); + ParseLine(line); + + if (_result.IsBinary) + break; } if (_needUnifyCommitSHA) @@ -44,13 +49,8 @@ namespace SourceGit.Commands return _result; } - protected override void OnReadline(string line) + private void ParseLine(string line) { - if (_result.IsBinary) - return; - if (string.IsNullOrEmpty(line)) - return; - if (line.IndexOf('\0', StringComparison.Ordinal) >= 0) { _result.IsBinary = true; @@ -67,7 +67,7 @@ namespace SourceGit.Commands var commit = match.Groups[1].Value; var author = match.Groups[2].Value; var timestamp = int.Parse(match.Groups[3].Value); - var when = UTC_START.AddSeconds(timestamp).ToString("yyyy/MM/dd"); + var when = DateTime.UnixEpoch.AddSeconds(timestamp).ToLocalTime().ToString(_dateFormat); var info = new Models.BlameLineInfo() { @@ -89,6 +89,7 @@ namespace SourceGit.Commands private readonly Models.BlameData _result = new Models.BlameData(); private readonly StringBuilder _content = new StringBuilder(); + private readonly string _dateFormat = Models.DateTimeFormat.Active.DateOnly; private string _lastSHA = string.Empty; private bool _needUnifyCommitSHA = false; private int _minSHALen = 64; diff --git a/src/Commands/Branch.cs b/src/Commands/Branch.cs new file mode 100644 index 00000000..0d1b1f8f --- /dev/null +++ b/src/Commands/Branch.cs @@ -0,0 +1,83 @@ +using System.Text; + +namespace SourceGit.Commands +{ + public static class Branch + { + public static string ShowCurrent(string repo) + { + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.Args = $"branch --show-current"; + return cmd.ReadToEnd().StdOut.Trim(); + } + + public static bool Create(string repo, string name, string basedOn, bool force, Models.ICommandLog log) + { + var builder = new StringBuilder(); + builder.Append("branch "); + if (force) + builder.Append("-f "); + builder.Append(name); + builder.Append(" "); + builder.Append(basedOn); + + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.Args = builder.ToString(); + cmd.Log = log; + return cmd.Exec(); + } + + public static bool Rename(string repo, string name, string to, Models.ICommandLog log) + { + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.Args = $"branch -M {name} {to}"; + cmd.Log = log; + return cmd.Exec(); + } + + public static bool SetUpstream(string repo, string name, string upstream, Models.ICommandLog log) + { + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.Log = log; + + if (string.IsNullOrEmpty(upstream)) + cmd.Args = $"branch {name} --unset-upstream"; + else + cmd.Args = $"branch {name} -u {upstream}"; + + return cmd.Exec(); + } + + public static bool DeleteLocal(string repo, string name, Models.ICommandLog log) + { + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.Args = $"branch -D {name}"; + cmd.Log = log; + return cmd.Exec(); + } + + public static bool DeleteRemote(string repo, string remote, string name, Models.ICommandLog log) + { + bool exists = new Remote(repo).HasBranch(remote, name); + if (exists) + return new Push(repo, remote, $"refs/heads/{name}", true) { Log = log }.Exec(); + + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.Args = $"branch -D -r {remote}/{name}"; + cmd.Log = log; + return cmd.Exec(); + } + } +} diff --git a/src/Commands/Checkout.cs b/src/Commands/Checkout.cs new file mode 100644 index 00000000..d2876740 --- /dev/null +++ b/src/Commands/Checkout.cs @@ -0,0 +1,82 @@ +using System.Collections.Generic; +using System.Text; + +namespace SourceGit.Commands +{ + public class Checkout : Command + { + public Checkout(string repo) + { + WorkingDirectory = repo; + Context = repo; + } + + public bool Branch(string branch, bool force) + { + var builder = new StringBuilder(); + builder.Append("checkout --progress "); + if (force) + builder.Append("--force "); + builder.Append(branch); + + Args = builder.ToString(); + return Exec(); + } + + public bool Branch(string branch, string basedOn, bool force, bool allowOverwrite) + { + var builder = new StringBuilder(); + builder.Append("checkout --progress "); + if (force) + builder.Append("--force "); + builder.Append(allowOverwrite ? "-B " : "-b "); + builder.Append(branch); + builder.Append(" "); + builder.Append(basedOn); + + Args = builder.ToString(); + return Exec(); + } + + public bool Commit(string commitId, bool force) + { + var option = force ? "--force" : string.Empty; + Args = $"checkout {option} --detach --progress {commitId}"; + return Exec(); + } + + public bool UseTheirs(List files) + { + var builder = new StringBuilder(); + builder.Append("checkout --theirs --"); + foreach (var f in files) + { + builder.Append(" \""); + builder.Append(f); + builder.Append("\""); + } + Args = builder.ToString(); + return Exec(); + } + + public bool UseMine(List files) + { + var builder = new StringBuilder(); + builder.Append("checkout --ours --"); + foreach (var f in files) + { + builder.Append(" \""); + builder.Append(f); + builder.Append("\""); + } + Args = builder.ToString(); + return Exec(); + } + + public bool FileWithRevision(string file, string revision) + { + Args = $"checkout --no-overlay {revision} -- \"{file}\""; + return Exec(); + } + } +} diff --git a/src/Commands/CherryPick.cs b/src/Commands/CherryPick.cs new file mode 100644 index 00000000..0c82b9fd --- /dev/null +++ b/src/Commands/CherryPick.cs @@ -0,0 +1,20 @@ +namespace SourceGit.Commands +{ + public class CherryPick : Command + { + public CherryPick(string repo, string commits, bool noCommit, bool appendSourceToMessage, string extraParams) + { + WorkingDirectory = repo; + Context = repo; + + Args = "cherry-pick "; + if (noCommit) + Args += "-n "; + if (appendSourceToMessage) + Args += "-x "; + if (!string.IsNullOrEmpty(extraParams)) + Args += $"{extraParams} "; + Args += commits; + } + } +} diff --git a/src/Commands/Clean.cs b/src/Commands/Clean.cs new file mode 100644 index 00000000..6ed74999 --- /dev/null +++ b/src/Commands/Clean.cs @@ -0,0 +1,12 @@ +namespace SourceGit.Commands +{ + public class Clean : Command + { + public Clean(string repo) + { + WorkingDirectory = repo; + Context = repo; + Args = "clean -qfdx"; + } + } +} diff --git a/src/Commands/Clone.cs b/src/Commands/Clone.cs new file mode 100644 index 00000000..efec264b --- /dev/null +++ b/src/Commands/Clone.cs @@ -0,0 +1,21 @@ +namespace SourceGit.Commands +{ + public class Clone : Command + { + public Clone(string ctx, string path, string url, string localName, string sshKey, string extraArgs) + { + Context = ctx; + WorkingDirectory = path; + SSHKey = sshKey; + Args = "clone --progress --verbose "; + + if (!string.IsNullOrEmpty(extraArgs)) + Args += $"{extraArgs} "; + + Args += $"{url} "; + + if (!string.IsNullOrEmpty(localName)) + Args += localName; + } + } +} diff --git a/src/Commands/Command.cs b/src/Commands/Command.cs new file mode 100644 index 00000000..9bfa1c15 --- /dev/null +++ b/src/Commands/Command.cs @@ -0,0 +1,220 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; + +using Avalonia.Threading; + +namespace SourceGit.Commands +{ + public partial class Command + { + public class ReadToEndResult + { + public bool IsSuccess { get; set; } = false; + public string StdOut { get; set; } = ""; + public string StdErr { get; set; } = ""; + } + + public enum EditorType + { + None, + CoreEditor, + RebaseEditor, + } + + public string Context { get; set; } = string.Empty; + public CancellationToken CancellationToken { get; set; } = CancellationToken.None; + public string WorkingDirectory { get; set; } = null; + public EditorType Editor { get; set; } = EditorType.CoreEditor; // Only used in Exec() mode + public string SSHKey { get; set; } = string.Empty; + public string Args { get; set; } = string.Empty; + public bool RaiseError { get; set; } = true; + public Models.ICommandLog Log { get; set; } = null; + + public bool Exec() + { + Log?.AppendLine($"$ git {Args}\n"); + + var start = CreateGitStartInfo(); + var errs = new List(); + var proc = new Process() { StartInfo = start }; + + proc.OutputDataReceived += (_, e) => HandleOutput(e.Data, errs); + proc.ErrorDataReceived += (_, e) => HandleOutput(e.Data, errs); + + var dummy = null as Process; + var dummyProcLock = new object(); + try + { + 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) + { + if (RaiseError) + Dispatcher.UIThread.Post(() => App.RaiseException(Context, e.Message)); + + Log?.AppendLine(string.Empty); + return false; + } + + proc.BeginOutputReadLine(); + proc.BeginErrorReadLine(); + proc.WaitForExit(); + + if (dummy != null) + { + lock (dummyProcLock) + { + dummy = null; + } + } + + int exitCode = proc.ExitCode; + proc.Close(); + Log?.AppendLine(string.Empty); + + if (!CancellationToken.IsCancellationRequested && exitCode != 0) + { + if (RaiseError) + { + var errMsg = string.Join("\n", errs).Trim(); + if (!string.IsNullOrEmpty(errMsg)) + Dispatcher.UIThread.Post(() => App.RaiseException(Context, errMsg)); + } + + return false; + } + + return true; + } + + public ReadToEndResult ReadToEnd() + { + var start = CreateGitStartInfo(); + var proc = new Process() { StartInfo = start }; + + try + { + proc.Start(); + } + catch (Exception e) + { + return new ReadToEndResult() + { + IsSuccess = false, + StdOut = string.Empty, + StdErr = e.Message, + }; + } + + var rs = new ReadToEndResult() + { + StdOut = proc.StandardOutput.ReadToEnd(), + StdErr = proc.StandardError.ReadToEnd(), + }; + + proc.WaitForExit(); + rs.IsSuccess = proc.ExitCode == 0; + proc.Close(); + + return rs; + } + + private ProcessStartInfo CreateGitStartInfo() + { + var start = new ProcessStartInfo(); + start.FileName = Native.OS.GitExecutable; + start.Arguments = "--no-pager -c core.quotepath=off -c credential.helper=manager "; + start.UseShellExecute = false; + start.CreateNoWindow = true; + start.RedirectStandardOutput = true; + start.RedirectStandardError = true; + start.StandardOutputEncoding = Encoding.UTF8; + start.StandardErrorEncoding = Encoding.UTF8; + + // Force using this app as SSH askpass program + var selfExecFile = Process.GetCurrentProcess().MainModule!.FileName; + if (!OperatingSystem.IsLinux()) + start.Environment.Add("DISPLAY", "required"); + start.Environment.Add("SSH_ASKPASS", selfExecFile); // Can not use parameter here, because it invoked by SSH with `exec` + start.Environment.Add("SSH_ASKPASS_REQUIRE", "prefer"); + start.Environment.Add("SOURCEGIT_LAUNCH_AS_ASKPASS", "TRUE"); + + // If an SSH private key was provided, sets the environment. + if (!start.Environment.ContainsKey("GIT_SSH_COMMAND") && !string.IsNullOrEmpty(SSHKey)) + start.Environment.Add("GIT_SSH_COMMAND", $"ssh -i '{SSHKey}'"); + + // Force using en_US.UTF-8 locale + if (OperatingSystem.IsLinux()) + { + start.Environment.Add("LANG", "C"); + start.Environment.Add("LC_ALL", "C"); + } + + // Force using this app as git editor. + switch (Editor) + { + case EditorType.CoreEditor: + start.Arguments += $"-c core.editor=\"\\\"{selfExecFile}\\\" --core-editor\" "; + break; + case EditorType.RebaseEditor: + start.Arguments += $"-c core.editor=\"\\\"{selfExecFile}\\\" --rebase-message-editor\" -c sequence.editor=\"\\\"{selfExecFile}\\\" --rebase-todo-editor\" -c rebase.abbreviateCommands=true "; + break; + default: + start.Arguments += "-c core.editor=true "; + break; + } + + // Append command args + start.Arguments += Args; + + // Working directory + if (!string.IsNullOrEmpty(WorkingDirectory)) + start.WorkingDirectory = WorkingDirectory; + + return start; + } + + private void HandleOutput(string line, List errs) + { + line = line ?? string.Empty; + Log?.AppendLine(line); + + // Lines to hide in error message. + if (line.Length > 0) + { + if (line.StartsWith("remote: Enumerating objects:", StringComparison.Ordinal) || + line.StartsWith("remote: Counting objects:", StringComparison.Ordinal) || + line.StartsWith("remote: Compressing objects:", StringComparison.Ordinal) || + line.StartsWith("Filtering content:", StringComparison.Ordinal) || + line.StartsWith("hint:", StringComparison.Ordinal)) + return; + + if (REG_PROGRESS().IsMatch(line)) + return; + } + + errs.Add(line); + } + + [GeneratedRegex(@"\d+%")] + private static partial Regex REG_PROGRESS(); + } +} diff --git a/src/Commands/Commit.cs b/src/Commands/Commit.cs new file mode 100644 index 00000000..17410bc9 --- /dev/null +++ b/src/Commands/Commit.cs @@ -0,0 +1,39 @@ +using System.IO; + +namespace SourceGit.Commands +{ + public class Commit : Command + { + public Commit(string repo, string message, bool signOff, bool amend, bool resetAuthor) + { + _tmpFile = Path.GetTempFileName(); + File.WriteAllText(_tmpFile, message); + + WorkingDirectory = repo; + Context = repo; + Args = $"commit --allow-empty --file=\"{_tmpFile}\""; + if (signOff) + Args += " --signoff"; + if (amend) + Args += resetAuthor ? " --amend --reset-author --no-edit" : " --amend --no-edit"; + } + + public bool Run() + { + var succ = Exec(); + + try + { + File.Delete(_tmpFile); + } + catch + { + // Ignore + } + + return succ; + } + + private string _tmpFile = string.Empty; + } +} diff --git a/src/SourceGit/Commands/CompareRevisions.cs b/src/Commands/CompareRevisions.cs similarity index 51% rename from src/SourceGit/Commands/CompareRevisions.cs rename to src/Commands/CompareRevisions.cs index a9efb36c..7b4a496d 100644 --- a/src/SourceGit/Commands/CompareRevisions.cs +++ b/src/Commands/CompareRevisions.cs @@ -1,32 +1,63 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Text.RegularExpressions; namespace SourceGit.Commands { public partial class CompareRevisions : Command { - [GeneratedRegex(@"^(\s?[\w\?]{1,4})\s+(.+)$")] + [GeneratedRegex(@"^([MADC])\s+(.+)$")] private static partial Regex REG_FORMAT(); + [GeneratedRegex(@"^R[0-9]{0,4}\s+(.+)$")] + private static partial Regex REG_RENAME_FORMAT(); public CompareRevisions(string repo, string start, string end) { WorkingDirectory = repo; Context = repo; - Args = $"diff --name-status {start} {end}"; + + var based = string.IsNullOrEmpty(start) ? "-R" : start; + 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 Result() { - Exec(); - _changes.Sort((l, r) => l.Path.CompareTo(r.Path)); + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return _changes; + + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + ParseLine(line); + + _changes.Sort((l, r) => string.Compare(l.Path, r.Path, StringComparison.Ordinal)); return _changes; } - protected override void OnReadline(string line) + private void ParseLine(string line) { var match = REG_FORMAT().Match(line); if (!match.Success) + { + match = REG_RENAME_FORMAT().Match(line); + if (match.Success) + { + var renamed = new Models.Change() { Path = match.Groups[1].Value }; + renamed.Set(Models.ChangeState.Renamed); + _changes.Add(renamed); + } + return; + } var change = new Models.Change() { Path = match.Groups[2].Value }; var status = match.Groups[1].Value; @@ -45,10 +76,6 @@ namespace SourceGit.Commands 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); diff --git a/src/SourceGit/Commands/Config.cs b/src/Commands/Config.cs similarity index 55% rename from src/SourceGit/Commands/Config.cs rename to src/Commands/Config.cs index 62340aa3..49e8fcb7 100644 --- a/src/SourceGit/Commands/Config.cs +++ b/src/Commands/Config.cs @@ -7,8 +7,17 @@ namespace SourceGit.Commands { public Config(string repository) { - WorkingDirectory = repository; - Context = repository; + if (string.IsNullOrEmpty(repository)) + { + WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + } + else + { + WorkingDirectory = repository; + Context = repository; + _isLocal = true; + } + RaiseError = false; } @@ -20,7 +29,7 @@ namespace SourceGit.Commands var rs = new Dictionary(); if (output.IsSuccess) { - var lines = output.StdOut.Split(new char[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + var lines = output.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); foreach (var line in lines) { var idx = line.IndexOf('=', StringComparison.Ordinal); @@ -28,14 +37,7 @@ namespace SourceGit.Commands { var key = line.Substring(0, idx).Trim(); var val = line.Substring(idx + 1).Trim(); - if (rs.ContainsKey(key)) - { - rs[key] = val; - } - else - { - rs.Add(key, val); - } + rs[key] = val; } } } @@ -51,30 +53,16 @@ namespace SourceGit.Commands public bool Set(string key, string value, bool allowEmpty = false) { + var scope = _isLocal ? "--local" : "--global"; + if (!allowEmpty && string.IsNullOrWhiteSpace(value)) - { - if (string.IsNullOrEmpty(WorkingDirectory)) - { - Args = $"config --global --unset {key}"; - } - else - { - Args = $"config --unset {key}"; - } - } + Args = $"config {scope} --unset {key}"; else - { - if (string.IsNullOrWhiteSpace(WorkingDirectory)) - { - Args = $"config --global {key} \"{value}\""; - } - else - { - Args = $"config {key} \"{value}\""; - } - } + Args = $"config {scope} {key} \"{value}\""; return Exec(); } + + private bool _isLocal = false; } } diff --git a/src/Commands/CountLocalChangesWithoutUntracked.cs b/src/Commands/CountLocalChangesWithoutUntracked.cs new file mode 100644 index 00000000..a704f313 --- /dev/null +++ b/src/Commands/CountLocalChangesWithoutUntracked.cs @@ -0,0 +1,26 @@ +using System; + +namespace SourceGit.Commands +{ + public class CountLocalChangesWithoutUntracked : Command + { + public CountLocalChangesWithoutUntracked(string repo) + { + WorkingDirectory = repo; + Context = repo; + Args = "--no-optional-locks status -uno --ignore-submodules=all --porcelain"; + } + + public int Result() + { + var rs = ReadToEnd(); + if (rs.IsSuccess) + { + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + return lines.Length; + } + + return 0; + } + } +} diff --git a/src/SourceGit/Commands/Diff.cs b/src/Commands/Diff.cs similarity index 60% rename from src/SourceGit/Commands/Diff.cs rename to src/Commands/Diff.cs index 090eba98..6af0a3cc 100644 --- a/src/SourceGit/Commands/Diff.cs +++ b/src/Commands/Diff.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Text.RegularExpressions; @@ -8,47 +8,92 @@ namespace SourceGit.Commands { [GeneratedRegex(@"^@@ \-(\d+),?\d* \+(\d+),?\d* @@")] private static partial Regex REG_INDICATOR(); + + [GeneratedRegex(@"^index\s([0-9a-f]{6,40})\.\.([0-9a-f]{6,40})(\s[1-9]{6})?")] + private static partial Regex REG_HASH_CHANGE(); + private const string PREFIX_LFS_NEW = "+version https://git-lfs.github.com/spec/"; private const string PREFIX_LFS_DEL = "-version https://git-lfs.github.com/spec/"; private const string PREFIX_LFS_MODIFY = " version https://git-lfs.github.com/spec/"; - public Diff(string repo, Models.DiffOption opt) + public Diff(string repo, Models.DiffOption opt, int unified, bool ignoreWhitespace) { + _result.TextDiff = new Models.TextDiff() + { + Repo = repo, + Option = opt, + }; + WorkingDirectory = repo; Context = repo; - Args = $"diff --ignore-cr-at-eol --unified=4 {opt}"; + + if (ignoreWhitespace) + Args = $"diff --no-ext-diff --patch --ignore-all-space --unified={unified} {opt}"; + else if (Models.DiffOption.IgnoreCRAtEOL) + Args = $"diff --no-ext-diff --patch --ignore-cr-at-eol --unified={unified} {opt}"; + else + Args = $"diff --no-ext-diff --patch --unified={unified} {opt}"; } public Models.DiffResult Result() { - Exec(); + var rs = ReadToEnd(); + var start = 0; + var end = rs.StdOut.IndexOf('\n', start); + while (end > 0) + { + var line = rs.StdOut.Substring(start, end - start); + ParseLine(line); - if (_result.IsBinary || _result.IsLFS) + start = end + 1; + end = rs.StdOut.IndexOf('\n', start); + } + + if (start < rs.StdOut.Length) + ParseLine(rs.StdOut.Substring(start)); + + if (_result.IsBinary || _result.IsLFS || _result.TextDiff.Lines.Count == 0) { _result.TextDiff = null; } else { ProcessInlineHighlights(); - - if (_result.TextDiff.Lines.Count == 0) - { - _result.TextDiff = null; - } - else - { - _result.TextDiff.MaxLineNumber = Math.Max(_newLine, _oldLine); - } + _result.TextDiff.MaxLineNumber = Math.Max(_newLine, _oldLine); } return _result; } - protected override void OnReadline(string line) + private void ParseLine(string line) { if (_result.IsBinary) return; + if (line.StartsWith("old mode ", StringComparison.Ordinal)) + { + _result.OldMode = line.Substring(9); + return; + } + + if (line.StartsWith("new mode ", StringComparison.Ordinal)) + { + _result.NewMode = line.Substring(9); + 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.IsLFS) { var ch = line[0]; @@ -60,7 +105,7 @@ namespace SourceGit.Commands } else if (line.StartsWith("-size ", StringComparison.Ordinal)) { - _result.LFSDiff.Old.Size = long.Parse(line.Substring(6)); + _result.LFSDiff.Old.Size = long.Parse(line.AsSpan(6)); } } else if (ch == '+') @@ -71,36 +116,52 @@ namespace SourceGit.Commands } else if (line.StartsWith("+size ", StringComparison.Ordinal)) { - _result.LFSDiff.New.Size = long.Parse(line.Substring(6)); + _result.LFSDiff.New.Size = long.Parse(line.AsSpan(6)); } } else if (line.StartsWith(" size ", StringComparison.Ordinal)) { - _result.LFSDiff.New.Size = _result.LFSDiff.Old.Size = long.Parse(line.Substring(6)); + _result.LFSDiff.New.Size = _result.LFSDiff.Old.Size = long.Parse(line.AsSpan(6)); } return; } if (_result.TextDiff.Lines.Count == 0) { - var match = REG_INDICATOR().Match(line); - if (!match.Success) + if (line.StartsWith("Binary", StringComparison.Ordinal)) { - if (line.StartsWith("Binary", StringComparison.Ordinal)) - _result.IsBinary = true; + _result.IsBinary = true; return; } - _oldLine = int.Parse(match.Groups[1].Value); - _newLine = int.Parse(match.Groups[2].Value); - _result.TextDiff.Lines.Add(new Models.TextDiffLine(Models.TextDiffLineType.Indicator, line, 0, 0)); + if (string.IsNullOrEmpty(_result.OldHash)) + { + var match = REG_HASH_CHANGE().Match(line); + if (!match.Success) + return; + + _result.OldHash = match.Groups[1].Value; + _result.NewHash = match.Groups[2].Value; + } + else + { + var match = REG_INDICATOR().Match(line); + if (!match.Success) + return; + + _oldLine = int.Parse(match.Groups[1].Value); + _newLine = int.Parse(match.Groups[2].Value); + _last = new Models.TextDiffLine(Models.TextDiffLineType.Indicator, line, 0, 0); + _result.TextDiff.Lines.Add(_last); + } } else { if (line.Length == 0) { ProcessInlineHighlights(); - _result.TextDiff.Lines.Add(new Models.TextDiffLine(Models.TextDiffLineType.Normal, "", _oldLine, _newLine)); + _last = new Models.TextDiffLine(Models.TextDiffLineType.Normal, "", _oldLine, _newLine); + _result.TextDiff.Lines.Add(_last); _oldLine++; _newLine++; return; @@ -116,7 +177,8 @@ namespace SourceGit.Commands return; } - _deleted.Add(new Models.TextDiffLine(Models.TextDiffLineType.Deleted, line.Substring(1), _oldLine, 0)); + _last = new Models.TextDiffLine(Models.TextDiffLineType.Deleted, line.Substring(1), _oldLine, 0); + _deleted.Add(_last); _oldLine++; } else if (ch == '+') @@ -128,7 +190,8 @@ namespace SourceGit.Commands return; } - _added.Add(new Models.TextDiffLine(Models.TextDiffLineType.Added, line.Substring(1), 0, _newLine)); + _last = new Models.TextDiffLine(Models.TextDiffLineType.Added, line.Substring(1), 0, _newLine); + _added.Add(_last); _newLine++; } else if (ch != '\\') @@ -139,7 +202,8 @@ namespace SourceGit.Commands { _oldLine = int.Parse(match.Groups[1].Value); _newLine = int.Parse(match.Groups[2].Value); - _result.TextDiff.Lines.Add(new Models.TextDiffLine(Models.TextDiffLineType.Indicator, line, 0, 0)); + _last = new Models.TextDiffLine(Models.TextDiffLineType.Indicator, line, 0, 0); + _result.TextDiff.Lines.Add(_last); } else { @@ -150,11 +214,16 @@ namespace SourceGit.Commands return; } - _result.TextDiff.Lines.Add(new Models.TextDiffLine(Models.TextDiffLineType.Normal, line.Substring(1), _oldLine, _newLine)); + _last = new Models.TextDiffLine(Models.TextDiffLineType.Normal, line.Substring(1), _oldLine, _newLine); + _result.TextDiff.Lines.Add(_last); _oldLine++; _newLine++; } } + else if (line.Equals("\\ No newline at end of file", StringComparison.Ordinal)) + { + _last.NoNewLineEndOfFile = true; + } } } @@ -202,9 +271,10 @@ namespace SourceGit.Commands } } - private readonly Models.DiffResult _result = new Models.DiffResult() { TextDiff = new Models.TextDiff() }; + private readonly Models.DiffResult _result = new Models.DiffResult(); private readonly List _deleted = new List(); private readonly List _added = new List(); + private Models.TextDiffLine _last = null; private int _oldLine = 0; private int _newLine = 0; } diff --git a/src/Commands/Discard.cs b/src/Commands/Discard.cs new file mode 100644 index 00000000..f36ca6c9 --- /dev/null +++ b/src/Commands/Discard.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using Avalonia.Threading; + +namespace SourceGit.Commands +{ + public static class Discard + { + /// + /// Discard all local changes (unstaged & staged) + /// + /// + /// + /// + public static void All(string repo, bool includeIgnored, Models.ICommandLog log) + { + var changes = new QueryLocalChanges(repo).Result(); + try + { + foreach (var c in changes) + { + if (c.WorkTree == Models.ChangeState.Untracked || + c.WorkTree == Models.ChangeState.Added || + c.Index == Models.ChangeState.Added || + c.Index == Models.ChangeState.Renamed) + { + var fullPath = Path.Combine(repo, c.Path); + if (Directory.Exists(fullPath)) + Directory.Delete(fullPath, true); + else + File.Delete(fullPath); + } + } + } + catch (Exception e) + { + Dispatcher.UIThread.Invoke(() => + { + App.RaiseException(repo, $"Failed to discard changes. Reason: {e.Message}"); + }); + } + + new Reset(repo, "HEAD", "--hard") { Log = log }.Exec(); + + if (includeIgnored) + new Clean(repo) { Log = log }.Exec(); + } + + /// + /// Discard selected changes (only unstaged). + /// + /// + /// + /// + public static void Changes(string repo, List changes, Models.ICommandLog log) + { + var restores = new List(); + + try + { + foreach (var c in changes) + { + if (c.WorkTree == Models.ChangeState.Untracked || c.WorkTree == Models.ChangeState.Added) + { + var fullPath = Path.Combine(repo, c.Path); + if (Directory.Exists(fullPath)) + Directory.Delete(fullPath, true); + else + File.Delete(fullPath); + } + else + { + restores.Add(c.Path); + } + } + } + catch (Exception e) + { + Dispatcher.UIThread.Invoke(() => + { + App.RaiseException(repo, $"Failed to discard changes. Reason: {e.Message}"); + }); + } + + if (restores.Count > 0) + { + var pathSpecFile = Path.GetTempFileName(); + File.WriteAllLines(pathSpecFile, restores); + new Restore(repo, pathSpecFile, false) { Log = log }.Exec(); + File.Delete(pathSpecFile); + } + } + } +} diff --git a/src/Commands/ExecuteCustomAction.cs b/src/Commands/ExecuteCustomAction.cs new file mode 100644 index 00000000..e59bc068 --- /dev/null +++ b/src/Commands/ExecuteCustomAction.cs @@ -0,0 +1,86 @@ +using System; +using System.Diagnostics; +using System.Text; + +using Avalonia.Threading; + +namespace SourceGit.Commands +{ + public static class ExecuteCustomAction + { + 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, Models.ICommandLog log) + { + var start = new ProcessStartInfo(); + start.FileName = file; + start.Arguments = args; + start.UseShellExecute = false; + start.CreateNoWindow = true; + start.RedirectStandardOutput = true; + start.RedirectStandardError = true; + start.StandardOutputEncoding = Encoding.UTF8; + start.StandardErrorEncoding = Encoding.UTF8; + start.WorkingDirectory = repo; + + log?.AppendLine($"$ {file} {args}\n"); + + var proc = new Process() { StartInfo = start }; + var builder = new StringBuilder(); + + proc.OutputDataReceived += (_, e) => + { + if (e.Data != null) + log?.AppendLine(e.Data); + }; + + proc.ErrorDataReceived += (_, e) => + { + if (e.Data != null) + { + log?.AppendLine(e.Data); + builder.AppendLine(e.Data); + } + }; + + try + { + proc.Start(); + proc.BeginOutputReadLine(); + proc.BeginErrorReadLine(); + 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) + { + Dispatcher.UIThread.Invoke(() => App.RaiseException(repo, e.Message)); + } + + proc.Close(); + } + } +} diff --git a/src/Commands/Fetch.cs b/src/Commands/Fetch.cs new file mode 100644 index 00000000..edf2a6dd --- /dev/null +++ b/src/Commands/Fetch.cs @@ -0,0 +1,31 @@ +namespace SourceGit.Commands +{ + public class Fetch : Command + { + public Fetch(string repo, string remote, bool noTags, bool force) + { + WorkingDirectory = repo; + Context = repo; + SSHKey = new Config(repo).Get($"remote.{remote}.sshkey"); + Args = "fetch --progress --verbose "; + + if (noTags) + Args += "--no-tags "; + else + Args += "--tags "; + + if (force) + Args += "--force "; + + Args += remote; + } + + public Fetch(string repo, Models.Branch local, Models.Branch remote) + { + WorkingDirectory = repo; + Context = repo; + SSHKey = new Config(repo).Get($"remote.{remote.Remote}.sshkey"); + Args = $"fetch --progress --verbose {remote.Remote} {remote.Name}:{local.Name}"; + } + } +} diff --git a/src/SourceGit/Commands/FormatPatch.cs b/src/Commands/FormatPatch.cs similarity index 69% rename from src/SourceGit/Commands/FormatPatch.cs rename to src/Commands/FormatPatch.cs index 2c7359c0..bf850d60 100644 --- a/src/SourceGit/Commands/FormatPatch.cs +++ b/src/Commands/FormatPatch.cs @@ -6,7 +6,8 @@ { WorkingDirectory = repo; Context = repo; - Args = $"format-patch {commit} -1 -o \"{saveTo}\""; + Editor = EditorType.None; + Args = $"format-patch {commit} -1 --output=\"{saveTo}\""; } } } diff --git a/src/Commands/GC.cs b/src/Commands/GC.cs new file mode 100644 index 00000000..0b27f487 --- /dev/null +++ b/src/Commands/GC.cs @@ -0,0 +1,12 @@ +namespace SourceGit.Commands +{ + public class GC : Command + { + public GC(string repo) + { + WorkingDirectory = repo; + Context = repo; + Args = "gc --prune=now"; + } + } +} diff --git a/src/Commands/GenerateCommitMessage.cs b/src/Commands/GenerateCommitMessage.cs new file mode 100644 index 00000000..df61fdd2 --- /dev/null +++ b/src/Commands/GenerateCommitMessage.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; + +using Avalonia.Threading; + +namespace SourceGit.Commands +{ + /// + /// A C# version of https://github.com/anjerodev/commitollama + /// + public class GenerateCommitMessage + { + public class GetDiffContent : Command + { + public GetDiffContent(string repo, Models.DiffOption opt) + { + WorkingDirectory = repo; + Context = repo; + Args = $"diff --diff-algorithm=minimal {opt}"; + } + } + + public GenerateCommitMessage(Models.OpenAIService service, string repo, List changes, CancellationToken cancelToken, Action onResponse) + { + _service = service; + _repo = repo; + _changes = changes; + _cancelToken = cancelToken; + _onResponse = onResponse; + } + + public void Exec() + { + try + { + _onResponse?.Invoke("Waiting for pre-file analyzing to completed...\n\n"); + + var responseBuilder = new StringBuilder(); + var summaryBuilder = new StringBuilder(); + foreach (var change in _changes) + { + if (_cancelToken.IsCancellationRequested) + return; + + responseBuilder.Append("- "); + summaryBuilder.Append("- "); + + var rs = new GetDiffContent(_repo, new Models.DiffOption(change, false)).ReadToEnd(); + if (rs.IsSuccess) + { + _service.Chat( + _service.AnalyzeDiffPrompt, + $"Here is the `git diff` output: {rs.StdOut}", + _cancelToken, + update => + { + responseBuilder.Append(update); + summaryBuilder.Append(update); + + _onResponse?.Invoke($"Waiting for pre-file analyzing to completed...\n\n{responseBuilder}"); + }); + } + + responseBuilder.Append("\n"); + summaryBuilder.Append("(file: "); + summaryBuilder.Append(change.Path); + summaryBuilder.Append(")\n"); + } + + if (_cancelToken.IsCancellationRequested) + return; + + var responseBody = responseBuilder.ToString(); + var subjectBuilder = new StringBuilder(); + _service.Chat( + _service.GenerateSubjectPrompt, + $"Here are the summaries changes:\n{summaryBuilder}", + _cancelToken, + update => + { + subjectBuilder.Append(update); + _onResponse?.Invoke($"{subjectBuilder}\n\n{responseBody}"); + }); + } + catch (Exception e) + { + Dispatcher.UIThread.Post(() => App.RaiseException(_repo, $"Failed to generate commit message: {e}")); + } + } + + private Models.OpenAIService _service; + private string _repo; + private List _changes; + private CancellationToken _cancelToken; + private Action _onResponse; + } +} diff --git a/src/Commands/GitFlow.cs b/src/Commands/GitFlow.cs new file mode 100644 index 00000000..1d33fa3a --- /dev/null +++ b/src/Commands/GitFlow.cs @@ -0,0 +1,92 @@ +using System.Text; +using Avalonia.Threading; + +namespace SourceGit.Commands +{ + public static class GitFlow + { + public static bool Init(string repo, string master, string develop, string feature, string release, string hotfix, string version, Models.ICommandLog log) + { + var config = new Config(repo); + config.Set("gitflow.branch.master", master); + config.Set("gitflow.branch.develop", develop); + config.Set("gitflow.prefix.feature", feature); + config.Set("gitflow.prefix.bugfix", "bugfix/"); + config.Set("gitflow.prefix.release", release); + config.Set("gitflow.prefix.hotfix", hotfix); + config.Set("gitflow.prefix.support", "support/"); + config.Set("gitflow.prefix.versiontag", version, true); + + var init = new Command(); + init.WorkingDirectory = repo; + init.Context = repo; + init.Args = "flow init -d"; + init.Log = log; + return init.Exec(); + } + + public static bool Start(string repo, Models.GitFlowBranchType type, string name, Models.ICommandLog log) + { + var start = new Command(); + start.WorkingDirectory = repo; + start.Context = repo; + + switch (type) + { + case Models.GitFlowBranchType.Feature: + start.Args = $"flow feature start {name}"; + break; + case Models.GitFlowBranchType.Release: + start.Args = $"flow release start {name}"; + break; + case Models.GitFlowBranchType.Hotfix: + start.Args = $"flow hotfix start {name}"; + break; + default: + Dispatcher.UIThread.Invoke(() => App.RaiseException(repo, "Bad git-flow branch type!!!")); + return false; + } + + start.Log = log; + return start.Exec(); + } + + public static bool Finish(string repo, Models.GitFlowBranchType type, string name, bool squash, bool push, bool keepBranch, Models.ICommandLog log) + { + var builder = new StringBuilder(); + builder.Append("flow "); + + switch (type) + { + case Models.GitFlowBranchType.Feature: + builder.Append("feature"); + break; + case Models.GitFlowBranchType.Release: + builder.Append("release"); + break; + case Models.GitFlowBranchType.Hotfix: + builder.Append("hotfix"); + break; + default: + Dispatcher.UIThread.Invoke(() => App.RaiseException(repo, "Bad git-flow branch type!!!")); + return false; + } + + builder.Append(" finish "); + if (squash) + builder.Append("--squash "); + if (push) + builder.Append("--push "); + if (keepBranch) + builder.Append("-k "); + builder.Append(name); + + var finish = new Command(); + finish.WorkingDirectory = repo; + finish.Context = repo; + finish.Args = builder.ToString(); + finish.Log = log; + return finish.Exec(); + } + } +} diff --git a/src/Commands/GitIgnore.cs b/src/Commands/GitIgnore.cs new file mode 100644 index 00000000..8b351f5e --- /dev/null +++ b/src/Commands/GitIgnore.cs @@ -0,0 +1,23 @@ +using System.IO; + +namespace SourceGit.Commands +{ + public static class GitIgnore + { + public static void Add(string repo, string pattern) + { + var file = Path.Combine(repo, ".gitignore"); + if (!File.Exists(file)) + { + File.WriteAllLines(file, [pattern]); + return; + } + + var org = File.ReadAllText(file); + if (!org.EndsWith('\n')) + File.AppendAllLines(file, ["", pattern]); + else + File.AppendAllLines(file, [pattern]); + } + } +} diff --git a/src/SourceGit/Commands/Init.cs b/src/Commands/Init.cs similarity index 100% rename from src/SourceGit/Commands/Init.cs rename to src/Commands/Init.cs diff --git a/src/Commands/IsBareRepository.cs b/src/Commands/IsBareRepository.cs new file mode 100644 index 00000000..f92d0888 --- /dev/null +++ b/src/Commands/IsBareRepository.cs @@ -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"; + } + } +} diff --git a/src/SourceGit/Commands/IsBinary.cs b/src/Commands/IsBinary.cs similarity index 83% rename from src/SourceGit/Commands/IsBinary.cs rename to src/Commands/IsBinary.cs index de59b5a4..af8f54bb 100644 --- a/src/SourceGit/Commands/IsBinary.cs +++ b/src/Commands/IsBinary.cs @@ -11,7 +11,7 @@ namespace SourceGit.Commands { WorkingDirectory = repo; Context = repo; - Args = $"diff 4b825dc642cb6eb9a060e54bf8d69288fbee4904 {commit} --numstat -- \"{path}\""; + Args = $"diff {Models.Commit.EmptyTreeSHA1} {commit} --numstat -- \"{path}\""; RaiseError = false; } diff --git a/src/Commands/IsCommitSHA.cs b/src/Commands/IsCommitSHA.cs new file mode 100644 index 00000000..1b0c50e3 --- /dev/null +++ b/src/Commands/IsCommitSHA.cs @@ -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"); + } + } +} diff --git a/src/Commands/IsConflictResolved.cs b/src/Commands/IsConflictResolved.cs new file mode 100644 index 00000000..9b243451 --- /dev/null +++ b/src/Commands/IsConflictResolved.cs @@ -0,0 +1,19 @@ +namespace SourceGit.Commands +{ + public class IsConflictResolved : Command + { + public IsConflictResolved(string repo, Models.Change change) + { + var opt = new Models.DiffOption(change, true); + + WorkingDirectory = repo; + Context = repo; + Args = $"diff -a --ignore-cr-at-eol --check {opt}"; + } + + public bool Result() + { + return ReadToEnd().IsSuccess; + } + } +} diff --git a/src/SourceGit/Commands/IsLFSFiltered.cs b/src/Commands/IsLFSFiltered.cs similarity index 57% rename from src/SourceGit/Commands/IsLFSFiltered.cs rename to src/Commands/IsLFSFiltered.cs index b29039de..2a7234bb 100644 --- a/src/SourceGit/Commands/IsLFSFiltered.cs +++ b/src/Commands/IsLFSFiltered.cs @@ -6,7 +6,15 @@ { WorkingDirectory = repo; Context = repo; - Args = $"check-attr -a -z \"{path}\""; + Args = $"check-attr -z filter \"{path}\""; + RaiseError = false; + } + + public IsLFSFiltered(string repo, string sha, string path) + { + WorkingDirectory = repo; + Context = repo; + Args = $"check-attr --source {sha} -z filter \"{path}\""; RaiseError = false; } diff --git a/src/Commands/LFS.cs b/src/Commands/LFS.cs new file mode 100644 index 00000000..e621ed7d --- /dev/null +++ b/src/Commands/LFS.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; + +namespace SourceGit.Commands +{ + public partial class LFS + { + [GeneratedRegex(@"^(.+)\s+([\w.]+)\s+\w+:(\d+)$")] + private static partial Regex REG_LOCK(); + + class SubCmd : Command + { + public SubCmd(string repo, string args, Models.ICommandLog log) + { + WorkingDirectory = repo; + Context = repo; + Args = args; + Log = log; + } + } + + public LFS(string repo) + { + _repo = repo; + } + + public bool IsEnabled() + { + var path = Path.Combine(_repo, ".git", "hooks", "pre-push"); + if (!File.Exists(path)) + return false; + + var content = File.ReadAllText(path); + return content.Contains("git lfs pre-push"); + } + + public bool Install(Models.ICommandLog log) + { + return new SubCmd(_repo, "lfs install --local", log).Exec(); + } + + public bool Track(string pattern, bool isFilenameMode, Models.ICommandLog log) + { + var opt = isFilenameMode ? "--filename" : ""; + return new SubCmd(_repo, $"lfs track {opt} \"{pattern}\"", log).Exec(); + } + + public void Fetch(string remote, Models.ICommandLog log) + { + new SubCmd(_repo, $"lfs fetch {remote}", log).Exec(); + } + + public void Pull(string remote, Models.ICommandLog log) + { + new SubCmd(_repo, $"lfs pull {remote}", log).Exec(); + } + + public void Push(string remote, Models.ICommandLog log) + { + new SubCmd(_repo, $"lfs push {remote}", log).Exec(); + } + + public void Prune(Models.ICommandLog log) + { + new SubCmd(_repo, "lfs prune", log).Exec(); + } + + public List Locks(string remote) + { + var locks = new List(); + var cmd = new SubCmd(_repo, $"lfs locks --remote={remote}", null); + var rs = cmd.ReadToEnd(); + if (rs.IsSuccess) + { + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var match = REG_LOCK().Match(line); + if (match.Success) + { + locks.Add(new Models.LFSLock() + { + File = match.Groups[1].Value, + User = match.Groups[2].Value, + ID = long.Parse(match.Groups[3].Value), + }); + } + } + } + + return locks; + } + + public bool Lock(string remote, string file, Models.ICommandLog log) + { + return new SubCmd(_repo, $"lfs lock --remote={remote} \"{file}\"", log).Exec(); + } + + public bool Unlock(string remote, string file, bool force, Models.ICommandLog log) + { + var opt = force ? "-f" : ""; + return new SubCmd(_repo, $"lfs unlock --remote={remote} {opt} \"{file}\"", log).Exec(); + } + + public bool Unlock(string remote, long id, bool force, Models.ICommandLog log) + { + var opt = force ? "-f" : ""; + return new SubCmd(_repo, $"lfs unlock --remote={remote} {opt} --id={id}", log).Exec(); + } + + private readonly string _repo; + } +} diff --git a/src/Commands/Merge.cs b/src/Commands/Merge.cs new file mode 100644 index 00000000..b08377b9 --- /dev/null +++ b/src/Commands/Merge.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Text; + +namespace SourceGit.Commands +{ + public class Merge : Command + { + public Merge(string repo, string source, string mode) + { + WorkingDirectory = repo; + Context = repo; + Args = $"merge --progress {source} {mode}"; + } + + public Merge(string repo, List targets, bool autoCommit, string strategy) + { + WorkingDirectory = repo; + Context = repo; + + var builder = new StringBuilder(); + builder.Append("merge --progress "); + if (!string.IsNullOrEmpty(strategy)) + builder.Append($"--strategy={strategy} "); + if (!autoCommit) + builder.Append("--no-commit "); + + foreach (var t in targets) + { + builder.Append(t); + builder.Append(' '); + } + + Args = builder.ToString(); + } + } +} diff --git a/src/Commands/MergeTool.cs b/src/Commands/MergeTool.cs new file mode 100644 index 00000000..fc6d0d75 --- /dev/null +++ b/src/Commands/MergeTool.cs @@ -0,0 +1,72 @@ +using System.IO; + +using Avalonia.Threading; + +namespace SourceGit.Commands +{ + public static class MergeTool + { + public static bool OpenForMerge(string repo, int toolType, string toolPath, string file) + { + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.RaiseError = true; + + // NOTE: If no names are specified, 'git mergetool' will run the merge tool program on every file with merge conflicts. + var fileArg = string.IsNullOrEmpty(file) ? "" : $"\"{file}\""; + + if (toolType == 0) + { + cmd.Args = $"mergetool {fileArg}"; + return cmd.Exec(); + } + + if (!File.Exists(toolPath)) + { + Dispatcher.UIThread.Post(() => App.RaiseException(repo, $"Can NOT find external merge tool in '{toolPath}'!")); + return false; + } + + var supported = Models.ExternalMerger.Supported.Find(x => x.Type == toolType); + if (supported == null) + { + Dispatcher.UIThread.Post(() => App.RaiseException(repo, "Invalid merge tool in preference setting!")); + return false; + } + + cmd.Args = $"-c mergetool.sourcegit.cmd=\"\\\"{toolPath}\\\" {supported.Cmd}\" -c mergetool.writeToTemp=true -c mergetool.keepBackup=false -c mergetool.trustExitCode=true mergetool --tool=sourcegit {fileArg}"; + return cmd.Exec(); + } + + public static bool OpenForDiff(string repo, int toolType, string toolPath, Models.DiffOption option) + { + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.RaiseError = true; + + if (toolType == 0) + { + cmd.Args = $"difftool -g --no-prompt {option}"; + return cmd.Exec(); + } + + if (!File.Exists(toolPath)) + { + Dispatcher.UIThread.Invoke(() => App.RaiseException(repo, $"Can NOT find external diff tool in '{toolPath}'!")); + return false; + } + + var supported = Models.ExternalMerger.Supported.Find(x => x.Type == toolType); + if (supported == null) + { + Dispatcher.UIThread.Post(() => App.RaiseException(repo, "Invalid merge tool in preference setting!")); + return false; + } + + cmd.Args = $"-c difftool.sourcegit.cmd=\"\\\"{toolPath}\\\" {supported.DiffCmd}\" difftool --tool=sourcegit --no-prompt {option}"; + return cmd.Exec(); + } + } +} diff --git a/src/Commands/Pull.cs b/src/Commands/Pull.cs new file mode 100644 index 00000000..698fbfce --- /dev/null +++ b/src/Commands/Pull.cs @@ -0,0 +1,18 @@ +namespace SourceGit.Commands +{ + public class Pull : Command + { + public Pull(string repo, string remote, string branch, bool useRebase) + { + WorkingDirectory = repo; + Context = repo; + SSHKey = new Config(repo).Get($"remote.{remote}.sshkey"); + Args = "pull --verbose --progress "; + + if (useRebase) + Args += "--rebase=true "; + + Args += $"{remote} {branch}"; + } + } +} diff --git a/src/Commands/Push.cs b/src/Commands/Push.cs new file mode 100644 index 00000000..8a5fe33c --- /dev/null +++ b/src/Commands/Push.cs @@ -0,0 +1,37 @@ +namespace SourceGit.Commands +{ + public class Push : Command + { + public Push(string repo, string local, string remote, string remoteBranch, bool withTags, bool checkSubmodules, bool track, bool force) + { + WorkingDirectory = repo; + Context = repo; + SSHKey = new Config(repo).Get($"remote.{remote}.sshkey"); + Args = "push --progress --verbose "; + + if (withTags) + Args += "--tags "; + if (checkSubmodules) + Args += "--recurse-submodules=check "; + if (track) + Args += "-u "; + if (force) + Args += "--force-with-lease "; + + Args += $"{remote} {local}:{remoteBranch}"; + } + + public Push(string repo, string remote, string refname, bool isDelete) + { + WorkingDirectory = repo; + Context = repo; + SSHKey = new Config(repo).Get($"remote.{remote}.sshkey"); + Args = "push "; + + if (isDelete) + Args += "--delete "; + + Args += $"{remote} {refname}"; + } + } +} diff --git a/src/Commands/QueryAssumeUnchangedFiles.cs b/src/Commands/QueryAssumeUnchangedFiles.cs new file mode 100644 index 00000000..b5c23b0b --- /dev/null +++ b/src/Commands/QueryAssumeUnchangedFiles.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace SourceGit.Commands +{ + public partial class QueryAssumeUnchangedFiles : Command + { + [GeneratedRegex(@"^(\w)\s+(.+)$")] + private static partial Regex REG_PARSE(); + + public QueryAssumeUnchangedFiles(string repo) + { + WorkingDirectory = repo; + Args = "ls-files -v"; + RaiseError = false; + } + + public List Result() + { + var outs = new List(); + var rs = ReadToEnd(); + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var match = REG_PARSE().Match(line); + if (!match.Success) + continue; + + if (match.Groups[1].Value == "h") + outs.Add(match.Groups[2].Value); + } + + return outs; + } + } +} diff --git a/src/Commands/QueryBranches.cs b/src/Commands/QueryBranches.cs new file mode 100644 index 00000000..d0ecd322 --- /dev/null +++ b/src/Commands/QueryBranches.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; + +namespace SourceGit.Commands +{ + public class QueryBranches : Command + { + private const string PREFIX_LOCAL = "refs/heads/"; + private const string PREFIX_REMOTE = "refs/remotes/"; + private const string PREFIX_DETACHED_AT = "(HEAD detached at"; + private const string PREFIX_DETACHED_FROM = "(HEAD detached from"; + + public QueryBranches(string repo) + { + WorkingDirectory = repo; + Context = repo; + Args = "branch -l --all -v --format=\"%(refname)%00%(committerdate:unix)%00%(objectname)%00%(HEAD)%00%(upstream)%00%(upstream:trackshort)\""; + } + + public List Result(out int localBranchesCount) + { + localBranchesCount = 0; + + var branches = new List(); + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return branches; + + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + var remoteHeads = new Dictionary(); + foreach (var line in lines) + { + var b = ParseLine(line); + if (b != null) + { + branches.Add(b); + if (!b.IsLocal) + remoteHeads.Add(b.FullName, b.Head); + else + localBranchesCount++; + } + } + + foreach (var b in branches) + { + if (b.IsLocal && !string.IsNullOrEmpty(b.Upstream)) + { + if (remoteHeads.TryGetValue(b.Upstream, out var upstreamHead)) + { + b.IsUpstreamGone = false; + + if (b.TrackStatus == null) + b.TrackStatus = new QueryTrackStatus(WorkingDirectory, b.Head, upstreamHead).Result(); + } + else + { + b.IsUpstreamGone = true; + + if (b.TrackStatus == null) + b.TrackStatus = new Models.BranchTrackStatus(); + } + } + } + + return branches; + } + + private Models.Branch ParseLine(string line) + { + var parts = line.Split('\0'); + if (parts.Length != 6) + return null; + + var branch = new Models.Branch(); + var refName = parts[0]; + if (refName.EndsWith("/HEAD", StringComparison.Ordinal)) + return null; + + branch.IsDetachedHead = refName.StartsWith(PREFIX_DETACHED_AT, StringComparison.Ordinal) || + refName.StartsWith(PREFIX_DETACHED_FROM, StringComparison.Ordinal); + + if (refName.StartsWith(PREFIX_LOCAL, StringComparison.Ordinal)) + { + branch.Name = refName.Substring(PREFIX_LOCAL.Length); + branch.IsLocal = true; + } + else if (refName.StartsWith(PREFIX_REMOTE, StringComparison.Ordinal)) + { + var name = refName.Substring(PREFIX_REMOTE.Length); + var shortNameIdx = name.IndexOf('/', StringComparison.Ordinal); + if (shortNameIdx < 0) + return null; + + branch.Remote = name.Substring(0, shortNameIdx); + branch.Name = name.Substring(branch.Remote.Length + 1); + branch.IsLocal = false; + } + else + { + branch.Name = refName; + branch.IsLocal = true; + } + + branch.FullName = refName; + branch.CommitterDate = ulong.Parse(parts[1]); + branch.Head = parts[2]; + branch.IsCurrent = parts[3] == "*"; + branch.Upstream = parts[4]; + branch.IsUpstreamGone = false; + + if (!branch.IsLocal || + string.IsNullOrEmpty(branch.Upstream) || + string.IsNullOrEmpty(parts[5]) || + parts[5].Equals("=", StringComparison.Ordinal)) + branch.TrackStatus = new Models.BranchTrackStatus(); + + return branch; + } + } +} diff --git a/src/Commands/QueryCommitChildren.cs b/src/Commands/QueryCommitChildren.cs new file mode 100644 index 00000000..4e99ce7a --- /dev/null +++ b/src/Commands/QueryCommitChildren.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; + +namespace SourceGit.Commands +{ + public class QueryCommitChildren : Command + { + public QueryCommitChildren(string repo, string commit, int max) + { + WorkingDirectory = repo; + Context = repo; + _commit = commit; + Args = $"rev-list -{max} --parents --branches --remotes --ancestry-path ^{commit}"; + } + + public List Result() + { + var rs = ReadToEnd(); + var outs = new List(); + if (rs.IsSuccess) + { + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + if (line.Contains(_commit)) + outs.Add(line.Substring(0, 40)); + } + } + + return outs; + } + + private string _commit; + } +} diff --git a/src/Commands/QueryCommitFullMessage.cs b/src/Commands/QueryCommitFullMessage.cs new file mode 100644 index 00000000..36b6d1c7 --- /dev/null +++ b/src/Commands/QueryCommitFullMessage.cs @@ -0,0 +1,20 @@ +namespace SourceGit.Commands +{ + public class QueryCommitFullMessage : Command + { + public QueryCommitFullMessage(string repo, string sha) + { + WorkingDirectory = repo; + Context = repo; + Args = $"show --no-show-signature --format=%B -s {sha}"; + } + + public string Result() + { + var rs = ReadToEnd(); + if (rs.IsSuccess) + return rs.StdOut.TrimEnd(); + return string.Empty; + } + } +} diff --git a/src/Commands/QueryCommitSignInfo.cs b/src/Commands/QueryCommitSignInfo.cs new file mode 100644 index 00000000..133949af --- /dev/null +++ b/src/Commands/QueryCommitSignInfo.cs @@ -0,0 +1,34 @@ +namespace SourceGit.Commands +{ + public class QueryCommitSignInfo : Command + { + public QueryCommitSignInfo(string repo, string sha, bool useFakeSignersFile) + { + WorkingDirectory = repo; + Context = repo; + + const string baseArgs = "show --no-show-signature --format=%G?%n%GS%n%GK -s"; + const string fakeSignersFileArg = "-c gpg.ssh.allowedSignersFile=/dev/null"; + Args = $"{(useFakeSignersFile ? fakeSignersFileArg : string.Empty)} {baseArgs} {sha}"; + } + + public Models.CommitSignInfo Result() + { + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return null; + + var raw = rs.StdOut.Trim().ReplaceLineEndings("\n"); + if (raw.Length <= 1) + return null; + + var lines = raw.Split('\n'); + return new Models.CommitSignInfo() + { + VerifyResult = lines[0][0], + Signer = lines[1], + Key = lines[2] + }; + } + } +} diff --git a/src/Commands/QueryCommits.cs b/src/Commands/QueryCommits.cs new file mode 100644 index 00000000..9e1d9918 --- /dev/null +++ b/src/Commands/QueryCommits.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace SourceGit.Commands +{ + public class QueryCommits : Command + { + public QueryCommits(string repo, string limits, bool needFindHead = true) + { + WorkingDirectory = repo; + Context = repo; + 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; + } + + public QueryCommits(string repo, string filter, Models.CommitSearchMethod method, bool onlyCurrentBranch) + { + string search = onlyCurrentBranch ? string.Empty : "--branches --remotes "; + + if (method == Models.CommitSearchMethod.ByAuthor) + { + search += $"-i --author=\"{filter}\""; + } + else if (method == Models.CommitSearchMethod.ByCommitter) + { + search += $"-i --committer=\"{filter}\""; + } + else if (method == Models.CommitSearchMethod.ByMessage) + { + var argsBuilder = new StringBuilder(); + argsBuilder.Append(search); + + var words = filter.Split([' ', '\t', '\r'], StringSplitOptions.RemoveEmptyEntries); + foreach (var word in words) + { + var escaped = word.Trim().Replace("\"", "\\\"", StringComparison.Ordinal); + argsBuilder.Append($"--grep=\"{escaped}\" "); + } + argsBuilder.Append("--all-match -i"); + + search = argsBuilder.ToString(); + } + else if (method == Models.CommitSearchMethod.ByFile) + { + search += $"-- \"{filter}\""; + } + else + { + search = $"-G\"{filter}\""; + } + + WorkingDirectory = repo; + Context = repo; + 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; + } + + public List Result() + { + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return _commits; + + var nextPartIdx = 0; + var start = 0; + var end = rs.StdOut.IndexOf('\n', start); + while (end > 0) + { + var line = rs.StdOut.Substring(start, end - start); + switch (nextPartIdx) + { + case 0: + _current = new Models.Commit() { SHA = line }; + _commits.Add(_current); + break; + case 1: + ParseParent(line); + break; + case 2: + _current.ParseDecorators(line); + if (_current.IsMerged && !_isHeadFounded) + _isHeadFounded = true; + break; + case 3: + _current.Author = Models.User.FindOrAdd(line); + break; + case 4: + _current.AuthorTime = ulong.Parse(line); + break; + case 5: + _current.Committer = Models.User.FindOrAdd(line); + break; + case 6: + _current.CommitterTime = ulong.Parse(line); + break; + case 7: + _current.Subject = line; + nextPartIdx = -1; + break; + } + + nextPartIdx++; + + start = end + 1; + end = rs.StdOut.IndexOf('\n', start); + } + + if (start < rs.StdOut.Length) + _current.Subject = rs.StdOut.Substring(start); + + if (_findFirstMerged && !_isHeadFounded && _commits.Count > 0) + MarkFirstMerged(); + + return _commits; + } + + private void ParseParent(string data) + { + if (data.Length < 8) + return; + + _current.Parents.AddRange(data.Split(separator: ' ', options: StringSplitOptions.RemoveEmptyEntries)); + } + + private void MarkFirstMerged() + { + Args = $"log --since=\"{_commits[^1].CommitterTimeStr}\" --format=\"%H\""; + + var rs = ReadToEnd(); + var shas = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + if (shas.Length == 0) + return; + + var set = new HashSet(); + foreach (var sha in shas) + set.Add(sha); + + foreach (var c in _commits) + { + if (set.Contains(c.SHA)) + { + c.IsMerged = true; + break; + } + } + } + + private List _commits = new List(); + private Models.Commit _current = null; + private bool _findFirstMerged = false; + private bool _isHeadFounded = false; + } +} diff --git a/src/Commands/QueryCommitsForInteractiveRebase.cs b/src/Commands/QueryCommitsForInteractiveRebase.cs new file mode 100644 index 00000000..615060a5 --- /dev/null +++ b/src/Commands/QueryCommitsForInteractiveRebase.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; + +namespace SourceGit.Commands +{ + public class QueryCommitsForInteractiveRebase : Command + { + public QueryCommitsForInteractiveRebase(string repo, string on) + { + _boundary = $"----- BOUNDARY OF COMMIT {Guid.NewGuid()} -----"; + + WorkingDirectory = repo; + Context = repo; + 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 Result() + { + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return _commits; + + var nextPartIdx = 0; + var start = 0; + var end = rs.StdOut.IndexOf('\n', start); + while (end > 0) + { + var line = rs.StdOut.Substring(start, end - start); + switch (nextPartIdx) + { + case 0: + _current = new Models.InteractiveCommit(); + _current.Commit.SHA = line; + _commits.Add(_current); + break; + case 1: + ParseParent(line); + break; + case 2: + _current.Commit.ParseDecorators(line); + break; + case 3: + _current.Commit.Author = Models.User.FindOrAdd(line); + break; + case 4: + _current.Commit.AuthorTime = ulong.Parse(line); + break; + case 5: + _current.Commit.Committer = Models.User.FindOrAdd(line); + break; + case 6: + _current.Commit.CommitterTime = ulong.Parse(line); + break; + default: + var boundary = rs.StdOut.IndexOf(_boundary, end + 1, StringComparison.Ordinal); + if (boundary > end) + { + _current.Message = rs.StdOut.Substring(start, boundary - start - 1); + end = boundary + _boundary.Length; + } + else + { + _current.Message = rs.StdOut.Substring(start); + end = rs.StdOut.Length - 2; + } + + nextPartIdx = -1; + break; + } + + nextPartIdx++; + + start = end + 1; + if (start >= rs.StdOut.Length - 1) + break; + + end = rs.StdOut.IndexOf('\n', start); + } + + return _commits; + } + + private void ParseParent(string data) + { + if (data.Length < 8) + return; + + _current.Commit.Parents.AddRange(data.Split(separator: ' ', options: StringSplitOptions.RemoveEmptyEntries)); + } + + private List _commits = []; + private Models.InteractiveCommit _current = null; + private string _boundary = ""; + } +} diff --git a/src/Commands/QueryFileContent.cs b/src/Commands/QueryFileContent.cs new file mode 100644 index 00000000..83d0a575 --- /dev/null +++ b/src/Commands/QueryFileContent.cs @@ -0,0 +1,73 @@ +using System; +using System.Diagnostics; +using System.IO; + +namespace SourceGit.Commands +{ + public static class QueryFileContent + { + public static Stream Run(string repo, string revision, string file) + { + var starter = new ProcessStartInfo(); + starter.WorkingDirectory = repo; + starter.FileName = Native.OS.GitExecutable; + starter.Arguments = $"show {revision}:\"{file}\""; + starter.UseShellExecute = false; + starter.CreateNoWindow = true; + starter.WindowStyle = ProcessWindowStyle.Hidden; + starter.RedirectStandardOutput = true; + + var stream = new MemoryStream(); + try + { + var proc = new Process() { StartInfo = starter }; + proc.Start(); + proc.StandardOutput.BaseStream.CopyTo(stream); + proc.WaitForExit(); + proc.Close(); + + stream.Position = 0; + } + catch (Exception e) + { + App.RaiseException(repo, $"Failed to query file content: {e}"); + } + + return stream; + } + + public static Stream FromLFS(string repo, string oid, long size) + { + var starter = new ProcessStartInfo(); + starter.WorkingDirectory = repo; + starter.FileName = Native.OS.GitExecutable; + starter.Arguments = $"lfs smudge"; + starter.UseShellExecute = false; + starter.CreateNoWindow = true; + starter.WindowStyle = ProcessWindowStyle.Hidden; + starter.RedirectStandardInput = true; + starter.RedirectStandardOutput = true; + + var stream = new MemoryStream(); + try + { + var proc = new Process() { StartInfo = starter }; + proc.Start(); + proc.StandardInput.WriteLine("version https://git-lfs.github.com/spec/v1"); + proc.StandardInput.WriteLine($"oid sha256:{oid}"); + proc.StandardInput.WriteLine($"size {size}"); + proc.StandardOutput.BaseStream.CopyTo(stream); + proc.WaitForExit(); + proc.Close(); + + stream.Position = 0; + } + catch (Exception e) + { + App.RaiseException(repo, $"Failed to query file content: {e}"); + } + + return stream; + } + } +} diff --git a/src/SourceGit/Commands/QueryFileSize.cs b/src/Commands/QueryFileSize.cs similarity index 90% rename from src/SourceGit/Commands/QueryFileSize.cs rename to src/Commands/QueryFileSize.cs index 5ce7641e..9016d826 100644 --- a/src/SourceGit/Commands/QueryFileSize.cs +++ b/src/Commands/QueryFileSize.cs @@ -4,7 +4,6 @@ namespace SourceGit.Commands { public partial class QueryFileSize : Command { - [GeneratedRegex(@"^\d+\s+\w+\s+[0-9a-f]+\s+(\d+)\s+.*$")] private static partial Regex REG_FORMAT(); @@ -12,7 +11,7 @@ namespace SourceGit.Commands { WorkingDirectory = repo; Context = repo; - Args = $"ls-tree {revision} -l -- {file}"; + Args = $"ls-tree {revision} -l -- \"{file}\""; } public long Result() @@ -25,9 +24,7 @@ namespace SourceGit.Commands { var match = REG_FORMAT().Match(rs.StdOut); if (match.Success) - { return long.Parse(match.Groups[1].Value); - } } return 0; diff --git a/src/Commands/QueryGitCommonDir.cs b/src/Commands/QueryGitCommonDir.cs new file mode 100644 index 00000000..1076243e --- /dev/null +++ b/src/Commands/QueryGitCommonDir.cs @@ -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)); + } + } +} diff --git a/src/SourceGit/Commands/QueryGitDir.cs b/src/Commands/QueryGitDir.cs similarity index 100% rename from src/SourceGit/Commands/QueryGitDir.cs rename to src/Commands/QueryGitDir.cs diff --git a/src/Commands/QueryLocalChanges.cs b/src/Commands/QueryLocalChanges.cs new file mode 100644 index 00000000..788ed617 --- /dev/null +++ b/src/Commands/QueryLocalChanges.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +using Avalonia.Threading; + +namespace SourceGit.Commands +{ + public partial class QueryLocalChanges : Command + { + [GeneratedRegex(@"^(\s?[\w\?]{1,4})\s+(.+)$")] + private static partial Regex REG_FORMAT(); + private static readonly string[] UNTRACKED = ["no", "all"]; + + public QueryLocalChanges(string repo, bool includeUntracked = true) + { + WorkingDirectory = repo; + Context = repo; + Args = $"--no-optional-locks status -u{UNTRACKED[includeUntracked ? 1 : 0]} --ignore-submodules=dirty --porcelain"; + } + + public List Result() + { + var outs = new List(); + var rs = ReadToEnd(); + if (!rs.IsSuccess) + { + Dispatcher.UIThread.Post(() => App.RaiseException(Context, rs.StdErr)); + return outs; + } + + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var match = REG_FORMAT().Match(line); + if (!match.Success) + continue; + + var change = new Models.Change() { Path = match.Groups[2].Value }; + var status = match.Groups[1].Value; + + switch (status) + { + case " M": + change.Set(Models.ChangeState.None, Models.ChangeState.Modified); + break; + case " T": + change.Set(Models.ChangeState.None, Models.ChangeState.TypeChanged); + break; + case " A": + change.Set(Models.ChangeState.None, Models.ChangeState.Added); + break; + case " D": + change.Set(Models.ChangeState.None, Models.ChangeState.Deleted); + break; + case " R": + change.Set(Models.ChangeState.None, Models.ChangeState.Renamed); + break; + case " C": + change.Set(Models.ChangeState.None, Models.ChangeState.Copied); + break; + case "M": + change.Set(Models.ChangeState.Modified); + break; + case "MM": + change.Set(Models.ChangeState.Modified, Models.ChangeState.Modified); + break; + case "MT": + change.Set(Models.ChangeState.Modified, Models.ChangeState.TypeChanged); + break; + case "MD": + change.Set(Models.ChangeState.Modified, Models.ChangeState.Deleted); + break; + case "T": + change.Set(Models.ChangeState.TypeChanged); + break; + case "TM": + change.Set(Models.ChangeState.TypeChanged, Models.ChangeState.Modified); + break; + case "TT": + change.Set(Models.ChangeState.TypeChanged, Models.ChangeState.TypeChanged); + break; + case "TD": + change.Set(Models.ChangeState.TypeChanged, Models.ChangeState.Deleted); + break; + case "A": + change.Set(Models.ChangeState.Added); + break; + case "AM": + change.Set(Models.ChangeState.Added, Models.ChangeState.Modified); + break; + case "AT": + change.Set(Models.ChangeState.Added, Models.ChangeState.TypeChanged); + break; + case "AD": + change.Set(Models.ChangeState.Added, Models.ChangeState.Deleted); + break; + case "D": + change.Set(Models.ChangeState.Deleted); + break; + case "R": + change.Set(Models.ChangeState.Renamed); + break; + case "RM": + change.Set(Models.ChangeState.Renamed, Models.ChangeState.Modified); + break; + case "RT": + change.Set(Models.ChangeState.Renamed, Models.ChangeState.TypeChanged); + break; + case "RD": + change.Set(Models.ChangeState.Renamed, Models.ChangeState.Deleted); + break; + case "C": + change.Set(Models.ChangeState.Copied); + break; + case "CM": + change.Set(Models.ChangeState.Copied, Models.ChangeState.Modified); + break; + case "CT": + change.Set(Models.ChangeState.Copied, Models.ChangeState.TypeChanged); + break; + case "CD": + change.Set(Models.ChangeState.Copied, Models.ChangeState.Deleted); + break; + case "DD": + change.ConflictReason = Models.ConflictReason.BothDeleted; + change.Set(Models.ChangeState.None, Models.ChangeState.Conflicted); + break; + case "AU": + change.ConflictReason = Models.ConflictReason.AddedByUs; + change.Set(Models.ChangeState.None, Models.ChangeState.Conflicted); + break; + case "UD": + change.ConflictReason = Models.ConflictReason.DeletedByThem; + change.Set(Models.ChangeState.None, Models.ChangeState.Conflicted); + break; + case "UA": + change.ConflictReason = Models.ConflictReason.AddedByThem; + change.Set(Models.ChangeState.None, Models.ChangeState.Conflicted); + break; + case "DU": + change.ConflictReason = Models.ConflictReason.DeletedByUs; + change.Set(Models.ChangeState.None, Models.ChangeState.Conflicted); + break; + case "AA": + change.ConflictReason = Models.ConflictReason.BothAdded; + change.Set(Models.ChangeState.None, Models.ChangeState.Conflicted); + break; + case "UU": + change.ConflictReason = Models.ConflictReason.BothModified; + change.Set(Models.ChangeState.None, Models.ChangeState.Conflicted); + break; + case "??": + change.Set(Models.ChangeState.None, Models.ChangeState.Untracked); + break; + } + + if (change.Index != Models.ChangeState.None || change.WorkTree != Models.ChangeState.None) + outs.Add(change); + } + + return outs; + } + } +} diff --git a/src/Commands/QueryRefsContainsCommit.cs b/src/Commands/QueryRefsContainsCommit.cs new file mode 100644 index 00000000..cabe1b50 --- /dev/null +++ b/src/Commands/QueryRefsContainsCommit.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; + +namespace SourceGit.Commands +{ + public class QueryRefsContainsCommit : Command + { + public QueryRefsContainsCommit(string repo, string commit) + { + WorkingDirectory = repo; + RaiseError = false; + Args = $"for-each-ref --format=\"%(refname)\" --contains {commit}"; + } + + public List Result() + { + var rs = new List(); + + var output = ReadToEnd(); + if (!output.IsSuccess) + return rs; + + var lines = output.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + if (line.EndsWith("/HEAD", StringComparison.Ordinal)) + continue; + + if (line.StartsWith("refs/heads/", StringComparison.Ordinal)) + rs.Add(new() { Name = line.Substring("refs/heads/".Length), Type = Models.DecoratorType.LocalBranchHead }); + else if (line.StartsWith("refs/remotes/", StringComparison.Ordinal)) + rs.Add(new() { Name = line.Substring("refs/remotes/".Length), Type = Models.DecoratorType.RemoteBranchHead }); + else if (line.StartsWith("refs/tags/", StringComparison.Ordinal)) + rs.Add(new() { Name = line.Substring("refs/tags/".Length), Type = Models.DecoratorType.Tag }); + } + + return rs; + } + } +} diff --git a/src/Commands/QueryRemotes.cs b/src/Commands/QueryRemotes.cs new file mode 100644 index 00000000..7afec74d --- /dev/null +++ b/src/Commands/QueryRemotes.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace SourceGit.Commands +{ + public partial class QueryRemotes : Command + { + [GeneratedRegex(@"^([\w\.\-]+)\s*(\S+).*$")] + private static partial Regex REG_REMOTE(); + + public QueryRemotes(string repo) + { + WorkingDirectory = repo; + Context = repo; + Args = "remote -v"; + } + + public List Result() + { + var outs = new List(); + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return outs; + + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var match = REG_REMOTE().Match(line); + if (!match.Success) + continue; + + var remote = new Models.Remote() + { + Name = match.Groups[1].Value, + URL = match.Groups[2].Value, + }; + + if (outs.Find(x => x.Name == remote.Name) != null) + continue; + + outs.Add(remote); + } + + return outs; + } + } +} diff --git a/src/SourceGit/Commands/QueryRepositoryRootPath.cs b/src/Commands/QueryRepositoryRootPath.cs similarity index 53% rename from src/SourceGit/Commands/QueryRepositoryRootPath.cs rename to src/Commands/QueryRepositoryRootPath.cs index 1eef5af8..016621c8 100644 --- a/src/SourceGit/Commands/QueryRepositoryRootPath.cs +++ b/src/Commands/QueryRepositoryRootPath.cs @@ -6,15 +6,6 @@ { WorkingDirectory = path; Args = "rev-parse --show-toplevel"; - RaiseError = false; - } - - public string Result() - { - var rs = ReadToEnd().StdOut; - if (string.IsNullOrEmpty(rs)) - return null; - return rs.Trim(); } } } diff --git a/src/Commands/QueryRevisionByRefName.cs b/src/Commands/QueryRevisionByRefName.cs new file mode 100644 index 00000000..7fb4ecfa --- /dev/null +++ b/src/Commands/QueryRevisionByRefName.cs @@ -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; + } + } +} diff --git a/src/Commands/QueryRevisionFileNames.cs b/src/Commands/QueryRevisionFileNames.cs new file mode 100644 index 00000000..c6fd7373 --- /dev/null +++ b/src/Commands/QueryRevisionFileNames.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; + +namespace SourceGit.Commands +{ + public class QueryRevisionFileNames : Command + { + public QueryRevisionFileNames(string repo, string revision) + { + WorkingDirectory = repo; + Context = repo; + Args = $"ls-tree -r -z --name-only {revision}"; + } + + public List Result() + { + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return []; + + var lines = rs.StdOut.Split('\0', System.StringSplitOptions.RemoveEmptyEntries); + var outs = new List(); + foreach (var line in lines) + outs.Add(line); + return outs; + } + } +} diff --git a/src/SourceGit/Commands/QueryRevisionObjects.cs b/src/Commands/QueryRevisionObjects.cs similarity index 57% rename from src/SourceGit/Commands/QueryRevisionObjects.cs rename to src/Commands/QueryRevisionObjects.cs index 7a3db057..de3406e8 100644 --- a/src/SourceGit/Commands/QueryRevisionObjects.cs +++ b/src/Commands/QueryRevisionObjects.cs @@ -5,25 +5,42 @@ namespace SourceGit.Commands { public partial class QueryRevisionObjects : Command { - [GeneratedRegex(@"^\d+\s+(\w+)\s+([0-9a-f]+)\s+(.*)$")] private static partial Regex REG_FORMAT(); - private readonly List objects = new List(); - public QueryRevisionObjects(string repo, string sha) + public QueryRevisionObjects(string repo, string sha, string parentFolder) { WorkingDirectory = repo; Context = repo; - Args = $"ls-tree -r {sha}"; + Args = $"ls-tree -z {sha}"; + + if (!string.IsNullOrEmpty(parentFolder)) + Args += $" -- \"{parentFolder}\""; } public List Result() { - Exec(); - return objects; + var rs = ReadToEnd(); + if (rs.IsSuccess) + { + var start = 0; + var end = rs.StdOut.IndexOf('\0', start); + while (end > 0) + { + var line = rs.StdOut.Substring(start, end - start); + Parse(line); + start = end + 1; + end = rs.StdOut.IndexOf('\0', start); + } + + if (start < rs.StdOut.Length) + Parse(rs.StdOut.Substring(start)); + } + + return _objects; } - protected override void OnReadline(string line) + private void Parse(string line) { var match = REG_FORMAT().Match(line); if (!match.Success) @@ -50,7 +67,9 @@ namespace SourceGit.Commands break; } - objects.Add(obj); + _objects.Add(obj); } + + private List _objects = new List(); } } diff --git a/src/Commands/QuerySingleCommit.cs b/src/Commands/QuerySingleCommit.cs new file mode 100644 index 00000000..35289ec5 --- /dev/null +++ b/src/Commands/QuerySingleCommit.cs @@ -0,0 +1,41 @@ +using System; + +namespace SourceGit.Commands +{ + public class QuerySingleCommit : Command + { + public QuerySingleCommit(string repo, string sha) + { + WorkingDirectory = repo; + Context = repo; + 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() + { + var rs = ReadToEnd(); + if (rs.IsSuccess && !string.IsNullOrEmpty(rs.StdOut)) + { + var commit = new Models.Commit(); + var lines = rs.StdOut.Split('\n'); + if (lines.Length < 8) + return null; + + commit.SHA = lines[0]; + if (!string.IsNullOrEmpty(lines[1])) + commit.Parents.AddRange(lines[1].Split(' ', StringSplitOptions.RemoveEmptyEntries)); + if (!string.IsNullOrEmpty(lines[2])) + commit.ParseDecorators(lines[2]); + commit.Author = Models.User.FindOrAdd(lines[3]); + commit.AuthorTime = ulong.Parse(lines[4]); + commit.Committer = Models.User.FindOrAdd(lines[5]); + commit.CommitterTime = ulong.Parse(lines[6]); + commit.Subject = lines[7]; + + return commit; + } + + return null; + } + } +} diff --git a/src/Commands/QueryStagedChangesWithAmend.cs b/src/Commands/QueryStagedChangesWithAmend.cs new file mode 100644 index 00000000..78980401 --- /dev/null +++ b/src/Commands/QueryStagedChangesWithAmend.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace SourceGit.Commands +{ + public partial class QueryStagedChangesWithAmend : Command + { + [GeneratedRegex(@"^:[\d]{6} ([\d]{6}) ([0-9a-f]{40}) [0-9a-f]{40} ([ACDMT])\d{0,6}\t(.*)$")] + private static partial Regex REG_FORMAT1(); + [GeneratedRegex(@"^:[\d]{6} ([\d]{6}) ([0-9a-f]{40}) [0-9a-f]{40} R\d{0,6}\t(.*\t.*)$")] + private static partial Regex REG_FORMAT2(); + + public QueryStagedChangesWithAmend(string repo, string parent) + { + WorkingDirectory = repo; + Context = repo; + Args = $"diff-index --cached -M {parent}"; + _parent = parent; + } + + public List Result() + { + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return []; + + var changes = new List(); + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var match = REG_FORMAT2().Match(line); + if (match.Success) + { + var change = new Models.Change() + { + Path = match.Groups[3].Value, + DataForAmend = new Models.ChangeDataForAmend() + { + FileMode = match.Groups[1].Value, + ObjectHash = match.Groups[2].Value, + ParentSHA = _parent, + }, + }; + change.Set(Models.ChangeState.Renamed); + changes.Add(change); + continue; + } + + match = REG_FORMAT1().Match(line); + if (match.Success) + { + var change = new Models.Change() + { + Path = match.Groups[4].Value, + DataForAmend = new Models.ChangeDataForAmend() + { + FileMode = match.Groups[1].Value, + ObjectHash = match.Groups[2].Value, + ParentSHA = _parent, + }, + }; + + var type = match.Groups[3].Value; + switch (type) + { + case "A": + change.Set(Models.ChangeState.Added); + break; + case "C": + change.Set(Models.ChangeState.Copied); + break; + case "D": + change.Set(Models.ChangeState.Deleted); + break; + case "M": + change.Set(Models.ChangeState.Modified); + break; + case "T": + change.Set(Models.ChangeState.TypeChanged); + break; + } + changes.Add(change); + } + } + + return changes; + } + + private readonly string _parent; + } +} diff --git a/src/SourceGit/Commands/QueryStagedFileBlobGuid.cs b/src/Commands/QueryStagedFileBlobGuid.cs similarity index 100% rename from src/SourceGit/Commands/QueryStagedFileBlobGuid.cs rename to src/Commands/QueryStagedFileBlobGuid.cs diff --git a/src/Commands/QueryStashes.cs b/src/Commands/QueryStashes.cs new file mode 100644 index 00000000..b4067aaf --- /dev/null +++ b/src/Commands/QueryStashes.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; + +namespace SourceGit.Commands +{ + public class QueryStashes : Command + { + public QueryStashes(string repo) + { + WorkingDirectory = repo; + Context = repo; + Args = "stash list --format=%H%n%P%n%ct%n%gd%n%s"; + } + + public List Result() + { + var outs = new List(); + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return outs; + + var nextPartIdx = 0; + var start = 0; + var end = rs.StdOut.IndexOf('\n', start); + while (end > 0) + { + var line = rs.StdOut.Substring(start, end - start); + + switch (nextPartIdx) + { + case 0: + _current = new Models.Stash() { SHA = line }; + outs.Add(_current); + break; + case 1: + ParseParent(line); + break; + case 2: + _current.Time = ulong.Parse(line); + break; + case 3: + _current.Name = line; + break; + case 4: + _current.Message = line; + break; + } + + nextPartIdx++; + if (nextPartIdx > 4) + nextPartIdx = 0; + + start = end + 1; + end = rs.StdOut.IndexOf('\n', start); + } + + if (start < rs.StdOut.Length) + _current.Message = rs.StdOut.Substring(start); + + return outs; + } + + private void ParseParent(string data) + { + if (data.Length < 8) + return; + + _current.Parents.AddRange(data.Split(separator: ' ', options: StringSplitOptions.RemoveEmptyEntries)); + } + + private Models.Stash _current = null; + } +} diff --git a/src/Commands/QuerySubmodules.cs b/src/Commands/QuerySubmodules.cs new file mode 100644 index 00000000..663c0ea0 --- /dev/null +++ b/src/Commands/QuerySubmodules.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; + +namespace SourceGit.Commands +{ + public partial class QuerySubmodules : Command + { + [GeneratedRegex(@"^([U\-\+ ])([0-9a-f]+)\s(.*?)(\s\(.*\))?$")] + private static partial Regex REG_FORMAT_STATUS(); + [GeneratedRegex(@"^\s?[\w\?]{1,4}\s+(.+)$")] + private static partial Regex REG_FORMAT_DIRTY(); + [GeneratedRegex(@"^submodule\.(\S*)\.(\w+)=(.*)$")] + private static partial Regex REG_FORMAT_MODULE_INFO(); + + public QuerySubmodules(string repo) + { + WorkingDirectory = repo; + Context = repo; + Args = "submodule status"; + } + + public List Result() + { + var submodules = new List(); + var rs = ReadToEnd(); + + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + var map = new Dictionary(); + var needCheckLocalChanges = false; + foreach (var line in lines) + { + var match = REG_FORMAT_STATUS().Match(line); + if (match.Success) + { + var stat = match.Groups[1].Value; + var sha = match.Groups[2].Value; + var path = match.Groups[3].Value; + + var module = new Models.Submodule() { Path = path, SHA = sha }; + switch (stat[0]) + { + case '-': + module.Status = Models.SubmoduleStatus.NotInited; + break; + case '+': + module.Status = Models.SubmoduleStatus.RevisionChanged; + break; + case 'U': + module.Status = Models.SubmoduleStatus.Unmerged; + break; + default: + module.Status = Models.SubmoduleStatus.Normal; + needCheckLocalChanges = true; + break; + } + + map.Add(path, module); + submodules.Add(module); + } + } + + if (submodules.Count > 0) + { + Args = "config --file .gitmodules --list"; + rs = ReadToEnd(); + if (rs.IsSuccess) + { + var modules = new Dictionary(); + lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var match = REG_FORMAT_MODULE_INFO().Match(line); + if (match.Success) + { + var name = match.Groups[1].Value; + var key = match.Groups[2].Value; + var val = match.Groups[3].Value; + + if (!modules.TryGetValue(name, out var m)) + { + m = new ModuleInfo(); + modules.Add(name, m); + } + + if (key.Equals("path", StringComparison.Ordinal)) + m.Path = val; + else if (key.Equals("url", StringComparison.Ordinal)) + m.URL = val; + } + } + + foreach (var kv in modules) + { + if (map.TryGetValue(kv.Value.Path, out var m)) + m.URL = kv.Value.URL; + } + } + } + + if (needCheckLocalChanges) + { + var builder = new StringBuilder(); + foreach (var kv in map) + { + if (kv.Value.Status == Models.SubmoduleStatus.Normal) + { + builder.Append('"'); + builder.Append(kv.Key); + builder.Append("\" "); + } + } + + Args = $"--no-optional-locks status --porcelain -- {builder}"; + rs = ReadToEnd(); + if (!rs.IsSuccess) + return submodules; + + lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var match = REG_FORMAT_DIRTY().Match(line); + if (match.Success) + { + var path = match.Groups[1].Value; + if (map.TryGetValue(path, out var m)) + m.Status = Models.SubmoduleStatus.Modified; + } + } + } + + return submodules; + } + + private class ModuleInfo + { + public string Path { get; set; } = string.Empty; + public string URL { get; set; } = string.Empty; + } + } +} diff --git a/src/Commands/QueryTags.cs b/src/Commands/QueryTags.cs new file mode 100644 index 00000000..4b706439 --- /dev/null +++ b/src/Commands/QueryTags.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; + +namespace SourceGit.Commands +{ + public class QueryTags : Command + { + public QueryTags(string repo) + { + _boundary = $"----- BOUNDARY OF TAGS {Guid.NewGuid()} -----"; + + Context = repo; + WorkingDirectory = repo; + Args = $"tag -l --format=\"{_boundary}%(refname)%00%(objecttype)%00%(objectname)%00%(*objectname)%00%(creatordate:unix)%00%(contents:subject)%0a%0a%(contents:body)\""; + } + + public List Result() + { + var tags = new List(); + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return tags; + + var records = rs.StdOut.Split(_boundary, StringSplitOptions.RemoveEmptyEntries); + foreach (var record in records) + { + var subs = record.Split('\0', StringSplitOptions.None); + if (subs.Length != 6) + continue; + + var name = subs[0].Substring(10); + var message = subs[5].Trim(); + if (!string.IsNullOrEmpty(message) && message.Equals(name, StringComparison.Ordinal)) + message = null; + + tags.Add(new Models.Tag() + { + Name = name, + IsAnnotated = subs[1].Equals("tag", StringComparison.Ordinal), + SHA = string.IsNullOrEmpty(subs[3]) ? subs[2] : subs[3], + CreatorDate = ulong.Parse(subs[4]), + Message = message, + }); + } + + return tags; + } + + private string _boundary = string.Empty; + } +} diff --git a/src/Commands/QueryTrackStatus.cs b/src/Commands/QueryTrackStatus.cs new file mode 100644 index 00000000..e7e1f1c9 --- /dev/null +++ b/src/Commands/QueryTrackStatus.cs @@ -0,0 +1,34 @@ +using System; + +namespace SourceGit.Commands +{ + public class QueryTrackStatus : Command + { + public QueryTrackStatus(string repo, string local, string upstream) + { + WorkingDirectory = repo; + Context = repo; + Args = $"rev-list --left-right {local}...{upstream}"; + } + + public Models.BranchTrackStatus Result() + { + var status = new Models.BranchTrackStatus(); + + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return status; + + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + if (line[0] == '>') + status.Behind.Add(line.Substring(1)); + else + status.Ahead.Add(line.Substring(1)); + } + + return status; + } + } +} diff --git a/src/Commands/QueryUpdatableSubmodules.cs b/src/Commands/QueryUpdatableSubmodules.cs new file mode 100644 index 00000000..03f4a24d --- /dev/null +++ b/src/Commands/QueryUpdatableSubmodules.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace SourceGit.Commands +{ + public partial class QueryUpdatableSubmodules : Command + { + [GeneratedRegex(@"^([U\-\+ ])([0-9a-f]+)\s(.*?)(\s\(.*\))?$")] + private static partial Regex REG_FORMAT_STATUS(); + + public QueryUpdatableSubmodules(string repo) + { + WorkingDirectory = repo; + Context = repo; + Args = "submodule status"; + } + + public List Result() + { + var submodules = new List(); + var rs = ReadToEnd(); + + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var match = REG_FORMAT_STATUS().Match(line); + if (match.Success) + { + var stat = match.Groups[1].Value; + var path = match.Groups[3].Value; + if (!stat.StartsWith(' ')) + submodules.Add(path); + } + } + + return submodules; + } + } +} diff --git a/src/SourceGit/Commands/Rebase.cs b/src/Commands/Rebase.cs similarity index 53% rename from src/SourceGit/Commands/Rebase.cs rename to src/Commands/Rebase.cs index d08d55ad..2ec50f3c 100644 --- a/src/SourceGit/Commands/Rebase.cs +++ b/src/Commands/Rebase.cs @@ -12,4 +12,15 @@ Args += basedOn; } } + + public class InteractiveRebase : Command + { + public InteractiveRebase(string repo, string basedOn) + { + WorkingDirectory = repo; + Context = repo; + Editor = EditorType.RebaseEditor; + Args = $"rebase -i --autosquash {basedOn}"; + } + } } diff --git a/src/SourceGit/Commands/Remote.cs b/src/Commands/Remote.cs similarity index 52% rename from src/SourceGit/Commands/Remote.cs rename to src/Commands/Remote.cs index 46aa37e3..beaf412b 100644 --- a/src/SourceGit/Commands/Remote.cs +++ b/src/Commands/Remote.cs @@ -32,10 +32,27 @@ return Exec(); } - public bool SetURL(string name, string url) + public string GetURL(string name, bool isPush) { - Args = $"remote set-url {name} {url}"; + Args = "remote get-url" + (isPush ? " --push " : " ") + name; + + var rs = ReadToEnd(); + return rs.IsSuccess ? rs.StdOut.Trim() : string.Empty; + } + + public bool SetURL(string name, string url, bool isPush) + { + Args = "remote set-url" + (isPush ? " --push " : " ") + $"{name} {url}"; return Exec(); } + + public bool HasBranch(string remote, string branch) + { + SSHKey = new Config(WorkingDirectory).Get($"remote.{remote}.sshkey"); + Args = $"ls-remote {remote} {branch}"; + + var rs = ReadToEnd(); + return rs.IsSuccess && rs.StdOut.Trim().Length > 0; + } } } diff --git a/src/Commands/Reset.cs b/src/Commands/Reset.cs new file mode 100644 index 00000000..6a54533b --- /dev/null +++ b/src/Commands/Reset.cs @@ -0,0 +1,12 @@ +namespace SourceGit.Commands +{ + public class Reset : Command + { + public Reset(string repo, string revision, string mode) + { + WorkingDirectory = repo; + Context = repo; + Args = $"reset {mode} {revision}"; + } + } +} diff --git a/src/Commands/Restore.cs b/src/Commands/Restore.cs new file mode 100644 index 00000000..663ea975 --- /dev/null +++ b/src/Commands/Restore.cs @@ -0,0 +1,53 @@ +using System.Text; + +namespace SourceGit.Commands +{ + public class Restore : Command + { + /// + /// Only used for single staged change. + /// + /// + /// + public Restore(string repo, Models.Change stagedChange) + { + WorkingDirectory = repo; + Context = repo; + + var builder = new StringBuilder(); + builder.Append("restore --staged -- \""); + builder.Append(stagedChange.Path); + builder.Append('"'); + + if (stagedChange.Index == Models.ChangeState.Renamed) + { + builder.Append(" \""); + builder.Append(stagedChange.OriginalPath); + builder.Append('"'); + } + + Args = builder.ToString(); + } + + /// + /// Restore changes given in a path-spec file. + /// + /// + /// + /// + public Restore(string repo, string pathspecFile, bool isStaged) + { + WorkingDirectory = repo; + Context = repo; + + var builder = new StringBuilder(); + builder.Append("restore "); + builder.Append(isStaged ? "--staged " : "--worktree --recurse-submodules "); + builder.Append("--pathspec-from-file=\""); + builder.Append(pathspecFile); + builder.Append('"'); + + Args = builder.ToString(); + } + } +} diff --git a/src/SourceGit/Commands/Revert.cs b/src/Commands/Revert.cs similarity index 73% rename from src/SourceGit/Commands/Revert.cs rename to src/Commands/Revert.cs index b6c3913a..2e7afd11 100644 --- a/src/SourceGit/Commands/Revert.cs +++ b/src/Commands/Revert.cs @@ -6,9 +6,9 @@ { WorkingDirectory = repo; Context = repo; - Args = $"revert {commit} --no-edit"; + Args = $"revert -m 1 {commit} --no-edit"; if (!autoCommit) - Args += " --no-commit"; + Args += " --no-commit"; } } } diff --git a/src/SourceGit/Commands/SaveChangesAsPatch.cs b/src/Commands/SaveChangesAsPatch.cs similarity index 60% rename from src/SourceGit/Commands/SaveChangesAsPatch.cs rename to src/Commands/SaveChangesAsPatch.cs index f15cc2f2..b10037a1 100644 --- a/src/SourceGit/Commands/SaveChangesAsPatch.cs +++ b/src/Commands/SaveChangesAsPatch.cs @@ -9,7 +9,7 @@ namespace SourceGit.Commands { public static class SaveChangesAsPatch { - public static bool Exec(string repo, List changes, bool isUnstaged, string saveTo) + public static bool ProcessLocalChanges(string repo, List changes, bool isUnstaged, string saveTo) { using (var sw = File.Create(saveTo)) { @@ -23,11 +23,38 @@ namespace SourceGit.Commands return true; } + public static bool ProcessRevisionCompareChanges(string repo, List changes, string baseRevision, string targetRevision, string saveTo) + { + using (var sw = File.Create(saveTo)) + { + foreach (var change in changes) + { + if (!ProcessSingleChange(repo, new Models.DiffOption(baseRevision, targetRevision, change), sw)) + return false; + } + } + + return true; + } + + public static bool ProcessStashChanges(string repo, List 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) { var starter = new ProcessStartInfo(); starter.WorkingDirectory = repo; - starter.FileName = Native.OS.GitInstallPath; + starter.FileName = Native.OS.GitExecutable; starter.Arguments = $"diff --ignore-cr-at-eol --unified=4 {opt}"; starter.UseShellExecute = false; starter.CreateNoWindow = true; diff --git a/src/SourceGit/Commands/SaveRevisionFile.cs b/src/Commands/SaveRevisionFile.cs similarity index 63% rename from src/SourceGit/Commands/SaveRevisionFile.cs rename to src/Commands/SaveRevisionFile.cs index 41575efc..550844ef 100644 --- a/src/SourceGit/Commands/SaveRevisionFile.cs +++ b/src/Commands/SaveRevisionFile.cs @@ -10,15 +10,11 @@ namespace SourceGit.Commands { public static void Run(string repo, string revision, string file, string saveTo) { - var isLFSFiltered = new IsLFSFiltered(repo, file).Result(); + var isLFSFiltered = new IsLFSFiltered(repo, revision, file).Result(); if (isLFSFiltered) { - var tmpFile = saveTo + ".tmp"; - if (ExecCmd(repo, $"show {revision}:\"{file}\"", tmpFile)) - { - ExecCmd(repo, $"lfs smudge", saveTo, tmpFile); - } - File.Delete(tmpFile); + var pointerStream = QueryFileContent.Run(repo, revision, file); + ExecCmd(repo, $"lfs smudge", saveTo, pointerStream); } else { @@ -26,11 +22,11 @@ namespace SourceGit.Commands } } - private static bool ExecCmd(string repo, string args, string outputFile, string inputFile = null) + private static bool ExecCmd(string repo, string args, string outputFile, Stream input = null) { var starter = new ProcessStartInfo(); starter.WorkingDirectory = repo; - starter.FileName = Native.OS.GitInstallPath; + starter.FileName = Native.OS.GitExecutable; starter.Arguments = args; starter.UseShellExecute = false; starter.CreateNoWindow = true; @@ -45,21 +41,8 @@ namespace SourceGit.Commands { var proc = new Process() { StartInfo = starter }; proc.Start(); - - if (inputFile != null) - { - using (StreamReader sr = new StreamReader(inputFile)) - { - while (true) - { - var line = sr.ReadLine(); - if (line == null) - break; - proc.StandardInput.WriteLine(line); - } - } - } - + if (input != null) + proc.StandardInput.Write(new StreamReader(input).ReadToEnd()); proc.StandardOutput.BaseStream.CopyTo(sw); proc.WaitForExit(); var rs = proc.ExitCode == 0; diff --git a/src/Commands/Stash.cs b/src/Commands/Stash.cs new file mode 100644 index 00000000..7d1a269b --- /dev/null +++ b/src/Commands/Stash.cs @@ -0,0 +1,101 @@ +using System.Collections.Generic; +using System.Text; + +namespace SourceGit.Commands +{ + public class Stash : Command + { + public Stash(string repo) + { + WorkingDirectory = repo; + Context = repo; + } + + public bool Push(string message, bool includeUntracked = true, bool keepIndex = false) + { + var builder = new StringBuilder(); + builder.Append("stash push "); + if (includeUntracked) + 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 changes, bool keepIndex) + { + var builder = new StringBuilder(); + builder.Append("stash push --include-untracked "); + if (keepIndex) + builder.Append("--keep-index "); + builder.Append("-m \""); + builder.Append(message); + builder.Append("\" -- "); + + foreach (var c in changes) + builder.Append($"\"{c.Path}\" "); + + Args = builder.ToString(); + return Exec(); + } + + public bool Push(string message, string pathspecFromFile, bool keepIndex) + { + 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(); + } + + public bool Pop(string name) + { + Args = $"stash pop -q --index \"{name}\""; + return Exec(); + } + + public bool Drop(string name) + { + Args = $"stash drop -q \"{name}\""; + return Exec(); + } + + public bool Clear() + { + Args = "stash clear"; + return Exec(); + } + } +} diff --git a/src/Commands/Statistics.cs b/src/Commands/Statistics.cs new file mode 100644 index 00000000..e11c1740 --- /dev/null +++ b/src/Commands/Statistics.cs @@ -0,0 +1,48 @@ +using System; + +namespace SourceGit.Commands +{ + public class Statistics : Command + { + public Statistics(string repo, int max) + { + WorkingDirectory = repo; + Context = repo; + Args = $"log --date-order --branches --remotes -{max} --format=%ct$%aN±%aE"; + } + + public Models.Statistics Result() + { + var statistics = new Models.Statistics(); + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return statistics; + + var start = 0; + var end = rs.StdOut.IndexOf('\n', start); + while (end > 0) + { + ParseLine(statistics, rs.StdOut.Substring(start, end - start)); + start = end + 1; + end = rs.StdOut.IndexOf('\n', start); + } + + if (start < rs.StdOut.Length) + ParseLine(statistics, rs.StdOut.Substring(start)); + + statistics.Complete(); + return statistics; + } + + private void ParseLine(Models.Statistics statistics, string line) + { + var dateEndIdx = line.IndexOf('$', StringComparison.Ordinal); + if (dateEndIdx == -1) + return; + + var dateStr = line.AsSpan(0, dateEndIdx); + if (double.TryParse(dateStr, out var date)) + statistics.AddCommit(line.Substring(dateEndIdx + 1), date); + } + } +} diff --git a/src/Commands/Submodule.cs b/src/Commands/Submodule.cs new file mode 100644 index 00000000..025d035a --- /dev/null +++ b/src/Commands/Submodule.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using System.Text; + +namespace SourceGit.Commands +{ + public class Submodule : Command + { + public Submodule(string repo) + { + WorkingDirectory = repo; + Context = repo; + } + + public bool Add(string url, string relativePath, bool recursive) + { + Args = $"-c protocol.file.allow=always submodule add \"{url}\" \"{relativePath}\""; + if (!Exec()) + return false; + + if (recursive) + { + Args = $"submodule update --init --recursive -- \"{relativePath}\""; + return Exec(); + } + else + { + Args = $"submodule update --init -- \"{relativePath}\""; + return true; + } + } + + public bool Update(List modules, bool init, bool recursive, bool useRemote = false) + { + var builder = new StringBuilder(); + builder.Append("submodule update"); + + if (init) + builder.Append(" --init"); + if (recursive) + builder.Append(" --recursive"); + if (useRemote) + builder.Append(" --remote"); + if (modules.Count > 0) + { + builder.Append(" --"); + foreach (var module in modules) + builder.Append($" \"{module}\""); + } + + Args = builder.ToString(); + return Exec(); + } + + public bool Deinit(string module, bool force) + { + Args = force ? $"submodule deinit -f -- \"{module}\"" : $"submodule deinit -- \"{module}\""; + return Exec(); + } + + public bool Delete(string module) + { + Args = $"rm -rf \"{module}\""; + return Exec(); + } + } +} diff --git a/src/Commands/Tag.cs b/src/Commands/Tag.cs new file mode 100644 index 00000000..017afea0 --- /dev/null +++ b/src/Commands/Tag.cs @@ -0,0 +1,51 @@ +using System.IO; + +namespace SourceGit.Commands +{ + public static class Tag + { + public static bool Add(string repo, string name, string basedOn, Models.ICommandLog log) + { + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.Args = $"tag --no-sign {name} {basedOn}"; + cmd.Log = log; + return cmd.Exec(); + } + + public static bool Add(string repo, string name, string basedOn, string message, bool sign, Models.ICommandLog log) + { + var param = sign ? "--sign -a" : "--no-sign -a"; + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.Args = $"tag {param} {name} {basedOn} "; + cmd.Log = log; + + if (!string.IsNullOrEmpty(message)) + { + string tmp = Path.GetTempFileName(); + File.WriteAllText(tmp, message); + cmd.Args += $"-F \"{tmp}\""; + + var succ = cmd.Exec(); + File.Delete(tmp); + return succ; + } + + cmd.Args += $"-m {name}"; + return cmd.Exec(); + } + + public static bool Delete(string repo, string name, Models.ICommandLog log) + { + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.Args = $"tag --delete {name}"; + cmd.Log = log; + return cmd.Exec(); + } + } +} diff --git a/src/Commands/UnstageChangesForAmend.cs b/src/Commands/UnstageChangesForAmend.cs new file mode 100644 index 00000000..c930f136 --- /dev/null +++ b/src/Commands/UnstageChangesForAmend.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; + +using Avalonia.Threading; + +namespace SourceGit.Commands +{ + public class UnstageChangesForAmend + { + public UnstageChangesForAmend(string repo, List changes) + { + _repo = repo; + + foreach (var c in changes) + { + if (c.Index == Models.ChangeState.Renamed) + { + _patchBuilder.Append("0 0000000000000000000000000000000000000000\t"); + _patchBuilder.Append(c.Path); + _patchBuilder.Append("\0100644 "); + _patchBuilder.Append(c.DataForAmend.ObjectHash); + _patchBuilder.Append("\t"); + _patchBuilder.Append(c.OriginalPath); + _patchBuilder.Append("\n"); + } + else if (c.Index == Models.ChangeState.Added) + { + _patchBuilder.Append("0 0000000000000000000000000000000000000000\t"); + _patchBuilder.Append(c.Path); + _patchBuilder.Append("\n"); + } + else if (c.Index == Models.ChangeState.Deleted) + { + _patchBuilder.Append("100644 "); + _patchBuilder.Append(c.DataForAmend.ObjectHash); + _patchBuilder.Append("\t"); + _patchBuilder.Append(c.Path); + _patchBuilder.Append("\n"); + } + else + { + _patchBuilder.Append(c.DataForAmend.FileMode); + _patchBuilder.Append(" "); + _patchBuilder.Append(c.DataForAmend.ObjectHash); + _patchBuilder.Append("\t"); + _patchBuilder.Append(c.Path); + _patchBuilder.Append("\n"); + } + } + } + + public bool Exec() + { + var starter = new ProcessStartInfo(); + starter.WorkingDirectory = _repo; + starter.FileName = Native.OS.GitExecutable; + starter.Arguments = "-c core.editor=true update-index --index-info"; + starter.UseShellExecute = false; + starter.CreateNoWindow = true; + starter.WindowStyle = ProcessWindowStyle.Hidden; + starter.RedirectStandardInput = true; + starter.RedirectStandardOutput = false; + starter.RedirectStandardError = true; + + try + { + var proc = new Process() { StartInfo = starter }; + proc.Start(); + proc.StandardInput.Write(_patchBuilder.ToString()); + proc.StandardInput.Close(); + + var err = proc.StandardError.ReadToEnd(); + proc.WaitForExit(); + var rs = proc.ExitCode == 0; + proc.Close(); + + if (!rs) + Dispatcher.UIThread.Invoke(() => App.RaiseException(_repo, err)); + + return rs; + } + catch (Exception e) + { + Dispatcher.UIThread.Invoke(() => + { + App.RaiseException(_repo, "Failed to unstage changes: " + e.Message); + }); + return false; + } + } + + private string _repo = ""; + private StringBuilder _patchBuilder = new StringBuilder(); + } +} diff --git a/src/Commands/Worktree.cs b/src/Commands/Worktree.cs new file mode 100644 index 00000000..1198a443 --- /dev/null +++ b/src/Commands/Worktree.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace SourceGit.Commands +{ + public class Worktree : Command + { + public Worktree(string repo) + { + WorkingDirectory = repo; + Context = repo; + } + + public List List() + { + Args = "worktree list --porcelain"; + + var rs = ReadToEnd(); + var worktrees = new List(); + var last = null as Models.Worktree; + if (rs.IsSuccess) + { + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + if (line.StartsWith("worktree ", StringComparison.Ordinal)) + { + last = new Models.Worktree() { FullPath = line.Substring(9).Trim() }; + last.RelativePath = Path.GetRelativePath(WorkingDirectory, last.FullPath); + worktrees.Add(last); + } + else if (line.StartsWith("bare", StringComparison.Ordinal)) + { + last!.IsBare = true; + } + else if (line.StartsWith("HEAD ", StringComparison.Ordinal)) + { + last!.Head = line.Substring(5).Trim(); + } + else if (line.StartsWith("branch ", StringComparison.Ordinal)) + { + last!.Branch = line.Substring(7).Trim(); + } + else if (line.StartsWith("detached", StringComparison.Ordinal)) + { + last!.IsDetached = true; + } + else if (line.StartsWith("locked", StringComparison.Ordinal)) + { + last!.IsLocked = true; + } + } + } + + return worktrees; + } + + public bool Add(string fullpath, string name, bool createNew, string tracking) + { + Args = "worktree add "; + + if (!string.IsNullOrEmpty(tracking)) + Args += "--track "; + + if (!string.IsNullOrEmpty(name)) + { + if (createNew) + Args += $"-b {name} "; + else + Args += $"-B {name} "; + } + + Args += $"\"{fullpath}\" "; + + if (!string.IsNullOrEmpty(tracking)) + Args += tracking; + else if (!string.IsNullOrEmpty(name) && !createNew) + Args += name; + + return Exec(); + } + + public bool Prune() + { + Args = "worktree prune -v"; + return Exec(); + } + + public bool Lock(string fullpath) + { + Args = $"worktree lock \"{fullpath}\""; + return Exec(); + } + + public bool Unlock(string fullpath) + { + Args = $"worktree unlock \"{fullpath}\""; + return Exec(); + } + + public bool Remove(string fullpath, bool force) + { + if (force) + Args = $"worktree remove -f \"{fullpath}\""; + else + Args = $"worktree remove \"{fullpath}\""; + + return Exec(); + } + } +} diff --git a/src/Converters/BoolConverters.cs b/src/Converters/BoolConverters.cs new file mode 100644 index 00000000..3563fb37 --- /dev/null +++ b/src/Converters/BoolConverters.cs @@ -0,0 +1,14 @@ +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace SourceGit.Converters +{ + public static class BoolConverters + { + public static readonly FuncValueConverter ToPageTabWidth = + new FuncValueConverter(x => x ? 200 : double.NaN); + + public static readonly FuncValueConverter IsBoldToFontWeight = + new FuncValueConverter(x => x ? FontWeight.Bold : FontWeight.Normal); + } +} diff --git a/src/Converters/DoubleConverters.cs b/src/Converters/DoubleConverters.cs new file mode 100644 index 00000000..5b7c0a03 --- /dev/null +++ b/src/Converters/DoubleConverters.cs @@ -0,0 +1,19 @@ +using Avalonia.Data.Converters; + +namespace SourceGit.Converters +{ + public static class DoubleConverters + { + public static readonly FuncValueConverter Increase = + new FuncValueConverter(v => v + 1.0); + + public static readonly FuncValueConverter Decrease = + new FuncValueConverter(v => v - 1.0); + + public static readonly FuncValueConverter ToPercentage = + new FuncValueConverter(v => (v * 100).ToString("F3") + "%"); + + public static readonly FuncValueConverter OneMinusToPercentage = + new FuncValueConverter(v => ((1.0 - v) * 100).ToString("F3") + "%"); + } +} diff --git a/src/Converters/FilterModeConverters.cs b/src/Converters/FilterModeConverters.cs new file mode 100644 index 00000000..c486af5e --- /dev/null +++ b/src/Converters/FilterModeConverters.cs @@ -0,0 +1,22 @@ +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace SourceGit.Converters +{ + public static class FilterModeConverters + { + public static readonly FuncValueConverter ToBorderBrush = + new FuncValueConverter(v => + { + switch (v) + { + case Models.FilterMode.Included: + return Brushes.Green; + case Models.FilterMode.Excluded: + return Brushes.Red; + default: + return Brushes.Transparent; + } + }); + } +} diff --git a/src/Converters/IntConverters.cs b/src/Converters/IntConverters.cs new file mode 100644 index 00000000..f21c5d24 --- /dev/null +++ b/src/Converters/IntConverters.cs @@ -0,0 +1,43 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace SourceGit.Converters +{ + public static class IntConverters + { + public static readonly FuncValueConverter IsGreaterThanZero = + new FuncValueConverter(v => v > 0); + + public static readonly FuncValueConverter IsGreaterThanFour = + new FuncValueConverter(v => v > 4); + + public static readonly FuncValueConverter IsZero = + new FuncValueConverter(v => v == 0); + + public static readonly FuncValueConverter IsOne = + new FuncValueConverter(v => v == 1); + + public static readonly FuncValueConverter IsNotOne = + new FuncValueConverter(v => v != 1); + + public static readonly FuncValueConverter IsSubjectLengthBad = + new FuncValueConverter(v => v > ViewModels.Preferences.Instance.SubjectGuideLength); + + public static readonly FuncValueConverter IsSubjectLengthGood = + new FuncValueConverter(v => v <= ViewModels.Preferences.Instance.SubjectGuideLength); + + public static readonly FuncValueConverter ToTreeMargin = + new FuncValueConverter(v => new Thickness(v * 16, 0, 0, 0)); + + public static readonly FuncValueConverter ToBookmarkBrush = + new FuncValueConverter(bookmark => + { + if (bookmark == 0) + return Application.Current?.FindResource("Brush.FG1") as IBrush; + else + return Models.Bookmarks.Brushes[bookmark]; + }); + } +} diff --git a/src/Converters/InteractiveRebaseActionConverters.cs b/src/Converters/InteractiveRebaseActionConverters.cs new file mode 100644 index 00000000..dbd183bd --- /dev/null +++ b/src/Converters/InteractiveRebaseActionConverters.cs @@ -0,0 +1,51 @@ +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace SourceGit.Converters +{ + public static class InteractiveRebaseActionConverters + { + public static readonly FuncValueConverter ToIconBrush = + new FuncValueConverter(v => + { + switch (v) + { + case Models.InteractiveRebaseAction.Pick: + return Brushes.Green; + case Models.InteractiveRebaseAction.Edit: + return Brushes.Orange; + case Models.InteractiveRebaseAction.Reword: + return Brushes.Orange; + case Models.InteractiveRebaseAction.Squash: + return Brushes.LightGray; + case Models.InteractiveRebaseAction.Fixup: + return Brushes.LightGray; + default: + return Brushes.Red; + } + }); + + public static readonly FuncValueConverter ToName = + new FuncValueConverter(v => + { + switch (v) + { + case Models.InteractiveRebaseAction.Pick: + return "Pick"; + case Models.InteractiveRebaseAction.Edit: + return "Edit"; + case Models.InteractiveRebaseAction.Reword: + return "Reword"; + case Models.InteractiveRebaseAction.Squash: + return "Squash"; + case Models.InteractiveRebaseAction.Fixup: + return "Fixup"; + default: + return "Drop"; + } + }); + + public static readonly FuncValueConverter CanEditMessage = + new FuncValueConverter(v => v == Models.InteractiveRebaseAction.Reword || v == Models.InteractiveRebaseAction.Squash); + } +} diff --git a/src/Converters/ListConverters.cs b/src/Converters/ListConverters.cs new file mode 100644 index 00000000..6f3ae98b --- /dev/null +++ b/src/Converters/ListConverters.cs @@ -0,0 +1,28 @@ +using System.Collections; +using System.Collections.Generic; + +using Avalonia.Data.Converters; + +namespace SourceGit.Converters +{ + public static class ListConverters + { + public static readonly FuncValueConverter Count = + new FuncValueConverter(v => v == null ? "0" : $"{v.Count}"); + + public static readonly FuncValueConverter ToCount = + new FuncValueConverter(v => v == null ? "(0)" : $"({v.Count})"); + + public static readonly FuncValueConverter IsNullOrEmpty = + new FuncValueConverter(v => v == null || v.Count == 0); + + public static readonly FuncValueConverter IsNotNullOrEmpty = + new FuncValueConverter(v => v != null && v.Count > 0); + + public static readonly FuncValueConverter, List> Top100Changes = + new FuncValueConverter, List>(v => (v == null || v.Count < 100) ? v : v.GetRange(0, 100)); + + public static readonly FuncValueConverter IsOnlyTop100Shows = + new FuncValueConverter(v => v != null && v.Count > 100); + } +} diff --git a/src/Converters/ObjectConverters.cs b/src/Converters/ObjectConverters.cs new file mode 100644 index 00000000..f7c57764 --- /dev/null +++ b/src/Converters/ObjectConverters.cs @@ -0,0 +1,27 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; + +namespace SourceGit.Converters +{ + public static class ObjectConverters + { + public class IsTypeOfConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value == null || parameter == null) + return false; + + return value.GetType().IsAssignableTo((Type)parameter); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + return new NotImplementedException(); + } + } + + public static readonly IsTypeOfConverter IsTypeOf = new IsTypeOfConverter(); + } +} diff --git a/src/Converters/PathConverters.cs b/src/Converters/PathConverters.cs new file mode 100644 index 00000000..ac1e61e5 --- /dev/null +++ b/src/Converters/PathConverters.cs @@ -0,0 +1,30 @@ +using System; +using System.IO; + +using Avalonia.Data.Converters; + +namespace SourceGit.Converters +{ + public static class PathConverters + { + public static readonly FuncValueConverter PureFileName = + new(v => Path.GetFileName(v) ?? ""); + + public static readonly FuncValueConverter PureDirectoryName = + new(v => Path.GetDirectoryName(v) ?? ""); + + public static readonly FuncValueConverter RelativeToHome = + new(v => + { + if (OperatingSystem.IsWindows()) + return v; + + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var prefixLen = home.EndsWith('/') ? home.Length - 1 : home.Length; + if (v.StartsWith(home, StringComparison.Ordinal)) + return $"~{v.AsSpan(prefixLen)}"; + + return v; + }); + } +} diff --git a/src/SourceGit/Converters/StringConverters.cs b/src/Converters/StringConverters.cs similarity index 62% rename from src/SourceGit/Converters/StringConverters.cs rename to src/Converters/StringConverters.cs index f743f69f..bcadfae9 100644 --- a/src/SourceGit/Converters/StringConverters.cs +++ b/src/Converters/StringConverters.cs @@ -17,7 +17,7 @@ namespace SourceGit.Converters public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { - return (value as Models.Locale).Key; + return (value as Models.Locale)?.Key; } } @@ -28,24 +28,21 @@ namespace SourceGit.Converters public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { var theme = (string)value; - if (theme.Equals("Light", StringComparison.OrdinalIgnoreCase)) - { - return ThemeVariant.Light; - } - else if (theme.Equals("Dark", StringComparison.OrdinalIgnoreCase)) - { - return ThemeVariant.Dark; - } - else - { + if (string.IsNullOrEmpty(theme)) return ThemeVariant.Default; - } + + if (theme.Equals("Light", StringComparison.OrdinalIgnoreCase)) + return ThemeVariant.Light; + + if (theme.Equals("Dark", StringComparison.OrdinalIgnoreCase)) + return ThemeVariant.Dark; + + return ThemeVariant.Default; } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { - var theme = (ThemeVariant)value; - return theme.Key; + return (value as ThemeVariant)?.Key; } } @@ -68,6 +65,24 @@ namespace SourceGit.Converters public static readonly FormatByResourceKeyConverter FormatByResourceKey = new FormatByResourceKeyConverter(); public static readonly FuncValueConverter ToShortSHA = - new FuncValueConverter(v => v.Length > 10 ? v.Substring(0, 10) : v); + new FuncValueConverter(v => v == null ? string.Empty : (v.Length > 10 ? v.Substring(0, 10) : v)); + + public static readonly FuncValueConverter TrimRefsPrefix = + new FuncValueConverter(v => + { + if (v == null) + return string.Empty; + if (v.StartsWith("refs/heads/", StringComparison.Ordinal)) + return v.Substring(11); + if (v.StartsWith("refs/remotes/", StringComparison.Ordinal)) + return v.Substring(13); + return v; + }); + + public static readonly FuncValueConverter ContainsSpaces = + new FuncValueConverter(v => v != null && v.Contains(' ')); + + public static readonly FuncValueConverter IsNotNullOrWhitespace = + new FuncValueConverter(v => v != null && v.Trim().Length > 0); } } diff --git a/src/Models/ApplyWhiteSpaceMode.cs b/src/Models/ApplyWhiteSpaceMode.cs new file mode 100644 index 00000000..aad45f57 --- /dev/null +++ b/src/Models/ApplyWhiteSpaceMode.cs @@ -0,0 +1,24 @@ +namespace SourceGit.Models +{ + 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 Desc { get; set; } + public string Arg { get; set; } + + public ApplyWhiteSpaceMode(string n, string d, string a) + { + Name = n; + Desc = d; + Arg = a; + } + } +} diff --git a/src/Models/AvatarManager.cs b/src/Models/AvatarManager.cs new file mode 100644 index 00000000..2edcb619 --- /dev/null +++ b/src/Models/AvatarManager.cs @@ -0,0 +1,243 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.Threading; + +namespace SourceGit.Models +{ + public interface IAvatarHost + { + void OnAvatarResourceChanged(string email, Bitmap image); + } + + public partial class AvatarManager + { + public static AvatarManager Instance + { + get + { + if (_instance == null) + _instance = new AvatarManager(); + + return _instance; + } + } + + private static AvatarManager _instance = null; + + [GeneratedRegex(@"^(?:(\d+)\+)?(.+?)@.+\.github\.com$")] + private static partial Regex REG_GITHUB_USER_EMAIL(); + + private object _synclock = new object(); + private string _storePath; + private List _avatars = new List(); + private Dictionary _resources = new Dictionary(); + private HashSet _requesting = new HashSet(); + private HashSet _defaultAvatars = new HashSet(); + + public void Start() + { + _storePath = Path.Combine(Native.OS.DataDir, "avatars"); + if (!Directory.Exists(_storePath)) + Directory.CreateDirectory(_storePath); + + LoadDefaultAvatar("noreply@github.com", "github.png"); + LoadDefaultAvatar("unrealbot@epicgames.com", "unreal.png"); + + Task.Run(() => + { + while (true) + { + var email = null as string; + + lock (_synclock) + { + foreach (var one in _requesting) + { + email = one; + break; + } + } + + if (email == null) + { + Thread.Sleep(100); + continue; + } + + var md5 = GetEmailHash(email); + var matchGithubUser = REG_GITHUB_USER_EMAIL().Match(email); + var url = matchGithubUser.Success ? + $"https://avatars.githubusercontent.com/{matchGithubUser.Groups[2].Value}" : + $"https://www.gravatar.com/avatar/{md5}?d=404"; + + var localFile = Path.Combine(_storePath, md5); + var img = null as Bitmap; + try + { + var client = new HttpClient() { Timeout = TimeSpan.FromSeconds(2) }; + var task = client.GetAsync(url); + task.Wait(); + + var rsp = task.Result; + if (rsp.IsSuccessStatusCode) + { + using (var stream = rsp.Content.ReadAsStream()) + { + using (var writer = File.OpenWrite(localFile)) + { + stream.CopyTo(writer); + } + } + + using (var reader = File.OpenRead(localFile)) + { + img = Bitmap.DecodeToWidth(reader, 128); + } + } + } + catch + { + // ignored + } + + lock (_synclock) + { + _requesting.Remove(email); + } + + Dispatcher.UIThread.InvokeAsync(() => + { + _resources[email] = img; + NotifyResourceChanged(email, img); + }); + } + + // ReSharper disable once FunctionNeverReturns + }); + } + + public void Subscribe(IAvatarHost host) + { + _avatars.Add(host); + } + + public void Unsubscribe(IAvatarHost host) + { + _avatars.Remove(host); + } + + public Bitmap Request(string email, bool forceRefetch) + { + if (forceRefetch) + { + if (_defaultAvatars.Contains(email)) + return null; + + if (_resources.ContainsKey(email)) + _resources.Remove(email); + + var localFile = Path.Combine(_storePath, GetEmailHash(email)); + if (File.Exists(localFile)) + File.Delete(localFile); + + NotifyResourceChanged(email, null); + } + else + { + if (_resources.TryGetValue(email, out var value)) + return value; + + var localFile = Path.Combine(_storePath, GetEmailHash(email)); + if (File.Exists(localFile)) + { + try + { + using (var stream = File.OpenRead(localFile)) + { + var img = Bitmap.DecodeToWidth(stream, 128); + _resources.Add(email, img); + return img; + } + } + catch + { + // ignore + } + } + } + + lock (_synclock) + { + if (!_requesting.Contains(email)) + _requesting.Add(email); + } + + return null; + } + + public void SetFromLocal(string email, string file) + { + try + { + Bitmap image = null; + + using (var stream = File.OpenRead(file)) + { + image = Bitmap.DecodeToWidth(stream, 128); + } + + if (image == null) + return; + + if (_resources.ContainsKey(email)) + _resources[email] = image; + else + _resources.Add(email, image); + + _requesting.Remove(email); + + var store = Path.Combine(_storePath, GetEmailHash(email)); + File.Copy(file, store, true); + NotifyResourceChanged(email, image); + } + catch + { + // ignore + } + } + + 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) + { + var lowered = email.ToLower(CultureInfo.CurrentCulture).Trim(); + var hash = MD5.HashData(Encoding.Default.GetBytes(lowered)); + var builder = new StringBuilder(hash.Length * 2); + foreach (var c in hash) + builder.Append(c.ToString("x2")); + return builder.ToString(); + } + + private void NotifyResourceChanged(string email, Bitmap image) + { + foreach (var avatar in _avatars) + avatar.OnAvatarResourceChanged(email, image); + } + } +} diff --git a/src/Models/Bisect.cs b/src/Models/Bisect.cs new file mode 100644 index 00000000..2ed8beb2 --- /dev/null +++ b/src/Models/Bisect.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public enum BisectState + { + None = 0, + WaitingForRange, + Detecting, + } + + [Flags] + public enum BisectCommitFlag + { + None = 0, + Good = 1 << 0, + Bad = 1 << 1, + } + + public class Bisect + { + public HashSet Bads + { + get; + set; + } = []; + + public HashSet Goods + { + get; + set; + } = []; + } +} diff --git a/src/SourceGit/Models/Blame.cs b/src/Models/Blame.cs similarity index 100% rename from src/SourceGit/Models/Blame.cs rename to src/Models/Blame.cs diff --git a/src/SourceGit/Models/Bookmarks.cs b/src/Models/Bookmarks.cs similarity index 100% rename from src/SourceGit/Models/Bookmarks.cs rename to src/Models/Bookmarks.cs diff --git a/src/Models/Branch.cs b/src/Models/Branch.cs new file mode 100644 index 00000000..7146da3f --- /dev/null +++ b/src/Models/Branch.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public class BranchTrackStatus + { + public List Ahead { get; set; } = new List(); + public List Behind { get; set; } = new List(); + + public bool IsVisible => Ahead.Count > 0 || Behind.Count > 0; + + public override string ToString() + { + if (Ahead.Count == 0 && Behind.Count == 0) + return string.Empty; + + var track = ""; + if (Ahead.Count > 0) + track += $"{Ahead.Count}↑"; + if (Behind.Count > 0) + track += $" {Behind.Count}↓"; + return track.Trim(); + } + } + + public enum BranchSortMode + { + Name = 0, + CommitterDate, + } + + public class Branch + { + public string Name { get; set; } + public string FullName { get; set; } + public ulong CommitterDate { get; set; } + public string Head { get; set; } + public bool IsLocal { get; set; } + public bool IsCurrent { get; set; } + public bool IsDetachedHead { get; set; } + public string Upstream { get; set; } + public BranchTrackStatus TrackStatus { get; set; } + public string Remote { get; set; } + public bool IsUpstreamGone { get; set; } + + public string FriendlyName => IsLocal ? Name : $"{Remote}/{Name}"; + } +} diff --git a/src/SourceGit/Models/CRLFMode.cs b/src/Models/CRLFMode.cs similarity index 100% rename from src/SourceGit/Models/CRLFMode.cs rename to src/Models/CRLFMode.cs diff --git a/src/Models/Change.cs b/src/Models/Change.cs new file mode 100644 index 00000000..129678be --- /dev/null +++ b/src/Models/Change.cs @@ -0,0 +1,126 @@ +using System; + +namespace SourceGit.Models +{ + public enum ChangeViewMode + { + List, + Grid, + Tree, + } + + public enum ChangeState + { + None, + Modified, + TypeChanged, + Added, + Deleted, + Renamed, + Copied, + Untracked, + Conflicted, + } + + public enum ConflictReason + { + None, + BothDeleted, + AddedByUs, + DeletedByThem, + AddedByThem, + DeletedByUs, + BothAdded, + BothModified, + } + + public class ChangeDataForAmend + { + public string FileMode { get; set; } = ""; + public string ObjectHash { get; set; } = ""; + public string ParentSHA { get; set; } = ""; + } + + public class Change + { + public ChangeState Index { get; set; } = ChangeState.None; + public ChangeState WorkTree { get; set; } = ChangeState.None; + public string Path { get; set; } = ""; + public string OriginalPath { get; set; } = ""; + public ChangeDataForAmend DataForAmend { get; set; } = null; + public ConflictReason ConflictReason { get; set; } = ConflictReason.None; + + public bool IsConflicted => WorkTree == ChangeState.Conflicted; + public string ConflictMarker => CONFLICT_MARKERS[(int)ConflictReason]; + public string ConflictDesc => CONFLICT_DESCS[(int)ConflictReason]; + + public string WorkTreeDesc => TYPE_DESCS[(int)WorkTree]; + public string IndexDesc => TYPE_DESCS[(int)Index]; + + public void Set(ChangeState index, ChangeState workTree = ChangeState.None) + { + Index = index; + WorkTree = workTree; + + if (index == ChangeState.Renamed || workTree == ChangeState.Renamed) + { + var idx = Path.IndexOf('\t', StringComparison.Ordinal); + if (idx >= 0) + { + OriginalPath = Path.Substring(0, idx); + Path = Path.Substring(idx + 1); + } + else + { + idx = Path.IndexOf(" -> ", StringComparison.Ordinal); + if (idx > 0) + { + OriginalPath = Path.Substring(0, idx); + Path = Path.Substring(idx + 4); + } + } + } + + if (Path[0] == '"') + Path = Path.Substring(1, Path.Length - 2); + + if (!string.IsNullOrEmpty(OriginalPath) && OriginalPath[0] == '"') + OriginalPath = OriginalPath.Substring(1, OriginalPath.Length - 2); + } + + private static readonly string[] TYPE_DESCS = + [ + "Unknown", + "Modified", + "Type Changed", + "Added", + "Deleted", + "Renamed", + "Copied", + "Untracked", + "Conflict" + ]; + private static readonly string[] CONFLICT_MARKERS = + [ + string.Empty, + "DD", + "AU", + "UD", + "UA", + "DU", + "AA", + "UU" + ]; + private static readonly string[] CONFLICT_DESCS = + [ + string.Empty, + "Both deleted", + "Added by us", + "Deleted by them", + "Added by them", + "Deleted by us", + "Both added", + "Both modified" + ]; + } +} diff --git a/src/Models/Commit.cs b/src/Models/Commit.cs new file mode 100644 index 00000000..865b3ac1 --- /dev/null +++ b/src/Models/Commit.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; + +using Avalonia; +using Avalonia.Media; + +namespace SourceGit.Models +{ + public enum CommitSearchMethod + { + BySHA = 0, + ByAuthor, + ByCommitter, + ByMessage, + ByFile, + ByContent, + } + + public class Commit + { + // As retrieved by: git mktree Parents { get; set; } = new(); + public List Decorators { get; set; } = new(); + public bool HasDecorators => Decorators.Count > 0; + + public string AuthorTimeStr => DateTime.UnixEpoch.AddSeconds(AuthorTime).ToLocalTime().ToString(DateTimeFormat.Active.DateTime); + public string CommitterTimeStr => DateTime.UnixEpoch.AddSeconds(CommitterTime).ToLocalTime().ToString(DateTimeFormat.Active.DateTime); + public string AuthorTimeShortStr => DateTime.UnixEpoch.AddSeconds(AuthorTime).ToLocalTime().ToString(DateTimeFormat.Active.DateOnly); + public string CommitterTimeShortStr => DateTime.UnixEpoch.AddSeconds(CommitterTime).ToLocalTime().ToString(DateTimeFormat.Active.DateOnly); + + public bool IsMerged { get; set; } = false; + public bool IsCommitterVisible => !Author.Equals(Committer) || AuthorTime != CommitterTime; + public bool IsCurrentHead => Decorators.Find(x => x.Type is DecoratorType.CurrentBranchHead or DecoratorType.CurrentCommitHead) != null; + + public int Color { get; set; } = 0; + public double Opacity => IsMerged ? 1 : OpacityForNotMerged; + public FontWeight FontWeight => IsCurrentHead ? FontWeight.Bold : FontWeight.Regular; + public Thickness Margin { get; set; } = new(0); + public IBrush Brush => CommitGraph.Pens[Color].Brush; + + public void ParseDecorators(string data) + { + if (data.Length < 3) + return; + + var subs = data.Split(',', StringSplitOptions.RemoveEmptyEntries); + foreach (var sub in subs) + { + var d = sub.Trim(); + if (d.EndsWith("/HEAD", StringComparison.Ordinal)) + continue; + + if (d.StartsWith("tag: refs/tags/", StringComparison.Ordinal)) + { + Decorators.Add(new Decorator() + { + Type = DecoratorType.Tag, + Name = d.Substring(15), + }); + } + else if (d.StartsWith("HEAD -> refs/heads/", StringComparison.Ordinal)) + { + IsMerged = true; + Decorators.Add(new Decorator() + { + Type = DecoratorType.CurrentBranchHead, + Name = d.Substring(19), + }); + } + else if (d.Equals("HEAD")) + { + IsMerged = true; + Decorators.Add(new Decorator() + { + Type = DecoratorType.CurrentCommitHead, + Name = d, + }); + } + else if (d.StartsWith("refs/heads/", StringComparison.Ordinal)) + { + Decorators.Add(new Decorator() + { + Type = DecoratorType.LocalBranchHead, + Name = d.Substring(11), + }); + } + else if (d.StartsWith("refs/remotes/", StringComparison.Ordinal)) + { + Decorators.Add(new Decorator() + { + Type = DecoratorType.RemoteBranchHead, + Name = d.Substring(13), + }); + } + } + + Decorators.Sort((l, r) => + { + if (l.Type != r.Type) + return (int)l.Type - (int)r.Type; + else + return string.Compare(l.Name, r.Name, StringComparison.Ordinal); + }); + } + } + + public class CommitFullMessage + { + public string Message { get; set; } = string.Empty; + public InlineElementCollector Inlines { get; set; } = new(); + } +} diff --git a/src/Models/CommitGraph.cs b/src/Models/CommitGraph.cs new file mode 100644 index 00000000..01488656 --- /dev/null +++ b/src/Models/CommitGraph.cs @@ -0,0 +1,386 @@ +using System; +using System.Collections.Generic; + +using Avalonia; +using Avalonia.Media; + +namespace SourceGit.Models +{ + public class CommitGraph + { + public static List Pens { get; } = []; + + public static void SetDefaultPens(double thickness = 2) + { + SetPens(s_defaultPenColors, thickness); + } + + public static void SetPens(List colors, double thickness) + { + Pens.Clear(); + + foreach (var c in colors) + Pens.Add(new Pen(c.ToUInt32(), thickness)); + + s_penCount = colors.Count; + } + + public class Path(int color, bool isMerged) + { + public List Points { get; } = []; + public int Color { get; } = color; + public bool IsMerged { get; } = isMerged; + } + + public class Link + { + public Point Start; + public Point Control; + public Point End; + public int Color; + public bool IsMerged; + } + + public enum DotType + { + Default, + Head, + Merge, + } + + public class Dot + { + public DotType Type; + public Point Center; + public int Color; + public bool IsMerged; + } + + public List Paths { get; } = []; + public List Links { get; } = []; + public List Dots { get; } = []; + + public static CommitGraph Parse(List commits, bool firstParentOnlyEnabled) + { + const double unitWidth = 12; + const double halfWidth = 6; + const double unitHeight = 1; + const double halfHeight = 0.5; + + var temp = new CommitGraph(); + var unsolved = new List(); + var ended = new List(); + var offsetY = -halfHeight; + var colorPicker = new ColorPicker(); + + foreach (var commit in commits) + { + var major = null as PathHelper; + var isMerged = commit.IsMerged; + + // Update current y offset + offsetY += unitHeight; + + // Find first curves that links to this commit and marks others that links to this commit ended. + var offsetX = 4 - halfWidth; + var maxOffsetOld = unsolved.Count > 0 ? unsolved[^1].LastX : offsetX + unitWidth; + foreach (var l in unsolved) + { + if (l.Next.Equals(commit.SHA, StringComparison.Ordinal)) + { + if (major == null) + { + offsetX += unitWidth; + major = l; + + if (commit.Parents.Count > 0) + { + major.Next = commit.Parents[0]; + major.Goto(offsetX, offsetY, halfHeight); + } + else + { + major.End(offsetX, offsetY, halfHeight); + ended.Add(l); + } + } + else + { + l.End(major.LastX, offsetY, halfHeight); + ended.Add(l); + } + + isMerged = isMerged || l.IsMerged; + } + else + { + offsetX += unitWidth; + l.Pass(offsetX, offsetY, halfHeight); + } + } + + // Remove ended curves from unsolved + foreach (var l in ended) + { + colorPicker.Recycle(l.Path.Color); + unsolved.Remove(l); + } + ended.Clear(); + + // If no path found, create new curve for branch head + // Otherwise, create new curve for new merged commit + if (major == null) + { + offsetX += unitWidth; + + if (commit.Parents.Count > 0) + { + major = new PathHelper(commit.Parents[0], isMerged, colorPicker.Next(), new Point(offsetX, offsetY)); + unsolved.Add(major); + temp.Paths.Add(major.Path); + } + } + else if (isMerged && !major.IsMerged && commit.Parents.Count > 0) + { + major.ReplaceMerged(); + temp.Paths.Add(major.Path); + } + + // Calculate link position of this commit. + var position = new Point(major?.LastX ?? offsetX, offsetY); + var dotColor = major?.Path.Color ?? 0; + var anchor = new Dot() { Center = position, Color = dotColor, IsMerged = isMerged }; + if (commit.IsCurrentHead) + anchor.Type = DotType.Head; + else if (commit.Parents.Count > 1) + anchor.Type = DotType.Merge; + else + anchor.Type = DotType.Default; + temp.Dots.Add(anchor); + + // Deal with other parents (the first parent has been processed) + if (!firstParentOnlyEnabled) + { + for (int j = 1; j < commit.Parents.Count; j++) + { + var parentHash = commit.Parents[j]; + var parent = unsolved.Find(x => x.Next.Equals(parentHash, StringComparison.Ordinal)); + if (parent != null) + { + if (isMerged && !parent.IsMerged) + { + parent.Goto(parent.LastX, offsetY + halfHeight, halfHeight); + parent.ReplaceMerged(); + temp.Paths.Add(parent.Path); + } + + temp.Links.Add(new Link + { + Start = position, + End = new Point(parent.LastX, offsetY + halfHeight), + Control = new Point(parent.LastX, position.Y), + Color = parent.Path.Color, + IsMerged = isMerged, + }); + } + else + { + offsetX += unitWidth; + + // Create new curve for parent commit that not includes before + var l = new PathHelper(parentHash, isMerged, colorPicker.Next(), position, new Point(offsetX, position.Y + halfHeight)); + unsolved.Add(l); + temp.Paths.Add(l.Path); + } + } + } + + // Margins & merge state (used by Views.Histories). + commit.IsMerged = isMerged; + commit.Margin = new Thickness(Math.Max(offsetX, maxOffsetOld) + halfWidth + 2, 0, 0, 0); + commit.Color = dotColor; + } + + // Deal with curves haven't ended yet. + for (var i = 0; i < unsolved.Count; i++) + { + var path = unsolved[i]; + var endY = (commits.Count - 0.5) * unitHeight; + + if (path.Path.Points.Count == 1 && Math.Abs(path.Path.Points[0].Y - endY) < 0.0001) + continue; + + path.End((i + 0.5) * unitWidth + 4, endY + halfHeight, halfHeight); + } + unsolved.Clear(); + + 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 _colorsQueue = new Queue(); + } + + private class PathHelper + { + public Path Path { get; private set; } + public string Next { get; set; } + public double LastX { get; private set; } + + public bool IsMerged => Path.IsMerged; + + public PathHelper(string next, bool isMerged, int color, Point start) + { + Next = next; + LastX = start.X; + _lastY = start.Y; + + Path = new Path(color, isMerged); + Path.Points.Add(start); + } + + public PathHelper(string next, bool isMerged, int color, Point start, Point to) + { + Next = next; + LastX = to.X; + _lastY = to.Y; + + Path = new Path(color, isMerged); + Path.Points.Add(start); + Path.Points.Add(to); + } + + /// + /// A path that just passed this row. + /// + /// + /// + /// + public void Pass(double x, double y, double halfHeight) + { + if (x > LastX) + { + Add(LastX, _lastY); + Add(x, y - halfHeight); + } + else if (x < LastX) + { + Add(LastX, y - halfHeight); + y += halfHeight; + Add(x, y); + } + + LastX = x; + _lastY = y; + } + + /// + /// A path that has commit in this row but not ended + /// + /// + /// + /// + public void Goto(double x, double y, double halfHeight) + { + if (x > LastX) + { + Add(LastX, _lastY); + Add(x, y - halfHeight); + } + else if (x < LastX) + { + var minY = y - halfHeight; + if (minY > _lastY) + minY -= halfHeight; + + Add(LastX, minY); + Add(x, y); + } + + LastX = x; + _lastY = y; + } + + /// + /// A path that has commit in this row and end. + /// + /// + /// + /// + public void End(double x, double y, double halfHeight) + { + if (x > LastX) + { + Add(LastX, _lastY); + Add(x, y - halfHeight); + } + else if (x < LastX) + { + Add(LastX, y - halfHeight); + } + + Add(x, y); + + LastX = x; + _lastY = y; + } + + /// + /// End the current path and create a new from the end. + /// + 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) + { + if (_endY < y) + { + Path.Points.Add(new Point(x, y)); + _endY = y; + } + } + + private double _lastY = 0; + private double _endY = 0; + } + + private static int s_penCount = 0; + private static readonly List s_defaultPenColors = [ + Colors.Orange, + Colors.ForestGreen, + Colors.Turquoise, + Colors.Olive, + Colors.Magenta, + Colors.Red, + Colors.Khaki, + Colors.Lime, + Colors.RoyalBlue, + Colors.Teal, + ]; + } +} diff --git a/src/Models/CommitLink.cs b/src/Models/CommitLink.cs new file mode 100644 index 00000000..2891e5d6 --- /dev/null +++ b/src/Models/CommitLink.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public class CommitLink + { + public string Name { get; set; } = null; + public string URLPrefix { get; set; } = null; + + public CommitLink(string name, string prefix) + { + Name = name; + URLPrefix = prefix; + } + + public static List Get(List remotes) + { + var outs = new List(); + + foreach (var remote in remotes) + { + if (remote.TryGetVisitURL(out var url)) + { + var trimmedUrl = url.AsSpan(); + if (url.EndsWith(".git")) + trimmedUrl = url.AsSpan(0, url.Length - 4); + + if (url.StartsWith("https://github.com/", StringComparison.Ordinal)) + outs.Add(new($"Github ({trimmedUrl.Slice(19)})", $"{url}/commit/")); + else if (url.StartsWith("https://gitlab.", StringComparison.Ordinal)) + outs.Add(new($"GitLab ({trimmedUrl.Slice(trimmedUrl.Slice(15).IndexOf('/') + 16)})", $"{url}/-/commit/")); + else if (url.StartsWith("https://gitee.com/", StringComparison.Ordinal)) + outs.Add(new($"Gitee ({trimmedUrl.Slice(18)})", $"{url}/commit/")); + else if (url.StartsWith("https://bitbucket.org/", StringComparison.Ordinal)) + outs.Add(new($"BitBucket ({trimmedUrl.Slice(22)})", $"{url}/commits/")); + else if (url.StartsWith("https://codeberg.org/", StringComparison.Ordinal)) + outs.Add(new($"Codeberg ({trimmedUrl.Slice(21)})", $"{url}/commit/")); + else if (url.StartsWith("https://gitea.org/", StringComparison.Ordinal)) + outs.Add(new($"Gitea ({trimmedUrl.Slice(18)})", $"{url}/commit/")); + else if (url.StartsWith("https://git.sr.ht/", StringComparison.Ordinal)) + outs.Add(new($"sourcehut ({trimmedUrl.Slice(18)})", $"{url}/commit/")); + } + } + + return outs; + } + } +} diff --git a/src/Models/CommitSignInfo.cs b/src/Models/CommitSignInfo.cs new file mode 100644 index 00000000..44b95e61 --- /dev/null +++ b/src/Models/CommitSignInfo.cs @@ -0,0 +1,60 @@ +using Avalonia.Media; + +namespace SourceGit.Models +{ + public class CommitSignInfo + { + public char VerifyResult { get; init; } = 'N'; + public string Signer { get; init; } = string.Empty; + public string Key { get; init; } = string.Empty; + public bool HasSigner => !string.IsNullOrEmpty(Signer); + + public IBrush Brush + { + get + { + switch (VerifyResult) + { + case 'G': + case 'U': + return Brushes.Green; + case 'X': + case 'Y': + case 'R': + return Brushes.DarkOrange; + case 'B': + case 'E': + return Brushes.Red; + default: + return Brushes.Transparent; + } + } + } + + public string ToolTip + { + get + { + switch (VerifyResult) + { + case 'G': + return "Good signature."; + case 'U': + return "Good signature with unknown validity."; + case 'X': + return "Good signature but has expired."; + case 'Y': + return "Good signature made by expired key."; + case 'R': + return "Good signature made by a revoked key."; + case 'B': + return "Bad signature."; + case 'E': + return "Signature cannot be checked."; + default: + return "No signature."; + } + } + } + } +} diff --git a/src/Models/CommitTemplate.cs b/src/Models/CommitTemplate.cs new file mode 100644 index 00000000..56e1992c --- /dev/null +++ b/src/Models/CommitTemplate.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.Models +{ + public partial class CommitTemplate : ObservableObject + { + public string Name + { + get => _name; + set => SetProperty(ref _name, value); + } + + public string Content + { + get => _content; + set => SetProperty(ref _content, value); + } + + public string Apply(Branch branch, List changes) + { + var te = new TemplateEngine(); + return te.Eval(_content, branch, changes); + } + + private string _name = string.Empty; + private string _content = string.Empty; + } +} diff --git a/src/Models/ConventionalCommitType.cs b/src/Models/ConventionalCommitType.cs new file mode 100644 index 00000000..531a16c0 --- /dev/null +++ b/src/Models/ConventionalCommitType.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public class ConventionalCommitType + { + public string Name { get; set; } + public string Type { get; set; } + public string Description { get; set; } + + public static readonly List Supported = [ + new("Features", "feat", "Adding a new feature"), + new("Bug Fixes", "fix", "Fixing a bug"), + new("Work In Progress", "wip", "Still being developed and not yet complete"), + new("Reverts", "revert", "Undoing a previous commit"), + new("Code Refactoring", "refactor", "Restructuring code without changing its external behavior"), + new("Performance Improvements", "perf", "Improves performance"), + new("Builds", "build", "Changes that affect the build system or external dependencies"), + new("Continuous Integrations", "ci", "Changes to CI configuration files and scripts"), + new("Documentations", "docs", "Updating documentation"), + new("Styles", "style", "Elements or code styles without changing the code logic"), + new("Tests", "test", "Adding or updating tests"), + new("Chores", "chore", "Other changes that don't modify src or test files"), + ]; + + public ConventionalCommitType(string name, string type, string description) + { + Name = name; + Type = type; + Description = description; + } + } +} diff --git a/src/Models/Count.cs b/src/Models/Count.cs new file mode 100644 index 00000000..d48b0c08 --- /dev/null +++ b/src/Models/Count.cs @@ -0,0 +1,19 @@ +using System; + +namespace SourceGit.Models +{ + public class Count : IDisposable + { + public int Value { get; set; } = 0; + + public Count(int value) + { + Value = value; + } + + public void Dispose() + { + // Ignore + } + } +} diff --git a/src/Models/CustomAction.cs b/src/Models/CustomAction.cs new file mode 100644 index 00000000..a614961a --- /dev/null +++ b/src/Models/CustomAction.cs @@ -0,0 +1,50 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.Models +{ + public enum CustomActionScope + { + Repository, + Commit, + Branch, + } + + public class CustomAction : ObservableObject + { + public string Name + { + get => _name; + set => SetProperty(ref _name, value); + } + + public CustomActionScope Scope + { + get => _scope; + set => SetProperty(ref _scope, value); + } + + public string Executable + { + get => _executable; + set => SetProperty(ref _executable, value); + } + + public string Arguments + { + get => _arguments; + set => SetProperty(ref _arguments, value); + } + + public bool WaitForExit + { + get => _waitForExit; + set => SetProperty(ref _waitForExit, value); + } + + private string _name = string.Empty; + private CustomActionScope _scope = CustomActionScope.Repository; + private string _executable = string.Empty; + private string _arguments = string.Empty; + private bool _waitForExit = true; + } +} diff --git a/src/Models/DateTimeFormat.cs b/src/Models/DateTimeFormat.cs new file mode 100644 index 00000000..16276c40 --- /dev/null +++ b/src/Models/DateTimeFormat.cs @@ -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 Active + { + get => Supported[ActiveIndex]; + } + + public static readonly List Supported = new List + { + 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); + } +} diff --git a/src/SourceGit/Models/Decorator.cs b/src/Models/Decorator.cs similarity index 53% rename from src/SourceGit/Models/Decorator.cs rename to src/Models/Decorator.cs index 10967b45..7d985e31 100644 --- a/src/SourceGit/Models/Decorator.cs +++ b/src/Models/Decorator.cs @@ -1,12 +1,11 @@ -using Avalonia.Media; - -namespace SourceGit.Models +namespace SourceGit.Models { public enum DecoratorType { None, CurrentBranchHead, LocalBranchHead, + CurrentCommitHead, RemoteBranchHead, Tag, } @@ -15,13 +14,6 @@ namespace SourceGit.Models { public DecoratorType Type { get; set; } = DecoratorType.None; public string Name { get; set; } = ""; - } - - public static class DecoratorResources - { - public static readonly IBrush[] Backgrounds = [ - new SolidColorBrush(0xFF02C302), - new SolidColorBrush(0xFFFFB835), - ]; + public bool IsTag => Type == DecoratorType.Tag; } } diff --git a/src/SourceGit/Models/DiffOption.cs b/src/Models/DiffOption.cs similarity index 82% rename from src/SourceGit/Models/DiffOption.cs rename to src/Models/DiffOption.cs index e98edffb..69f93980 100644 --- a/src/SourceGit/Models/DiffOption.cs +++ b/src/Models/DiffOption.cs @@ -5,6 +5,15 @@ namespace SourceGit.Models { public class DiffOption { + /// + /// Enable `--ignore-cr-at-eol` by default? + /// + public static bool IgnoreCRAtEOL + { + get; + set; + } = true; + public Change WorkingCopyChange => _workingCopyChange; public bool IsUnstaged => _isUnstaged; public List Revisions => _revisions; @@ -39,7 +48,11 @@ namespace SourceGit.Models } else { - _extra = "--cached"; + if (change.DataForAmend != null) + _extra = $"--cached {change.DataForAmend.ParentSHA}"; + else + _extra = "--cached"; + _path = change.Path; _orgPath = change.OriginalPath; } @@ -52,7 +65,7 @@ namespace SourceGit.Models /// public DiffOption(Commit commit, Change change) { - var baseRevision = commit.Parents.Count == 0 ? "4b825dc642cb6eb9a060e54bf8d69288fbee4904" : $"{commit.SHA}^"; + var baseRevision = commit.Parents.Count == 0 ? Commit.EmptyTreeSHA1 : $"{commit.SHA}^"; _revisions.Add(baseRevision); _revisions.Add(commit.SHA); _path = change.Path; @@ -66,7 +79,7 @@ namespace SourceGit.Models /// public DiffOption(Commit commit, string file) { - var baseRevision = commit.Parents.Count == 0 ? "4b825dc642cb6eb9a060e54bf8d69288fbee4904" : $"{commit.SHA}^"; + var baseRevision = commit.Parents.Count == 0 ? Commit.EmptyTreeSHA1 : $"{commit.SHA}^"; _revisions.Add(baseRevision); _revisions.Add(commit.SHA); _path = file; @@ -80,7 +93,7 @@ namespace SourceGit.Models /// public DiffOption(string baseRevision, string targetRevision, Change change) { - _revisions.Add(baseRevision); + _revisions.Add(string.IsNullOrEmpty(baseRevision) ? "-R" : baseRevision); _revisions.Add(targetRevision); _path = change.Path; _orgPath = change.OriginalPath; @@ -108,9 +121,9 @@ namespace SourceGit.Models private readonly Change _workingCopyChange = null; private readonly bool _isUnstaged = false; + private readonly string _path; private readonly string _orgPath = string.Empty; - private readonly string _path = string.Empty; private readonly string _extra = string.Empty; - private readonly List _revisions = new List(); + private readonly List _revisions = []; } } diff --git a/src/SourceGit/Models/DiffResult.cs b/src/Models/DiffResult.cs similarity index 83% rename from src/SourceGit/Models/DiffResult.cs rename to src/Models/DiffResult.cs index 8cceced8..b2d91310 100644 --- a/src/SourceGit/Models/DiffResult.cs +++ b/src/Models/DiffResult.cs @@ -1,7 +1,8 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Text; using System.Text.RegularExpressions; +using Avalonia; using Avalonia.Media.Imaging; namespace SourceGit.Models @@ -18,8 +19,8 @@ namespace SourceGit.Models public class TextInlineRange { public int Start { get; set; } - public int Count { get; set; } - public TextInlineRange(int p, int n) { Start = p; Count = n; } + public int End { get; set; } + public TextInlineRange(int p, int n) { Start = p; End = p + n - 1; } } public class TextDiffLine @@ -29,6 +30,7 @@ namespace SourceGit.Models public int OldLineNumber { get; set; } = 0; public int NewLineNumber { get; set; } = 0; public List Highlights { get; set; } = new List(); + public bool NoNewLineEndOfFile { get; set; } = false; public string OldLine => OldLineNumber == 0 ? string.Empty : OldLineNumber.ToString(); public string NewLine => NewLineNumber == 0 ? string.Empty : NewLineNumber.ToString(); @@ -62,12 +64,90 @@ namespace SourceGit.Models { public string File { get; set; } = string.Empty; public List Lines { get; set; } = new List(); + public Vector ScrollOffset { get; set; } = Vector.Zero; public int MaxLineNumber = 0; + public string Repo { get; set; } = null; + public DiffOption Option { get; set; } = null; + + public TextDiffSelection MakeSelection(int startLine, int endLine, bool isCombined, bool isOldSide) + { + var rs = new TextDiffSelection(); + rs.StartLine = startLine; + rs.EndLine = endLine; + + for (int i = 0; i < startLine - 1; i++) + { + var line = Lines[i]; + if (line.Type == TextDiffLineType.Added) + { + rs.HasLeftChanges = true; + rs.IgnoredAdds++; + } + else if (line.Type == TextDiffLineType.Deleted) + { + rs.HasLeftChanges = true; + rs.IgnoredDeletes++; + } + } + + for (int i = startLine - 1; i < endLine; i++) + { + var line = Lines[i]; + if (line.Type == TextDiffLineType.Added) + { + if (isCombined) + { + rs.HasChanges = true; + break; + } + else if (isOldSide) + { + rs.HasLeftChanges = true; + } + else + { + rs.HasChanges = true; + } + } + else if (line.Type == TextDiffLineType.Deleted) + { + if (isCombined) + { + rs.HasChanges = true; + break; + } + else if (isOldSide) + { + rs.HasChanges = true; + } + else + { + rs.HasLeftChanges = true; + } + } + } + + if (!rs.HasLeftChanges) + { + for (int i = endLine; i < Lines.Count; i++) + { + var line = Lines[i]; + if (line.Type == TextDiffLineType.Added || line.Type == TextDiffLineType.Deleted) + { + rs.HasLeftChanges = true; + break; + } + } + } + + return rs; + } + public void GenerateNewPatchFromSelection(Change change, string fileBlobGuid, TextDiffSelection selection, bool revert, string output) { var isTracked = !string.IsNullOrEmpty(fileBlobGuid); - var fileGuid = isTracked ? fileBlobGuid.Substring(0, 8) : "00000000"; + var fileGuid = isTracked ? fileBlobGuid : "00000000"; var builder = new StringBuilder(); builder.Append("diff --git a/").Append(change.Path).Append(" b/").Append(change.Path).Append('\n'); @@ -387,12 +467,9 @@ namespace SourceGit.Models System.IO.File.WriteAllText(output, builder.ToString()); } - [GeneratedRegex(@"^@@ \-(\d+),?\d* \+(\d+),?\d* @@")] - private static partial Regex indicatorRegex(); - private bool ProcessIndicatorForPatch(StringBuilder builder, TextDiffLine indicator, int idx, int start, int end, int ignoreRemoves, int ignoreAdds, bool revert, bool tailed) { - var match = indicatorRegex().Match(indicator.Content); + var match = REG_INDICATOR().Match(indicator.Content); var oldStart = int.Parse(match.Groups[1].Value); var newStart = int.Parse(match.Groups[2].Value) + ignoreRemoves - ignoreAdds; var oldCount = 0; @@ -461,7 +538,7 @@ namespace SourceGit.Models private bool ProcessIndicatorForPatchSingleSide(StringBuilder builder, TextDiffLine indicator, int idx, int start, int end, int ignoreRemoves, int ignoreAdds, bool revert, bool isOldSide, bool tailed) { - var match = indicatorRegex().Match(indicator.Content); + var match = REG_INDICATOR().Match(indicator.Content); var oldStart = int.Parse(match.Groups[1].Value); var newStart = int.Parse(match.Groups[2].Value) + ignoreRemoves - ignoreAdds; var oldCount = 0; @@ -549,6 +626,9 @@ namespace SourceGit.Models builder.Append($"\n@@ -{oldStart},{oldCount} +{newStart},{newCount} @@"); return true; } + + [GeneratedRegex(@"^@@ \-(\d+),?\d* \+(\d+),?\d* @@")] + private static partial Regex REG_INDICATOR(); } public class LFSDiff @@ -568,19 +648,52 @@ namespace SourceGit.Models public Bitmap Old { get; set; } = null; public Bitmap New { get; set; } = null; - public string OldSize => Old != null ? $"{Old.PixelSize.Width} x {Old.PixelSize.Height}" : "0 x 0"; - public string NewSize => New != null ? $"{New.PixelSize.Width} x {New.PixelSize.Height}" : "0 x 0"; + public long OldFileSize { get; set; } = 0; + public long NewFileSize { get; set; } = 0; + + public string OldImageSize => Old != null ? $"{Old.PixelSize.Width} x {Old.PixelSize.Height}" : "0 x 0"; + public string NewImageSize => New != null ? $"{New.PixelSize.Width} x {New.PixelSize.Height}" : "0 x 0"; } public class NoOrEOLChange { } + public class FileModeDiff + { + public string Old { get; set; } = string.Empty; + public string New { get; set; } = string.Empty; + } + + public class SubmoduleDiff + { + public RevisionSubmodule Old { get; set; } = null; + public RevisionSubmodule New { get; set; } = null; + } + public class DiffResult { public bool IsBinary { get; set; } = false; public bool IsLFS { get; set; } = false; + public string OldHash { get; set; } = string.Empty; + public string NewHash { get; set; } = string.Empty; + public string OldMode { get; set; } = string.Empty; + public string NewMode { get; set; } = string.Empty; public TextDiff TextDiff { get; set; } = null; public LFSDiff LFSDiff { get; set; } = null; + + 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}"; + } + } } } diff --git a/src/Models/DirtyState.cs b/src/Models/DirtyState.cs new file mode 100644 index 00000000..2b9d898d --- /dev/null +++ b/src/Models/DirtyState.cs @@ -0,0 +1,12 @@ +using System; + +namespace SourceGit.Models +{ + [Flags] + public enum DirtyState + { + None = 0, + HasLocalChanges = 1 << 0, + HasPendingPullOrPush = 1 << 1, + } +} diff --git a/src/Models/ExternalMerger.cs b/src/Models/ExternalMerger.cs new file mode 100644 index 00000000..fe67ad6a --- /dev/null +++ b/src/Models/ExternalMerger.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using Avalonia.Media.Imaging; +using Avalonia.Platform; + +namespace SourceGit.Models +{ + public class ExternalMerger + { + public int Type { get; set; } + public string Icon { get; set; } + public string Name { get; set; } + public string Exec { get; set; } + public string Cmd { get; set; } + public string DiffCmd { get; set; } + + public Bitmap IconImage + { + get + { + var icon = AssetLoader.Open(new Uri($"avares://SourceGit/Resources/Images/ExternalToolIcons/{Icon}.png", UriKind.RelativeOrAbsolute)); + return new Bitmap(icon); + } + } + + public static readonly List Supported; + + static ExternalMerger() + { + if (OperatingSystem.IsWindows()) + { + Supported = new List() { + new ExternalMerger(0, "git", "Use Git Settings", "", "", ""), + new ExternalMerger(1, "vscode", "Visual Studio Code", "Code.exe", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(2, "vscode_insiders", "Visual Studio Code - Insiders", "Code - Insiders.exe", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(3, "vs", "Visual Studio", "vsDiffMerge.exe", "\"$REMOTE\" \"$LOCAL\" \"$BASE\" \"$MERGED\" /m", "\"$LOCAL\" \"$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(6, "beyond_compare", "Beyond Compare", "BComp.exe", "\"$REMOTE\" \"$LOCAL\" \"$BASE\" \"$MERGED\"", "\"$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(9, "p4merge", "P4Merge", "p4merge.exe", "-tw 4 \"$BASE\" \"$LOCAL\" \"$REMOTE\" \"$MERGED\"", "-tw 4 \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(10, "plastic_merge", "Plastic SCM", "mergetool.exe", "-s=\"$REMOTE\" -b=\"$BASE\" -d=\"$LOCAL\" -r=\"$MERGED\" --automatic", "-s=\"$LOCAL\" -d=\"$REMOTE\""), + new ExternalMerger(11, "meld", "Meld", "Meld.exe", "\"$LOCAL\" \"$BASE\" \"$REMOTE\" --output \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), + }; + } + else if (OperatingSystem.IsMacOS()) + { + Supported = new List() { + new ExternalMerger(0, "git", "Use Git Settings", "", "", ""), + new ExternalMerger(1, "xcode", "FileMerge", "/usr/bin/opendiff", "\"$BASE\" \"$LOCAL\" \"$REMOTE\" -ancestor \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(2, "vscode", "Visual Studio Code", "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(3, "vscode_insiders", "Visual Studio Code - Insiders", "/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/bin/code", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(4, "kdiff3", "KDiff3", "/Applications/kdiff3.app/Contents/MacOS/kdiff3", "\"$REMOTE\" -b \"$BASE\" \"$LOCAL\" -o \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(5, "beyond_compare", "Beyond Compare", "/Applications/Beyond Compare.app/Contents/MacOS/bcomp", "\"$REMOTE\" \"$LOCAL\" \"$BASE\" \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(6, "codium", "VSCodium", "/Applications/VSCodium.app/Contents/Resources/app/bin/codium", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(7, "p4merge", "P4Merge", "/Applications/p4merge.app/Contents/Resources/launchp4merge", "-tw 4 \"$BASE\" \"$LOCAL\" \"$REMOTE\" \"$MERGED\"", "-tw 4 \"$LOCAL\" \"$REMOTE\""), + }; + } + else if (OperatingSystem.IsLinux()) + { + Supported = new List() { + new ExternalMerger(0, "git", "Use Git Settings", "", "", ""), + new ExternalMerger(1, "vscode", "Visual Studio Code", "/usr/share/code/code", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(2, "vscode_insiders", "Visual Studio Code - Insiders", "/usr/share/code-insiders/code-insiders", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(3, "kdiff3", "KDiff3", "/usr/bin/kdiff3", "\"$REMOTE\" -b \"$BASE\" \"$LOCAL\" -o \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(4, "beyond_compare", "Beyond Compare", "/usr/bin/bcomp", "\"$REMOTE\" \"$LOCAL\" \"$BASE\" \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(5, "meld", "Meld", "/usr/bin/meld", "\"$LOCAL\" \"$BASE\" \"$REMOTE\" --output \"$MERGED\"", "\"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(6, "codium", "VSCodium", "/usr/share/codium/bin/codium", "-n --wait \"$MERGED\"", "-n --wait --diff \"$LOCAL\" \"$REMOTE\""), + new ExternalMerger(7, "p4merge", "P4Merge", "/usr/local/bin/p4merge", "-tw 4 \"$BASE\" \"$LOCAL\" \"$REMOTE\" \"$MERGED\"", "-tw 4 \"$LOCAL\" \"$REMOTE\""), + }; + } + else + { + Supported = new List() { + new ExternalMerger(0, "git", "Use Git Settings", "", "", ""), + }; + } + } + + public ExternalMerger(int type, string icon, string name, string exec, string cmd, string diffCmd) + { + Type = type; + Icon = icon; + Name = name; + Exec = exec; + Cmd = cmd; + DiffCmd = diffCmd; + } + + public string[] GetPatterns() + { + if (OperatingSystem.IsWindows()) + { + return Exec.Split(';'); + } + else + { + var patterns = new List(); + var choices = Exec.Split(';', StringSplitOptions.RemoveEmptyEntries); + foreach (var c in choices) + { + patterns.Add(Path.GetFileName(c)); + } + return patterns.ToArray(); + } + } + } +} diff --git a/src/Models/ExternalTool.cs b/src/Models/ExternalTool.cs new file mode 100644 index 00000000..103e91bc --- /dev/null +++ b/src/Models/ExternalTool.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; + +using Avalonia.Media.Imaging; +using Avalonia.Platform; + +namespace SourceGit.Models +{ + public class ExternalTool + { + public string Name { get; private set; } + public Bitmap IconImage { get; private set; } = null; + + public ExternalTool(string name, string icon, string execFile, Func execArgsGenerator = null) + { + Name = name; + _execFile = execFile; + _execArgsGenerator = execArgsGenerator ?? (repo => $"\"{repo}\""); + + try + { + var asset = AssetLoader.Open(new Uri($"avares://SourceGit/Resources/Images/ExternalToolIcons/{icon}.png", + UriKind.RelativeOrAbsolute)); + IconImage = new Bitmap(asset); + } + catch + { + // ignore + } + } + + public void Open(string repo) + { + Process.Start(new ProcessStartInfo() + { + WorkingDirectory = repo, + FileName = _execFile, + Arguments = _execArgsGenerator.Invoke(repo), + UseShellExecute = false, + }); + } + + private string _execFile = string.Empty; + private Func _execArgsGenerator = null; + } + + public class JetBrainsState + { + [JsonPropertyName("version")] + public int Version { get; set; } = 0; + [JsonPropertyName("appVersion")] + public string AppVersion { get; set; } = string.Empty; + [JsonPropertyName("tools")] + public List Tools { get; set; } = new List(); + } + + public class JetBrainsTool + { + [JsonPropertyName("channelId")] + public string ChannelId { get; set; } + [JsonPropertyName("toolId")] + public string ToolId { get; set; } + [JsonPropertyName("productCode")] + public string ProductCode { get; set; } + [JsonPropertyName("tag")] + public string Tag { get; set; } + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } + [JsonPropertyName("displayVersion")] + public string DisplayVersion { get; set; } + [JsonPropertyName("buildNumber")] + public string BuildNumber { get; set; } + [JsonPropertyName("installLocation")] + public string InstallLocation { get; set; } + [JsonPropertyName("launchCommand")] + public string LaunchCommand { get; set; } + } + + public class ExternalToolPaths + { + [JsonPropertyName("tools")] + public Dictionary Tools { get; set; } = new Dictionary(); + } + + public class ExternalToolsFinder + { + public List Founded + { + get; + private set; + } = new List(); + + public ExternalToolsFinder() + { + var customPathsConfig = Path.Combine(Native.OS.DataDir, "external_editors.json"); + try + { + if (File.Exists(customPathsConfig)) + _customPaths = JsonSerializer.Deserialize(File.ReadAllText(customPathsConfig), JsonCodeGen.Default.ExternalToolPaths); + } + catch + { + // Ignore + } + + if (_customPaths == null) + _customPaths = new ExternalToolPaths(); + } + + public void TryAdd(string name, string icon, Func finder, Func execArgsGenerator = null) + { + if (_customPaths.Tools.TryGetValue(name, out var customPath) && File.Exists(customPath)) + { + Founded.Add(new ExternalTool(name, icon, customPath, execArgsGenerator)); + } + else + { + var path = finder(); + if (!string.IsNullOrEmpty(path) && File.Exists(path)) + Founded.Add(new ExternalTool(name, icon, path, execArgsGenerator)); + } + } + + public void VSCode(Func platformFinder) + { + TryAdd("Visual Studio Code", "vscode", platformFinder); + } + + public void VSCodeInsiders(Func platformFinder) + { + TryAdd("Visual Studio Code - Insiders", "vscode_insiders", platformFinder); + } + + public void VSCodium(Func platformFinder) + { + TryAdd("VSCodium", "codium", platformFinder); + } + + public void Fleet(Func platformFinder) + { + TryAdd("Fleet", "fleet", platformFinder); + } + + public void SublimeText(Func platformFinder) + { + TryAdd("Sublime Text", "sublime_text", platformFinder); + } + + public void Zed(Func platformFinder) + { + TryAdd("Zed", "zed", platformFinder); + } + + public void FindJetBrainsFromToolbox(Func platformFinder) + { + var exclude = new List { "fleet", "dotmemory", "dottrace", "resharper-u", "androidstudio" }; + var supported_icons = new List { "CL", "DB", "DL", "DS", "GO", "JB", "PC", "PS", "PY", "QA", "QD", "RD", "RM", "RR", "WRS", "WS" }; + var state = Path.Combine(platformFinder(), "state.json"); + if (File.Exists(state)) + { + var stateData = JsonSerializer.Deserialize(File.ReadAllText(state), JsonCodeGen.Default.JetBrainsState); + foreach (var tool in stateData.Tools) + { + if (exclude.Contains(tool.ToolId.ToLowerInvariant())) + continue; + + Founded.Add(new ExternalTool( + $"{tool.DisplayName} {tool.DisplayVersion}", + supported_icons.Contains(tool.ProductCode) ? $"JetBrains/{tool.ProductCode}" : "JetBrains/JB", + Path.Combine(tool.InstallLocation, tool.LaunchCommand))); + } + } + } + + private ExternalToolPaths _customPaths = null; + } +} diff --git a/src/Models/Filter.cs b/src/Models/Filter.cs new file mode 100644 index 00000000..af4569fa --- /dev/null +++ b/src/Models/Filter.cs @@ -0,0 +1,60 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.Models +{ + public enum FilterType + { + LocalBranch = 0, + LocalBranchFolder, + RemoteBranch, + RemoteBranchFolder, + Tag, + } + + public enum FilterMode + { + None = 0, + Included, + Excluded, + } + + public class Filter : ObservableObject + { + public string Pattern + { + get => _pattern; + set => SetProperty(ref _pattern, value); + } + + public FilterType Type + { + get; + set; + } = FilterType.LocalBranch; + + public FilterMode Mode + { + get => _mode; + set => SetProperty(ref _mode, value); + } + + public bool IsBranch + { + get => Type != FilterType.Tag; + } + + public Filter() + { + } + + public Filter(string pattern, FilterType type, FilterMode mode) + { + _pattern = pattern; + _mode = mode; + Type = type; + } + + private string _pattern = string.Empty; + private FilterMode _mode = FilterMode.None; + } +} diff --git a/src/Models/GPGFormat.cs b/src/Models/GPGFormat.cs new file mode 100644 index 00000000..0ba4e9e2 --- /dev/null +++ b/src/Models/GPGFormat.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public class GPGFormat(string name, string value, string desc, string program, bool needFindProgram) + { + public string Name { get; set; } = name; + public string Value { get; set; } = value; + public string Desc { get; set; } = desc; + public string Program { get; set; } = program; + public bool NeedFindProgram { get; set; } = needFindProgram; + + public static readonly List Supported = [ + new GPGFormat("OPENPGP", "openpgp", "DEFAULT", "gpg", true), + new GPGFormat("X.509", "x509", "", "gpgsm", true), + new GPGFormat("SSH", "ssh", "Requires Git >= 2.34.0", "ssh-keygen", false), + ]; + } +} diff --git a/src/Models/GitFlow.cs b/src/Models/GitFlow.cs new file mode 100644 index 00000000..5d26072b --- /dev/null +++ b/src/Models/GitFlow.cs @@ -0,0 +1,46 @@ +namespace SourceGit.Models +{ + public enum GitFlowBranchType + { + None = 0, + Feature, + Release, + Hotfix, + } + + public class GitFlow + { + public string Master { get; set; } = string.Empty; + public string Develop { get; set; } = string.Empty; + public string FeaturePrefix { get; set; } = string.Empty; + public string ReleasePrefix { get; set; } = string.Empty; + public string HotfixPrefix { get; set; } = string.Empty; + + public bool IsValid + { + get + { + return !string.IsNullOrEmpty(Master) && + !string.IsNullOrEmpty(Develop) && + !string.IsNullOrEmpty(FeaturePrefix) && + !string.IsNullOrEmpty(ReleasePrefix) && + !string.IsNullOrEmpty(HotfixPrefix); + } + } + + public string GetPrefix(GitFlowBranchType type) + { + switch (type) + { + case GitFlowBranchType.Feature: + return FeaturePrefix; + case GitFlowBranchType.Release: + return ReleasePrefix; + case GitFlowBranchType.Hotfix: + return HotfixPrefix; + default: + return string.Empty; + } + } + } +} diff --git a/src/Models/GitVersions.cs b/src/Models/GitVersions.cs new file mode 100644 index 00000000..8aae63a3 --- /dev/null +++ b/src/Models/GitVersions.cs @@ -0,0 +1,20 @@ +namespace SourceGit.Models +{ + public static class GitVersions + { + /// + /// The minimal version of Git that required by this app. + /// + public static readonly System.Version MINIMAL = new(2, 25, 1); + + /// + /// The minimal version of Git that supports the `stash push` command with the `--pathspec-from-file` option. + /// + public static readonly System.Version STASH_PUSH_WITH_PATHSPECFILE = new(2, 26, 0); + + /// + /// The minimal version of Git that supports the `stash push` command with the `--staged` option. + /// + public static readonly System.Version STASH_PUSH_ONLY_STAGED = new(2, 35, 0); + } +} diff --git a/src/Models/ICommandLog.cs b/src/Models/ICommandLog.cs new file mode 100644 index 00000000..34ec7031 --- /dev/null +++ b/src/Models/ICommandLog.cs @@ -0,0 +1,7 @@ +namespace SourceGit.Models +{ + public interface ICommandLog + { + void AppendLine(string line); + } +} diff --git a/src/Models/IRepository.cs b/src/Models/IRepository.cs new file mode 100644 index 00000000..2fc7c612 --- /dev/null +++ b/src/Models/IRepository.cs @@ -0,0 +1,15 @@ +namespace SourceGit.Models +{ + public interface IRepository + { + bool MayHaveSubmodules(); + + void RefreshBranches(); + void RefreshWorktrees(); + void RefreshTags(); + void RefreshCommits(); + void RefreshSubmodules(); + void RefreshWorkingCopyChanges(); + void RefreshStashes(); + } +} diff --git a/src/Models/ImageDecoder.cs b/src/Models/ImageDecoder.cs new file mode 100644 index 00000000..ce3a44c1 --- /dev/null +++ b/src/Models/ImageDecoder.cs @@ -0,0 +1,9 @@ +namespace SourceGit.Models +{ + public enum ImageDecoder + { + None = 0, + Builtin, + Pfim + } +} diff --git a/src/Models/InlineElement.cs b/src/Models/InlineElement.cs new file mode 100644 index 00000000..ea7bcee8 --- /dev/null +++ b/src/Models/InlineElement.cs @@ -0,0 +1,37 @@ +namespace SourceGit.Models +{ + public enum InlineElementType + { + Keyword = 0, + Link, + CommitSHA, + Code, + } + + public class InlineElement + { + public InlineElementType Type { get; } + public int Start { get; } + public int Length { get; } + public string Link { get; } + + public InlineElement(InlineElementType type, int start, int length, string link) + { + Type = type; + Start = start; + Length = length; + Link = link; + } + + public bool IsIntersecting(int start, int length) + { + if (start == Start) + return true; + + if (start < Start) + return start + length > Start; + + return start < Start + Length; + } + } +} diff --git a/src/Models/InlineElementCollector.cs b/src/Models/InlineElementCollector.cs new file mode 100644 index 00000000..d81aaf8d --- /dev/null +++ b/src/Models/InlineElementCollector.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public class InlineElementCollector + { + public int Count => _implementation.Count; + public InlineElement this[int index] => _implementation[index]; + + public InlineElement Intersect(int start, int length) + { + foreach (var elem in _implementation) + { + if (elem.IsIntersecting(start, length)) + return elem; + } + + return null; + } + + public void Add(InlineElement element) + { + _implementation.Add(element); + } + + public void Sort() + { + _implementation.Sort((l, r) => l.Start.CompareTo(r.Start)); + } + + public void Clear() + { + _implementation.Clear(); + } + + private readonly List _implementation = []; + } +} diff --git a/src/Models/InteractiveRebase.cs b/src/Models/InteractiveRebase.cs new file mode 100644 index 00000000..d1710d4a --- /dev/null +++ b/src/Models/InteractiveRebase.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public enum InteractiveRebaseAction + { + Pick, + Edit, + Reword, + Squash, + Fixup, + Drop, + } + + public class InteractiveCommit + { + public Commit Commit { get; set; } = new Commit(); + public string Message { get; set; } = string.Empty; + } + + public class InteractiveRebaseJob + { + public string SHA { get; set; } = string.Empty; + public InteractiveRebaseAction Action { get; set; } = InteractiveRebaseAction.Pick; + public string Message { get; set; } = string.Empty; + } + + public class InteractiveRebaseJobCollection + { + public string OrigHead { get; set; } = string.Empty; + public string Onto { get; set; } = string.Empty; + public List Jobs { get; set; } = new List(); + } +} diff --git a/src/Models/IpcChannel.cs b/src/Models/IpcChannel.cs new file mode 100644 index 00000000..c2a6c6c7 --- /dev/null +++ b/src/Models/IpcChannel.cs @@ -0,0 +1,104 @@ +using System; +using System.IO; +using System.IO.Pipes; +using System.Threading; +using System.Threading.Tasks; + +namespace SourceGit.Models +{ + public class IpcChannel : IDisposable + { + public bool IsFirstInstance + { + get => _isFirstInstance; + } + + public event Action MessageReceived; + + public IpcChannel() + { + try + { + _singletonLock = File.Open(Path.Combine(Native.OS.DataDir, "process.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + _isFirstInstance = true; + _server = new NamedPipeServerStream( + "SourceGitIPCChannel" + Environment.UserName, + PipeDirection.In, + -1, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + _cancellationTokenSource = new CancellationTokenSource(); + Task.Run(StartServer); + } + catch + { + _isFirstInstance = false; + } + } + + public void SendToFirstInstance(string cmd) + { + try + { + using (var client = new NamedPipeClientStream(".", "SourceGitIPCChannel" + Environment.UserName, PipeDirection.Out, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly)) + { + client.Connect(1000); + if (!client.IsConnected) + return; + + using (var writer = new StreamWriter(client)) + { + writer.WriteLine(cmd); + writer.Flush(); + } + + if (OperatingSystem.IsWindows()) + client.WaitForPipeDrain(); + else + Thread.Sleep(1000); + } + } + catch + { + // IGNORE + } + } + + public void Dispose() + { + _cancellationTokenSource?.Cancel(); + _singletonLock?.Dispose(); + } + + private async void StartServer() + { + using var reader = new StreamReader(_server); + + while (!_cancellationTokenSource.IsCancellationRequested) + { + try + { + await _server.WaitForConnectionAsync(_cancellationTokenSource.Token); + + if (!_cancellationTokenSource.IsCancellationRequested) + { + var line = await reader.ReadToEndAsync(_cancellationTokenSource.Token); + MessageReceived?.Invoke(line?.Trim()); + } + + _server.Disconnect(); + } + catch + { + if (!_cancellationTokenSource.IsCancellationRequested && _server.IsConnected) + _server.Disconnect(); + } + } + } + + private FileStream _singletonLock = null; + private bool _isFirstInstance = false; + private NamedPipeServerStream _server = null; + private CancellationTokenSource _cancellationTokenSource = null; + } +} diff --git a/src/Models/IssueTrackerRule.cs b/src/Models/IssueTrackerRule.cs new file mode 100644 index 00000000..40c84b9e --- /dev/null +++ b/src/Models/IssueTrackerRule.cs @@ -0,0 +1,82 @@ +using System.Text.RegularExpressions; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.Models +{ + public class IssueTrackerRule : ObservableObject + { + public string Name + { + get => _name; + set => SetProperty(ref _name, value); + } + + public string RegexString + { + get => _regexString; + set + { + if (SetProperty(ref _regexString, value)) + { + try + { + _regex = null; + _regex = new Regex(_regexString, RegexOptions.Multiline); + } + catch + { + // Ignore errors. + } + } + + OnPropertyChanged(nameof(IsRegexValid)); + } + } + + public bool IsRegexValid + { + get => _regex != null; + } + + public string URLTemplate + { + get => _urlTemplate; + set => SetProperty(ref _urlTemplate, value); + } + + public void Matches(InlineElementCollector outs, string message) + { + if (_regex == null || string.IsNullOrEmpty(_urlTemplate)) + return; + + var matches = _regex.Matches(message); + for (var i = 0; i < matches.Count; i++) + { + var match = matches[i]; + if (!match.Success) + continue; + + var start = match.Index; + var len = match.Length; + if (outs.Intersect(start, len) != null) + continue; + + var link = _urlTemplate; + for (var j = 1; j < match.Groups.Count; j++) + { + var group = match.Groups[j]; + if (group.Success) + link = link.Replace($"${j}", group.Value); + } + + outs.Add(new InlineElement(InlineElementType.Link, start, len, link)); + } + } + + private string _name; + private string _regexString; + private string _urlTemplate; + private Regex _regex = null; + } +} diff --git a/src/Models/LFSLock.cs b/src/Models/LFSLock.cs new file mode 100644 index 00000000..0a328cfb --- /dev/null +++ b/src/Models/LFSLock.cs @@ -0,0 +1,9 @@ +namespace SourceGit.Models +{ + public class LFSLock + { + public string File { get; set; } = string.Empty; + public string User { get; set; } = string.Empty; + public long ID { get; set; } = 0; + } +} diff --git a/src/Models/LFSObject.cs b/src/Models/LFSObject.cs new file mode 100644 index 00000000..8bc2dda2 --- /dev/null +++ b/src/Models/LFSObject.cs @@ -0,0 +1,22 @@ +using System.Text.RegularExpressions; + +namespace SourceGit.Models +{ + public partial class LFSObject + { + [GeneratedRegex(@"^version https://git-lfs.github.com/spec/v\d+\r?\noid sha256:([0-9a-f]+)\r?\nsize (\d+)[\r\n]*$")] + private static partial Regex REG_FORMAT(); + + public string Oid { get; set; } = string.Empty; + public long Size { get; set; } = 0; + + public static LFSObject Parse(string content) + { + var match = REG_FORMAT().Match(content); + if (match.Success) + return new() { Oid = match.Groups[1].Value, Size = long.Parse(match.Groups[2].Value) }; + + return null; + } + } +} diff --git a/src/Models/Locales.cs b/src/Models/Locales.cs new file mode 100644 index 00000000..1788a9b2 --- /dev/null +++ b/src/Models/Locales.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public class Locale + { + public string Name { get; set; } + public string Key { get; set; } + + public static readonly List Supported = new List() { + new Locale("Deutsch", "de_DE"), + new Locale("English", "en_US"), + new Locale("Español", "es_ES"), + new Locale("Français", "fr_FR"), + new Locale("Italiano", "it_IT"), + new Locale("Português (Brasil)", "pt_BR"), + new Locale("Українська", "uk_UA"), + new Locale("Русский", "ru_RU"), + new Locale("简体中文", "zh_CN"), + new Locale("繁體中文", "zh_TW"), + new Locale("日本語", "ja_JP"), + new Locale("தமிழ் (Tamil)", "ta_IN"), + }; + + public Locale(string name, string key) + { + Name = name; + Key = key; + } + } +} diff --git a/src/Models/MergeMode.cs b/src/Models/MergeMode.cs new file mode 100644 index 00000000..5dc70030 --- /dev/null +++ b/src/Models/MergeMode.cs @@ -0,0 +1,25 @@ +namespace SourceGit.Models +{ + public class MergeMode + { + public static readonly MergeMode[] Supported = + [ + 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("Squash", "Squash merge", "--squash"), + new MergeMode("Don't commit", "Merge without commit", "--no-ff --no-commit"), + ]; + + public string Name { get; set; } + public string Desc { get; set; } + public string Arg { get; set; } + + public MergeMode(string n, string d, string a) + { + Name = n; + Desc = d; + Arg = a; + } + } +} diff --git a/src/Models/MergeStrategy.cs b/src/Models/MergeStrategy.cs new file mode 100644 index 00000000..ab1d446b --- /dev/null +++ b/src/Models/MergeStrategy.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace SourceGit.Models +{ + public class MergeStrategy + { + public string Name { get; internal set; } + public string Desc { get; internal set; } + public string Arg { get; internal set; } + + public static List ForMultiple { get; private set; } = [ + new MergeStrategy("Default", "Let Git automatically select a strategy", string.Empty), + new MergeStrategy("Octopus", "Attempt merging multiple heads", "octopus"), + new MergeStrategy("Ours", "Record the merge without modifying the tree", "ours"), + ]; + + public MergeStrategy(string n, string d, string a) + { + Name = n; + Desc = d; + Arg = a; + } + } +} diff --git a/src/SourceGit/Models/Notification.cs b/src/Models/Notification.cs similarity index 59% rename from src/SourceGit/Models/Notification.cs rename to src/Models/Notification.cs index 2d175da9..473947b0 100644 --- a/src/SourceGit/Models/Notification.cs +++ b/src/Models/Notification.cs @@ -5,9 +5,4 @@ public bool IsError { get; set; } = false; public string Message { get; set; } = string.Empty; } - - public interface INotificationReceiver - { - void OnReceiveNotification(string ctx, Notification notice); - } } diff --git a/src/Models/Null.cs b/src/Models/Null.cs new file mode 100644 index 00000000..e22ef8b3 --- /dev/null +++ b/src/Models/Null.cs @@ -0,0 +1,6 @@ +namespace SourceGit.Models +{ + public class Null + { + } +} diff --git a/src/Models/NumericSort.cs b/src/Models/NumericSort.cs new file mode 100644 index 00000000..ed5002e6 --- /dev/null +++ b/src/Models/NumericSort.cs @@ -0,0 +1,67 @@ +namespace SourceGit.Models +{ + public static class NumericSort + { + public static int Compare(string s1, string s2) + { + int len1 = s1.Length; + int len2 = s2.Length; + + int marker1 = 0; + int marker2 = 0; + + char[] tmp1 = new char[len1]; + char[] tmp2 = new char[len2]; + + while (marker1 < len1 && marker2 < len2) + { + char c1 = s1[marker1]; + char c2 = s2[marker2]; + int loc1 = 0; + int loc2 = 0; + + bool isDigit1 = char.IsDigit(c1); + bool isDigit2 = char.IsDigit(c2); + if (isDigit1 != isDigit2) + return c1.CompareTo(c2); + + do + { + tmp1[loc1] = c1; + loc1++; + marker1++; + + if (marker1 < len1) + c1 = s1[marker1]; + else + break; + } while (char.IsDigit(c1) == isDigit1); + + do + { + tmp2[loc2] = c2; + loc2++; + marker2++; + + if (marker2 < len2) + c2 = s2[marker2]; + else + break; + } while (char.IsDigit(c2) == isDigit2); + + string sub1 = new string(tmp1, 0, loc1); + string sub2 = new string(tmp2, 0, loc2); + int result; + if (isDigit1) + result = loc1 == loc2 ? string.CompareOrdinal(sub1, sub2) : loc1 - loc2; + else + result = string.CompareOrdinal(sub1, sub2); + + if (result != 0) + return result; + } + + return len1 - len2; + } + } +} diff --git a/src/SourceGit/Models/Object.cs b/src/Models/Object.cs similarity index 100% rename from src/SourceGit/Models/Object.cs rename to src/Models/Object.cs diff --git a/src/Models/OpenAI.cs b/src/Models/OpenAI.cs new file mode 100644 index 00000000..22fbcd51 --- /dev/null +++ b/src/Models/OpenAI.cs @@ -0,0 +1,235 @@ +using System; +using System.ClientModel; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using Azure.AI.OpenAI; +using CommunityToolkit.Mvvm.ComponentModel; +using OpenAI; +using OpenAI.Chat; + +namespace SourceGit.Models +{ + public partial class OpenAIResponse + { + public OpenAIResponse(Action onUpdate) + { + _onUpdate = onUpdate; + } + + public void Append(string text) + { + var buffer = text; + + if (_thinkTail.Length > 0) + { + _thinkTail.Append(buffer); + buffer = _thinkTail.ToString(); + _thinkTail.Clear(); + } + + 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); + } + } + + public void End() + { + if (_thinkTail.Length > 0) + { + OnReceive(_thinkTail.ToString()); + _thinkTail.Clear(); + } + } + + private void OnReceive(string text) + { + if (!_hasTrimmedStart) + { + text = text.TrimStart(); + if (string.IsNullOrEmpty(text)) + return; + + _hasTrimmedStart = true; + } + + _onUpdate.Invoke(text); + } + + [GeneratedRegex(@"<(think|thought|thinking|thought_chain)>.*?\1>", RegexOptions.Singleline)] + private static partial Regex REG_COT(); + + private Action _onUpdate = null; + private StringBuilder _thinkTail = new StringBuilder(); + private HashSet _thinkTags = ["think", "thought", "thinking", "thought_chain"]; + private bool _hasTrimmedStart = false; + } + + public class OpenAIService : ObservableObject + { + public string Name + { + get => _name; + set => SetProperty(ref _name, value); + } + + public string Server + { + get => _server; + set => SetProperty(ref _server, value); + } + + public string ApiKey + { + get => _apiKey; + set => SetProperty(ref _apiKey, value); + } + + public string Model + { + get => _model; + set => SetProperty(ref _model, value); + } + + public bool Streaming + { + get => _streaming; + set => SetProperty(ref _streaming, value); + } + + public string AnalyzeDiffPrompt + { + get => _analyzeDiffPrompt; + set => SetProperty(ref _analyzeDiffPrompt, value); + } + + public string GenerateSubjectPrompt + { + get => _generateSubjectPrompt; + set => SetProperty(ref _generateSubjectPrompt, value); + } + + public OpenAIService() + { + AnalyzeDiffPrompt = """ + You are an expert developer specialist in creating commits. + Provide a super concise one sentence overall changes summary of the user `git diff` output following strictly the next rules: + - Do not use any code snippets, imports, file routes or bullets points. + - Do not mention the route of file that has been change. + - Write clear, concise, and descriptive messages that explain the MAIN GOAL made of the changes. + - Use the present tense and active voice in the message, for example, "Fix bug" instead of "Fixed bug.". + - Use the imperative mood, which gives the message a sense of command, e.g. "Add feature" instead of "Added feature". + - Avoid using general terms like "update" or "change", be specific about what was updated or changed. + - Avoid using terms like "The main goal of", just output directly the summary in plain text + """; + + GenerateSubjectPrompt = """ + You are an expert developer specialist in creating commits messages. + Your only goal is to retrieve a single commit message. + Based on the provided user changes, combine them in ONE SINGLE commit message retrieving the global idea, following strictly the next rules: + - Assign the commit {type} according to the next conditions: + feat: Only when adding a new feature. + fix: When fixing a bug. + docs: When updating documentation. + style: When changing elements styles or design and/or making changes to the code style (formatting, missing semicolons, etc.) without changing the code logic. + test: When adding or updating tests. + chore: When making changes to the build process or auxiliary tools and libraries. + revert: When undoing a previous commit. + refactor: When restructuring code without changing its external behavior, or is any of the other refactor types. + - Do not add any issues numeration, explain your output nor introduce your answer. + - Output directly only one commit message in plain text with the next format: {type}: {commit_message}. + - Be as concise as possible, keep the message under 50 characters. + """; + } + + public void Chat(string prompt, string question, CancellationToken cancellation, Action onUpdate) + { + var server = new Uri(_server); + var key = new ApiKeyCredential(_apiKey); + var client = null as ChatClient; + if (_server.Contains("openai.azure.com/", StringComparison.Ordinal)) + { + var azure = new AzureOpenAIClient(server, key); + client = azure.GetChatClient(_model); + } + else + { + var openai = new OpenAIClient(key, new() { Endpoint = server }); + client = openai.GetChatClient(_model); + } + + var messages = new List(); + messages.Add(_model.Equals("o1-mini", StringComparison.Ordinal) ? new UserChatMessage(prompt) : new SystemChatMessage(prompt)); + messages.Add(new UserChatMessage(question)); + + try + { + var rsp = new OpenAIResponse(onUpdate); + + if (_streaming) + { + 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); + } + + rsp.End(); + } + catch + { + if (!cancellation.IsCancellationRequested) + throw; + } + } + + private string _name; + private string _server; + private string _apiKey; + private string _model; + private bool _streaming = true; + private string _analyzeDiffPrompt; + private string _generateSubjectPrompt; + } +} diff --git a/src/Models/Remote.cs b/src/Models/Remote.cs new file mode 100644 index 00000000..6e36cfb9 --- /dev/null +++ b/src/Models/Remote.cs @@ -0,0 +1,85 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; + +namespace SourceGit.Models +{ + public partial class Remote + { + [GeneratedRegex(@"^https?://[^/]+/.+[^/\.]$")] + private static partial Regex REG_HTTPS(); + [GeneratedRegex(@"^git://[^/]+/.+[^/\.]$")] + private static partial Regex REG_GIT(); + [GeneratedRegex(@"^[\w\-]+@[\w\.\-]+(\:[0-9]+)?:([a-zA-z0-9~%][\w\-\./~%]*)?[a-zA-Z0-9](\.git)?$")] + private static partial Regex REG_SSH1(); + [GeneratedRegex(@"^ssh://([\w\-]+@)?[\w\.\-]+(\:[0-9]+)?/([a-zA-z0-9~%][\w\-\./~%]*)?[a-zA-Z0-9](\.git)?$")] + private static partial Regex REG_SSH2(); + + [GeneratedRegex(@"^git@([\w\.\-]+):([\w\-/~%]+/[\w\-\.%]+)\.git$")] + private static partial Regex REG_TO_VISIT_URL_CAPTURE(); + + private static readonly Regex[] URL_FORMATS = [ + REG_HTTPS(), + REG_GIT(), + REG_SSH1(), + REG_SSH2(), + ]; + + public string Name { get; set; } + public string URL { get; set; } + + public static bool IsSSH(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return false; + + if (REG_SSH1().IsMatch(url)) + return true; + + return REG_SSH2().IsMatch(url); + } + + public static bool IsValidURL(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return false; + + foreach (var fmt in URL_FORMATS) + { + if (fmt.IsMatch(url)) + return true; + } + + return url.StartsWith("file://", StringComparison.Ordinal) || + url.StartsWith("./", StringComparison.Ordinal) || + url.StartsWith("../", StringComparison.Ordinal) || + Directory.Exists(url); + } + + public bool TryGetVisitURL(out string url) + { + url = null; + + if (URL.StartsWith("http", StringComparison.Ordinal)) + { + // Try to remove the user before host and `.git` extension. + var uri = new Uri(URL.EndsWith(".git", StringComparison.Ordinal) ? URL.Substring(0, URL.Length - 4) : URL); + if (uri.Port != 80 && uri.Port != 443) + url = $"{uri.Scheme}://{uri.Host}:{uri.Port}{uri.LocalPath}"; + else + url = $"{uri.Scheme}://{uri.Host}{uri.LocalPath}"; + + return true; + } + + var match = REG_TO_VISIT_URL_CAPTURE().Match(URL); + if (match.Success) + { + url = $"https://{match.Groups[1].Value}/{match.Groups[2].Value}"; + return true; + } + + return false; + } + } +} diff --git a/src/Models/RepositorySettings.cs b/src/Models/RepositorySettings.cs new file mode 100644 index 00000000..3a864ffe --- /dev/null +++ b/src/Models/RepositorySettings.cs @@ -0,0 +1,471 @@ +using System; +using System.Collections.Generic; +using System.Text; + +using Avalonia.Collections; + +namespace SourceGit.Models +{ + public class RepositorySettings + { + public string DefaultRemote + { + get; + set; + } = string.Empty; + + public bool EnableReflog + { + get; + set; + } = false; + + public bool EnableFirstParentInHistories + { + get; + set; + } = false; + + public bool EnableTopoOrderInHistories + { + get; + set; + } = false; + + public bool OnlyHighlightCurrentBranchInHistories + { + get; + set; + } = false; + + public BranchSortMode LocalBranchSortMode + { + get; + set; + } = BranchSortMode.Name; + + public BranchSortMode RemoteBranchSortMode + { + get; + set; + } = BranchSortMode.Name; + + public TagSortMode TagSortMode + { + get; + set; + } = TagSortMode.CreatorDate; + + public bool IncludeUntrackedInLocalChanges + { + get; + set; + } = true; + + public bool EnableForceOnFetch + { + get; + set; + } = false; + + public bool FetchWithoutTags + { + get; + set; + } = false; + + public bool PreferRebaseInsteadOfMerge + { + get; + set; + } = true; + + public bool CheckSubmodulesOnPush + { + get; + set; + } = true; + + public bool PushAllTags + { + get; + set; + } = false; + + public bool PushToRemoteWhenCreateTag + { + get; + set; + } = true; + + public bool PushToRemoteWhenDeleteTag + { + get; + set; + } = false; + + public bool CheckoutBranchOnCreateBranch + { + get; + set; + } = true; + + public bool UpdateSubmodulesOnCheckoutBranch + { + get; + set; + } = true; + + public AvaloniaList HistoriesFilters + { + get; + set; + } = []; + + public AvaloniaList CommitTemplates + { + get; + set; + } = []; + + public AvaloniaList CommitMessages + { + get; + set; + } = []; + + public AvaloniaList IssueTrackerRules + { + get; + set; + } = []; + + public AvaloniaList CustomActions + { + get; + set; + } = []; + + public bool EnableAutoFetch + { + get; + set; + } = false; + + public int AutoFetchInterval + { + get; + set; + } = 10; + + public bool EnableSignOffForCommit + { + get; + set; + } = false; + + public bool IncludeUntrackedWhenStash + { + get; + set; + } = true; + + public bool OnlyStagedWhenStash + { + get; + set; + } = false; + + public bool KeepIndexWhenStash + { + get; + set; + } = false; + + public bool AutoRestoreAfterStash + { + get; + set; + } = false; + + public string PreferredOpenAIService + { + get; + 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 ExpandedBranchNodesInSideBar + { + get; + set; + } = []; + + public int PreferredMergeMode + { + get; + set; + } = 0; + + public string LastCommitMessage + { + get; + set; + } = string.Empty; + + public Dictionary CollectHistoriesFilters() + { + var map = new Dictionary(); + foreach (var filter in HistoriesFilters) + map.Add(filter.Pattern, filter.Mode); + return map; + } + + public bool UpdateHistoriesFilter(string pattern, FilterType type, FilterMode mode) + { + // Clear all filters when there's a filter that has different mode. + if (mode != FilterMode.None) + { + var clear = false; + foreach (var filter in HistoriesFilters) + { + if (filter.Mode != mode) + { + clear = true; + break; + } + } + + if (clear) + { + HistoriesFilters.Clear(); + HistoriesFilters.Add(new Filter(pattern, type, mode)); + return true; + } + } + else + { + for (int i = 0; i < HistoriesFilters.Count; i++) + { + var filter = HistoriesFilters[i]; + if (filter.Type == type && filter.Pattern.Equals(pattern, StringComparison.Ordinal)) + { + HistoriesFilters.RemoveAt(i); + return true; + } + } + + return false; + } + + for (int i = 0; i < HistoriesFilters.Count; i++) + { + var filter = HistoriesFilters[i]; + if (filter.Type != type) + continue; + + if (filter.Pattern.Equals(pattern, StringComparison.Ordinal)) + return false; + } + + HistoriesFilters.Add(new Filter(pattern, type, mode)); + return true; + } + + public void RemoveChildrenBranchFilters(string pattern) + { + var dirty = new List(); + var prefix = $"{pattern}/"; + + foreach (var filter in HistoriesFilters) + { + if (filter.Type == FilterType.Tag) + continue; + + if (filter.Pattern.StartsWith(prefix, StringComparison.Ordinal)) + dirty.Add(filter); + } + + foreach (var filter in dirty) + HistoriesFilters.Remove(filter); + } + + public string BuildHistoriesFilter() + { + var includedRefs = new List(); + var excludedBranches = new List(); + var excludedRemotes = new List(); + var excludedTags = new List(); + foreach (var filter in HistoriesFilters) + { + if (filter.Type == FilterType.LocalBranch) + { + if (filter.Mode == FilterMode.Included) + includedRefs.Add(filter.Pattern); + else if (filter.Mode == FilterMode.Excluded) + excludedBranches.Add($"--exclude=\"{filter.Pattern.AsSpan(11)}\" --decorate-refs-exclude=\"{filter.Pattern}\""); + } + else if (filter.Type == FilterType.LocalBranchFolder) + { + if (filter.Mode == FilterMode.Included) + includedRefs.Add($"--branches={filter.Pattern.AsSpan(11)}/*"); + else if (filter.Mode == FilterMode.Excluded) + excludedBranches.Add($"--exclude=\"{filter.Pattern.AsSpan(11)}/*\" --decorate-refs-exclude=\"{filter.Pattern}/*\""); + } + else if (filter.Type == FilterType.RemoteBranch) + { + if (filter.Mode == FilterMode.Included) + includedRefs.Add(filter.Pattern); + else if (filter.Mode == FilterMode.Excluded) + excludedRemotes.Add($"--exclude=\"{filter.Pattern.AsSpan(13)}\" --decorate-refs-exclude=\"{filter.Pattern}\""); + } + else if (filter.Type == FilterType.RemoteBranchFolder) + { + if (filter.Mode == FilterMode.Included) + includedRefs.Add($"--remotes={filter.Pattern.AsSpan(13)}/*"); + else if (filter.Mode == FilterMode.Excluded) + excludedRemotes.Add($"--exclude=\"{filter.Pattern.AsSpan(13)}/*\" --decorate-refs-exclude=\"{filter.Pattern}/*\""); + } + else if (filter.Type == FilterType.Tag) + { + if (filter.Mode == FilterMode.Included) + includedRefs.Add($"refs/tags/{filter.Pattern}"); + else if (filter.Mode == FilterMode.Excluded) + excludedTags.Add($"--exclude=\"{filter.Pattern}\" --decorate-refs-exclude=\"refs/tags/{filter.Pattern}\""); + } + } + + var builder = new StringBuilder(); + if (includedRefs.Count > 0) + { + foreach (var r in includedRefs) + { + builder.Append(r); + builder.Append(' '); + } + } + else if (excludedBranches.Count + excludedRemotes.Count + excludedTags.Count > 0) + { + foreach (var b in excludedBranches) + { + builder.Append(b); + builder.Append(' '); + } + + builder.Append("--exclude=HEAD --branches "); + + foreach (var r in excludedRemotes) + { + builder.Append(r); + builder.Append(' '); + } + + builder.Append("--exclude=origin/HEAD --remotes "); + + foreach (var t in excludedTags) + { + builder.Append(t); + builder.Append(' '); + } + + builder.Append("--tags "); + } + + return builder.ToString(); + } + + public void PushCommitMessage(string message) + { + message = message.Trim().ReplaceLineEndings("\n"); + var existIdx = CommitMessages.IndexOf(message); + if (existIdx == 0) + return; + + if (existIdx > 0) + { + CommitMessages.Move(existIdx, 0); + return; + } + + if (CommitMessages.Count > 9) + CommitMessages.RemoveRange(9, CommitMessages.Count - 9); + + CommitMessages.Insert(0, message); + } + + public IssueTrackerRule AddIssueTracker(string name, string regex, string url) + { + var rule = new IssueTrackerRule() + { + Name = name, + RegexString = regex, + URLTemplate = url, + }; + + IssueTrackerRules.Add(rule); + return rule; + } + + public void RemoveIssueTracker(IssueTrackerRule rule) + { + if (rule != null) + IssueTrackerRules.Remove(rule); + } + + public CustomAction AddNewCustomAction() + { + var act = new CustomAction() { Name = "Unnamed Action" }; + CustomActions.Add(act); + return act; + } + + public void RemoveCustomAction(CustomAction act) + { + if (act != null) + CustomActions.Remove(act); + } + + public void MoveCustomActionUp(CustomAction act) + { + var idx = CustomActions.IndexOf(act); + if (idx > 0) + CustomActions.Move(idx - 1, idx); + } + + public void MoveCustomActionDown(CustomAction act) + { + var idx = CustomActions.IndexOf(act); + if (idx < CustomActions.Count - 1) + CustomActions.Move(idx + 1, idx); + } + } +} diff --git a/src/Models/ResetMode.cs b/src/Models/ResetMode.cs new file mode 100644 index 00000000..827ccaa9 --- /dev/null +++ b/src/Models/ResetMode.cs @@ -0,0 +1,31 @@ +using Avalonia.Media; + +namespace SourceGit.Models +{ + public class ResetMode + { + public static readonly ResetMode[] Supported = + [ + new ResetMode("Soft", "Keep all changes. Stage differences", "--soft", "S", Brushes.Green), + new ResetMode("Mixed", "Keep all changes. Unstage differences", "--mixed", "M", Brushes.Orange), + new ResetMode("Merge", "Reset while keeping unmerged changes", "--merge", "G", Brushes.Purple), + new ResetMode("Keep", "Reset while keeping local modifications", "--keep", "K", Brushes.Purple), + new ResetMode("Hard", "Discard all changes", "--hard", "H", Brushes.Red), + ]; + + public string Name { get; set; } + public string Desc { get; set; } + public string Arg { get; set; } + public string Key { get; set; } + public IBrush Color { get; set; } + + public ResetMode(string n, string d, string a, string k, IBrush b) + { + Name = n; + Desc = d; + Arg = a; + Key = k; + Color = b; + } + } +} diff --git a/src/Models/RevisionFile.cs b/src/Models/RevisionFile.cs new file mode 100644 index 00000000..29a23efa --- /dev/null +++ b/src/Models/RevisionFile.cs @@ -0,0 +1,43 @@ +using System.Globalization; +using System.IO; +using Avalonia.Media.Imaging; + +namespace SourceGit.Models +{ + public class RevisionBinaryFile + { + public long Size { get; set; } = 0; + } + + public class RevisionImageFile + { + public Bitmap Image { get; } + public long FileSize { get; } + public string ImageType { get; } + public string ImageSize => Image != null ? $"{Image.PixelSize.Width} x {Image.PixelSize.Height}" : "0 x 0"; + + public RevisionImageFile(string file, Bitmap img, long size) + { + Image = img; + FileSize = size; + ImageType = Path.GetExtension(file)!.Substring(1).ToUpper(CultureInfo.CurrentCulture); + } + } + + public class RevisionTextFile + { + public string FileName { get; set; } + public string Content { get; set; } + } + + public class RevisionLFSObject + { + public LFSObject Object { get; set; } + } + + public class RevisionSubmodule + { + public Commit Commit { get; set; } = null; + public CommitFullMessage FullMessage { get; set; } = null; + } +} diff --git a/src/Models/SelfUpdate.cs b/src/Models/SelfUpdate.cs new file mode 100644 index 00000000..e02f80d8 --- /dev/null +++ b/src/Models/SelfUpdate.cs @@ -0,0 +1,56 @@ +using System; +using System.Reflection; +using System.Text.Json.Serialization; + +namespace SourceGit.Models +{ + public class Version + { + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("tag_name")] + public string TagName { get; set; } + + [JsonPropertyName("body")] + public string Body { get; set; } + + public bool IsNewVersion + { + get + { + try + { + System.Version version = new System.Version(TagName.Substring(1)); + System.Version current = Assembly.GetExecutingAssembly().GetName().Version!; + return current.CompareTo(version) < 0; + } + catch + { + return false; + } + } + } + } + + 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; + } + } +} diff --git a/src/Models/ShellOrTerminal.cs b/src/Models/ShellOrTerminal.cs new file mode 100644 index 00000000..7dfb2237 --- /dev/null +++ b/src/Models/ShellOrTerminal.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; + +using Avalonia.Media.Imaging; +using Avalonia.Platform; + +namespace SourceGit.Models +{ + public class ShellOrTerminal + { + public string Type { get; set; } + public string Name { get; set; } + public string Exec { get; set; } + + public Bitmap Icon + { + get + { + var icon = AssetLoader.Open(new Uri($"avares://SourceGit/Resources/Images/ShellIcons/{Type}.png", UriKind.RelativeOrAbsolute)); + return new Bitmap(icon); + } + } + + public static readonly List
Open-source GUI client for git users