diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..56725e7b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,306 @@ +# editorconfig.org + +# top-most EditorConfig file +root = true + +# Default settings: +# A newline ending every file +# Use 4 spaces as indentation +[*] +insert_final_newline = true +indent_style = space +indent_size = 4 +dotnet_style_operator_placement_when_wrapping = beginning_of_line +tab_width = 4 +end_of_line = crlf +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_object_initializer = true:suggestion +dotnet_style_prefer_collection_expression = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:silent +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion +dotnet_style_namespace_match_folder = true:suggestion +dotnet_style_readonly_field = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion +dotnet_style_allow_multiple_blank_lines_experimental = true:silent + +# C# files +[*.cs] +# New line preferences +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true +# trim_trailing_whitespace = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = one_less_than_current + +# avoid this. unless absolutely necessary +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion + +# prefer var +csharp_style_var_for_built_in_types = true +csharp_style_var_when_type_is_apparent = true +csharp_style_var_elsewhere = true:suggestion + +# use language keywords instead of BCL types +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion + +# name all constant fields using PascalCase +dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style + +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.required_modifiers = const + +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +# private static fields should have s_ prefix +dotnet_naming_rule.private_static_fields_should_have_prefix.severity = suggestion +dotnet_naming_rule.private_static_fields_should_have_prefix.symbols = private_static_fields +dotnet_naming_rule.private_static_fields_should_have_prefix.style = private_static_prefix_style + +dotnet_naming_symbols.private_static_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_fields.required_modifiers = static +dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private + +dotnet_naming_style.private_static_prefix_style.required_prefix = s_ +dotnet_naming_style.private_static_prefix_style.capitalization = camel_case + +# internal and private fields should be _camelCase +dotnet_naming_rule.camel_case_for_private_internal_fields.severity = suggestion +dotnet_naming_rule.camel_case_for_private_internal_fields.symbols = private_internal_fields +dotnet_naming_rule.camel_case_for_private_internal_fields.style = camel_case_underscore_style + +dotnet_naming_symbols.private_internal_fields.applicable_kinds = field +dotnet_naming_symbols.private_internal_fields.applicable_accessibilities = private, internal + +dotnet_naming_style.camel_case_underscore_style.required_prefix = _ +dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case + +# use accessibility modifiers +dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion + +# Code style defaults +dotnet_sort_system_directives_first = true +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = false + +# Expression-level preferences +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion + +# Expression-bodied members +csharp_style_expression_bodied_methods = false:none +csharp_style_expression_bodied_constructors = false:none +csharp_style_expression_bodied_operators = false:none +csharp_style_expression_bodied_properties = true:none +csharp_style_expression_bodied_indexers = true:none +csharp_style_expression_bodied_accessors = true:none + +# Pattern matching +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion + +# Null checking preferences +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false +space_within_single_line_array_initializer_braces = true + +#Net Analyzer +dotnet_analyzer_diagnostic.category-Performance.severity = none #error - Uncomment when all violations are fixed. + +# CS0649: Field 'field' is never assigned to, and will always have its default value 'value' +dotnet_diagnostic.CS0649.severity = error + +# CS1591: Missing XML comment for publicly visible type or member +dotnet_diagnostic.CS1591.severity = suggestion + +# CS0162: Remove unreachable code +dotnet_diagnostic.CS0162.severity = error +# CA1018: Mark attributes with AttributeUsageAttribute +dotnet_diagnostic.CA1018.severity = error +# CA1304: Specify CultureInfo +dotnet_diagnostic.CA1304.severity = warning +# CA1802: Use literals where appropriate +dotnet_diagnostic.CA1802.severity = warning +# CA1813: Avoid unsealed attributes +dotnet_diagnostic.CA1813.severity = error +# CA1815: Override equals and operator equals on value types +dotnet_diagnostic.CA1815.severity = warning +# CA1820: Test for empty strings using string length +dotnet_diagnostic.CA1820.severity = warning +# CA1821: Remove empty finalizers +dotnet_diagnostic.CA1821.severity = warning +# CA1822: Mark members as static +dotnet_diagnostic.CA1822.severity = suggestion +# CA1823: Avoid unused private fields +dotnet_diagnostic.CA1823.severity = warning +dotnet_code_quality.CA1822.api_surface = private, internal +# CA1825: Avoid zero-length array allocations +dotnet_diagnostic.CA1825.severity = warning +# CA1826: Use property instead of Linq Enumerable method +dotnet_diagnostic.CA1826.severity = suggestion +# CA1827: Do not use Count/LongCount when Any can be used +dotnet_diagnostic.CA1827.severity = warning +# CA1828: Do not use CountAsync/LongCountAsync when AnyAsync can be used +dotnet_diagnostic.CA1828.severity = warning +# CA1829: Use Length/Count property instead of Enumerable.Count method +dotnet_diagnostic.CA1829.severity = warning +#CA1847: Use string.Contains(char) instead of string.Contains(string) with single characters +dotnet_diagnostic.CA1847.severity = warning +#CA1854: Prefer the IDictionary.TryGetValue(TKey, out TValue) method +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 + +# Avalonia DevAnalyzer preferences +dotnet_diagnostic.AVADEV2001.severity = error + +# Avalonia PublicAnalyzer preferences +dotnet_diagnostic.AVP1000.severity = error +dotnet_diagnostic.AVP1001.severity = error +dotnet_diagnostic.AVP1002.severity = error +dotnet_diagnostic.AVP1010.severity = error +dotnet_diagnostic.AVP1011.severity = error +dotnet_diagnostic.AVP1012.severity = warning +dotnet_diagnostic.AVP1013.severity = error +dotnet_diagnostic.AVP1020.severity = error +dotnet_diagnostic.AVP1021.severity = error +dotnet_diagnostic.AVP1022.severity = error +dotnet_diagnostic.AVP1030.severity = error +dotnet_diagnostic.AVP1031.severity = error +dotnet_diagnostic.AVP1032.severity = error +dotnet_diagnostic.AVP1040.severity = error +dotnet_diagnostic.AVA2001.severity = error +csharp_using_directive_placement = outside_namespace:silent +csharp_prefer_simple_using_statement = true:suggestion +csharp_prefer_braces = true:silent +csharp_style_namespace_declarations = block_scoped:silent +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_top_level_statements = true:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent +csharp_style_prefer_readonly_struct = true:suggestion +csharp_prefer_static_local_function = true:suggestion +csharp_style_prefer_readonly_struct_member = true:suggestion + +# Xaml files +[*.{xaml,axaml}] +indent_size = 2 +# DuplicateSetterError +avalonia_xaml_diagnostic.AVLN2203.severity = error +# StyleInMergedDictionaries +avalonia_xaml_diagnostic.AVLN2204.severity = error +# RequiredTemplatePartMissing +avalonia_xaml_diagnostic.AVLN2205.severity = error +# OptionalTemplatePartMissing +avalonia_xaml_diagnostic.AVLN2206.severity = info +# TemplatePartWrongType +avalonia_xaml_diagnostic.AVLN2207.severity = error +# Obsolete +avalonia_xaml_diagnostic.AVLN5001.severity = error + +# Xml project files +[*.{csproj,vcxproj,vcxproj.filters,proj,nativeproj,locproj}] +indent_size = 2 + +# Xml build files +[*.builds] +indent_size = 2 + +# Xml files +[*.{xml,stylecop,resx,ruleset}] +indent_size = 2 + +# Xml config files +[*.{props,targets,config,nuspec}] +indent_size = 2 + +[*.json] +indent_size = 2 + +# Shell scripts +[*.sh] +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 new file mode 100644 index 00000000..bd1dfea9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +* text=auto +*.md text +*.png binary +*.ico binary +*.sh text eol=lf +*.spec text eol=lf +control text eol=lf +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf +*.json text + +.gitattributes export-ignore +.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 new file mode 100644 index 00000000..50e02dc9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: Continuous Integration +on: + push: + branches: [develop] + pull_request: + branches: [develop] + workflow_dispatch: + workflow_call: +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: Checkout sources + uses: actions/checkout@v4 + - 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 f348333a..e686a534 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,41 @@ -.idea -.vs -.vscode -bin -obj -publish +.vs/ +.vscode/ +.idea/ + +*.sln.docstates *.user +*.suo +*.code-workspace + +.DS_Store +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +bin/ +obj/ +# ignore ci node files +node_modules/ +package.json +package-lock.json + +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/LICENSE b/LICENSE index e7266704..442ce085 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2021 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 ca35f786..f9ba3072 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,207 @@ -# SourceGit - -Opensouce Git GUI client for Windows. - -## High-lights - -* Opensource/Free -* Light-weight -* Fast -* English/简体中文 -* Build-in light/dark themes -* Visual commit graph -* Supports SSH access with each remote -* GIT commands with GUI - * Clone/Fetch/Pull/Push... - * Branches - * Remotes - * Tags - * Stashes - * Submodules - * Subtrees - * Archive - * Patch/apply - * File histories - * Blame - * Revision Diffs - -## Download - -Pre-build Binaries:[Releases](https://github.com/sourcegit-scm/sourcegit/releases) - -> NOTE: You need install Git first. - -## Screen Shots - -* Drak Theme - - - -* Light Theme - - - -## Thanks - -* [XiaoLinger](https://gitee.com/LingerNN) Hotkey: `CTRL + Enter` to commit -* [carterl](https://gitee.com/carterl) Supports Windows Terminal; Rewrite way to find git executable -* [PUMA](https://gitee.com/whgfu) Configure for default user -* [Rwing](https://gitee.com/rwing) GitFlow: add an option to keep branch after finish -* [XiaoLinger](https://gitee.com/LingerNN) Fix localizations in popup panel +# SourceGit - 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 +* 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 + * 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)) + +> [!WARNING] +> **Linux** only tested on **Debian 12** on both **X11** & **Wayland**. + +## Translation Status + +You can find the current translation status in [TRANSLATION.md](https://github.com/sourcegit-scm/sourcegit/blob/develop/TRANSLATION.md) + +## 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. +* 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: + +* 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 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: + +* Thanks [@aikawayataro](https://github.com/aikawayataro) for providing `rpm` and `deb` repositories, hosted on [Codeberg](https://codeberg.org/yataro/-/packages). + + `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 new file mode 100644 index 00000000..624322f8 --- /dev/null +++ b/SourceGit.sln @@ -0,0 +1,122 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.9.34714.143 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SourceGit", "src\SourceGit.csproj", "{2091C34D-4A17-4375-BEF3-4D60BE8113E4}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{773082AC-D9C8-4186-8521-4B6A7BEE6158}" +EndProject +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 + VERSION = VERSION + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "app", "app", "{ABC98884-F023-4EF4-A9C9-5DE9452BE955}" + ProjectSection(SolutionItems) = preProject + 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}") = "_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\resources\_common\usr\share\applications\sourcegit.desktop = build\resources\_common\usr\share\applications\sourcegit.desktop + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "icons", "icons", "{7166EC6C-17F5-4B5E-B38E-1E53C81EACF6}" + ProjectSection(SolutionItems) = preProject + build\resources\_common\usr\share\icons\sourcegit.png = build\resources\_common\usr\share\icons\sourcegit.png + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deb", "deb", "{9C2F0CDA-B56E-44A5-94B6-F3EA7AC20CDC}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DEBIAN", "DEBIAN", "{F101849D-BDB7-40D4-A516-751150C3CCFC}" + ProjectSection(SolutionItems) = preProject + 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 + GlobalSection(SolutionConfigurationPlatforms) = preSolution + 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(NestedProjects) = preSolution + {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..051440f0 --- /dev/null +++ b/TRANSLATION.md @@ -0,0 +1,511 @@ +# 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.Checkout.WithFastForward +- Text.Checkout.WithFastForward.Upstream +- 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 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.Checkout.WithFastForward +- Text.Checkout.WithFastForward.Upstream +- 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.Checkout.WithFastForward +- Text.Checkout.WithFastForward.Upstream +- 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.Checkout.WithFastForward +- Text.Checkout.WithFastForward.Upstream +- 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.Checkout.WithFastForward +- Text.Checkout.WithFastForward.Upstream +- 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 ru_RU.axaml + +- Text.Checkout.WithFastForward +- Text.Checkout.WithFastForward.Upstream + + + +###  + + +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.Checkout.WithFastForward +- Text.Checkout.WithFastForward.Upstream +- 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.Checkout.WithFastForward +- Text.Checkout.WithFastForward.Upstream +- 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..d3e094ba --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +2025.22 \ 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/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/App.icns b/build/resources/app/App.icns new file mode 100644 index 00000000..4dc51b20 Binary files /dev/null and b/build/resources/app/App.icns differ 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 new file mode 100644 index 00000000..a27a2b82 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "9.0.0", + "rollForward": "latestMajor", + "allowPrerelease": false + } +} \ No newline at end of file diff --git a/screenshots/theme_dark.png b/screenshots/theme_dark.png index 1a052161..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 8076f778..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..8e579373 --- /dev/null +++ b/src/App.axaml.cs @@ -0,0 +1,706 @@ +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 null; + } + + 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; + + if (Current?.FindResource(key) is StreamGeometry geo) + 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(); + if (name.Contains('#', StringComparison.Ordinal)) + { + 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/App.manifest b/src/App.manifest index 5a6db8f3..11a2ff11 100644 --- a/src/App.manifest +++ b/src/App.manifest @@ -1,14 +1,18 @@ - - - - - - - - PerMonitorV2 - true - + + + + + + + + + + diff --git a/src/App.xaml b/src/App.xaml deleted file mode 100644 index 1f48956b..00000000 --- a/src/App.xaml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - diff --git a/src/App.xaml.cs b/src/App.xaml.cs deleted file mode 100644 index 1528ae9b..00000000 --- a/src/App.xaml.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System; -using System.IO; -using System.Windows; - -namespace SourceGit { - - /// - /// 程序入口. - /// - public partial class App : Application { - - /// - /// 读取本地化字串 - /// - /// 本地化字串的Key - /// 可选格式化参数 - /// 本地化字串 - public static string Text(string key, params object[] args) { - var data = Current.FindResource($"Text.{key}") as string; - if (string.IsNullOrEmpty(data)) return $"Text.{key}"; - return string.Format(data, args); - } - - /// - /// 启动. - /// - /// - /// - protected override void OnStartup(StartupEventArgs e) { - base.OnStartup(e); - - // 崩溃上报 - AppDomain.CurrentDomain.UnhandledException += (_, ev) => Models.Issue.Create(ev.ExceptionObject as Exception); - - // 创建必要目录 - if (!Directory.Exists(Views.Controls.Avatar.CACHE_PATH)) { - Directory.CreateDirectory(Views.Controls.Avatar.CACHE_PATH); - } - - Models.Theme.Change(); - Models.Locale.Change(); - - // 如果启动命令中指定了路径,打开指定目录的仓库 - var launcher = new Views.Launcher(); - if (Models.Preference.Instance.IsReady) { - if (e.Args.Length > 0) { - var repo = Models.Preference.Instance.FindRepository(e.Args[0]); - if (repo == null) { - var path = new Commands.GetRepositoryRootPath(e.Args[0]).Result(); - if (path != null) { - var gitDir = new Commands.QueryGitDir(path).Result(); - repo = Models.Preference.Instance.AddRepository(path, gitDir); - } - } - - if (repo != null) Models.Watcher.Open(repo); - } else if (Models.Preference.Instance.Restore.IsEnabled) { - var restore = Models.Preference.Instance.Restore; - var actived = null as Models.Repository; - if (restore.Opened.Count > 0) { - foreach (var path in restore.Opened) { - if (!Directory.Exists(path)) continue; - var repo = Models.Preference.Instance.FindRepository(path); - if (repo != null) Models.Watcher.Open(repo); - if (path == restore.Actived) actived = repo; - } - - if (actived != null) Models.Watcher.Open(actived); - } - } - } - - // 主界面显示 - MainWindow = launcher; - MainWindow.Show(); - } - - /// - /// 后台运行 - /// - /// - /// - protected override void OnDeactivated(EventArgs e) { - base.OnDeactivated(e); - GC.Collect(); - Models.Preference.Save(); - } - } -} diff --git a/src/Commands/Add.cs b/src/Commands/Add.cs index fb98cf87..210eb4b2 100644 --- a/src/Commands/Add.cs +++ b/src/Commands/Add.cs @@ -1,27 +1,26 @@ -using System.Collections.Generic; -using System.Text; - -namespace SourceGit.Commands { - /// - /// `git add`命令 - /// - public class Add : Command { - public Add(string repo) { - Cwd = repo; - Args = "add ."; +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, List paths) { - StringBuilder builder = new StringBuilder(); - builder.Append("add --"); - foreach (var p in paths) { - builder.Append(" \""); - builder.Append(p); - builder.Append("\""); - } + public Add(string repo, Models.Change change) + { + WorkingDirectory = repo; + Context = repo; + Args = $"add -- \"{change.Path}\""; + } - Cwd = repo; - Args = builder.ToString(); + public Add(string repo, string pathspecFromFile) + { + WorkingDirectory = repo; + Context = repo; + Args = $"add --pathspec-from-file=\"{pathspecFromFile}\""; } } } diff --git a/src/Commands/Apply.cs b/src/Commands/Apply.cs index f62f113c..d1c9ffbc 100644 --- a/src/Commands/Apply.cs +++ b/src/Commands/Apply.cs @@ -1,14 +1,18 @@ -namespace SourceGit.Commands { - /// - /// 应用Patch - /// - public class Apply : Command { - - public Apply(string repo, string file, bool ignoreWhitespace, string whitespaceMode) { - Cwd = repo; +namespace SourceGit.Commands +{ + public class Apply : Command + { + public Apply(string repo, string file, bool ignoreWhitespace, string whitespaceMode, string extra) + { + WorkingDirectory = repo; + Context = repo; Args = "apply "; - if (ignoreWhitespace) Args += "--ignore-whitespace "; - else Args += $"--whitespace={whitespaceMode} "; + if (ignoreWhitespace) + Args += "--ignore-whitespace "; + else + Args += $"--whitespace={whitespaceMode} "; + if (!string.IsNullOrEmpty(extra)) + Args += $"{extra} "; Args += $"\"{file}\""; } } diff --git a/src/Commands/Archive.cs b/src/Commands/Archive.cs index b4153e34..5e0919f7 100644 --- a/src/Commands/Archive.cs +++ b/src/Commands/Archive.cs @@ -1,22 +1,12 @@ -using System; - -namespace SourceGit.Commands { - - /// - /// 存档命令 - /// - public class Archive : Command { - private Action handler; - - public Archive(string repo, string revision, string to, Action onProgress) { - Cwd = repo; - Args = $"archive --format=zip --verbose --output=\"{to}\" {revision}"; - TraitErrorAsOutput = true; - handler = onProgress; - } - - public override void OnReadline(string line) { - handler?.Invoke(line); +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 index 9a0af3d9..28f78280 100644 --- a/src/Commands/AssumeUnchanged.cs +++ b/src/Commands/AssumeUnchanged.cs @@ -1,60 +1,14 @@ -using System.Collections.Generic; -using System.Text.RegularExpressions; +namespace SourceGit.Commands +{ + public class AssumeUnchanged : Command + { + public AssumeUnchanged(string repo, string file, bool bAdd) + { + var mode = bAdd ? "--assume-unchanged" : "--no-assume-unchanged"; -namespace SourceGit.Commands { - /// - /// 查看、添加或移除忽略变更文件 - /// - public class AssumeUnchanged { - private string repo; - - class ViewCommand : Command { - private static readonly Regex REG = new Regex(@"^(\w)\s+(.+)$"); - private List outs = new List(); - - public ViewCommand(string repo) { - Cwd = repo; - Args = "ls-files -v"; - } - - public List Result() { - Exec(); - return outs; - } - - public override void OnReadline(string line) { - var match = REG.Match(line); - if (!match.Success) return; - - if (match.Groups[1].Value == "h") { - outs.Add(match.Groups[2].Value); - } - } - } - - class ModCommand : Command { - public ModCommand(string repo, string file, bool bAdd) { - var mode = bAdd ? "--assume-unchanged" : "--no-assume-unchanged"; - - Cwd = repo; - Args = $"update-index {mode} -- \"{file}\""; - } - } - - public AssumeUnchanged(string repo) { - this.repo = repo; - } - - public List View() { - return new ViewCommand(repo).Result(); - } - - public void Add(string file) { - new ModCommand(repo, file, true).Exec(); - } - - public void Remove(string file) { - new ModCommand(repo, file, false).Exec(); + 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/Commands/Blame.cs b/src/Commands/Blame.cs index 571ff17e..1fc51fa4 100644 --- a/src/Commands/Blame.cs +++ b/src/Commands/Blame.cs @@ -1,77 +1,97 @@ -using System; -using System.Collections.Generic; +using System; +using System.Text; using System.Text.RegularExpressions; -namespace SourceGit.Commands { - /// - /// 逐行追溯 - /// - public class Blame : Command { - private static readonly Regex REG_FORMAT = new Regex(@"^\^?([0-9a-f]+)\s+.*\((.*)\s+(\d+)\s+[\-\+]?\d+\s+\d+\) (.*)"); - private static readonly DateTime UTC_START = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).ToLocalTime(); +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 Data data = new Data(); - private bool needUnifyCommitSHA = false; - private int minSHALen = 0; - - public class Data { - public List Lines = new List(); - public bool IsBinary = false; - } - - public Blame(string repo, string file, string revision) { - Cwd = repo; + public Blame(string repo, string file, string revision) + { + WorkingDirectory = repo; + Context = repo; Args = $"blame -t {revision} -- \"{file}\""; + RaiseError = false; + + _result.File = file; } - public Data Result() { - Exec(); + public Models.BlameData Result() + { + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return _result; - if (needUnifyCommitSHA) { - foreach (var line in data.Lines) { - if (line.CommitSHA.Length > minSHALen) { - line.CommitSHA = line.CommitSHA.Substring(0, minSHALen); + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + ParseLine(line); + + if (_result.IsBinary) + break; + } + + if (_needUnifyCommitSHA) + { + foreach (var line in _result.LineInfos) + { + if (line.CommitSHA.Length > _minSHALen) + { + line.CommitSHA = line.CommitSHA.Substring(0, _minSHALen); } } } - return data; + _result.Content = _content.ToString(); + return _result; } - public override void OnReadline(string line) { - if (data.IsBinary) return; - if (string.IsNullOrEmpty(line)) return; - - if (line.IndexOf('\0') >= 0) { - data.IsBinary = true; - data.Lines.Clear(); + private void ParseLine(string line) + { + if (line.Contains('\0', StringComparison.Ordinal)) + { + _result.IsBinary = true; + _result.LineInfos.Clear(); return; } - var match = REG_FORMAT.Match(line); - if (!match.Success) return; + var match = REG_FORMAT().Match(line); + if (!match.Success) + return; + + _content.AppendLine(match.Groups[4].Value); var commit = match.Groups[1].Value; var author = match.Groups[2].Value; var timestamp = int.Parse(match.Groups[3].Value); - var content = match.Groups[4].Value; - var when = UTC_START.AddSeconds(timestamp).ToString("yyyy/MM/dd"); + var when = DateTime.UnixEpoch.AddSeconds(timestamp).ToLocalTime().ToString(_dateFormat); - var blameLine = new Models.BlameLine() { - LineNumber = $"{data.Lines.Count + 1}", + var info = new Models.BlameLineInfo() + { + IsFirstInGroup = commit != _lastSHA, CommitSHA = commit, Author = author, Time = when, - Content = content, }; - if (line[0] == '^') { - needUnifyCommitSHA = true; - if (minSHALen == 0) minSHALen = commit.Length; - else if (commit.Length < minSHALen) minSHALen = commit.Length; - } + _result.LineInfos.Add(info); + _lastSHA = commit; - data.Lines.Add(blameLine); + if (line[0] == '^') + { + _needUnifyCommitSHA = true; + _minSHALen = Math.Min(_minSHALen, commit.Length); + } } + + 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 index 387892ba..0d1b1f8f 100644 --- a/src/Commands/Branch.cs +++ b/src/Commands/Branch.cs @@ -1,38 +1,83 @@ -namespace SourceGit.Commands { - /// - /// 分支相关操作 - /// - class Branch : Command { - private string target = null; +using System.Text; - public Branch(string repo, string branch) { - Cwd = repo; - target = branch; +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 void Create(string basedOn) { - Args = $"branch {target} {basedOn}"; - Exec(); + 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 void Rename(string to) { - Args = $"branch -M {target} {to}"; - 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 void SetUpstream(string upstream) { - Args = $"branch {target} "; - if (string.IsNullOrEmpty(upstream)) { - Args += "--unset-upstream"; - } else { - Args += $"-u {upstream}"; - } - 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 void Delete() { - Args = $"branch -D {target}"; - 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/Branches.cs b/src/Commands/Branches.cs deleted file mode 100644 index 88e767b3..00000000 --- a/src/Commands/Branches.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.RegularExpressions; - -namespace SourceGit.Commands { - /// - /// 解析所有的分支 - /// - public class Branches : Command { - private static readonly string PREFIX_LOCAL = "refs/heads/"; - private static readonly string PREFIX_REMOTE = "refs/remotes/"; - private static readonly string CMD = "branch -l --all -v --format=\"$%(refname)$%(objectname)$%(HEAD)$%(upstream)$%(upstream:track)$%(contents:subject)\""; - private static readonly Regex REG_FORMAT = new Regex(@"\$(.*)\$(.*)\$([\* ])\$(.*)\$(.*?)\$(.*)"); - private static readonly Regex REG_AHEAD = new Regex(@"ahead (\d+)"); - private static readonly Regex REG_BEHIND = new Regex(@"behind (\d+)"); - - private List loaded = new List(); - - public Branches(string path) { - Cwd = path; - Args = CMD; - } - - public List Result() { - Exec(); - return loaded; - } - - public override void OnReadline(string line) { - var match = REG_FORMAT.Match(line); - if (!match.Success) return; - - var branch = new Models.Branch(); - var refName = match.Groups[1].Value; - if (refName.EndsWith("/HEAD")) return; - - 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('/'); - if (shortNameIdx < 0) return; - - 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.Head = match.Groups[2].Value; - branch.IsCurrent = match.Groups[3].Value == "*"; - branch.Upstream = match.Groups[4].Value; - branch.UpstreamTrackStatus = ParseTrackStatus(match.Groups[5].Value); - branch.HeadSubject = match.Groups[6].Value; - - loaded.Add(branch); - } - - private string ParseTrackStatus(string data) { - if (string.IsNullOrEmpty(data)) return string.Empty; - - string track = string.Empty; - - var ahead = REG_AHEAD.Match(data); - if (ahead.Success) { - track += ahead.Groups[1].Value + "↑ "; - } - - var behind = REG_BEHIND.Match(data); - if (behind.Success) { - track += behind.Groups[1].Value + "↓"; - } - - return track.Trim(); - } - } -} diff --git a/src/Commands/Checkout.cs b/src/Commands/Checkout.cs index 1f169992..d2876740 100644 --- a/src/Commands/Checkout.cs +++ b/src/Commands/Checkout.cs @@ -1,51 +1,56 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Text; -namespace SourceGit.Commands { - /// - /// 检出 - /// - public class Checkout : Command { - private Action handler = null; - - public Checkout(string repo) { - Cwd = repo; +namespace SourceGit.Commands +{ + public class Checkout : Command + { + public Checkout(string repo) + { + WorkingDirectory = repo; + Context = repo; } - public bool Branch(string branch, Action onProgress) { - Args = $"checkout --progress {branch}"; - TraitErrorAsOutput = true; - handler = onProgress; + 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, Action onProgress) { - Args = $"checkout --progress -b {branch} {basedOn}"; - TraitErrorAsOutput = true; - handler = onProgress; + 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 File(string file, bool useTheirs) { - if (useTheirs) { - Args = $"checkout --theirs -- \"{file}\""; - } else { - Args = $"checkout --ours -- \"{file}\""; - } - + public bool Commit(string commitId, bool force) + { + var option = force ? "--force" : string.Empty; + Args = $"checkout {option} --detach --progress {commitId}"; return Exec(); } - public bool FileWithRevision(string file, string revision) { - Args = $"checkout {revision} -- \"{file}\""; - return Exec(); - } - - public bool Files(List files) { - StringBuilder builder = new StringBuilder(); - builder.Append("checkout -f -q --"); - foreach (var f in files) { + 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("\""); @@ -54,8 +59,24 @@ namespace SourceGit.Commands { return Exec(); } - public override void OnReadline(string line) { - handler?.Invoke(line); + 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 index ca939e76..0c82b9fd 100644 --- a/src/Commands/CherryPick.cs +++ b/src/Commands/CherryPick.cs @@ -1,13 +1,20 @@ -namespace SourceGit.Commands { - /// - /// 遴选命令 - /// - public class CherryPick : Command { +namespace SourceGit.Commands +{ + public class CherryPick : Command + { + public CherryPick(string repo, string commits, bool noCommit, bool appendSourceToMessage, string extraParams) + { + WorkingDirectory = repo; + Context = repo; - public CherryPick(string repo, string commit, bool noCommit) { - var mode = noCommit ? "-n" : "--ff"; - Cwd = repo; - Args = $"cherry-pick {mode} {commit}"; + 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 index 38a9a477..6ed74999 100644 --- a/src/Commands/Clean.cs +++ b/src/Commands/Clean.cs @@ -1,28 +1,12 @@ -using System.Collections.Generic; -using System.Text; - -namespace SourceGit.Commands { - /// - /// 清理指令 - /// - public class Clean : Command { - - public Clean(string repo) { - Cwd = repo; - Args = "clean -qfd"; - } - - public Clean(string repo, List files) { - StringBuilder builder = new StringBuilder(); - builder.Append("clean -qfd --"); - foreach (var f in files) { - builder.Append(" \""); - builder.Append(f); - builder.Append("\""); - } - - Cwd = repo; - Args = builder.ToString(); +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 index fefc0f3e..efec264b 100644 --- a/src/Commands/Clone.cs +++ b/src/Commands/Clone.cs @@ -1,39 +1,21 @@ -using System; +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 "; -namespace SourceGit.Commands { + if (!string.IsNullOrEmpty(extraArgs)) + Args += $"{extraArgs} "; - /// - /// 克隆 - /// - public class Clone : Command { - private Action handler = null; - private Action onError = null; - - public Clone(string path, string url, string localName, string sshKey, string extraArgs, Action outputHandler, Action errHandler) { - Cwd = path; - TraitErrorAsOutput = true; - handler = outputHandler; - onError = errHandler; - - if (string.IsNullOrEmpty(sshKey)) { - Args = $"-c core.sshCommand=\"ssh -i '{sshKey}'\" "; - } else { - Args = "-c credential.helper=manager "; - } - - Args += "clone --progress --verbose --recurse-submodules "; - - if (!string.IsNullOrEmpty(extraArgs)) Args += $"{extraArgs} "; Args += $"{url} "; - if (!string.IsNullOrEmpty(localName)) Args += localName; - } - public override void OnReadline(string line) { - handler?.Invoke(line); - } - - public override void OnException(string message) { - onError?.Invoke(message); + if (!string.IsNullOrEmpty(localName)) + Args += localName; } } } diff --git a/src/Commands/Command.cs b/src/Commands/Command.cs index 3c22903b..975922fc 100644 --- a/src/Commands/Command.cs +++ b/src/Commands/Command.cs @@ -1,180 +1,220 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Text; -using System.Text.RegularExpressions; - -namespace SourceGit.Commands { - - /// - /// 用于取消命令执行的上下文对象 - /// - public class Context { - public bool IsCancelRequested { get; set; } = false; - } - - /// - /// 命令接口 - /// - public class Command { - - /// - /// 读取全部输出时的结果 - /// - public class ReadToEndResult { - public bool IsSuccess { get; set; } - public string Output { get; set; } - public string Error { get; set; } - } - - /// - /// 上下文 - /// - public Context Ctx { get; set; } = null; - - /// - /// 运行路径 - /// - public string Cwd { get; set; } = ""; - - /// - /// 参数 - /// - public string Args { get; set; } = ""; - - /// - /// 是否忽略错误 - /// - public bool DontRaiseError { get; set; } = false; - - /// - /// 使用标准错误输出 - /// - public bool TraitErrorAsOutput { get; set; } = false; - - /// - /// 运行 - /// - public bool Exec() { - var start = new ProcessStartInfo(); - start.FileName = Models.Preference.Instance.Git.Path; - start.Arguments = "--no-pager -c core.quotepath=off " + Args; - start.UseShellExecute = false; - start.CreateNoWindow = true; - start.RedirectStandardOutput = true; - start.RedirectStandardError = true; - start.StandardOutputEncoding = Encoding.UTF8; - start.StandardErrorEncoding = Encoding.UTF8; - - if (!string.IsNullOrEmpty(Cwd)) start.WorkingDirectory = Cwd; - - var progressFilter = new Regex(@"\s\d+%\s"); - var errs = new List(); - var proc = new Process() { StartInfo = start }; - var isCancelled = false; - - proc.OutputDataReceived += (o, e) => { - if (Ctx != null && Ctx.IsCancelRequested) { - isCancelled = true; - proc.CancelErrorRead(); - proc.CancelOutputRead(); - if (!proc.HasExited) proc.Kill(); - return; - } - - if (e.Data == null) return; - OnReadline(e.Data); - }; - proc.ErrorDataReceived += (o, e) => { - if (Ctx != null && Ctx.IsCancelRequested) { - isCancelled = true; - proc.CancelErrorRead(); - proc.CancelOutputRead(); - if (!proc.HasExited) proc.Kill(); - return; - } - - if (string.IsNullOrEmpty(e.Data)) return; - if (TraitErrorAsOutput) OnReadline(e.Data); - - if (progressFilter.IsMatch(e.Data)) return; - if (e.Data.StartsWith("remote: Counting objects:", StringComparison.Ordinal)) return; - errs.Add(e.Data); - }; - - try { - proc.Start(); - } catch (Exception e) { - if (!DontRaiseError) OnException(e.Message); - return false; - } - - proc.BeginOutputReadLine(); - proc.BeginErrorReadLine(); - proc.WaitForExit(); - - int exitCode = proc.ExitCode; - proc.Close(); - - if (!isCancelled && exitCode != 0 && errs.Count > 0) { - if (!DontRaiseError) OnException(string.Join("\n", errs)); - return false; - } else { - return true; - } - } - - /// - /// 直接读取全部标准输出 - /// - public ReadToEndResult ReadToEnd() { - var start = new ProcessStartInfo(); - start.FileName = Models.Preference.Instance.Git.Path; - start.Arguments = "--no-pager -c core.quotepath=off " + Args; - start.UseShellExecute = false; - start.CreateNoWindow = true; - start.RedirectStandardOutput = true; - start.RedirectStandardError = true; - start.StandardOutputEncoding = Encoding.UTF8; - start.StandardErrorEncoding = Encoding.UTF8; - - if (!string.IsNullOrEmpty(Cwd)) start.WorkingDirectory = Cwd; - - var proc = new Process() { StartInfo = start }; - try { - proc.Start(); - } catch (Exception e) { - return new ReadToEndResult() { - Output = string.Empty, - Error = e.Message, - IsSuccess = false, - }; - } - - var rs = new ReadToEndResult(); - rs.Output = proc.StandardOutput.ReadToEnd(); - rs.Error = proc.StandardError.ReadToEnd(); - - proc.WaitForExit(); - rs.IsSuccess = proc.ExitCode == 0; - proc.Close(); - - return rs; - } - - /// - /// 调用Exec时的读取函数 - /// - /// - public virtual void OnReadline(string line) { - } - - /// - /// 默认异常处理函数 - /// - /// - public virtual void OnException(string message) { - Models.Exception.Raise(message); - } - } -} +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 ??= 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 index a3dde9a9..1585e7e3 100644 --- a/src/Commands/Commit.cs +++ b/src/Commands/Commit.cs @@ -1,17 +1,39 @@ -using System.IO; +using System.IO; -namespace SourceGit.Commands { - /// - /// `git commit`命令 - /// - public class Commit : Command { - public Commit(string repo, string message, bool amend) { - var file = Path.GetTempFileName(); - File.WriteAllText(file, message); +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); - Cwd = repo; - Args = $"commit --file=\"{file}\""; - if (amend) Args += " --amend --no-edit"; + 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 readonly string _tmpFile; } } diff --git a/src/Commands/CommitChanges.cs b/src/Commands/CommitChanges.cs deleted file mode 100644 index defcbff2..00000000 --- a/src/Commands/CommitChanges.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Collections.Generic; -using System.Text.RegularExpressions; - -namespace SourceGit.Commands { - - /// - /// 取得一个提交的变更列表 - /// - public class CommitChanges : Command { - private static readonly Regex REG_FORMAT = new Regex(@"^(\s?[\w\?]{1,4})\s+(.+)$"); - private List changes = new List(); - - public CommitChanges(string cwd, string commit) { - Cwd = cwd; - Args = $"show --name-status {commit}"; - } - - public List Result() { - Exec(); - return changes; - } - - public override void OnReadline(string line) { - var match = REG_FORMAT.Match(line); - if (!match.Success) return; - - var change = new Models.Change() { Path = match.Groups[2].Value }; - var status = match.Groups[1].Value; - - switch (status[0]) { - case 'M': change.Set(Models.Change.Status.Modified); changes.Add(change); break; - case 'A': change.Set(Models.Change.Status.Added); changes.Add(change); break; - case 'D': change.Set(Models.Change.Status.Deleted); changes.Add(change); break; - case 'R': change.Set(Models.Change.Status.Renamed); changes.Add(change); break; - case 'C': change.Set(Models.Change.Status.Copied); changes.Add(change); break; - } - } - } -} diff --git a/src/Commands/CommitRangeChanges.cs b/src/Commands/CommitRangeChanges.cs deleted file mode 100644 index 05cc778e..00000000 --- a/src/Commands/CommitRangeChanges.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Collections.Generic; -using System.Text.RegularExpressions; - -namespace SourceGit.Commands { - - /// - /// 对比两个提交间的变更 - /// - public class CommitRangeChanges : Command { - private static readonly Regex REG_FORMAT = new Regex(@"^(\s?[\w\?]{1,4})\s+(.+)$"); - private List changes = new List(); - - public CommitRangeChanges(string cwd, string start, string end) { - Cwd = cwd; - Args = $"diff --name-status {start} {end}"; - } - - public List Result() { - Exec(); - return changes; - } - - public override void OnReadline(string line) { - var match = REG_FORMAT.Match(line); - if (!match.Success) return; - - var change = new Models.Change() { Path = match.Groups[2].Value }; - var status = match.Groups[1].Value; - - switch (status[0]) { - case 'M': change.Set(Models.Change.Status.Modified); changes.Add(change); break; - case 'A': change.Set(Models.Change.Status.Added); changes.Add(change); break; - case 'D': change.Set(Models.Change.Status.Deleted); changes.Add(change); break; - case 'R': change.Set(Models.Change.Status.Renamed); changes.Add(change); break; - case 'C': change.Set(Models.Change.Status.Copied); changes.Add(change); break; - } - } - } -} diff --git a/src/Commands/Commits.cs b/src/Commands/Commits.cs deleted file mode 100644 index b1b4c28f..00000000 --- a/src/Commands/Commits.cs +++ /dev/null @@ -1,157 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace SourceGit.Commands { - - /// - /// 取得提交列表 - /// - public class Commits : Command { - private static readonly string GPGSIG_START = "gpgsig -----BEGIN PGP SIGNATURE-----"; - private static readonly string GPGSIG_END = " -----END PGP SIGNATURE-----"; - - private List commits = new List(); - private Models.Commit current = null; - private bool isSkipingGpgsig = false; - private bool isHeadFounded = false; - private bool findFirstMerged = true; - - public Commits(string path, string limits, bool needFindHead = true) { - Cwd = path; - Args = "log --date-order --decorate=full --pretty=raw " + limits; - findFirstMerged = needFindHead; - } - - public List Result() { - Exec(); - - if (current != null) { - current.Message = current.Message.Trim(); - commits.Add(current); - } - - if (findFirstMerged && !isHeadFounded && commits.Count > 0) { - MarkFirstMerged(); - } - - return commits; - } - - public override void OnReadline(string line) { - if (isSkipingGpgsig) { - if (line.StartsWith(GPGSIG_END, StringComparison.Ordinal)) isSkipingGpgsig = false; - return; - } else if (line.StartsWith(GPGSIG_START, StringComparison.Ordinal)) { - isSkipingGpgsig = true; - return; - } - - if (line.StartsWith("commit ", StringComparison.Ordinal)) { - if (current != null) { - current.Message = current.Message.Trim(); - commits.Add(current); - } - - current = new Models.Commit(); - line = line.Substring(7); - - var decoratorStart = line.IndexOf('('); - if (decoratorStart < 0) { - current.SHA = line.Trim(); - } else { - current.SHA = line.Substring(0, decoratorStart).Trim(); - current.IsMerged = ParseDecorators(current.Decorators, line.Substring(decoratorStart + 1)); - if (!isHeadFounded) isHeadFounded = current.IsMerged; - } - - return; - } - - if (current == null) return; - - if (line.StartsWith("tree ", StringComparison.Ordinal)) { - return; - } else if (line.StartsWith("parent ", StringComparison.Ordinal)) { - current.Parents.Add(line.Substring("parent ".Length)); - } else if (line.StartsWith("author ", StringComparison.Ordinal)) { - Models.User user = Models.User.Invalid; - ulong time = 0; - Models.Commit.ParseUserAndTime(line, ref user, ref time); - current.Author = user; - current.AuthorTime = time; - } else if (line.StartsWith("committer ", StringComparison.Ordinal)) { - Models.User user = Models.User.Invalid; - ulong time = 0; - Models.Commit.ParseUserAndTime(line, ref user, ref time); - current.Committer = user; - current.CommitterTime = time; - } else if (string.IsNullOrEmpty(current.Subject)) { - current.Subject = line.Trim(); - } else { - current.Message += (line.Trim() + "\n"); - } - } - - private bool ParseDecorators(List decorators, string data) { - bool isHeadOfCurrent = false; - - var subs = data.Split(new char[] { ',', ')', '(' }, StringSplitOptions.RemoveEmptyEntries); - foreach (var sub in subs) { - var d = sub.Trim(); - if (d.StartsWith("tag: refs/tags/", StringComparison.Ordinal)) { - decorators.Add(new Models.Decorator() { - Type = Models.DecoratorType.Tag, - Name = d.Substring(15).Trim(), - }); - } else if (d.EndsWith("/HEAD", StringComparison.Ordinal)) { - continue; - } else if (d.StartsWith("HEAD -> refs/heads/", StringComparison.Ordinal)) { - isHeadOfCurrent = true; - decorators.Add(new Models.Decorator() { - Type = Models.DecoratorType.CurrentBranchHead, - Name = d.Substring(19).Trim(), - }); - } else if (d.StartsWith("refs/heads/", StringComparison.Ordinal)) { - decorators.Add(new Models.Decorator() { - Type = Models.DecoratorType.LocalBranchHead, - Name = d.Substring(11).Trim(), - }); - } else if (d.StartsWith("refs/remotes/", StringComparison.Ordinal)) { - decorators.Add(new Models.Decorator() { - Type = Models.DecoratorType.RemoteBranchHead, - Name = d.Substring(13).Trim(), - }); - } - } - - decorators.Sort((l, r) => { - if (l.Type != r.Type) { - return (int)l.Type - (int)r.Type; - } else { - return l.Name.CompareTo(r.Name); - } - }); - - return isHeadOfCurrent; - } - - private void MarkFirstMerged() { - Args = $"log --since=\"{commits.Last().CommitterTimeStr}\" --format=\"%H\""; - - var rs = ReadToEnd(); - var shas = rs.Output.Split(new char[] { '\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; - } - } - } - } -} diff --git a/src/Commands/CompareRevisions.cs b/src/Commands/CompareRevisions.cs new file mode 100644 index 00000000..c88e087a --- /dev/null +++ b/src/Commands/CompareRevisions.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace SourceGit.Commands +{ + public partial class CompareRevisions : Command + { + [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; + + 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() + { + 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) => Models.NumericSort.Compare(l.Path, r.Path)); + return _changes; + } + + 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; + + switch (status[0]) + { + case 'M': + change.Set(Models.ChangeState.Modified); + _changes.Add(change); + break; + case 'A': + change.Set(Models.ChangeState.Added); + _changes.Add(change); + break; + case 'D': + change.Set(Models.ChangeState.Deleted); + _changes.Add(change); + break; + case 'C': + change.Set(Models.ChangeState.Copied); + _changes.Add(change); + break; + } + } + + private readonly List _changes = new List(); + } +} diff --git a/src/Commands/Config.cs b/src/Commands/Config.cs index f77216dd..49e8fcb7 100644 --- a/src/Commands/Config.cs +++ b/src/Commands/Config.cs @@ -1,36 +1,68 @@ -namespace SourceGit.Commands { - /// - /// config命令 - /// - public class Config : Command { +using System; +using System.Collections.Generic; - public Config() { } +namespace SourceGit.Commands +{ + public class Config : Command + { + public Config(string repository) + { + if (string.IsNullOrEmpty(repository)) + { + WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + } + else + { + WorkingDirectory = repository; + Context = repository; + _isLocal = true; + } - public Config(string repo) { - Cwd = repo; + RaiseError = false; } - public string Get(string key) { - Args = $"config {key}"; - return ReadToEnd().Output.Trim(); - } + public Dictionary ListAll() + { + Args = "config -l"; - public bool Set(string key, string val, bool allowEmpty = false) { - if (!allowEmpty && string.IsNullOrEmpty(val)) { - if (string.IsNullOrEmpty(Cwd)) { - Args = $"config --global --unset {key}"; - } else { - Args = $"config --unset {key}"; - } - } else { - if (string.IsNullOrEmpty(Cwd)) { - Args = $"config --global {key} \"{val}\""; - } else { - Args = $"config {key} \"{val}\""; + var output = ReadToEnd(); + var rs = new Dictionary(); + if (output.IsSuccess) + { + var lines = output.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var idx = line.IndexOf('=', StringComparison.Ordinal); + if (idx != -1) + { + var key = line.Substring(0, idx).Trim(); + var val = line.Substring(idx + 1).Trim(); + rs[key] = val; + } } } + return rs; + } + + public string Get(string key) + { + Args = $"config {key}"; + return ReadToEnd().StdOut.Trim(); + } + + public bool Set(string key, string value, bool allowEmpty = false) + { + var scope = _isLocal ? "--local" : "--global"; + + if (!allowEmpty && string.IsNullOrWhiteSpace(value)) + Args = $"config {scope} --unset {key}"; + else + 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/Commands/Diff.cs b/src/Commands/Diff.cs index 4e4e641a..6af0a3cc 100644 --- a/src/Commands/Diff.cs +++ b/src/Commands/Diff.cs @@ -1,111 +1,281 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Text.RegularExpressions; -namespace SourceGit.Commands { - /// - /// Diff命令(用于文件文件比对) - /// - public class Diff : Command { - private static readonly Regex REG_INDICATOR = new Regex(@"^@@ \-(\d+),?\d* \+(\d+),?\d* @@"); - private Models.TextChanges changes = new Models.TextChanges(); - private List deleted = new List(); - private List added = new List(); - private int oldLine = 0; - private int newLine = 0; - private int lineIndex = 0; +namespace SourceGit.Commands +{ + public partial class Diff : Command + { + [GeneratedRegex(@"^@@ \-(\d+),?\d* \+(\d+),?\d* @@")] + private static partial Regex REG_INDICATOR(); - public Diff(string repo, string args) { - Cwd = repo; - Args = $"diff --ignore-cr-at-eol --unified=4 {args}"; + [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, int unified, bool ignoreWhitespace) + { + _result.TextDiff = new Models.TextDiff() + { + Repo = repo, + Option = opt, + }; + + WorkingDirectory = repo; + Context = repo; + + 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.TextChanges Result() { - Exec(); - ProcessChanges(); - if (changes.IsBinary) changes.Lines.Clear(); - lineIndex = 0; - return changes; + public Models.DiffResult Result() + { + 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); + + 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(); + _result.TextDiff.MaxLineNumber = Math.Max(_newLine, _oldLine); + } + + return _result; } - public override void OnReadline(string line) { - if (changes.IsBinary) return; + private void ParseLine(string line) + { + if (_result.IsBinary) + return; - if (changes.Lines.Count == 0) { - var match = REG_INDICATOR.Match(line); - if (!match.Success) { - if (line.StartsWith("Binary", StringComparison.Ordinal)) changes.IsBinary = true; + 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]; + if (ch == '-') + { + if (line.StartsWith("-oid sha256:", StringComparison.Ordinal)) + { + _result.LFSDiff.Old.Oid = line.Substring(12); + } + else if (line.StartsWith("-size ", StringComparison.Ordinal)) + { + _result.LFSDiff.Old.Size = long.Parse(line.AsSpan(6)); + } + } + else if (ch == '+') + { + if (line.StartsWith("+oid sha256:", StringComparison.Ordinal)) + { + _result.LFSDiff.New.Oid = line.Substring(12); + } + else if (line.StartsWith("+size ", StringComparison.Ordinal)) + { + _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.AsSpan(6)); + } + return; + } + + if (_result.TextDiff.Lines.Count == 0) + { + if (line.StartsWith("Binary", StringComparison.Ordinal)) + { + _result.IsBinary = true; return; } - oldLine = int.Parse(match.Groups[1].Value); - newLine = int.Parse(match.Groups[2].Value); - changes.Lines.Add(new Models.TextChanges.Line(lineIndex++, Models.TextChanges.LineMode.Indicator, line, "", "")); - } else { - if (line.Length == 0) { - ProcessChanges(); - changes.Lines.Add(new Models.TextChanges.Line(lineIndex++, Models.TextChanges.LineMode.Normal, "", $"{oldLine}", $"{newLine}")); - oldLine++; - newLine++; + 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(); + _last = new Models.TextDiffLine(Models.TextDiffLineType.Normal, "", _oldLine, _newLine); + _result.TextDiff.Lines.Add(_last); + _oldLine++; + _newLine++; return; } var ch = line[0]; - if (ch == '-') { - deleted.Add(new Models.TextChanges.Line(lineIndex++, Models.TextChanges.LineMode.Deleted, line.Substring(1), $"{oldLine}", "")); - oldLine++; - } else if (ch == '+') { - added.Add(new Models.TextChanges.Line(lineIndex++, Models.TextChanges.LineMode.Added, line.Substring(1), "", $"{newLine}")); - newLine++; - } else if (ch != '\\') { - ProcessChanges(); - var match = REG_INDICATOR.Match(line); - if (match.Success) { - oldLine = int.Parse(match.Groups[1].Value); - newLine = int.Parse(match.Groups[2].Value); - changes.Lines.Add(new Models.TextChanges.Line(lineIndex++, Models.TextChanges.LineMode.Indicator, line, "", "")); - } else { - changes.Lines.Add(new Models.TextChanges.Line(lineIndex++, Models.TextChanges.LineMode.Normal, line.Substring(1), $"{oldLine}", $"{newLine}")); - oldLine++; - newLine++; + if (ch == '-') + { + if (_oldLine == 1 && _newLine == 0 && line.StartsWith(PREFIX_LFS_DEL, StringComparison.Ordinal)) + { + _result.IsLFS = true; + _result.LFSDiff = new Models.LFSDiff(); + return; } + + _last = new Models.TextDiffLine(Models.TextDiffLineType.Deleted, line.Substring(1), _oldLine, 0); + _deleted.Add(_last); + _oldLine++; + } + else if (ch == '+') + { + if (_oldLine == 0 && _newLine == 1 && line.StartsWith(PREFIX_LFS_NEW, StringComparison.Ordinal)) + { + _result.IsLFS = true; + _result.LFSDiff = new Models.LFSDiff(); + return; + } + + _last = new Models.TextDiffLine(Models.TextDiffLineType.Added, line.Substring(1), 0, _newLine); + _added.Add(_last); + _newLine++; + } + else if (ch != '\\') + { + ProcessInlineHighlights(); + var match = REG_INDICATOR().Match(line); + if (match.Success) + { + _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 (_oldLine == 1 && _newLine == 1 && line.StartsWith(PREFIX_LFS_MODIFY, StringComparison.Ordinal)) + { + _result.IsLFS = true; + _result.LFSDiff = new Models.LFSDiff(); + return; + } + + _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; } } } - private void ProcessChanges() { - if (deleted.Any()) { - if (added.Count == deleted.Count) { - for (int i = added.Count - 1; i >= 0; i--) { - var left = deleted[i]; - var right = added[i]; + private void ProcessInlineHighlights() + { + if (_deleted.Count > 0) + { + if (_added.Count == _deleted.Count) + { + for (int i = _added.Count - 1; i >= 0; i--) + { + var left = _deleted[i]; + var right = _added[i]; - if (left.Content.Length > 1024 || right.Content.Length > 1024) continue; + if (left.Content.Length > 1024 || right.Content.Length > 1024) + continue; - var chunks = Models.TextCompare.Process(left.Content, right.Content); - if (chunks.Count > 4) continue; + var chunks = Models.TextInlineChange.Compare(left.Content, right.Content); + if (chunks.Count > 4) + continue; - foreach (var chunk in chunks) { - if (chunk.DeletedCount > 0) { - left.Highlights.Add(new Models.TextChanges.HighlightRange(chunk.DeletedStart, chunk.DeletedCount)); + foreach (var chunk in chunks) + { + if (chunk.DeletedCount > 0) + { + left.Highlights.Add(new Models.TextInlineRange(chunk.DeletedStart, chunk.DeletedCount)); } - if (chunk.AddedCount > 0) { - right.Highlights.Add(new Models.TextChanges.HighlightRange(chunk.AddedStart, chunk.AddedCount)); + if (chunk.AddedCount > 0) + { + right.Highlights.Add(new Models.TextInlineRange(chunk.AddedStart, chunk.AddedCount)); } } } } - changes.Lines.AddRange(deleted); - deleted.Clear(); + _result.TextDiff.Lines.AddRange(_deleted); + _deleted.Clear(); } - if (added.Any()) { - changes.Lines.AddRange(added); - added.Clear(); + if (_added.Count > 0) + { + _result.TextDiff.Lines.AddRange(_added); + _added.Clear(); } } + + 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 index dd35d0ca..f36ca6c9 100644 --- a/src/Commands/Discard.cs +++ b/src/Commands/Discard.cs @@ -1,42 +1,95 @@ -using System; +using System; using System.Collections.Generic; +using System.IO; -namespace SourceGit.Commands { - /// - /// 忽略变更 - /// - public class Discard { - private string repo = null; +using Avalonia.Threading; - public Discard(string repo) { - this.repo = repo; - } - - public void Whole() { - new Reset(repo, "HEAD", "--hard").Exec(); - new Clean(repo).Exec(); - } - - public void Changes(List changes) { - var needClean = new List(); - var needCheckout = new List(); - - foreach (var c in changes) { - if (c.WorkTree == Models.Change.Status.Untracked || c.WorkTree == Models.Change.Status.Added) { - needClean.Add(c.Path); - } else { - needCheckout.Add(c.Path); +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); + } } } - - for (int i = 0; i < needClean.Count; i += 10) { - var count = Math.Min(10, needClean.Count - i); - new Clean(repo, needClean.GetRange(i, count)).Exec(); + catch (Exception e) + { + Dispatcher.UIThread.Invoke(() => + { + App.RaiseException(repo, $"Failed to discard changes. Reason: {e.Message}"); + }); } - for (int i = 0; i < needCheckout.Count; i += 10) { - var count = Math.Min(10, needCheckout.Count - i); - new Checkout(repo).Files(needCheckout.GetRange(i, count)); + 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 index ad3925cc..edf2a6dd 100644 --- a/src/Commands/Fetch.cs +++ b/src/Commands/Fetch.cs @@ -1,102 +1,31 @@ -using System; -using System.Collections.Generic; -using System.Threading; +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 "; -namespace SourceGit.Commands { + if (noTags) + Args += "--no-tags "; + else + Args += "--tags "; - /// - /// 拉取 - /// - public class Fetch : Command { - private Action handler = null; + if (force) + Args += "--force "; - public Fetch(string repo, string remote, bool prune, Action outputHandler) { - Cwd = repo; - TraitErrorAsOutput = true; - - var sshKey = new Config(repo).Get($"remote.{remote}.sshkey"); - if (!string.IsNullOrEmpty(sshKey)) { - Args = $"-c core.sshCommand=\"ssh -i '{sshKey}'\" "; - } else { - Args = "-c credential.helper=manager "; - } - - Args += "fetch --progress --verbose "; - if (prune) Args += "--prune "; Args += remote; - handler = outputHandler; - AutoFetch.MarkFetched(repo); } - public Fetch(string repo, string remote, string localBranch, string remoteBranch, Action outputHandler) { - Cwd = repo; - TraitErrorAsOutput = true; - - var sshKey = new Config(repo).Get($"remote.{remote}.sshkey"); - if (!string.IsNullOrEmpty(sshKey)) { - Args = $"-c core.sshCommand=\"ssh -i '{sshKey}'\" "; - } else { - Args = "-c credential.helper=manager "; - } - - Args += $"fetch --progress --verbose {remote} {remoteBranch}:{localBranch}"; - handler = outputHandler; - } - - public override void OnReadline(string line) { - handler?.Invoke(line); - } - } - - /// - /// 自动拉取(每隔10分钟) - /// - public class AutoFetch { - private static Dictionary jobs = new Dictionary(); - - private Fetch cmd = null; - private long nextFetchPoint = 0; - private Timer timer = null; - - public static void Start(string repo) { - if (!Models.Preference.Instance.Git.AutoFetchRemotes) return; - - // 只自动更新加入管理列表中的仓库(子模块等不自动更新) - var exists = Models.Preference.Instance.FindRepository(repo); - if (exists == null) return; - - var job = new AutoFetch(repo); - jobs.Add(repo, job); - } - - public static void MarkFetched(string repo) { - if (!jobs.ContainsKey(repo)) return; - jobs[repo].nextFetchPoint = DateTime.Now.AddMinutes(10).ToFileTime(); - } - - public static void Stop(string repo) { - if (!jobs.ContainsKey(repo)) return; - - jobs[repo].timer.Dispose(); - jobs.Remove(repo); - } - - public AutoFetch(string repo) { - cmd = new Fetch(repo, "--all", true, null); - cmd.DontRaiseError = true; - - nextFetchPoint = DateTime.Now.AddMinutes(10).ToFileTime(); - timer = new Timer(OnTick, null, 60000, 10000); - } - - private void OnTick(object o) { - var now = DateTime.Now.ToFileTime(); - if (nextFetchPoint > now) return; - - Models.Watcher.SetEnabled(cmd.Cwd, false); - cmd.Exec(); - nextFetchPoint = DateTime.Now.AddMinutes(10).ToFileTime(); - Models.Watcher.SetEnabled(cmd.Cwd, true); + 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/Commands/FormatPatch.cs b/src/Commands/FormatPatch.cs index af5fb624..bf850d60 100644 --- a/src/Commands/FormatPatch.cs +++ b/src/Commands/FormatPatch.cs @@ -1,12 +1,13 @@ -namespace SourceGit.Commands { - /// - /// 将Commit另存为Patch文件 - /// - public class FormatPatch : Command { - - public FormatPatch(string repo, string commit, string path) { - Cwd = repo; - Args = $"format-patch {commit} -1 -o \"{path}\""; +namespace SourceGit.Commands +{ + public class FormatPatch : Command + { + public FormatPatch(string repo, string commit, string saveTo) + { + WorkingDirectory = repo; + Context = repo; + Editor = EditorType.None; + Args = $"format-patch {commit} -1 --output=\"{saveTo}\""; } } } diff --git a/src/Commands/GC.cs b/src/Commands/GC.cs index ceadb0b7..0b27f487 100644 --- a/src/Commands/GC.cs +++ b/src/Commands/GC.cs @@ -1,21 +1,12 @@ -using System; - -namespace SourceGit.Commands { - /// - /// GC - /// - public class GC : Command { - private Action handler; - - public GC(string repo, Action onProgress) { - Cwd = repo; - Args = "gc"; - TraitErrorAsOutput = true; - handler = onProgress; - } - - public override void OnReadline(string line) { - handler?.Invoke(line); +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/GetRepositoryRootPath.cs b/src/Commands/GetRepositoryRootPath.cs deleted file mode 100644 index c4dc6777..00000000 --- a/src/Commands/GetRepositoryRootPath.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace SourceGit.Commands { - /// - /// 取得一个库的根路径 - /// - public class GetRepositoryRootPath : Command { - public GetRepositoryRootPath(string path) { - Cwd = path; - Args = "rev-parse --show-toplevel"; - } - - public string Result() { - var rs = ReadToEnd().Output; - if (string.IsNullOrEmpty(rs)) return null; - return rs.Trim(); - } - } -} diff --git a/src/Commands/GitFlow.cs b/src/Commands/GitFlow.cs index b6707b06..1d33fa3a 100644 --- a/src/Commands/GitFlow.cs +++ b/src/Commands/GitFlow.cs @@ -1,72 +1,92 @@ -namespace SourceGit.Commands { - /// - /// Git-Flow命令 - /// - public class GitFlow : Command { +using System.Text; +using Avalonia.Threading; - public GitFlow(string repo) { - Cwd = repo; +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 bool Init(string master, string develop, string feature, string release, string hotfix, string version) { - var branches = new Branches(Cwd).Result(); - var current = branches.Find(x => x.IsCurrent); + public static bool Start(string repo, Models.GitFlowBranchType type, string name, Models.ICommandLog log) + { + var start = new Command(); + start.WorkingDirectory = repo; + start.Context = repo; - var masterBranch = branches.Find(x => x.Name == master); - if (masterBranch == null && current != null) new Branch(Cwd, develop).Create(current.Head); - - var devBranch = branches.Find(x => x.Name == develop); - if (devBranch == null && current != null) new Branch(Cwd, develop).Create(current.Head); - - var cmd = new Config(Cwd); - cmd.Set("gitflow.branch.master", master); - cmd.Set("gitflow.branch.develop", develop); - cmd.Set("gitflow.prefix.feature", feature); - cmd.Set("gitflow.prefix.bugfix", "bugfix/"); - cmd.Set("gitflow.prefix.release", release); - cmd.Set("gitflow.prefix.hotfix", hotfix); - cmd.Set("gitflow.prefix.support", "support/"); - cmd.Set("gitflow.prefix.versiontag", version, true); - - Args = "flow init -d"; - return Exec(); - } - - public void Start(Models.GitFlowBranchType type, string name) { - switch (type) { - case Models.GitFlowBranchType.Feature: - Args = $"flow feature start {name}"; - break; - case Models.GitFlowBranchType.Release: - Args = $"flow release start {name}"; - break; - case Models.GitFlowBranchType.Hotfix: - Args = $"flow hotfix start {name}"; - break; - default: - return; + 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; } - Exec(); + start.Log = log; + return start.Exec(); } - public void Finish(Models.GitFlowBranchType type, string name, bool keepBranch) { - var option = keepBranch ? "-k" : string.Empty; - switch (type) { - case Models.GitFlowBranchType.Feature: - Args = $"flow feature finish {option} {name}"; - break; - case Models.GitFlowBranchType.Release: - Args = $"flow release finish {option} {name} -m \"RELEASE_DONE\""; - break; - case Models.GitFlowBranchType.Hotfix: - Args = $"flow hotfix finish {option} {name} -m \"HOTFIX_DONE\""; - break; - default: - return; + 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; } - Exec(); + 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/Commands/Init.cs b/src/Commands/Init.cs index 35dde5a2..c44486da 100644 --- a/src/Commands/Init.cs +++ b/src/Commands/Init.cs @@ -1,12 +1,11 @@ -namespace SourceGit.Commands { - - /// - /// 初始化Git仓库 - /// - public class Init : Command { - - public Init(string workDir) { - Cwd = workDir; +namespace SourceGit.Commands +{ + public class Init : Command + { + public Init(string ctx, string dir) + { + Context = ctx; + WorkingDirectory = dir; Args = "init -q"; } } 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/Commands/IsBinary.cs b/src/Commands/IsBinary.cs new file mode 100644 index 00000000..af8f54bb --- /dev/null +++ b/src/Commands/IsBinary.cs @@ -0,0 +1,23 @@ +using System.Text.RegularExpressions; + +namespace SourceGit.Commands +{ + public partial class IsBinary : Command + { + [GeneratedRegex(@"^\-\s+\-\s+.*$")] + private static partial Regex REG_TEST(); + + public IsBinary(string repo, string commit, string path) + { + WorkingDirectory = repo; + Context = repo; + Args = $"diff {Models.Commit.EmptyTreeSHA1} {commit} --numstat -- \"{path}\""; + RaiseError = false; + } + + public bool Result() + { + return REG_TEST().IsMatch(ReadToEnd().StdOut); + } + } +} diff --git a/src/Commands/IsBinaryFile.cs b/src/Commands/IsBinaryFile.cs deleted file mode 100644 index 68cbff62..00000000 --- a/src/Commands/IsBinaryFile.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Text.RegularExpressions; - -namespace SourceGit.Commands { - /// - /// 查询指定版本下的某文件是否是二进制文件 - /// - public class IsBinaryFile : Command { - private static readonly Regex REG_TEST = new Regex(@"^\-\s+\-\s+.*$"); - public IsBinaryFile(string repo, string commit, string path) { - Cwd = repo; - Args = $"diff 4b825dc642cb6eb9a060e54bf8d69288fbee4904 {commit} --numstat -- \"{path}\""; - } - - public bool Result() { - return REG_TEST.IsMatch(ReadToEnd().Output); - } - } -} 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/Commands/IsLFSFiltered.cs b/src/Commands/IsLFSFiltered.cs new file mode 100644 index 00000000..2a7234bb --- /dev/null +++ b/src/Commands/IsLFSFiltered.cs @@ -0,0 +1,27 @@ +namespace SourceGit.Commands +{ + public class IsLFSFiltered : Command + { + public IsLFSFiltered(string repo, string path) + { + WorkingDirectory = repo; + Context = repo; + 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; + } + + public bool Result() + { + var rs = ReadToEnd(); + return rs.IsSuccess && rs.StdOut.Contains("filter\0lfs"); + } + } +} diff --git a/src/Commands/LFS.cs b/src/Commands/LFS.cs index 87d3e378..18d2ba93 100644 --- a/src/Commands/LFS.cs +++ b/src/Commands/LFS.cs @@ -1,51 +1,115 @@ -using System; +using System; +using System.Collections.Generic; using System.IO; +using System.Text.RegularExpressions; -namespace SourceGit.Commands { - /// - /// LFS相关 - /// - public class LFS { - private string repo; +namespace SourceGit.Commands +{ + public partial class LFS + { + [GeneratedRegex(@"^(.+)\s+([\w.]+)\s+\w+:(\d+)$")] + private static partial Regex REG_LOCK(); - private class PruneCmd : Command { - private Action handler; - - public PruneCmd(string repo, Action onProgress) { - Cwd = repo; - Args = "lfs prune"; - TraitErrorAsOutput = true; - handler = onProgress; - } - - public override void OnReadline(string line) { - handler?.Invoke(line); + private class SubCmd : Command + { + public SubCmd(string repo, string args, Models.ICommandLog log) + { + WorkingDirectory = repo; + Context = repo; + Args = args; + Log = log; } } - public LFS(string repo) { - this.repo = repo; + public LFS(string repo) + { + _repo = repo; } - public bool IsEnabled() { - var path = Path.Combine(repo, ".git", "hooks", "pre-push"); - if (!File.Exists(path)) return false; + 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 IsFiltered(string path) { - var cmd = new Command(); - cmd.Cwd = repo; - cmd.Args = $"check-attr -a -z \"{path}\""; + 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(); - return rs.Output.Contains("filter\0lfs"); + 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 void Prune(Action onProgress) { - new PruneCmd(repo, onProgress).Exec(); + 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/LocalChanges.cs b/src/Commands/LocalChanges.cs deleted file mode 100644 index edb156db..00000000 --- a/src/Commands/LocalChanges.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.RegularExpressions; - -namespace SourceGit.Commands { - /// - /// 取得本地工作副本变更 - /// - public class LocalChanges : Command { - private static readonly Regex REG_FORMAT = new Regex(@"^(\s?[\w\?]{1,4})\s+(.+)$"); - private static readonly string[] UNTRACKED = new string[] { "no", "all" }; - private List changes = new List(); - - public LocalChanges(string path, bool includeUntracked = true) { - Cwd = path; - Args = $"status -u{UNTRACKED[includeUntracked ? 1 : 0]} --ignore-submodules=dirty --porcelain"; - } - - public List Result() { - Exec(); - return changes; - } - - public override void OnReadline(string line) { - var match = REG_FORMAT.Match(line); - if (!match.Success) return; - if (line.EndsWith("/", StringComparison.Ordinal)) return; // Ignore changes with git-worktree - - var change = new Models.Change() { Path = match.Groups[2].Value }; - var status = match.Groups[1].Value; - - switch (status) { - case " M": change.Set(Models.Change.Status.None, Models.Change.Status.Modified); break; - case " A": change.Set(Models.Change.Status.None, Models.Change.Status.Added); break; - case " D": change.Set(Models.Change.Status.None, Models.Change.Status.Deleted); break; - case " R": change.Set(Models.Change.Status.None, Models.Change.Status.Renamed); break; - case " C": change.Set(Models.Change.Status.None, Models.Change.Status.Copied); break; - case "M": change.Set(Models.Change.Status.Modified, Models.Change.Status.None); break; - case "MM": change.Set(Models.Change.Status.Modified, Models.Change.Status.Modified); break; - case "MD": change.Set(Models.Change.Status.Modified, Models.Change.Status.Deleted); break; - case "A": change.Set(Models.Change.Status.Added, Models.Change.Status.None); break; - case "AM": change.Set(Models.Change.Status.Added, Models.Change.Status.Modified); break; - case "AD": change.Set(Models.Change.Status.Added, Models.Change.Status.Deleted); break; - case "D": change.Set(Models.Change.Status.Deleted, Models.Change.Status.None); break; - case "R": change.Set(Models.Change.Status.Renamed, Models.Change.Status.None); break; - case "RM": change.Set(Models.Change.Status.Renamed, Models.Change.Status.Modified); break; - case "RD": change.Set(Models.Change.Status.Renamed, Models.Change.Status.Deleted); break; - case "C": change.Set(Models.Change.Status.Copied, Models.Change.Status.None); break; - case "CM": change.Set(Models.Change.Status.Copied, Models.Change.Status.Modified); break; - case "CD": change.Set(Models.Change.Status.Copied, Models.Change.Status.Deleted); break; - case "DR": change.Set(Models.Change.Status.Deleted, Models.Change.Status.Renamed); break; - case "DC": change.Set(Models.Change.Status.Deleted, Models.Change.Status.Copied); break; - case "DD": change.Set(Models.Change.Status.Deleted, Models.Change.Status.Deleted); break; - case "AU": change.Set(Models.Change.Status.Added, Models.Change.Status.Unmerged); break; - case "UD": change.Set(Models.Change.Status.Unmerged, Models.Change.Status.Deleted); break; - case "UA": change.Set(Models.Change.Status.Unmerged, Models.Change.Status.Added); break; - case "DU": change.Set(Models.Change.Status.Deleted, Models.Change.Status.Unmerged); break; - case "AA": change.Set(Models.Change.Status.Added, Models.Change.Status.Added); break; - case "UU": change.Set(Models.Change.Status.Unmerged, Models.Change.Status.Unmerged); break; - case "??": change.Set(Models.Change.Status.Untracked, Models.Change.Status.Untracked); break; - default: return; - } - - changes.Add(change); - } - } -} diff --git a/src/Commands/Merge.cs b/src/Commands/Merge.cs index a44c72a1..b08377b9 100644 --- a/src/Commands/Merge.cs +++ b/src/Commands/Merge.cs @@ -1,21 +1,36 @@ -using System; +using System.Collections.Generic; +using System.Text; -namespace SourceGit.Commands { - /// - /// 合并分支 - /// - public class Merge : Command { - private Action handler = null; - - public Merge(string repo, string source, string mode, Action onProgress) { - Cwd = repo; +namespace SourceGit.Commands +{ + public class Merge : Command + { + public Merge(string repo, string source, string mode) + { + WorkingDirectory = repo; + Context = repo; Args = $"merge --progress {source} {mode}"; - TraitErrorAsOutput = true; - handler = onProgress; } - public override void OnReadline(string line) { - handler?.Invoke(line); + 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 index e2ab522a..698fbfce 100644 --- a/src/Commands/Pull.cs +++ b/src/Commands/Pull.cs @@ -1,51 +1,18 @@ -using System; +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 "; -namespace SourceGit.Commands { + if (useRebase) + Args += "--rebase=true "; - /// - /// 拉回 - /// - public class Pull : Command { - private Action handler = null; - private bool needStash = false; - - public Pull(string repo, string remote, string branch, bool useRebase, bool autoStash, Action onProgress) { - Cwd = repo; - TraitErrorAsOutput = true; - handler = onProgress; - needStash = autoStash; - - var sshKey = new Config(repo).Get($"remote.{remote}.sshkey"); - if (!string.IsNullOrEmpty(sshKey)) { - Args = $"-c core.sshCommand=\"ssh -i '{sshKey}'\" "; - } else { - Args = "-c credential.helper=manager "; - } - - Args += "pull --verbose --progress --tags "; - if (useRebase) Args += "--rebase "; Args += $"{remote} {branch}"; } - - public bool Run() { - if (needStash) { - var changes = new LocalChanges(Cwd).Result(); - if (changes.Count > 0) { - if (!new Stash(Cwd).Push(changes, "PULL_AUTO_STASH", true)) { - return false; - } - } else { - needStash = false; - } - } - - var succ = Exec(); - if (succ && needStash) new Stash(Cwd).Pop("stash@{0}"); - return succ; - } - - public override void OnReadline(string line) { - handler?.Invoke(line); - } } } diff --git a/src/Commands/Push.cs b/src/Commands/Push.cs index 7784c581..8a5fe33c 100644 --- a/src/Commands/Push.cs +++ b/src/Commands/Push.cs @@ -1,63 +1,37 @@ -using System; +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 "; -namespace SourceGit.Commands { - /// - /// 推送 - /// - public class Push : Command { - private Action handler = null; - - public Push(string repo, string local, string remote, string remoteBranch, bool withTags, bool force, bool track, Action onProgress) { - Cwd = repo; - TraitErrorAsOutput = true; - handler = onProgress; - - var sshKey = new Config(repo).Get($"remote.{remote}.sshkey"); - if (!string.IsNullOrEmpty(sshKey)) { - Args = $"-c core.sshCommand=\"ssh -i '{sshKey}'\" "; - } else { - Args = "-c credential.helper=manager "; - } - - Args += "push --progress --verbose "; - - if (withTags) Args += "--tags "; - if (track) Args += "-u "; - if (force) Args += "--force-with-lease "; + 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 branch) { - Cwd = repo; + public Push(string repo, string remote, string refname, bool isDelete) + { + WorkingDirectory = repo; + Context = repo; + SSHKey = new Config(repo).Get($"remote.{remote}.sshkey"); + Args = "push "; - var sshKey = new Config(repo).Get($"remote.{remote}.sshkey"); - if (!string.IsNullOrEmpty(sshKey)) { - Args = $"-c core.sshCommand=\"ssh -i '{sshKey}'\" "; - } else { - Args = "-c credential.helper=manager "; - } + if (isDelete) + Args += "--delete "; - Args += $"push {remote} --delete {branch}"; - } - - public Push(string repo, string remote, string tag, bool isDelete) { - Cwd = repo; - - var sshKey = new Config(repo).Get($"remote.{remote}.sshkey"); - if (!string.IsNullOrEmpty(sshKey)) { - Args = $"-c core.sshCommand=\"ssh -i '{sshKey}'\" "; - } else { - Args = "-c credential.helper=manager "; - } - - Args += "push "; - if (isDelete) Args += "--delete "; - Args += $"{remote} refs/tags/{tag}"; - } - - public override void OnReadline(string line) { - handler?.Invoke(line); + 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..9f238319 --- /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 readonly string _boundary; + } +} diff --git a/src/Commands/QueryFileContent.cs b/src/Commands/QueryFileContent.cs index 04eab006..83d0a575 100644 --- a/src/Commands/QueryFileContent.cs +++ b/src/Commands/QueryFileContent.cs @@ -1,26 +1,73 @@ -using System.Collections.Generic; +using System; +using System.Diagnostics; +using System.IO; -namespace SourceGit.Commands { - /// - /// 取得指定提交下的某文件内容 - /// - public class QueryFileContent : Command { - private List lines = new List(); - private int added = 0; +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; - public QueryFileContent(string repo, string commit, string path) { - Cwd = repo; - Args = $"show {commit}:\"{path}\""; + 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 List Result() { - Exec(); - return lines; - } + 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; - public override void OnReadline(string line) { - added++; - lines.Add(new Models.TextLine() { Number = added, Data = line }); + 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/Commands/QueryFileSize.cs b/src/Commands/QueryFileSize.cs new file mode 100644 index 00000000..30af7715 --- /dev/null +++ b/src/Commands/QueryFileSize.cs @@ -0,0 +1,30 @@ +using System.Text.RegularExpressions; + +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(); + + public QueryFileSize(string repo, string file, string revision) + { + WorkingDirectory = repo; + Context = repo; + Args = $"ls-tree {revision} -l -- \"{file}\""; + } + + public long Result() + { + var rs = ReadToEnd(); + if (rs.IsSuccess) + { + var match = REG_FORMAT().Match(rs.StdOut); + if (match.Success) + return long.Parse(match.Groups[1].Value); + } + + return 0; + } + } +} diff --git a/src/Commands/QueryFileSizeChange.cs b/src/Commands/QueryFileSizeChange.cs deleted file mode 100644 index 70792e2a..00000000 --- a/src/Commands/QueryFileSizeChange.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.IO; - -namespace SourceGit.Commands { - /// - /// 查询文件大小变化 - /// - public class QueryFileSizeChange { - - class QuerySizeCmd : Command { - public QuerySizeCmd(string repo, string path, string revision) { - Cwd = repo; - Args = $"cat-file -s {revision}:\"{path}\""; - } - - public long Result() { - string data = ReadToEnd().Output; - long size; - if (!long.TryParse(data, out size)) size = 0; - return size; - } - } - - private Models.FileSizeChange change = new Models.FileSizeChange(); - - public QueryFileSizeChange(string repo, string[] revisions, string path, string orgPath) { - if (revisions.Length == 0) { - change.NewSize = new FileInfo(Path.Combine(repo, path)).Length; - change.OldSize = new QuerySizeCmd(repo, path, "HEAD").Result(); - } else if (revisions.Length == 1) { - change.NewSize = new QuerySizeCmd(repo, path, "HEAD").Result(); - if (string.IsNullOrEmpty(orgPath)) { - change.OldSize = new QuerySizeCmd(repo, path, revisions[0]).Result(); - } else { - change.OldSize = new QuerySizeCmd(repo, orgPath, revisions[0]).Result(); - } - } else { - change.NewSize = new QuerySizeCmd(repo, path, revisions[1]).Result(); - if (string.IsNullOrEmpty(orgPath)) { - change.OldSize = new QuerySizeCmd(repo, path, revisions[0]).Result(); - } else { - change.OldSize = new QuerySizeCmd(repo, orgPath, revisions[0]).Result(); - } - } - } - - public Models.FileSizeChange Result() { - return change; - } - } -} 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/Commands/QueryGitDir.cs b/src/Commands/QueryGitDir.cs index af45273d..e3a94baf 100644 --- a/src/Commands/QueryGitDir.cs +++ b/src/Commands/QueryGitDir.cs @@ -1,23 +1,26 @@ -using System.IO; +using System.IO; -namespace SourceGit.Commands { - - /// - /// 取得GitDir - /// - public class QueryGitDir : Command { - public QueryGitDir(string workDir) { - Cwd = workDir; +namespace SourceGit.Commands +{ + public class QueryGitDir : Command + { + public QueryGitDir(string workDir) + { + WorkingDirectory = workDir; Args = "rev-parse --git-dir"; + RaiseError = false; } - public string Result() { - var rs = ReadToEnd().Output; - if (string.IsNullOrEmpty(rs)) return null; + 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(Cwd, rs)); + if (Path.IsPathRooted(rs)) + return rs; + return Path.GetFullPath(Path.Combine(WorkingDirectory, rs)); } } } diff --git a/src/Commands/QueryLFSObject.cs b/src/Commands/QueryLFSObject.cs deleted file mode 100644 index 8db8bbe0..00000000 --- a/src/Commands/QueryLFSObject.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; - -namespace SourceGit.Commands { - /// - /// 取得一个LFS对象的信息 - /// - public class QueryLFSObject : Command { - private Models.LFSObject obj = new Models.LFSObject(); - - public QueryLFSObject(string repo, string commit, string path) { - Cwd = repo; - Args = $"show {commit}:\"{path}\""; - } - - public Models.LFSObject Result() { - Exec(); - return obj; - } - - public override void OnReadline(string line) { - if (line.StartsWith("oid sha256:", StringComparison.Ordinal)) { - obj.OID = line.Substring(11).Trim(); - } else if (line.StartsWith("size")) { - obj.Size = int.Parse(line.Substring(4).Trim()); - } - } - } -} diff --git a/src/Commands/QueryLFSObjectChange.cs b/src/Commands/QueryLFSObjectChange.cs deleted file mode 100644 index 5f9e1631..00000000 --- a/src/Commands/QueryLFSObjectChange.cs +++ /dev/null @@ -1,41 +0,0 @@ -namespace SourceGit.Commands { - /// - /// 查询LFS对象变更 - /// - public class QueryLFSObjectChange : Command { - private Models.LFSChange change = new Models.LFSChange(); - - public QueryLFSObjectChange(string repo, string args) { - Cwd = repo; - Args = $"diff --ignore-cr-at-eol {args}"; - } - - public Models.LFSChange Result() { - Exec(); - return change; - } - - public override void OnReadline(string line) { - var ch = line[0]; - if (ch == '-') { - if (change.Old == null) change.Old = new Models.LFSObject(); - line = line.Substring(1); - if (line.StartsWith("oid sha256:")) { - change.Old.OID = line.Substring(11); - } else if (line.StartsWith("size ")) { - change.Old.Size = int.Parse(line.Substring(5)); - } - } else if (ch == '+') { - if (change.New == null) change.New = new Models.LFSObject(); - line = line.Substring(1); - if (line.StartsWith("oid sha256:")) { - change.New.OID = line.Substring(11); - } else if (line.StartsWith("size ")) { - change.New.Size = int.Parse(line.Substring(5)); - } - } else if (line.StartsWith(" size ")) { - change.New.Size = change.Old.Size = int.Parse(line.Substring(6)); - } - } - } -} 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/Commands/QueryRepositoryRootPath.cs b/src/Commands/QueryRepositoryRootPath.cs new file mode 100644 index 00000000..016621c8 --- /dev/null +++ b/src/Commands/QueryRepositoryRootPath.cs @@ -0,0 +1,11 @@ +namespace SourceGit.Commands +{ + public class QueryRepositoryRootPath : Command + { + public QueryRepositoryRootPath(string path) + { + WorkingDirectory = path; + Args = "rev-parse --show-toplevel"; + } + } +} 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/Commands/QueryRevisionObjects.cs b/src/Commands/QueryRevisionObjects.cs new file mode 100644 index 00000000..de3406e8 --- /dev/null +++ b/src/Commands/QueryRevisionObjects.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace SourceGit.Commands +{ + public partial class QueryRevisionObjects : Command + { + [GeneratedRegex(@"^\d+\s+(\w+)\s+([0-9a-f]+)\s+(.*)$")] + private static partial Regex REG_FORMAT(); + + public QueryRevisionObjects(string repo, string sha, string parentFolder) + { + WorkingDirectory = repo; + Context = repo; + Args = $"ls-tree -z {sha}"; + + if (!string.IsNullOrEmpty(parentFolder)) + Args += $" -- \"{parentFolder}\""; + } + + public List Result() + { + 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; + } + + private void Parse(string line) + { + var match = REG_FORMAT().Match(line); + if (!match.Success) + return; + + var obj = new Models.Object(); + obj.SHA = match.Groups[2].Value; + obj.Type = Models.ObjectType.Blob; + obj.Path = match.Groups[3].Value; + + switch (match.Groups[1].Value) + { + case "blob": + obj.Type = Models.ObjectType.Blob; + break; + case "tree": + obj.Type = Models.ObjectType.Tree; + break; + case "tag": + obj.Type = Models.ObjectType.Tag; + break; + case "commit": + obj.Type = Models.ObjectType.Commit; + break; + } + + _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/Commands/QueryStagedFileBlobGuid.cs b/src/Commands/QueryStagedFileBlobGuid.cs new file mode 100644 index 00000000..3f52a5f2 --- /dev/null +++ b/src/Commands/QueryStagedFileBlobGuid.cs @@ -0,0 +1,29 @@ +using System.Text.RegularExpressions; + +namespace SourceGit.Commands +{ + public partial class QueryStagedFileBlobGuid : Command + { + [GeneratedRegex(@"^\d+\s+([0-9a-f]+)\s+.*$")] + private static partial Regex REG_FORMAT(); + + public QueryStagedFileBlobGuid(string repo, string file) + { + WorkingDirectory = repo; + Context = repo; + Args = $"ls-files -s -- \"{file}\""; + } + + public string Result() + { + var rs = ReadToEnd(); + var match = REG_FORMAT().Match(rs.StdOut.Trim()); + if (match.Success) + { + return match.Groups[1].Value; + } + + return string.Empty; + } + } +} 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/Commands/Rebase.cs b/src/Commands/Rebase.cs index 791233af..2ec50f3c 100644 --- a/src/Commands/Rebase.cs +++ b/src/Commands/Rebase.cs @@ -1,14 +1,26 @@ -namespace SourceGit.Commands { - /// - /// 变基命令 - /// - public class Rebase : Command { - - public Rebase(string repo, string basedOn, bool autoStash) { - Cwd = repo; +namespace SourceGit.Commands +{ + public class Rebase : Command + { + public Rebase(string repo, string basedOn, bool autoStash) + { + WorkingDirectory = repo; + Context = repo; Args = "rebase "; - if (autoStash) Args += "--autostash "; + if (autoStash) + Args += "--autostash "; 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/Commands/Remote.cs b/src/Commands/Remote.cs index 09326afd..beaf412b 100644 --- a/src/Commands/Remote.cs +++ b/src/Commands/Remote.cs @@ -1,36 +1,58 @@ -namespace SourceGit.Commands { - /// - /// 远程操作 - /// - public class Remote : Command { - - public Remote(string repo) { - Cwd = repo; +namespace SourceGit.Commands +{ + public class Remote : Command + { + public Remote(string repo) + { + WorkingDirectory = repo; + Context = repo; } - public bool Add(string name, string url) { + public bool Add(string name, string url) + { Args = $"remote add {name} {url}"; return Exec(); } - public bool Delete(string name) { + public bool Delete(string name) + { Args = $"remote remove {name}"; return Exec(); } - public bool Rename(string name, string to) { + public bool Rename(string name, string to) + { Args = $"remote rename {name} {to}"; return Exec(); } - public bool Prune(string name) { + public bool Prune(string name) + { Args = $"remote prune {name}"; return Exec(); } - public bool SetURL(string name, string url) { - Args = $"remote set-url {name} {url}"; + public string GetURL(string name, bool isPush) + { + 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/Remotes.cs b/src/Commands/Remotes.cs deleted file mode 100644 index 1866b7f2..00000000 --- a/src/Commands/Remotes.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Collections.Generic; -using System.Text.RegularExpressions; - -namespace SourceGit.Commands { - /// - /// 获取远程列表 - /// - public class Remotes : Command { - private static readonly Regex REG_REMOTE = new Regex(@"^([\w\.\-]+)\s*(\S+).*$"); - private List loaded = new List(); - - public Remotes(string repo) { - Cwd = repo; - Args = "remote -v"; - } - - public List Result() { - Exec(); - return loaded; - } - - public override void OnReadline(string line) { - var match = REG_REMOTE.Match(line); - if (!match.Success) return; - - var remote = new Models.Remote() { - Name = match.Groups[1].Value, - URL = match.Groups[2].Value, - }; - - if (loaded.Find(x => x.Name == remote.Name) != null) return; - loaded.Add(remote); - } - } -} diff --git a/src/Commands/Reset.cs b/src/Commands/Reset.cs index b60ca174..6a54533b 100644 --- a/src/Commands/Reset.cs +++ b/src/Commands/Reset.cs @@ -1,33 +1,12 @@ -using System.Collections.Generic; -using System.Text; - -namespace SourceGit.Commands { - /// - /// 重置命令 - /// - public class Reset : Command { - - public Reset(string repo) { - Cwd = repo; - Args = "reset"; - } - - public Reset(string repo, string revision, string mode) { - Cwd = repo; +namespace SourceGit.Commands +{ + public class Reset : Command + { + public Reset(string repo, string revision, string mode) + { + WorkingDirectory = repo; + Context = repo; Args = $"reset {mode} {revision}"; } - - public Reset(string repo, List files) { - Cwd = repo; - - StringBuilder builder = new StringBuilder(); - builder.Append("reset --"); - foreach (var f in files) { - builder.Append(" \""); - builder.Append(f); - builder.Append("\""); - } - Args = builder.ToString(); - } } } 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/Commands/Revert.cs b/src/Commands/Revert.cs index 2a656fc8..2e7afd11 100644 --- a/src/Commands/Revert.cs +++ b/src/Commands/Revert.cs @@ -1,13 +1,14 @@ -namespace SourceGit.Commands { - /// - /// 撤销提交 - /// - public class Revert : Command { - - public Revert(string repo, string commit, bool autoCommit) { - Cwd = repo; - Args = $"revert {commit} --no-edit"; - if (!autoCommit) Args += " --no-commit"; +namespace SourceGit.Commands +{ + public class Revert : Command + { + public Revert(string repo, string commit, bool autoCommit) + { + WorkingDirectory = repo; + Context = repo; + Args = $"revert -m 1 {commit} --no-edit"; + if (!autoCommit) + Args += " --no-commit"; } } } diff --git a/src/Commands/RevisionObjects.cs b/src/Commands/RevisionObjects.cs deleted file mode 100644 index 25a190ec..00000000 --- a/src/Commands/RevisionObjects.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Collections.Generic; -using System.Text.RegularExpressions; - -namespace SourceGit.Commands { - /// - /// 取出指定Revision下的文件列表 - /// - public class RevisionObjects : Command { - private static readonly Regex REG_FORMAT = new Regex(@"^\d+\s+(\w+)\s+([0-9a-f]+)\s+(.*)$"); - private List objects = new List(); - - public RevisionObjects(string cwd, string sha) { - Cwd = cwd; - Args = $"ls-tree -r {sha}"; - } - - public List Result() { - Exec(); - return objects; - } - - public override void OnReadline(string line) { - var match = REG_FORMAT.Match(line); - if (!match.Success) return; - - var obj = new Models.Object(); - obj.SHA = match.Groups[2].Value; - obj.Type = Models.ObjectType.Blob; - obj.Path = match.Groups[3].Value; - - switch (match.Groups[1].Value) { - case "blob": obj.Type = Models.ObjectType.Blob; break; - case "tree": obj.Type = Models.ObjectType.Tree; break; - case "tag": obj.Type = Models.ObjectType.Tag; break; - case "commit": obj.Type = Models.ObjectType.Commit; break; - } - - objects.Add(obj); - } - } -} diff --git a/src/Commands/Reword.cs b/src/Commands/Reword.cs deleted file mode 100644 index 3296c37c..00000000 --- a/src/Commands/Reword.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.IO; - -namespace SourceGit.Commands { - /// - /// 编辑HEAD的提交信息 - /// - public class Reword : Command { - public Reword(string repo, string msg) { - var tmp = Path.GetTempFileName(); - File.WriteAllText(tmp, msg); - - Cwd = repo; - Args = $"commit --amend --allow-empty --file=\"{tmp}\""; - } - } -} diff --git a/src/Commands/SaveChangesAsPatch.cs b/src/Commands/SaveChangesAsPatch.cs new file mode 100644 index 00000000..b10037a1 --- /dev/null +++ b/src/Commands/SaveChangesAsPatch.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; + +using Avalonia.Threading; + +namespace SourceGit.Commands +{ + public static class SaveChangesAsPatch + { + public static bool ProcessLocalChanges(string repo, List changes, bool isUnstaged, string saveTo) + { + using (var sw = File.Create(saveTo)) + { + foreach (var change in changes) + { + if (!ProcessSingleChange(repo, new Models.DiffOption(change, isUnstaged), sw)) + return false; + } + } + + 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.GitExecutable; + starter.Arguments = $"diff --ignore-cr-at-eol --unified=4 {opt}"; + starter.UseShellExecute = false; + starter.CreateNoWindow = true; + starter.WindowStyle = ProcessWindowStyle.Hidden; + starter.RedirectStandardOutput = true; + + try + { + var proc = new Process() { StartInfo = starter }; + proc.Start(); + proc.StandardOutput.BaseStream.CopyTo(writer); + proc.WaitForExit(); + var rs = proc.ExitCode == 0; + proc.Close(); + + return rs; + } + catch (Exception e) + { + Dispatcher.UIThread.Invoke(() => + { + App.RaiseException(repo, "Save change to patch failed: " + e.Message); + }); + return false; + } + } + } +} diff --git a/src/Commands/SaveChangesToPatch.cs b/src/Commands/SaveChangesToPatch.cs deleted file mode 100644 index b40b9bee..00000000 --- a/src/Commands/SaveChangesToPatch.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.IO; - -namespace SourceGit.Commands { - /// - /// 将Changes保存到文件流中 - /// - public class SaveChangeToStream : Command { - private StreamWriter writer = null; - - public SaveChangeToStream(string repo, Models.Change change, StreamWriter to) { - Cwd = repo; - if (change.WorkTree == Models.Change.Status.Added || change.WorkTree == Models.Change.Status.Untracked) { - Args = $"diff --no-index --no-ext-diff --find-renames -- /dev/null \"{change.Path}\""; - } else { - var pathspec = $"\"{change.Path}\""; - if (!string.IsNullOrEmpty(change.OriginalPath)) pathspec = $"\"{change.OriginalPath}\" \"{change.Path}\""; - Args = $"diff --binary --no-ext-diff --find-renames --full-index -- {pathspec}"; - } - writer = to; - } - - public override void OnReadline(string line) { - writer.WriteLine(line); - } - } -} diff --git a/src/Commands/SaveRevisionFile.cs b/src/Commands/SaveRevisionFile.cs index a83fc618..550844ef 100644 --- a/src/Commands/SaveRevisionFile.cs +++ b/src/Commands/SaveRevisionFile.cs @@ -1,44 +1,64 @@ +using System; using System.Diagnostics; using System.IO; -namespace SourceGit.Commands { - /// - /// 保存指定版本的文件 - /// - public class SaveRevisionFile { - private string cwd = ""; - private string bat = ""; +using Avalonia.Threading; - public SaveRevisionFile(string repo, string path, string sha, string saveTo) { - var tmp = Path.GetTempFileName(); - var cmd = $"\"{Models.Preference.Instance.Git.Path}\" --no-pager "; - - var isLFS = new LFS(repo).IsFiltered(path); - if (isLFS) { - cmd += $"show {sha}:\"{path}\" > {tmp}.lfs\n"; - cmd += $"\"{Models.Preference.Instance.Git.Path}\" --no-pager lfs smudge < {tmp}.lfs > \"{saveTo}\"\n"; - } else { - cmd += $"show {sha}:\"{path}\" > \"{saveTo}\"\n"; +namespace SourceGit.Commands +{ + public static class SaveRevisionFile + { + public static void Run(string repo, string revision, string file, string saveTo) + { + var isLFSFiltered = new IsLFSFiltered(repo, revision, file).Result(); + if (isLFSFiltered) + { + var pointerStream = QueryFileContent.Run(repo, revision, file); + ExecCmd(repo, $"lfs smudge", saveTo, pointerStream); + } + else + { + ExecCmd(repo, $"show {revision}:\"{file}\"", saveTo); } - - cwd = repo; - bat = tmp + ".bat"; - - File.WriteAllText(bat, cmd); } - public void Exec() { + private static bool ExecCmd(string repo, string args, string outputFile, Stream input = null) + { var starter = new ProcessStartInfo(); - starter.FileName = bat; - starter.WorkingDirectory = cwd; + starter.WorkingDirectory = repo; + starter.FileName = Native.OS.GitExecutable; + starter.Arguments = args; + starter.UseShellExecute = false; starter.CreateNoWindow = true; starter.WindowStyle = ProcessWindowStyle.Hidden; + starter.RedirectStandardInput = true; + starter.RedirectStandardOutput = true; + starter.RedirectStandardError = true; - var proc = Process.Start(starter); - proc.WaitForExit(); - proc.Close(); + using (var sw = File.OpenWrite(outputFile)) + { + try + { + var proc = new Process() { StartInfo = starter }; + proc.Start(); + if (input != null) + proc.StandardInput.Write(new StreamReader(input).ReadToEnd()); + proc.StandardOutput.BaseStream.CopyTo(sw); + proc.WaitForExit(); + var rs = proc.ExitCode == 0; + proc.Close(); - File.Delete(bat); + return rs; + } + catch (Exception e) + { + Dispatcher.UIThread.Invoke(() => + { + App.RaiseException(repo, "Save file failed: " + e.Message); + }); + return false; + } + } } } } diff --git a/src/Commands/Stash.cs b/src/Commands/Stash.cs index 0a923f2c..7d1a269b 100644 --- a/src/Commands/Stash.cs +++ b/src/Commands/Stash.cs @@ -1,82 +1,100 @@ -using System.Collections.Generic; -using System.IO; +using System.Collections.Generic; +using System.Text; -namespace SourceGit.Commands { - /// - /// 单个贮藏相关操作 - /// - public class Stash : Command { - - public Stash(string repo) { - Cwd = repo; +namespace SourceGit.Commands +{ + public class Stash : Command + { + public Stash(string repo) + { + WorkingDirectory = repo; + Context = repo; } - public bool Push(List changes, string message, bool bFull) { - if (bFull) { - var needAdd = new List(); - foreach (var c in changes) { - if (c.WorkTree == Models.Change.Status.Added || c.WorkTree == Models.Change.Status.Untracked) { - needAdd.Add(c.Path); - if (needAdd.Count > 10) { - new Add(Cwd, needAdd).Exec(); - needAdd.Clear(); - } - } - } + 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("\""); - if (needAdd.Count > 0) { - new Add(Cwd, needAdd).Exec(); - needAdd.Clear(); - } - - Args = $"stash push -m \"{message}\""; - return Exec(); - } else { - var temp = Path.GetTempFileName(); - var stream = new FileStream(temp, FileMode.Create); - var writer = new StreamWriter(stream); - - var needAdd = new List(); - foreach (var c in changes) { - writer.WriteLine(c.Path); - - if (c.WorkTree == Models.Change.Status.Added || c.WorkTree == Models.Change.Status.Untracked) { - needAdd.Add(c.Path); - if (needAdd.Count > 10) { - new Add(Cwd, needAdd).Exec(); - needAdd.Clear(); - } - } - } - if (needAdd.Count > 0) { - new Add(Cwd, needAdd).Exec(); - needAdd.Clear(); - } - - writer.Flush(); - stream.Flush(); - writer.Close(); - stream.Close(); - - Args = $"stash push -m \"{message}\" --pathspec-from-file=\"{temp}\""; - var succ = Exec(); - File.Delete(temp); - return succ; - } - } - - public bool Apply(string name) { - Args = $"stash apply -q {name}"; + Args = builder.ToString(); return Exec(); } - public bool Pop(string name) { - Args = $"stash pop -q {name}"; + 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 Drop(string name) { - Args = $"stash drop -q {name}"; + 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/StashChanges.cs b/src/Commands/StashChanges.cs deleted file mode 100644 index 459a3776..00000000 --- a/src/Commands/StashChanges.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Collections.Generic; -using System.Text.RegularExpressions; - -namespace SourceGit.Commands { - /// - /// 查看Stash中的修改 - /// - public class StashChanges : Command { - private static readonly Regex REG_FORMAT = new Regex(@"^(\s?[\w\?]{1,4})\s+(.+)$"); - private List changes = new List(); - - public StashChanges(string repo, string sha) { - Cwd = repo; - Args = $"diff --name-status --pretty=format: {sha}^ {sha}"; - } - - public List Result() { - Exec(); - return changes; - } - - public override void OnReadline(string line) { - var match = REG_FORMAT.Match(line); - if (!match.Success) return; - - var change = new Models.Change() { Path = match.Groups[2].Value }; - var status = match.Groups[1].Value; - - switch (status[0]) { - case 'M': change.Set(Models.Change.Status.Modified); changes.Add(change); break; - case 'A': change.Set(Models.Change.Status.Added); changes.Add(change); break; - case 'D': change.Set(Models.Change.Status.Deleted); changes.Add(change); break; - case 'R': change.Set(Models.Change.Status.Renamed); changes.Add(change); break; - case 'C': change.Set(Models.Change.Status.Copied); changes.Add(change); break; - } - } - } -} diff --git a/src/Commands/Stashes.cs b/src/Commands/Stashes.cs deleted file mode 100644 index 3e7d9713..00000000 --- a/src/Commands/Stashes.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.RegularExpressions; - -namespace SourceGit.Commands { - /// - /// 解析当前仓库中的贮藏 - /// - public class Stashes : Command { - private static readonly Regex REG_STASH = new Regex(@"^Reflog: refs/(stash@\{\d+\}).*$"); - private List parsed = new List(); - private Models.Stash current = null; - - public Stashes(string path) { - Cwd = path; - Args = "stash list --pretty=raw"; - } - - public List Result() { - Exec(); - if (current != null) parsed.Add(current); - return parsed; - } - - public override void OnReadline(string line) { - if (line.StartsWith("commit ", StringComparison.Ordinal)) { - if (current != null && !string.IsNullOrEmpty(current.Name)) parsed.Add(current); - current = new Models.Stash() { SHA = line.Substring(7, 8) }; - return; - } - - if (current == null) return; - - if (line.StartsWith("Reflog: refs/stash@", StringComparison.Ordinal)) { - var match = REG_STASH.Match(line); - if (match.Success) current.Name = match.Groups[1].Value; - } else if (line.StartsWith("Reflog message: ", StringComparison.Ordinal)) { - current.Message = line.Substring(16); - } else if (line.StartsWith("author ", StringComparison.Ordinal)) { - Models.User user = Models.User.Invalid; - ulong time = 0; - Models.Commit.ParseUserAndTime(line, ref user, ref time); - current.Author = user; - current.Time = time; - } - } - } -} 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/SubTree.cs b/src/Commands/SubTree.cs deleted file mode 100644 index 62f632fe..00000000 --- a/src/Commands/SubTree.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using System.IO; - -namespace SourceGit.Commands { - /// - /// 子树相关操作 - /// - public class SubTree : Command { - private Action handler = null; - - public SubTree(string repo) { - Cwd = repo; - TraitErrorAsOutput = true; - } - - public override void OnReadline(string line) { - handler?.Invoke(line); - } - - public bool Add(string prefix, string source, string revision, bool squash, Action onProgress) { - var path = Path.Combine(Cwd, prefix); - if (Directory.Exists(path)) return true; - - handler = onProgress; - Args = $"subtree add --prefix=\"{prefix}\" {source} {revision}"; - if (squash) Args += " --squash"; - return Exec(); - } - - public void Pull(string prefix, string source, string branch, bool squash, Action onProgress) { - handler = onProgress; - Args = $"subtree pull --prefix=\"{prefix}\" {source} {branch}"; - if (squash) Args += " --squash"; - Exec(); - } - - public void Push(string prefix, string source, string branch, Action onProgress) { - handler = onProgress; - Args = $"subtree push --prefix=\"{prefix}\" {source} {branch}"; - Exec(); - } - } -} diff --git a/src/Commands/Submodule.cs b/src/Commands/Submodule.cs index bf3ba87d..025d035a 100644 --- a/src/Commands/Submodule.cs +++ b/src/Commands/Submodule.cs @@ -1,44 +1,66 @@ -using System; +using System.Collections.Generic; +using System.Text; -namespace SourceGit.Commands { - /// - /// 子模块 - /// - public class Submodule : Command { - private Action onProgress = null; - - public Submodule(string cwd) { - Cwd = cwd; +namespace SourceGit.Commands +{ + public class Submodule : Command + { + public Submodule(string repo) + { + WorkingDirectory = repo; + Context = repo; } - public bool Add(string url, string path, bool recursive, Action handler) { - Args = $"submodule add {url} {path}"; - onProgress = handler; - if (!Exec()) return false; + 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 -- {path}"; + if (recursive) + { + Args = $"submodule update --init --recursive -- \"{relativePath}\""; return Exec(); - } else { + } + else + { + Args = $"submodule update --init -- \"{relativePath}\""; return true; } } - public bool Update() { - Args = $"submodule update --rebase --remote"; + 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 Delete(string path) { - Args = $"submodule deinit -f {path}"; - if (!Exec()) return false; - - Args = $"rm -rf {path}"; + public bool Deinit(string module, bool force) + { + Args = force ? $"submodule deinit -f -- \"{module}\"" : $"submodule deinit -- \"{module}\""; return Exec(); } - public override void OnReadline(string line) { - onProgress?.Invoke(line); + public bool Delete(string module) + { + Args = $"rm -rf \"{module}\""; + return Exec(); } } } diff --git a/src/Commands/Submodules.cs b/src/Commands/Submodules.cs deleted file mode 100644 index 4daf69f6..00000000 --- a/src/Commands/Submodules.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Collections.Generic; -using System.Text.RegularExpressions; - -namespace SourceGit.Commands { - /// - /// 获取子模块列表 - /// - public class Submodules : Command { - private readonly Regex REG_FORMAT = new Regex(@"^[\-\+ ][0-9a-f]+\s(.*)\s\(.*\)$"); - private List modules = new List(); - - public Submodules(string repo) { - Cwd = repo; - Args = "submodule status"; - } - - public List Result() { - Exec(); - return modules; - } - - public override void OnReadline(string line) { - var match = REG_FORMAT.Match(line); - if (!match.Success) return; - modules.Add(match.Groups[1].Value); - } - } -} diff --git a/src/Commands/Tag.cs b/src/Commands/Tag.cs index 88942878..017afea0 100644 --- a/src/Commands/Tag.cs +++ b/src/Commands/Tag.cs @@ -1,42 +1,51 @@ -using System.IO; +using System.IO; -namespace SourceGit.Commands { - - /// - /// 标签相关指令 - /// - public class Tag : Command { - - public Tag(string repo) { - Cwd = repo; +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 bool Add(string name, string basedOn, string message) { - Args = $"tag -a {name} {basedOn} "; + 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)) { + if (!string.IsNullOrEmpty(message)) + { string tmp = Path.GetTempFileName(); File.WriteAllText(tmp, message); - Args += $"-F \"{tmp}\""; - } else { - Args += $"-m {name}"; + cmd.Args += $"-F \"{tmp}\""; + + var succ = cmd.Exec(); + File.Delete(tmp); + return succ; } - return Exec(); + cmd.Args += $"-m {name}"; + return cmd.Exec(); } - public bool Delete(string name, bool push) { - Args = $"tag --delete {name}"; - if (!Exec()) return false; - - if (push) { - var remotes = new Remotes(Cwd).Result(); - foreach (var r in remotes) { - new Push(Cwd, r.Name, name, true).Exec(); - } - } - - return true; + 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/Tags.cs b/src/Commands/Tags.cs deleted file mode 100644 index f534c7b2..00000000 --- a/src/Commands/Tags.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Collections.Generic; -using System.Text.RegularExpressions; - -namespace SourceGit.Commands { - /// - /// 解析所有的Tags - /// - public class Tags : Command { - public static readonly string CMD = "for-each-ref --sort=-creatordate --format=\"$%(refname:short)$%(objectname)$%(*objectname)\" refs/tags"; - public static readonly Regex REG_FORMAT = new Regex(@"\$(.*)\$(.*)\$(.*)"); - - private List loaded = new List(); - - public Tags(string path) { - Cwd = path; - Args = CMD; - } - - public List Result() { - Exec(); - return loaded; - } - - public override void OnReadline(string line) { - var match = REG_FORMAT.Match(line); - if (!match.Success) return; - - var name = match.Groups[1].Value; - var commit = match.Groups[2].Value; - var dereference = match.Groups[3].Value; - - if (string.IsNullOrEmpty(dereference)) { - loaded.Add(new Models.Tag() { - Name = name, - SHA = commit, - }); - } else { - loaded.Add(new Models.Tag() { - Name = name, - SHA = dereference, - }); - } - } - } -} diff --git a/src/Commands/UnstageChangesForAmend.cs b/src/Commands/UnstageChangesForAmend.cs new file mode 100644 index 00000000..19def067 --- /dev/null +++ b/src/Commands/UnstageChangesForAmend.cs @@ -0,0 +1,95 @@ +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); + } + else if (c.Index == Models.ChangeState.Added) + { + _patchBuilder.Append("0 0000000000000000000000000000000000000000\t"); + _patchBuilder.Append(c.Path); + } + else if (c.Index == Models.ChangeState.Deleted) + { + _patchBuilder.Append("100644 "); + _patchBuilder.Append(c.DataForAmend.ObjectHash); + _patchBuilder.Append("\t"); + _patchBuilder.Append(c.Path); + } + 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/Version.cs b/src/Commands/Version.cs deleted file mode 100644 index 1911f34a..00000000 --- a/src/Commands/Version.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; - -namespace SourceGit.Commands { - /// - /// 检测git是否可用,并获取git版本信息 - /// - public class Version : Command { - const string GitVersionPrefix = "git version "; - public string Query() { - Args = $"--version"; - var result = ReadToEnd(); - if (!result.IsSuccess || string.IsNullOrEmpty(result.Output)) return null; - var version = result.Output.Trim(); - if (!version.StartsWith(GitVersionPrefix, StringComparison.Ordinal)) return null; - return version.Substring(GitVersionPrefix.Length); - } - } -} 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/Converters/StringConverters.cs b/src/Converters/StringConverters.cs new file mode 100644 index 00000000..bcadfae9 --- /dev/null +++ b/src/Converters/StringConverters.cs @@ -0,0 +1,88 @@ +using System; +using System.Globalization; + +using Avalonia.Data.Converters; +using Avalonia.Styling; + +namespace SourceGit.Converters +{ + public static class StringConverters + { + public class ToLocaleConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return Models.Locale.Supported.Find(x => x.Key == value as string); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + return (value as Models.Locale)?.Key; + } + } + + public static readonly ToLocaleConverter ToLocale = new ToLocaleConverter(); + + public class ToThemeConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var theme = (string)value; + 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) + { + return (value as ThemeVariant)?.Key; + } + } + + public static readonly ToThemeConverter ToTheme = new ToThemeConverter(); + + public class FormatByResourceKeyConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var key = parameter as string; + return App.Text(key, value); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } + + public static readonly FormatByResourceKeyConverter FormatByResourceKey = new FormatByResourceKeyConverter(); + + public static readonly FuncValueConverter ToShortSHA = + 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/FodyWeavers.xml b/src/FodyWeavers.xml deleted file mode 100644 index 5029e706..00000000 --- a/src/FodyWeavers.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/src/FodyWeavers.xsd b/src/FodyWeavers.xsd deleted file mode 100644 index 05e92c11..00000000 --- a/src/FodyWeavers.xsd +++ /dev/null @@ -1,141 +0,0 @@ - - - - - - -
Open-source GUI client for git users