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 36c030fa..e686a534 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,41 @@ -.idea -.vs -bin -obj -publish \ No newline at end of file +.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 b67c6d52..442ce085 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2020 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 cea77427..f9ba3072 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,207 @@ -# SourceGit +# SourceGit - Opensource Git GUI client. -开源的Git客户端,仅用于Windows。 +[](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 -[发行版](https://gitee.com/sourcegit/SourceGit/releases/) +* 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)) -1. `SourceGit.exe`为不包含`.NET 5.0`运行时的可执行文件。如果本机已有`.NET 5.0`可下载使用 -2. `SourceGit.zip`为`self-contained`包。包含了`.NET 5.0`运行时。 +> [!WARNING] +> **Linux** only tested on **Debian 12** on both **X11** & **Wayland**. -## 预览 +## Translation Status -* DarkTheme +You can find the current translation status in [TRANSLATION.md](https://github.com/sourcegit-scm/sourcegit/blob/develop/TRANSLATION.md) - +## How to Use -* LightTheme +**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. -## Thanks +| OS | PATH | +|---------|-----------------------------------------------------| +| Windows | `%APPDATA%\SourceGit` | +| Linux | `${HOME}/.config/SourceGit` or `${HOME}/.sourcegit` | +| macOS | `${HOME}/Library/Application Support/SourceGit` | -* [PUMA](https://gitee.com/whgfu) 配置默认User +> [!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.ps1 b/build.ps1 deleted file mode 100644 index 24b0c369..00000000 --- a/build.ps1 +++ /dev/null @@ -1,6 +0,0 @@ -cd src - -dotnet publish -c Release -r win-x64 -o ..\publish\ -p:PublishSingleFile=true -p:PublishTrimmed=true -p:TrimMode=link -p:IncludeNativeLibrariesForSelfExtract=true --self-contained=true -Compress-Archive -Update -Path ..\publish\SourceGit.exe -DestinationPath ..\publish\SourceGit.zip - -dotnet publish -c Release -r win-x64 -o ..\publish\ -p:PublishSingleFile=true --self-contained=false \ 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 6e9b8dfd..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 7e80d9be..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.ico b/src/App.ico index 9063ffee..fb537a6a 100644 Binary files a/src/App.ico and b/src/App.ico differ 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.preference.cs b/src/App.preference.cs deleted file mode 100644 index da6b6467..00000000 --- a/src/App.preference.cs +++ /dev/null @@ -1,265 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; - -namespace SourceGit { - - /// - /// User's preference settings. Serialized to - /// - public class Preference { - - /// - /// Tools setting. - /// - public class ToolSetting { - /// - /// Git executable file path. - /// - public string GitExecutable { get; set; } - /// - /// Default clone directory. - /// - public string GitDefaultCloneDir { get; set; } - /// - /// Selected merge tool. - /// - public int MergeTool { get; set; } = 0; - /// - /// Executable file path for merge tool. - /// - public string MergeExecutable { get; set; } = "--"; - } - - /// - /// File's display mode. - /// - public enum FilesDisplayMode { - Tree, - List, - Grid, - } - - /// - /// Settings for UI. - /// - public class UISetting { - /// - /// Use light theme? - /// - public bool UseLightTheme { get; set; } - /// - /// Main window width - /// - public double WindowWidth { get; set; } - /// - /// Main window height - /// - public double WindowHeight { get; set; } - /// - /// Move commit viewer from bottom to right - /// - public bool MoveCommitViewerRight { get; set; } - /// - /// File's display mode in unstaged view. - /// - public FilesDisplayMode UnstageFileDisplayMode { get; set; } - /// - /// File's display mode in staged view. - /// - public FilesDisplayMode StagedFileDisplayMode { get; set; } - /// - /// Use DataGrid instead of TreeView in changes view. - /// - public bool UseListInChanges { get; set; } - /// - /// Use combined instead of side-by-side mode in diff viewer. - /// - public bool UseCombinedDiff { get; set; } - } - - /// - /// Group(Virtual folder) for watched repositories. - /// - public class Group { - /// - /// Unique ID of this group. - /// - public string Id { get; set; } - /// - /// Display name. - /// - public string Name { get; set; } - /// - /// Parent ID. - /// - public string ParentId { get; set; } - /// - /// Cache UI IsExpended status. - /// - public bool IsExpended { get; set; } - } - - #region SAVED_DATAS - /// - /// Check for updates. - /// - public bool CheckUpdate { get; set; } = true; - /// - /// Settings for executables. - /// - public ToolSetting Tools { get; set; } = new ToolSetting(); - /// - /// Use light color theme. - /// - public UISetting UI { get; set; } = new UISetting(); - #endregion - - #region SETTING_REPOS - /// - /// Groups for repositories. - /// - public List Groups { get; set; } = new List(); - /// - /// Watched repositories. - /// - public List Repositories { get; set; } = new List(); - #endregion - - #region METHODS_ON_GROUP - /// - /// Add new group(virtual folder). - /// - /// Display name. - /// Parent group ID. - /// Added group instance. - public Group AddGroup(string name, string parentId) { - var group = new Group() { - Name = name, - Id = Guid.NewGuid().ToString(), - ParentId = parentId, - IsExpended = false, - }; - - Groups.Add(group); - Groups.Sort((l, r) => l.Name.CompareTo(r.Name)); - - return group; - } - - /// - /// Find group by ID. - /// - /// Unique ID - /// Founded group's instance. - public Group FindGroup(string id) { - foreach (var group in Groups) { - if (group.Id == id) return group; - } - return null; - } - - /// - /// Rename group. - /// - /// Unique ID - /// New name. - public void RenameGroup(string id, string newName) { - foreach (var group in Groups) { - if (group.Id == id) { - group.Name = newName; - break; - } - } - - Groups.Sort((l, r) => l.Name.CompareTo(r.Name)); - } - - /// - /// Remove a group. - /// - /// Unique ID - public void RemoveGroup(string id) { - int removedIdx = -1; - - for (int i = 0; i < Groups.Count; i++) { - if (Groups[i].Id == id) { - removedIdx = i; - break; - } - } - - if (removedIdx >= 0) Groups.RemoveAt(removedIdx); - } - #endregion - - #region METHODS_ON_REPOS - /// - /// Add repository. - /// - /// Local storage path. - /// Group's ID - /// Added repository instance. - public Git.Repository AddRepository(string path, string groupId) { - var repo = FindRepository(path); - if (repo != null) return repo; - - var dir = new DirectoryInfo(path); - repo = new Git.Repository() { - Path = dir.FullName, - Name = dir.Name, - GroupId = groupId, - LastOpenTime = 0, - }; - - Repositories.Add(repo); - Repositories.Sort((l, r) => l.Name.CompareTo(r.Name)); - return repo; - } - - /// - /// Find repository by path. - /// - /// Local storage path. - /// Founded repository instance. - public Git.Repository FindRepository(string path) { - var dir = new DirectoryInfo(path); - foreach (var repo in Repositories) { - if (repo.Path == dir.FullName) return repo; - } - return null; - } - - /// - /// Change a repository's display name in RepositoryManager. - /// - /// Local storage path. - /// New name - public void RenameRepository(string path, string newName) { - var repo = FindRepository(path); - if (repo == null) return; - - repo.Name = newName; - Repositories.Sort((l, r) => l.Name.CompareTo(r.Name)); - } - - /// - /// Remove a repository in RepositoryManager. - /// - /// Local storage path. - public void RemoveRepository(string path) { - var dir = new DirectoryInfo(path); - var removedIdx = -1; - - for (int i = 0; i < Repositories.Count; i++) { - if (Repositories[i].Path == dir.FullName) { - removedIdx = i; - break; - } - } - - if (removedIdx >= 0) Repositories.RemoveAt(removedIdx); - } - #endregion - } -} diff --git a/src/App.xaml b/src/App.xaml deleted file mode 100644 index 408df2f0..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 3243a7c9..00000000 --- a/src/App.xaml.cs +++ /dev/null @@ -1,135 +0,0 @@ -using Microsoft.Win32; -using System; -using System.IO; -using System.Text.Json; -using System.Windows; - -namespace SourceGit { - - /// - /// Application. - /// - public partial class App : Application { - /// - /// Getter/Setter for application user setting. - /// - public static Preference Setting { get; set; } - - /// - /// Check if GIT has been configured. - /// - public static bool IsGitConfigured { - get { - return !string.IsNullOrEmpty(Setting.Tools.GitExecutable) - && File.Exists(Setting.Tools.GitExecutable); - } - } - - /// - /// Raise error message. - /// - /// - public static void RaiseError(string msg) { - Current.Dispatcher.Invoke(() => { - (Current.MainWindow as UI.Launcher).Errors.Add(msg); - }); - } - - /// - /// Startup event. - /// - /// - /// - private void OnAppStartup(object sender, StartupEventArgs e) { - // Use this app as a sequence editor? - var args = e.Args; - if (args.Length > 1) { - if (args[0] == "--interactive-rebase") { - if (args.Length < 3) { - Environment.Exit(1); - return; - } - - File.WriteAllText(args[2], File.ReadAllText(args[1])); - } - - Environment.Exit(0); - return; - } - - // Load settings. - var settingFile = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "SourceGit", - "preference.json"); - if (!File.Exists(settingFile)) { - Setting = new Preference(); - } else { - Setting = JsonSerializer.Deserialize(File.ReadAllText(settingFile)); - } - - // Try auto configure git via registry. - if (!IsGitConfigured) { - var root = RegistryKey.OpenBaseKey( - RegistryHive.LocalMachine, - Environment.Is64BitOperatingSystem ? RegistryView.Registry64 : RegistryView.Registry32); - - var git = root.OpenSubKey("SOFTWARE\\GitForWindows"); - if (git != null) { - Setting.Tools.GitExecutable = Path.Combine( - git.GetValue("InstallPath") as string, - "bin", - "git.exe"); - } - } - - // Apply themes - if (Setting.UI.UseLightTheme) { - foreach (var rs in Current.Resources.MergedDictionaries) { - if (rs.Source != null && rs.Source.OriginalString.StartsWith("pack://application:,,,/Resources/Themes/")) { - rs.Source = new Uri("pack://application:,,,/Resources/Themes/Light.xaml", UriKind.Absolute); - break; - } - } - } - - // Show main window - Current.MainWindow = new UI.Launcher(); - Current.MainWindow.Show(); - } - - /// - /// Open repository. - /// - /// - public static void Open(Git.Repository repo) { - (Current.MainWindow as UI.Launcher).Open(repo); - } - - /// - /// Save settings. - /// - public static void SaveSetting() { - var settingFile = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "SourceGit", - "preference.json"); - - var dir = Path.GetDirectoryName(settingFile); - if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); - - var data = JsonSerializer.Serialize(Setting, new JsonSerializerOptions() { WriteIndented = true }); - File.WriteAllText(settingFile, data); - } - - /// - /// Deactivated event. - /// - /// - /// - private void OnAppDeactivated(object sender, EventArgs e) { - GC.Collect(); - SaveSetting(); - } - } -} diff --git a/src/Commands/Add.cs b/src/Commands/Add.cs new file mode 100644 index 00000000..210eb4b2 --- /dev/null +++ b/src/Commands/Add.cs @@ -0,0 +1,26 @@ +namespace SourceGit.Commands +{ + public class Add : Command + { + public Add(string repo, bool includeUntracked) + { + WorkingDirectory = repo; + Context = repo; + Args = includeUntracked ? "add ." : "add -u ."; + } + + public Add(string repo, Models.Change change) + { + WorkingDirectory = repo; + Context = repo; + Args = $"add -- \"{change.Path}\""; + } + + public Add(string repo, string pathspecFromFile) + { + WorkingDirectory = repo; + Context = repo; + Args = $"add --pathspec-from-file=\"{pathspecFromFile}\""; + } + } +} diff --git a/src/Commands/Apply.cs b/src/Commands/Apply.cs new file mode 100644 index 00000000..d1c9ffbc --- /dev/null +++ b/src/Commands/Apply.cs @@ -0,0 +1,19 @@ +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 (!string.IsNullOrEmpty(extra)) + Args += $"{extra} "; + Args += $"\"{file}\""; + } + } +} diff --git a/src/Commands/Archive.cs b/src/Commands/Archive.cs new file mode 100644 index 00000000..5e0919f7 --- /dev/null +++ b/src/Commands/Archive.cs @@ -0,0 +1,12 @@ +namespace SourceGit.Commands +{ + public class Archive : Command + { + public Archive(string repo, string revision, string saveTo) + { + WorkingDirectory = repo; + Context = repo; + Args = $"archive --format=zip --verbose --output=\"{saveTo}\" {revision}"; + } + } +} diff --git a/src/Commands/AssumeUnchanged.cs b/src/Commands/AssumeUnchanged.cs new file mode 100644 index 00000000..28f78280 --- /dev/null +++ b/src/Commands/AssumeUnchanged.cs @@ -0,0 +1,14 @@ +namespace SourceGit.Commands +{ + public class AssumeUnchanged : Command + { + public AssumeUnchanged(string repo, string file, bool bAdd) + { + var mode = bAdd ? "--assume-unchanged" : "--no-assume-unchanged"; + + WorkingDirectory = repo; + Context = repo; + Args = $"update-index {mode} -- \"{file}\""; + } + } +} diff --git a/src/Commands/Bisect.cs b/src/Commands/Bisect.cs new file mode 100644 index 00000000..a3bf1a97 --- /dev/null +++ b/src/Commands/Bisect.cs @@ -0,0 +1,13 @@ +namespace SourceGit.Commands +{ + public class Bisect : Command + { + public Bisect(string repo, string subcmd) + { + WorkingDirectory = repo; + Context = repo; + RaiseError = false; + Args = $"bisect {subcmd}"; + } + } +} diff --git a/src/Commands/Blame.cs b/src/Commands/Blame.cs new file mode 100644 index 00000000..1fc51fa4 --- /dev/null +++ b/src/Commands/Blame.cs @@ -0,0 +1,97 @@ +using System; +using System.Text; +using System.Text.RegularExpressions; + +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(); + + public Blame(string repo, string file, string revision) + { + WorkingDirectory = repo; + Context = repo; + Args = $"blame -t {revision} -- \"{file}\""; + RaiseError = false; + + _result.File = file; + } + + public Models.BlameData Result() + { + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return _result; + + 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); + } + } + } + + _result.Content = _content.ToString(); + return _result; + } + + 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; + + _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 when = DateTime.UnixEpoch.AddSeconds(timestamp).ToLocalTime().ToString(_dateFormat); + + var info = new Models.BlameLineInfo() + { + IsFirstInGroup = commit != _lastSHA, + CommitSHA = commit, + Author = author, + Time = when, + }; + + _result.LineInfos.Add(info); + _lastSHA = commit; + + 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 new file mode 100644 index 00000000..0d1b1f8f --- /dev/null +++ b/src/Commands/Branch.cs @@ -0,0 +1,83 @@ +using System.Text; + +namespace SourceGit.Commands +{ + public static class Branch + { + public static string ShowCurrent(string repo) + { + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.Args = $"branch --show-current"; + return cmd.ReadToEnd().StdOut.Trim(); + } + + public static bool Create(string repo, string name, string basedOn, bool force, Models.ICommandLog log) + { + var builder = new StringBuilder(); + builder.Append("branch "); + if (force) + builder.Append("-f "); + builder.Append(name); + builder.Append(" "); + builder.Append(basedOn); + + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.Args = builder.ToString(); + cmd.Log = log; + return cmd.Exec(); + } + + public static bool Rename(string repo, string name, string to, Models.ICommandLog log) + { + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.Args = $"branch -M {name} {to}"; + cmd.Log = log; + return cmd.Exec(); + } + + public static bool SetUpstream(string repo, string name, string upstream, Models.ICommandLog log) + { + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.Log = log; + + if (string.IsNullOrEmpty(upstream)) + cmd.Args = $"branch {name} --unset-upstream"; + else + cmd.Args = $"branch {name} -u {upstream}"; + + return cmd.Exec(); + } + + public static bool DeleteLocal(string repo, string name, Models.ICommandLog log) + { + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.Args = $"branch -D {name}"; + cmd.Log = log; + return cmd.Exec(); + } + + public static bool DeleteRemote(string repo, string remote, string name, Models.ICommandLog log) + { + bool exists = new Remote(repo).HasBranch(remote, name); + if (exists) + return new Push(repo, remote, $"refs/heads/{name}", true) { Log = log }.Exec(); + + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.Args = $"branch -D -r {remote}/{name}"; + cmd.Log = log; + return cmd.Exec(); + } + } +} diff --git a/src/Commands/Checkout.cs b/src/Commands/Checkout.cs new file mode 100644 index 00000000..d2876740 --- /dev/null +++ b/src/Commands/Checkout.cs @@ -0,0 +1,82 @@ +using System.Collections.Generic; +using System.Text; + +namespace SourceGit.Commands +{ + public class Checkout : Command + { + public Checkout(string repo) + { + WorkingDirectory = repo; + Context = repo; + } + + public bool Branch(string branch, bool force) + { + var builder = new StringBuilder(); + builder.Append("checkout --progress "); + if (force) + builder.Append("--force "); + builder.Append(branch); + + Args = builder.ToString(); + return Exec(); + } + + public bool Branch(string branch, string basedOn, bool force, bool allowOverwrite) + { + var builder = new StringBuilder(); + builder.Append("checkout --progress "); + if (force) + builder.Append("--force "); + builder.Append(allowOverwrite ? "-B " : "-b "); + builder.Append(branch); + builder.Append(" "); + builder.Append(basedOn); + + Args = builder.ToString(); + return Exec(); + } + + public bool Commit(string commitId, bool force) + { + var option = force ? "--force" : string.Empty; + Args = $"checkout {option} --detach --progress {commitId}"; + return Exec(); + } + + public bool UseTheirs(List files) + { + var builder = new StringBuilder(); + builder.Append("checkout --theirs --"); + foreach (var f in files) + { + builder.Append(" \""); + builder.Append(f); + builder.Append("\""); + } + Args = builder.ToString(); + return Exec(); + } + + public bool UseMine(List files) + { + var builder = new StringBuilder(); + builder.Append("checkout --ours --"); + foreach (var f in files) + { + builder.Append(" \""); + builder.Append(f); + builder.Append("\""); + } + Args = builder.ToString(); + return Exec(); + } + + public bool FileWithRevision(string file, string revision) + { + Args = $"checkout --no-overlay {revision} -- \"{file}\""; + return Exec(); + } + } +} diff --git a/src/Commands/CherryPick.cs b/src/Commands/CherryPick.cs new file mode 100644 index 00000000..0c82b9fd --- /dev/null +++ b/src/Commands/CherryPick.cs @@ -0,0 +1,20 @@ +namespace SourceGit.Commands +{ + public class CherryPick : Command + { + public CherryPick(string repo, string commits, bool noCommit, bool appendSourceToMessage, string extraParams) + { + WorkingDirectory = repo; + Context = repo; + + Args = "cherry-pick "; + if (noCommit) + Args += "-n "; + if (appendSourceToMessage) + Args += "-x "; + if (!string.IsNullOrEmpty(extraParams)) + Args += $"{extraParams} "; + Args += commits; + } + } +} diff --git a/src/Commands/Clean.cs b/src/Commands/Clean.cs new file mode 100644 index 00000000..6ed74999 --- /dev/null +++ b/src/Commands/Clean.cs @@ -0,0 +1,12 @@ +namespace SourceGit.Commands +{ + public class Clean : Command + { + public Clean(string repo) + { + WorkingDirectory = repo; + Context = repo; + Args = "clean -qfdx"; + } + } +} diff --git a/src/Commands/Clone.cs b/src/Commands/Clone.cs new file mode 100644 index 00000000..efec264b --- /dev/null +++ b/src/Commands/Clone.cs @@ -0,0 +1,21 @@ +namespace SourceGit.Commands +{ + public class Clone : Command + { + public Clone(string ctx, string path, string url, string localName, string sshKey, string extraArgs) + { + Context = ctx; + WorkingDirectory = path; + SSHKey = sshKey; + Args = "clone --progress --verbose "; + + if (!string.IsNullOrEmpty(extraArgs)) + Args += $"{extraArgs} "; + + Args += $"{url} "; + + if (!string.IsNullOrEmpty(localName)) + Args += localName; + } + } +} diff --git a/src/Commands/Command.cs b/src/Commands/Command.cs new file mode 100644 index 00000000..975922fc --- /dev/null +++ b/src/Commands/Command.cs @@ -0,0 +1,220 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; + +using Avalonia.Threading; + +namespace SourceGit.Commands +{ + public partial class Command + { + public class ReadToEndResult + { + public bool IsSuccess { get; set; } = false; + public string StdOut { get; set; } = ""; + public string StdErr { get; set; } = ""; + } + + public enum EditorType + { + None, + CoreEditor, + RebaseEditor, + } + + public string Context { get; set; } = string.Empty; + public CancellationToken CancellationToken { get; set; } = CancellationToken.None; + public string WorkingDirectory { get; set; } = null; + public EditorType Editor { get; set; } = EditorType.CoreEditor; // Only used in Exec() mode + public string SSHKey { get; set; } = string.Empty; + public string Args { get; set; } = string.Empty; + public bool RaiseError { get; set; } = true; + public Models.ICommandLog Log { get; set; } = null; + + public bool Exec() + { + Log?.AppendLine($"$ git {Args}\n"); + + var start = CreateGitStartInfo(); + var errs = new List(); + var proc = new Process() { StartInfo = start }; + + proc.OutputDataReceived += (_, e) => HandleOutput(e.Data, errs); + proc.ErrorDataReceived += (_, e) => HandleOutput(e.Data, errs); + + var dummy = null as Process; + var dummyProcLock = new object(); + try + { + proc.Start(); + + // It not safe, please only use `CancellationToken` in readonly commands. + if (CancellationToken.CanBeCanceled) + { + dummy = proc; + CancellationToken.Register(() => + { + lock (dummyProcLock) + { + if (dummy is { HasExited: false }) + dummy.Kill(); + } + }); + } + } + catch (Exception e) + { + if (RaiseError) + Dispatcher.UIThread.Post(() => App.RaiseException(Context, e.Message)); + + Log?.AppendLine(string.Empty); + return false; + } + + proc.BeginOutputReadLine(); + proc.BeginErrorReadLine(); + proc.WaitForExit(); + + if (dummy != null) + { + lock (dummyProcLock) + { + dummy = null; + } + } + + int exitCode = proc.ExitCode; + proc.Close(); + Log?.AppendLine(string.Empty); + + if (!CancellationToken.IsCancellationRequested && exitCode != 0) + { + if (RaiseError) + { + var errMsg = string.Join("\n", errs).Trim(); + if (!string.IsNullOrEmpty(errMsg)) + Dispatcher.UIThread.Post(() => App.RaiseException(Context, errMsg)); + } + + return false; + } + + return true; + } + + public ReadToEndResult ReadToEnd() + { + var start = CreateGitStartInfo(); + var proc = new Process() { StartInfo = start }; + + try + { + proc.Start(); + } + catch (Exception e) + { + return new ReadToEndResult() + { + IsSuccess = false, + StdOut = string.Empty, + StdErr = e.Message, + }; + } + + var rs = new ReadToEndResult() + { + StdOut = proc.StandardOutput.ReadToEnd(), + StdErr = proc.StandardError.ReadToEnd(), + }; + + proc.WaitForExit(); + rs.IsSuccess = proc.ExitCode == 0; + proc.Close(); + + return rs; + } + + private ProcessStartInfo CreateGitStartInfo() + { + var start = new ProcessStartInfo(); + start.FileName = Native.OS.GitExecutable; + start.Arguments = "--no-pager -c core.quotepath=off -c credential.helper=manager "; + start.UseShellExecute = false; + start.CreateNoWindow = true; + start.RedirectStandardOutput = true; + start.RedirectStandardError = true; + start.StandardOutputEncoding = Encoding.UTF8; + start.StandardErrorEncoding = Encoding.UTF8; + + // Force using this app as SSH askpass program + var selfExecFile = Process.GetCurrentProcess().MainModule!.FileName; + if (!OperatingSystem.IsLinux()) + start.Environment.Add("DISPLAY", "required"); + start.Environment.Add("SSH_ASKPASS", selfExecFile); // Can not use parameter here, because it invoked by SSH with `exec` + start.Environment.Add("SSH_ASKPASS_REQUIRE", "prefer"); + start.Environment.Add("SOURCEGIT_LAUNCH_AS_ASKPASS", "TRUE"); + + // If an SSH private key was provided, sets the environment. + if (!start.Environment.ContainsKey("GIT_SSH_COMMAND") && !string.IsNullOrEmpty(SSHKey)) + start.Environment.Add("GIT_SSH_COMMAND", $"ssh -i '{SSHKey}'"); + + // Force using en_US.UTF-8 locale + if (OperatingSystem.IsLinux()) + { + start.Environment.Add("LANG", "C"); + start.Environment.Add("LC_ALL", "C"); + } + + // Force using this app as git editor. + switch (Editor) + { + case EditorType.CoreEditor: + start.Arguments += $"-c core.editor=\"\\\"{selfExecFile}\\\" --core-editor\" "; + break; + case EditorType.RebaseEditor: + start.Arguments += $"-c core.editor=\"\\\"{selfExecFile}\\\" --rebase-message-editor\" -c sequence.editor=\"\\\"{selfExecFile}\\\" --rebase-todo-editor\" -c rebase.abbreviateCommands=true "; + break; + default: + start.Arguments += "-c core.editor=true "; + break; + } + + // Append command args + start.Arguments += Args; + + // Working directory + if (!string.IsNullOrEmpty(WorkingDirectory)) + start.WorkingDirectory = WorkingDirectory; + + return start; + } + + private void HandleOutput(string line, List errs) + { + line ??= string.Empty; + Log?.AppendLine(line); + + // Lines to hide in error message. + if (line.Length > 0) + { + if (line.StartsWith("remote: Enumerating objects:", StringComparison.Ordinal) || + line.StartsWith("remote: Counting objects:", StringComparison.Ordinal) || + line.StartsWith("remote: Compressing objects:", StringComparison.Ordinal) || + line.StartsWith("Filtering content:", StringComparison.Ordinal) || + line.StartsWith("hint:", StringComparison.Ordinal)) + return; + + if (REG_PROGRESS().IsMatch(line)) + return; + } + + errs.Add(line); + } + + [GeneratedRegex(@"\d+%")] + private static partial Regex REG_PROGRESS(); + } +} diff --git a/src/Commands/Commit.cs b/src/Commands/Commit.cs new file mode 100644 index 00000000..1585e7e3 --- /dev/null +++ b/src/Commands/Commit.cs @@ -0,0 +1,39 @@ +using System.IO; + +namespace SourceGit.Commands +{ + public class Commit : Command + { + public Commit(string repo, string message, bool signOff, bool amend, bool resetAuthor) + { + _tmpFile = Path.GetTempFileName(); + File.WriteAllText(_tmpFile, message); + + WorkingDirectory = repo; + Context = repo; + Args = $"commit --allow-empty --file=\"{_tmpFile}\""; + if (signOff) + Args += " --signoff"; + if (amend) + Args += resetAuthor ? " --amend --reset-author --no-edit" : " --amend --no-edit"; + } + + public bool Run() + { + var succ = Exec(); + + try + { + File.Delete(_tmpFile); + } + catch + { + // Ignore + } + + return succ; + } + + private readonly string _tmpFile; + } +} 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 new file mode 100644 index 00000000..49e8fcb7 --- /dev/null +++ b/src/Commands/Config.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; + +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; + } + + RaiseError = false; + } + + public Dictionary ListAll() + { + Args = "config -l"; + + 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 new file mode 100644 index 00000000..6af0a3cc --- /dev/null +++ b/src/Commands/Diff.cs @@ -0,0 +1,281 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace SourceGit.Commands +{ + public partial class Diff : Command + { + [GeneratedRegex(@"^@@ \-(\d+),?\d* \+(\d+),?\d* @@")] + private static partial Regex REG_INDICATOR(); + + [GeneratedRegex(@"^index\s([0-9a-f]{6,40})\.\.([0-9a-f]{6,40})(\s[1-9]{6})?")] + private static partial Regex REG_HASH_CHANGE(); + + private const string PREFIX_LFS_NEW = "+version https://git-lfs.github.com/spec/"; + private const string PREFIX_LFS_DEL = "-version https://git-lfs.github.com/spec/"; + private const string PREFIX_LFS_MODIFY = " version https://git-lfs.github.com/spec/"; + + public Diff(string repo, Models.DiffOption opt, 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.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; + } + + private void ParseLine(string line) + { + if (_result.IsBinary) + return; + + if (line.StartsWith("old mode ", StringComparison.Ordinal)) + { + _result.OldMode = line.Substring(9); + return; + } + + if (line.StartsWith("new mode ", StringComparison.Ordinal)) + { + _result.NewMode = line.Substring(9); + return; + } + + if (line.StartsWith("deleted file mode ", StringComparison.Ordinal)) + { + _result.OldMode = line.Substring(18); + return; + } + + if (line.StartsWith("new file mode ", StringComparison.Ordinal)) + { + _result.NewMode = line.Substring(14); + return; + } + + if (_result.IsLFS) + { + var ch = line[0]; + 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; + } + + 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 == '-') + { + 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 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; + + 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.TextInlineRange(chunk.DeletedStart, chunk.DeletedCount)); + } + + if (chunk.AddedCount > 0) + { + right.Highlights.Add(new Models.TextInlineRange(chunk.AddedStart, chunk.AddedCount)); + } + } + } + } + + _result.TextDiff.Lines.AddRange(_deleted); + _deleted.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 new file mode 100644 index 00000000..f36ca6c9 --- /dev/null +++ b/src/Commands/Discard.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using Avalonia.Threading; + +namespace SourceGit.Commands +{ + public static class Discard + { + /// + /// Discard all local changes (unstaged & staged) + /// + /// + /// + /// + public static void All(string repo, bool includeIgnored, Models.ICommandLog log) + { + var changes = new QueryLocalChanges(repo).Result(); + try + { + foreach (var c in changes) + { + if (c.WorkTree == Models.ChangeState.Untracked || + c.WorkTree == Models.ChangeState.Added || + c.Index == Models.ChangeState.Added || + c.Index == Models.ChangeState.Renamed) + { + var fullPath = Path.Combine(repo, c.Path); + if (Directory.Exists(fullPath)) + Directory.Delete(fullPath, true); + else + File.Delete(fullPath); + } + } + } + catch (Exception e) + { + Dispatcher.UIThread.Invoke(() => + { + App.RaiseException(repo, $"Failed to discard changes. Reason: {e.Message}"); + }); + } + + new Reset(repo, "HEAD", "--hard") { Log = log }.Exec(); + + if (includeIgnored) + new Clean(repo) { Log = log }.Exec(); + } + + /// + /// Discard selected changes (only unstaged). + /// + /// + /// + /// + public static void Changes(string repo, List changes, Models.ICommandLog log) + { + var restores = new List(); + + try + { + foreach (var c in changes) + { + if (c.WorkTree == Models.ChangeState.Untracked || c.WorkTree == Models.ChangeState.Added) + { + var fullPath = Path.Combine(repo, c.Path); + if (Directory.Exists(fullPath)) + Directory.Delete(fullPath, true); + else + File.Delete(fullPath); + } + else + { + restores.Add(c.Path); + } + } + } + catch (Exception e) + { + Dispatcher.UIThread.Invoke(() => + { + App.RaiseException(repo, $"Failed to discard changes. Reason: {e.Message}"); + }); + } + + if (restores.Count > 0) + { + var pathSpecFile = Path.GetTempFileName(); + File.WriteAllLines(pathSpecFile, restores); + new Restore(repo, pathSpecFile, false) { Log = log }.Exec(); + File.Delete(pathSpecFile); + } + } + } +} diff --git a/src/Commands/ExecuteCustomAction.cs b/src/Commands/ExecuteCustomAction.cs new file mode 100644 index 00000000..e59bc068 --- /dev/null +++ b/src/Commands/ExecuteCustomAction.cs @@ -0,0 +1,86 @@ +using System; +using System.Diagnostics; +using System.Text; + +using Avalonia.Threading; + +namespace SourceGit.Commands +{ + public static class ExecuteCustomAction + { + public static void Run(string repo, string file, string args) + { + var start = new ProcessStartInfo(); + start.FileName = file; + start.Arguments = args; + start.UseShellExecute = false; + start.CreateNoWindow = true; + start.WorkingDirectory = repo; + + try + { + Process.Start(start); + } + catch (Exception e) + { + Dispatcher.UIThread.Invoke(() => App.RaiseException(repo, e.Message)); + } + } + + public static void RunAndWait(string repo, string file, string args, Models.ICommandLog log) + { + var start = new ProcessStartInfo(); + start.FileName = file; + start.Arguments = args; + start.UseShellExecute = false; + start.CreateNoWindow = true; + start.RedirectStandardOutput = true; + start.RedirectStandardError = true; + start.StandardOutputEncoding = Encoding.UTF8; + start.StandardErrorEncoding = Encoding.UTF8; + start.WorkingDirectory = repo; + + log?.AppendLine($"$ {file} {args}\n"); + + var proc = new Process() { StartInfo = start }; + var builder = new StringBuilder(); + + proc.OutputDataReceived += (_, e) => + { + if (e.Data != null) + log?.AppendLine(e.Data); + }; + + proc.ErrorDataReceived += (_, e) => + { + if (e.Data != null) + { + log?.AppendLine(e.Data); + builder.AppendLine(e.Data); + } + }; + + try + { + proc.Start(); + proc.BeginOutputReadLine(); + proc.BeginErrorReadLine(); + proc.WaitForExit(); + + var exitCode = proc.ExitCode; + if (exitCode != 0) + { + var errMsg = builder.ToString().Trim(); + if (!string.IsNullOrEmpty(errMsg)) + Dispatcher.UIThread.Invoke(() => App.RaiseException(repo, errMsg)); + } + } + catch (Exception e) + { + Dispatcher.UIThread.Invoke(() => App.RaiseException(repo, e.Message)); + } + + proc.Close(); + } + } +} diff --git a/src/Commands/Fetch.cs b/src/Commands/Fetch.cs new file mode 100644 index 00000000..edf2a6dd --- /dev/null +++ b/src/Commands/Fetch.cs @@ -0,0 +1,31 @@ +namespace SourceGit.Commands +{ + public class Fetch : Command + { + public Fetch(string repo, string remote, bool noTags, bool force) + { + WorkingDirectory = repo; + Context = repo; + SSHKey = new Config(repo).Get($"remote.{remote}.sshkey"); + Args = "fetch --progress --verbose "; + + if (noTags) + Args += "--no-tags "; + else + Args += "--tags "; + + if (force) + Args += "--force "; + + Args += remote; + } + + public Fetch(string repo, Models.Branch local, Models.Branch remote) + { + WorkingDirectory = repo; + Context = repo; + SSHKey = new Config(repo).Get($"remote.{remote.Remote}.sshkey"); + Args = $"fetch --progress --verbose {remote.Remote} {remote.Name}:{local.Name}"; + } + } +} diff --git a/src/Commands/FormatPatch.cs b/src/Commands/FormatPatch.cs new file mode 100644 index 00000000..bf850d60 --- /dev/null +++ b/src/Commands/FormatPatch.cs @@ -0,0 +1,13 @@ +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 new file mode 100644 index 00000000..0b27f487 --- /dev/null +++ b/src/Commands/GC.cs @@ -0,0 +1,12 @@ +namespace SourceGit.Commands +{ + public class GC : Command + { + public GC(string repo) + { + WorkingDirectory = repo; + Context = repo; + Args = "gc --prune=now"; + } + } +} diff --git a/src/Commands/GenerateCommitMessage.cs b/src/Commands/GenerateCommitMessage.cs new file mode 100644 index 00000000..df61fdd2 --- /dev/null +++ b/src/Commands/GenerateCommitMessage.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; + +using Avalonia.Threading; + +namespace SourceGit.Commands +{ + /// + /// A C# version of https://github.com/anjerodev/commitollama + /// + public class GenerateCommitMessage + { + public class GetDiffContent : Command + { + public GetDiffContent(string repo, Models.DiffOption opt) + { + WorkingDirectory = repo; + Context = repo; + Args = $"diff --diff-algorithm=minimal {opt}"; + } + } + + public GenerateCommitMessage(Models.OpenAIService service, string repo, List changes, CancellationToken cancelToken, Action onResponse) + { + _service = service; + _repo = repo; + _changes = changes; + _cancelToken = cancelToken; + _onResponse = onResponse; + } + + public void Exec() + { + try + { + _onResponse?.Invoke("Waiting for pre-file analyzing to completed...\n\n"); + + var responseBuilder = new StringBuilder(); + var summaryBuilder = new StringBuilder(); + foreach (var change in _changes) + { + if (_cancelToken.IsCancellationRequested) + return; + + responseBuilder.Append("- "); + summaryBuilder.Append("- "); + + var rs = new GetDiffContent(_repo, new Models.DiffOption(change, false)).ReadToEnd(); + if (rs.IsSuccess) + { + _service.Chat( + _service.AnalyzeDiffPrompt, + $"Here is the `git diff` output: {rs.StdOut}", + _cancelToken, + update => + { + responseBuilder.Append(update); + summaryBuilder.Append(update); + + _onResponse?.Invoke($"Waiting for pre-file analyzing to completed...\n\n{responseBuilder}"); + }); + } + + responseBuilder.Append("\n"); + summaryBuilder.Append("(file: "); + summaryBuilder.Append(change.Path); + summaryBuilder.Append(")\n"); + } + + if (_cancelToken.IsCancellationRequested) + return; + + var responseBody = responseBuilder.ToString(); + var subjectBuilder = new StringBuilder(); + _service.Chat( + _service.GenerateSubjectPrompt, + $"Here are the summaries changes:\n{summaryBuilder}", + _cancelToken, + update => + { + subjectBuilder.Append(update); + _onResponse?.Invoke($"{subjectBuilder}\n\n{responseBody}"); + }); + } + catch (Exception e) + { + Dispatcher.UIThread.Post(() => App.RaiseException(_repo, $"Failed to generate commit message: {e}")); + } + } + + private Models.OpenAIService _service; + private string _repo; + private List _changes; + private CancellationToken _cancelToken; + private Action _onResponse; + } +} diff --git a/src/Commands/GitFlow.cs b/src/Commands/GitFlow.cs new file mode 100644 index 00000000..1d33fa3a --- /dev/null +++ b/src/Commands/GitFlow.cs @@ -0,0 +1,92 @@ +using System.Text; +using Avalonia.Threading; + +namespace SourceGit.Commands +{ + public static class GitFlow + { + public static bool Init(string repo, string master, string develop, string feature, string release, string hotfix, string version, Models.ICommandLog log) + { + var config = new Config(repo); + config.Set("gitflow.branch.master", master); + config.Set("gitflow.branch.develop", develop); + config.Set("gitflow.prefix.feature", feature); + config.Set("gitflow.prefix.bugfix", "bugfix/"); + config.Set("gitflow.prefix.release", release); + config.Set("gitflow.prefix.hotfix", hotfix); + config.Set("gitflow.prefix.support", "support/"); + config.Set("gitflow.prefix.versiontag", version, true); + + var init = new Command(); + init.WorkingDirectory = repo; + init.Context = repo; + init.Args = "flow init -d"; + init.Log = log; + return init.Exec(); + } + + public static bool Start(string repo, Models.GitFlowBranchType type, string name, Models.ICommandLog log) + { + var start = new Command(); + start.WorkingDirectory = repo; + start.Context = repo; + + switch (type) + { + case Models.GitFlowBranchType.Feature: + start.Args = $"flow feature start {name}"; + break; + case Models.GitFlowBranchType.Release: + start.Args = $"flow release start {name}"; + break; + case Models.GitFlowBranchType.Hotfix: + start.Args = $"flow hotfix start {name}"; + break; + default: + Dispatcher.UIThread.Invoke(() => App.RaiseException(repo, "Bad git-flow branch type!!!")); + return false; + } + + start.Log = log; + return start.Exec(); + } + + public static bool Finish(string repo, Models.GitFlowBranchType type, string name, bool squash, bool push, bool keepBranch, Models.ICommandLog log) + { + var builder = new StringBuilder(); + builder.Append("flow "); + + switch (type) + { + case Models.GitFlowBranchType.Feature: + builder.Append("feature"); + break; + case Models.GitFlowBranchType.Release: + builder.Append("release"); + break; + case Models.GitFlowBranchType.Hotfix: + builder.Append("hotfix"); + break; + default: + Dispatcher.UIThread.Invoke(() => App.RaiseException(repo, "Bad git-flow branch type!!!")); + return false; + } + + builder.Append(" finish "); + if (squash) + builder.Append("--squash "); + if (push) + builder.Append("--push "); + if (keepBranch) + builder.Append("-k "); + builder.Append(name); + + var finish = new Command(); + finish.WorkingDirectory = repo; + finish.Context = repo; + finish.Args = builder.ToString(); + finish.Log = log; + return finish.Exec(); + } + } +} diff --git a/src/Commands/GitIgnore.cs b/src/Commands/GitIgnore.cs new file mode 100644 index 00000000..8b351f5e --- /dev/null +++ b/src/Commands/GitIgnore.cs @@ -0,0 +1,23 @@ +using System.IO; + +namespace SourceGit.Commands +{ + public static class GitIgnore + { + public static void Add(string repo, string pattern) + { + var file = Path.Combine(repo, ".gitignore"); + if (!File.Exists(file)) + { + File.WriteAllLines(file, [pattern]); + return; + } + + var org = File.ReadAllText(file); + if (!org.EndsWith('\n')) + File.AppendAllLines(file, ["", pattern]); + else + File.AppendAllLines(file, [pattern]); + } + } +} diff --git a/src/Commands/Init.cs b/src/Commands/Init.cs new file mode 100644 index 00000000..c44486da --- /dev/null +++ b/src/Commands/Init.cs @@ -0,0 +1,12 @@ +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/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 new file mode 100644 index 00000000..18d2ba93 --- /dev/null +++ b/src/Commands/LFS.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; + +namespace SourceGit.Commands +{ + public partial class LFS + { + [GeneratedRegex(@"^(.+)\s+([\w.]+)\s+\w+:(\d+)$")] + private static partial Regex REG_LOCK(); + + 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) + { + _repo = repo; + } + + public bool IsEnabled() + { + var path = Path.Combine(_repo, ".git", "hooks", "pre-push"); + if (!File.Exists(path)) + return false; + + var content = File.ReadAllText(path); + return content.Contains("git lfs pre-push"); + } + + public bool Install(Models.ICommandLog log) + { + return new SubCmd(_repo, "lfs install --local", log).Exec(); + } + + public bool Track(string pattern, bool isFilenameMode, Models.ICommandLog log) + { + var opt = isFilenameMode ? "--filename" : ""; + return new SubCmd(_repo, $"lfs track {opt} \"{pattern}\"", log).Exec(); + } + + public void Fetch(string remote, Models.ICommandLog log) + { + new SubCmd(_repo, $"lfs fetch {remote}", log).Exec(); + } + + public void Pull(string remote, Models.ICommandLog log) + { + new SubCmd(_repo, $"lfs pull {remote}", log).Exec(); + } + + public void Push(string remote, Models.ICommandLog log) + { + new SubCmd(_repo, $"lfs push {remote}", log).Exec(); + } + + public void Prune(Models.ICommandLog log) + { + new SubCmd(_repo, "lfs prune", log).Exec(); + } + + public List Locks(string remote) + { + var locks = new List(); + var cmd = new SubCmd(_repo, $"lfs locks --remote={remote}", null); + var rs = cmd.ReadToEnd(); + if (rs.IsSuccess) + { + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var match = REG_LOCK().Match(line); + if (match.Success) + { + locks.Add(new Models.LFSLock() + { + File = match.Groups[1].Value, + User = match.Groups[2].Value, + ID = long.Parse(match.Groups[3].Value), + }); + } + } + } + + return locks; + } + + public bool Lock(string remote, string file, Models.ICommandLog log) + { + return new SubCmd(_repo, $"lfs lock --remote={remote} \"{file}\"", log).Exec(); + } + + public bool Unlock(string remote, string file, bool force, Models.ICommandLog log) + { + var opt = force ? "-f" : ""; + return new SubCmd(_repo, $"lfs unlock --remote={remote} {opt} \"{file}\"", log).Exec(); + } + + public bool Unlock(string remote, long id, bool force, Models.ICommandLog log) + { + var opt = force ? "-f" : ""; + return new SubCmd(_repo, $"lfs unlock --remote={remote} {opt} --id={id}", log).Exec(); + } + + private readonly string _repo; + } +} diff --git a/src/Commands/Merge.cs b/src/Commands/Merge.cs new file mode 100644 index 00000000..b08377b9 --- /dev/null +++ b/src/Commands/Merge.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Text; + +namespace SourceGit.Commands +{ + public class Merge : Command + { + public Merge(string repo, string source, string mode) + { + WorkingDirectory = repo; + Context = repo; + Args = $"merge --progress {source} {mode}"; + } + + public Merge(string repo, List targets, bool autoCommit, string strategy) + { + WorkingDirectory = repo; + Context = repo; + + var builder = new StringBuilder(); + builder.Append("merge --progress "); + if (!string.IsNullOrEmpty(strategy)) + builder.Append($"--strategy={strategy} "); + if (!autoCommit) + builder.Append("--no-commit "); + + foreach (var t in targets) + { + builder.Append(t); + builder.Append(' '); + } + + Args = builder.ToString(); + } + } +} diff --git a/src/Commands/MergeTool.cs b/src/Commands/MergeTool.cs new file mode 100644 index 00000000..fc6d0d75 --- /dev/null +++ b/src/Commands/MergeTool.cs @@ -0,0 +1,72 @@ +using System.IO; + +using Avalonia.Threading; + +namespace SourceGit.Commands +{ + public static class MergeTool + { + public static bool OpenForMerge(string repo, int toolType, string toolPath, string file) + { + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.RaiseError = true; + + // NOTE: If no names are specified, 'git mergetool' will run the merge tool program on every file with merge conflicts. + var fileArg = string.IsNullOrEmpty(file) ? "" : $"\"{file}\""; + + if (toolType == 0) + { + cmd.Args = $"mergetool {fileArg}"; + return cmd.Exec(); + } + + if (!File.Exists(toolPath)) + { + Dispatcher.UIThread.Post(() => App.RaiseException(repo, $"Can NOT find external merge tool in '{toolPath}'!")); + return false; + } + + var supported = Models.ExternalMerger.Supported.Find(x => x.Type == toolType); + if (supported == null) + { + Dispatcher.UIThread.Post(() => App.RaiseException(repo, "Invalid merge tool in preference setting!")); + return false; + } + + cmd.Args = $"-c mergetool.sourcegit.cmd=\"\\\"{toolPath}\\\" {supported.Cmd}\" -c mergetool.writeToTemp=true -c mergetool.keepBackup=false -c mergetool.trustExitCode=true mergetool --tool=sourcegit {fileArg}"; + return cmd.Exec(); + } + + public static bool OpenForDiff(string repo, int toolType, string toolPath, Models.DiffOption option) + { + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.RaiseError = true; + + if (toolType == 0) + { + cmd.Args = $"difftool -g --no-prompt {option}"; + return cmd.Exec(); + } + + if (!File.Exists(toolPath)) + { + Dispatcher.UIThread.Invoke(() => App.RaiseException(repo, $"Can NOT find external diff tool in '{toolPath}'!")); + return false; + } + + var supported = Models.ExternalMerger.Supported.Find(x => x.Type == toolType); + if (supported == null) + { + Dispatcher.UIThread.Post(() => App.RaiseException(repo, "Invalid merge tool in preference setting!")); + return false; + } + + cmd.Args = $"-c difftool.sourcegit.cmd=\"\\\"{toolPath}\\\" {supported.DiffCmd}\" difftool --tool=sourcegit --no-prompt {option}"; + return cmd.Exec(); + } + } +} diff --git a/src/Commands/Pull.cs b/src/Commands/Pull.cs new file mode 100644 index 00000000..698fbfce --- /dev/null +++ b/src/Commands/Pull.cs @@ -0,0 +1,18 @@ +namespace SourceGit.Commands +{ + public class Pull : Command + { + public Pull(string repo, string remote, string branch, bool useRebase) + { + WorkingDirectory = repo; + Context = repo; + SSHKey = new Config(repo).Get($"remote.{remote}.sshkey"); + Args = "pull --verbose --progress "; + + if (useRebase) + Args += "--rebase=true "; + + Args += $"{remote} {branch}"; + } + } +} diff --git a/src/Commands/Push.cs b/src/Commands/Push.cs new file mode 100644 index 00000000..8a5fe33c --- /dev/null +++ b/src/Commands/Push.cs @@ -0,0 +1,37 @@ +namespace SourceGit.Commands +{ + public class Push : Command + { + public Push(string repo, string local, string remote, string remoteBranch, bool withTags, bool checkSubmodules, bool track, bool force) + { + WorkingDirectory = repo; + Context = repo; + SSHKey = new Config(repo).Get($"remote.{remote}.sshkey"); + Args = "push --progress --verbose "; + + if (withTags) + Args += "--tags "; + if (checkSubmodules) + Args += "--recurse-submodules=check "; + if (track) + Args += "-u "; + if (force) + Args += "--force-with-lease "; + + Args += $"{remote} {local}:{remoteBranch}"; + } + + public Push(string repo, string remote, string refname, bool isDelete) + { + WorkingDirectory = repo; + Context = repo; + SSHKey = new Config(repo).Get($"remote.{remote}.sshkey"); + Args = "push "; + + if (isDelete) + Args += "--delete "; + + Args += $"{remote} {refname}"; + } + } +} diff --git a/src/Commands/QueryAssumeUnchangedFiles.cs b/src/Commands/QueryAssumeUnchangedFiles.cs new file mode 100644 index 00000000..b5c23b0b --- /dev/null +++ b/src/Commands/QueryAssumeUnchangedFiles.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace SourceGit.Commands +{ + public partial class QueryAssumeUnchangedFiles : Command + { + [GeneratedRegex(@"^(\w)\s+(.+)$")] + private static partial Regex REG_PARSE(); + + public QueryAssumeUnchangedFiles(string repo) + { + WorkingDirectory = repo; + Args = "ls-files -v"; + RaiseError = false; + } + + public List Result() + { + var outs = new List(); + var rs = ReadToEnd(); + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var match = REG_PARSE().Match(line); + if (!match.Success) + continue; + + if (match.Groups[1].Value == "h") + outs.Add(match.Groups[2].Value); + } + + return outs; + } + } +} diff --git a/src/Commands/QueryBranches.cs b/src/Commands/QueryBranches.cs new file mode 100644 index 00000000..d0ecd322 --- /dev/null +++ b/src/Commands/QueryBranches.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; + +namespace SourceGit.Commands +{ + public class QueryBranches : Command + { + private const string PREFIX_LOCAL = "refs/heads/"; + private const string PREFIX_REMOTE = "refs/remotes/"; + private const string PREFIX_DETACHED_AT = "(HEAD detached at"; + private const string PREFIX_DETACHED_FROM = "(HEAD detached from"; + + public QueryBranches(string repo) + { + WorkingDirectory = repo; + Context = repo; + Args = "branch -l --all -v --format=\"%(refname)%00%(committerdate:unix)%00%(objectname)%00%(HEAD)%00%(upstream)%00%(upstream:trackshort)\""; + } + + public List Result(out int localBranchesCount) + { + localBranchesCount = 0; + + var branches = new List(); + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return branches; + + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + var remoteHeads = new Dictionary(); + foreach (var line in lines) + { + var b = ParseLine(line); + if (b != null) + { + branches.Add(b); + if (!b.IsLocal) + remoteHeads.Add(b.FullName, b.Head); + else + localBranchesCount++; + } + } + + foreach (var b in branches) + { + if (b.IsLocal && !string.IsNullOrEmpty(b.Upstream)) + { + if (remoteHeads.TryGetValue(b.Upstream, out var upstreamHead)) + { + b.IsUpstreamGone = false; + + if (b.TrackStatus == null) + b.TrackStatus = new QueryTrackStatus(WorkingDirectory, b.Head, upstreamHead).Result(); + } + else + { + b.IsUpstreamGone = true; + + if (b.TrackStatus == null) + b.TrackStatus = new Models.BranchTrackStatus(); + } + } + } + + return branches; + } + + private Models.Branch ParseLine(string line) + { + var parts = line.Split('\0'); + if (parts.Length != 6) + return null; + + var branch = new Models.Branch(); + var refName = parts[0]; + if (refName.EndsWith("/HEAD", StringComparison.Ordinal)) + return null; + + branch.IsDetachedHead = refName.StartsWith(PREFIX_DETACHED_AT, StringComparison.Ordinal) || + refName.StartsWith(PREFIX_DETACHED_FROM, StringComparison.Ordinal); + + if (refName.StartsWith(PREFIX_LOCAL, StringComparison.Ordinal)) + { + branch.Name = refName.Substring(PREFIX_LOCAL.Length); + branch.IsLocal = true; + } + else if (refName.StartsWith(PREFIX_REMOTE, StringComparison.Ordinal)) + { + var name = refName.Substring(PREFIX_REMOTE.Length); + var shortNameIdx = name.IndexOf('/', StringComparison.Ordinal); + if (shortNameIdx < 0) + return null; + + branch.Remote = name.Substring(0, shortNameIdx); + branch.Name = name.Substring(branch.Remote.Length + 1); + branch.IsLocal = false; + } + else + { + branch.Name = refName; + branch.IsLocal = true; + } + + branch.FullName = refName; + branch.CommitterDate = ulong.Parse(parts[1]); + branch.Head = parts[2]; + branch.IsCurrent = parts[3] == "*"; + branch.Upstream = parts[4]; + branch.IsUpstreamGone = false; + + if (!branch.IsLocal || + string.IsNullOrEmpty(branch.Upstream) || + string.IsNullOrEmpty(parts[5]) || + parts[5].Equals("=", StringComparison.Ordinal)) + branch.TrackStatus = new Models.BranchTrackStatus(); + + return branch; + } + } +} diff --git a/src/Commands/QueryCommitChildren.cs b/src/Commands/QueryCommitChildren.cs new file mode 100644 index 00000000..4e99ce7a --- /dev/null +++ b/src/Commands/QueryCommitChildren.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; + +namespace SourceGit.Commands +{ + public class QueryCommitChildren : Command + { + public QueryCommitChildren(string repo, string commit, int max) + { + WorkingDirectory = repo; + Context = repo; + _commit = commit; + Args = $"rev-list -{max} --parents --branches --remotes --ancestry-path ^{commit}"; + } + + public List Result() + { + var rs = ReadToEnd(); + var outs = new List(); + if (rs.IsSuccess) + { + var lines = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + if (line.Contains(_commit)) + outs.Add(line.Substring(0, 40)); + } + } + + return outs; + } + + private string _commit; + } +} diff --git a/src/Commands/QueryCommitFullMessage.cs b/src/Commands/QueryCommitFullMessage.cs new file mode 100644 index 00000000..36b6d1c7 --- /dev/null +++ b/src/Commands/QueryCommitFullMessage.cs @@ -0,0 +1,20 @@ +namespace SourceGit.Commands +{ + public class QueryCommitFullMessage : Command + { + public QueryCommitFullMessage(string repo, string sha) + { + WorkingDirectory = repo; + Context = repo; + Args = $"show --no-show-signature --format=%B -s {sha}"; + } + + public string Result() + { + var rs = ReadToEnd(); + if (rs.IsSuccess) + return rs.StdOut.TrimEnd(); + return string.Empty; + } + } +} diff --git a/src/Commands/QueryCommitSignInfo.cs b/src/Commands/QueryCommitSignInfo.cs new file mode 100644 index 00000000..133949af --- /dev/null +++ b/src/Commands/QueryCommitSignInfo.cs @@ -0,0 +1,34 @@ +namespace SourceGit.Commands +{ + public class QueryCommitSignInfo : Command + { + public QueryCommitSignInfo(string repo, string sha, bool useFakeSignersFile) + { + WorkingDirectory = repo; + Context = repo; + + const string baseArgs = "show --no-show-signature --format=%G?%n%GS%n%GK -s"; + const string fakeSignersFileArg = "-c gpg.ssh.allowedSignersFile=/dev/null"; + Args = $"{(useFakeSignersFile ? fakeSignersFileArg : string.Empty)} {baseArgs} {sha}"; + } + + public Models.CommitSignInfo Result() + { + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return null; + + var raw = rs.StdOut.Trim().ReplaceLineEndings("\n"); + if (raw.Length <= 1) + return null; + + var lines = raw.Split('\n'); + return new Models.CommitSignInfo() + { + VerifyResult = lines[0][0], + Signer = lines[1], + Key = lines[2] + }; + } + } +} diff --git a/src/Commands/QueryCommits.cs b/src/Commands/QueryCommits.cs new file mode 100644 index 00000000..9e1d9918 --- /dev/null +++ b/src/Commands/QueryCommits.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace SourceGit.Commands +{ + public class QueryCommits : Command + { + public QueryCommits(string repo, string limits, bool needFindHead = true) + { + WorkingDirectory = repo; + Context = repo; + Args = $"log --no-show-signature --decorate=full --format=%H%n%P%n%D%n%aN±%aE%n%at%n%cN±%cE%n%ct%n%s {limits}"; + _findFirstMerged = needFindHead; + } + + public QueryCommits(string repo, string filter, Models.CommitSearchMethod method, bool onlyCurrentBranch) + { + string search = onlyCurrentBranch ? string.Empty : "--branches --remotes "; + + if (method == Models.CommitSearchMethod.ByAuthor) + { + search += $"-i --author=\"{filter}\""; + } + else if (method == Models.CommitSearchMethod.ByCommitter) + { + search += $"-i --committer=\"{filter}\""; + } + else if (method == Models.CommitSearchMethod.ByMessage) + { + var argsBuilder = new StringBuilder(); + argsBuilder.Append(search); + + var words = filter.Split([' ', '\t', '\r'], StringSplitOptions.RemoveEmptyEntries); + foreach (var word in words) + { + var escaped = word.Trim().Replace("\"", "\\\"", StringComparison.Ordinal); + argsBuilder.Append($"--grep=\"{escaped}\" "); + } + argsBuilder.Append("--all-match -i"); + + search = argsBuilder.ToString(); + } + else if (method == Models.CommitSearchMethod.ByFile) + { + search += $"-- \"{filter}\""; + } + else + { + search = $"-G\"{filter}\""; + } + + WorkingDirectory = repo; + Context = repo; + Args = $"log -1000 --date-order --no-show-signature --decorate=full --format=%H%n%P%n%D%n%aN±%aE%n%at%n%cN±%cE%n%ct%n%s {search}"; + _findFirstMerged = false; + } + + public List Result() + { + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return _commits; + + var nextPartIdx = 0; + var start = 0; + var end = rs.StdOut.IndexOf('\n', start); + while (end > 0) + { + var line = rs.StdOut.Substring(start, end - start); + switch (nextPartIdx) + { + case 0: + _current = new Models.Commit() { SHA = line }; + _commits.Add(_current); + break; + case 1: + ParseParent(line); + break; + case 2: + _current.ParseDecorators(line); + if (_current.IsMerged && !_isHeadFounded) + _isHeadFounded = true; + break; + case 3: + _current.Author = Models.User.FindOrAdd(line); + break; + case 4: + _current.AuthorTime = ulong.Parse(line); + break; + case 5: + _current.Committer = Models.User.FindOrAdd(line); + break; + case 6: + _current.CommitterTime = ulong.Parse(line); + break; + case 7: + _current.Subject = line; + nextPartIdx = -1; + break; + } + + nextPartIdx++; + + start = end + 1; + end = rs.StdOut.IndexOf('\n', start); + } + + if (start < rs.StdOut.Length) + _current.Subject = rs.StdOut.Substring(start); + + if (_findFirstMerged && !_isHeadFounded && _commits.Count > 0) + MarkFirstMerged(); + + return _commits; + } + + private void ParseParent(string data) + { + if (data.Length < 8) + return; + + _current.Parents.AddRange(data.Split(separator: ' ', options: StringSplitOptions.RemoveEmptyEntries)); + } + + private void MarkFirstMerged() + { + Args = $"log --since=\"{_commits[^1].CommitterTimeStr}\" --format=\"%H\""; + + var rs = ReadToEnd(); + var shas = rs.StdOut.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + if (shas.Length == 0) + return; + + var set = new HashSet(); + foreach (var sha in shas) + set.Add(sha); + + foreach (var c in _commits) + { + if (set.Contains(c.SHA)) + { + c.IsMerged = true; + break; + } + } + } + + private List _commits = new List(); + private Models.Commit _current = null; + private bool _findFirstMerged = false; + private bool _isHeadFounded = false; + } +} diff --git a/src/Commands/QueryCommitsForInteractiveRebase.cs b/src/Commands/QueryCommitsForInteractiveRebase.cs new file mode 100644 index 00000000..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 new file mode 100644 index 00000000..83d0a575 --- /dev/null +++ b/src/Commands/QueryFileContent.cs @@ -0,0 +1,73 @@ +using System; +using System.Diagnostics; +using System.IO; + +namespace SourceGit.Commands +{ + public static class QueryFileContent + { + public static Stream Run(string repo, string revision, string file) + { + var starter = new ProcessStartInfo(); + starter.WorkingDirectory = repo; + starter.FileName = Native.OS.GitExecutable; + starter.Arguments = $"show {revision}:\"{file}\""; + starter.UseShellExecute = false; + starter.CreateNoWindow = true; + starter.WindowStyle = ProcessWindowStyle.Hidden; + starter.RedirectStandardOutput = true; + + var stream = new MemoryStream(); + try + { + var proc = new Process() { StartInfo = starter }; + proc.Start(); + proc.StandardOutput.BaseStream.CopyTo(stream); + proc.WaitForExit(); + proc.Close(); + + stream.Position = 0; + } + catch (Exception e) + { + App.RaiseException(repo, $"Failed to query file content: {e}"); + } + + return stream; + } + + public static Stream FromLFS(string repo, string oid, long size) + { + var starter = new ProcessStartInfo(); + starter.WorkingDirectory = repo; + starter.FileName = Native.OS.GitExecutable; + starter.Arguments = $"lfs smudge"; + starter.UseShellExecute = false; + starter.CreateNoWindow = true; + starter.WindowStyle = ProcessWindowStyle.Hidden; + starter.RedirectStandardInput = true; + starter.RedirectStandardOutput = true; + + var stream = new MemoryStream(); + try + { + var proc = new Process() { StartInfo = starter }; + proc.Start(); + proc.StandardInput.WriteLine("version https://git-lfs.github.com/spec/v1"); + proc.StandardInput.WriteLine($"oid sha256:{oid}"); + proc.StandardInput.WriteLine($"size {size}"); + proc.StandardOutput.BaseStream.CopyTo(stream); + proc.WaitForExit(); + proc.Close(); + + stream.Position = 0; + } + catch (Exception e) + { + App.RaiseException(repo, $"Failed to query file content: {e}"); + } + + return stream; + } + } +} diff --git a/src/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/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 new file mode 100644 index 00000000..e3a94baf --- /dev/null +++ b/src/Commands/QueryGitDir.cs @@ -0,0 +1,26 @@ +using System.IO; + +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().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/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 new file mode 100644 index 00000000..2ec50f3c --- /dev/null +++ b/src/Commands/Rebase.cs @@ -0,0 +1,26 @@ +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 "; + 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 new file mode 100644 index 00000000..beaf412b --- /dev/null +++ b/src/Commands/Remote.cs @@ -0,0 +1,58 @@ +namespace SourceGit.Commands +{ + public class Remote : Command + { + public Remote(string repo) + { + WorkingDirectory = repo; + Context = repo; + } + + public bool Add(string name, string url) + { + Args = $"remote add {name} {url}"; + return Exec(); + } + + public bool Delete(string name) + { + Args = $"remote remove {name}"; + return Exec(); + } + + public bool Rename(string name, string to) + { + Args = $"remote rename {name} {to}"; + return Exec(); + } + + public bool Prune(string name) + { + Args = $"remote prune {name}"; + return Exec(); + } + + 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/Reset.cs b/src/Commands/Reset.cs new file mode 100644 index 00000000..6a54533b --- /dev/null +++ b/src/Commands/Reset.cs @@ -0,0 +1,12 @@ +namespace SourceGit.Commands +{ + public class Reset : Command + { + public Reset(string repo, string revision, string mode) + { + WorkingDirectory = repo; + Context = repo; + Args = $"reset {mode} {revision}"; + } + } +} diff --git a/src/Commands/Restore.cs b/src/Commands/Restore.cs new file mode 100644 index 00000000..663ea975 --- /dev/null +++ b/src/Commands/Restore.cs @@ -0,0 +1,53 @@ +using System.Text; + +namespace SourceGit.Commands +{ + public class Restore : Command + { + /// + /// Only used for single staged change. + /// + /// + /// + public Restore(string repo, Models.Change stagedChange) + { + WorkingDirectory = repo; + Context = repo; + + var builder = new StringBuilder(); + builder.Append("restore --staged -- \""); + builder.Append(stagedChange.Path); + builder.Append('"'); + + if (stagedChange.Index == Models.ChangeState.Renamed) + { + builder.Append(" \""); + builder.Append(stagedChange.OriginalPath); + builder.Append('"'); + } + + Args = builder.ToString(); + } + + /// + /// Restore changes given in a path-spec file. + /// + /// + /// + /// + public Restore(string repo, string pathspecFile, bool isStaged) + { + WorkingDirectory = repo; + Context = repo; + + var builder = new StringBuilder(); + builder.Append("restore "); + builder.Append(isStaged ? "--staged " : "--worktree --recurse-submodules "); + builder.Append("--pathspec-from-file=\""); + builder.Append(pathspecFile); + builder.Append('"'); + + Args = builder.ToString(); + } + } +} diff --git a/src/Commands/Revert.cs b/src/Commands/Revert.cs new file mode 100644 index 00000000..2e7afd11 --- /dev/null +++ b/src/Commands/Revert.cs @@ -0,0 +1,14 @@ +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/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/SaveRevisionFile.cs b/src/Commands/SaveRevisionFile.cs new file mode 100644 index 00000000..550844ef --- /dev/null +++ b/src/Commands/SaveRevisionFile.cs @@ -0,0 +1,64 @@ +using System; +using System.Diagnostics; +using System.IO; + +using Avalonia.Threading; + +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); + } + } + + private static bool ExecCmd(string repo, string args, string outputFile, Stream input = null) + { + var starter = new ProcessStartInfo(); + 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; + + 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(); + + 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 new file mode 100644 index 00000000..7d1a269b --- /dev/null +++ b/src/Commands/Stash.cs @@ -0,0 +1,101 @@ +using System.Collections.Generic; +using System.Text; + +namespace SourceGit.Commands +{ + public class Stash : Command + { + public Stash(string repo) + { + WorkingDirectory = repo; + Context = repo; + } + + public bool Push(string message, bool includeUntracked = true, bool keepIndex = false) + { + var builder = new StringBuilder(); + builder.Append("stash push "); + if (includeUntracked) + builder.Append("--include-untracked "); + if (keepIndex) + builder.Append("--keep-index "); + builder.Append("-m \""); + builder.Append(message); + builder.Append("\""); + + Args = builder.ToString(); + return Exec(); + } + + public bool Push(string message, List changes, bool keepIndex) + { + var builder = new StringBuilder(); + builder.Append("stash push --include-untracked "); + if (keepIndex) + builder.Append("--keep-index "); + builder.Append("-m \""); + builder.Append(message); + builder.Append("\" -- "); + + foreach (var c in changes) + builder.Append($"\"{c.Path}\" "); + + Args = builder.ToString(); + return Exec(); + } + + public bool Push(string message, string pathspecFromFile, bool keepIndex) + { + var builder = new StringBuilder(); + builder.Append("stash push --include-untracked --pathspec-from-file=\""); + builder.Append(pathspecFromFile); + builder.Append("\" "); + if (keepIndex) + builder.Append("--keep-index "); + builder.Append("-m \""); + builder.Append(message); + builder.Append("\""); + + Args = builder.ToString(); + return Exec(); + } + + public bool PushOnlyStaged(string message, bool keepIndex) + { + var builder = new StringBuilder(); + builder.Append("stash push --staged "); + if (keepIndex) + builder.Append("--keep-index "); + builder.Append("-m \""); + builder.Append(message); + builder.Append("\""); + Args = builder.ToString(); + return Exec(); + } + + public bool Apply(string name, bool restoreIndex) + { + var opts = restoreIndex ? "--index" : string.Empty; + Args = $"stash apply -q {opts} \"{name}\""; + return Exec(); + } + + public bool Pop(string name) + { + Args = $"stash pop -q --index \"{name}\""; + return Exec(); + } + + public bool Drop(string name) + { + Args = $"stash drop -q \"{name}\""; + return Exec(); + } + + public bool Clear() + { + Args = "stash clear"; + return Exec(); + } + } +} diff --git a/src/Commands/Statistics.cs b/src/Commands/Statistics.cs new file mode 100644 index 00000000..e11c1740 --- /dev/null +++ b/src/Commands/Statistics.cs @@ -0,0 +1,48 @@ +using System; + +namespace SourceGit.Commands +{ + public class Statistics : Command + { + public Statistics(string repo, int max) + { + WorkingDirectory = repo; + Context = repo; + Args = $"log --date-order --branches --remotes -{max} --format=%ct$%aN±%aE"; + } + + public Models.Statistics Result() + { + var statistics = new Models.Statistics(); + var rs = ReadToEnd(); + if (!rs.IsSuccess) + return statistics; + + var start = 0; + var end = rs.StdOut.IndexOf('\n', start); + while (end > 0) + { + ParseLine(statistics, rs.StdOut.Substring(start, end - start)); + start = end + 1; + end = rs.StdOut.IndexOf('\n', start); + } + + if (start < rs.StdOut.Length) + ParseLine(statistics, rs.StdOut.Substring(start)); + + statistics.Complete(); + return statistics; + } + + private void ParseLine(Models.Statistics statistics, string line) + { + var dateEndIdx = line.IndexOf('$', StringComparison.Ordinal); + if (dateEndIdx == -1) + return; + + var dateStr = line.AsSpan(0, dateEndIdx); + if (double.TryParse(dateStr, out var date)) + statistics.AddCommit(line.Substring(dateEndIdx + 1), date); + } + } +} diff --git a/src/Commands/Submodule.cs b/src/Commands/Submodule.cs new file mode 100644 index 00000000..025d035a --- /dev/null +++ b/src/Commands/Submodule.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using System.Text; + +namespace SourceGit.Commands +{ + public class Submodule : Command + { + public Submodule(string repo) + { + WorkingDirectory = repo; + Context = repo; + } + + public bool Add(string url, string relativePath, bool recursive) + { + Args = $"-c protocol.file.allow=always submodule add \"{url}\" \"{relativePath}\""; + if (!Exec()) + return false; + + if (recursive) + { + Args = $"submodule update --init --recursive -- \"{relativePath}\""; + return Exec(); + } + else + { + Args = $"submodule update --init -- \"{relativePath}\""; + return true; + } + } + + public bool Update(List modules, bool init, bool recursive, bool useRemote = false) + { + var builder = new StringBuilder(); + builder.Append("submodule update"); + + if (init) + builder.Append(" --init"); + if (recursive) + builder.Append(" --recursive"); + if (useRemote) + builder.Append(" --remote"); + if (modules.Count > 0) + { + builder.Append(" --"); + foreach (var module in modules) + builder.Append($" \"{module}\""); + } + + Args = builder.ToString(); + return Exec(); + } + + public bool Deinit(string module, bool force) + { + Args = force ? $"submodule deinit -f -- \"{module}\"" : $"submodule deinit -- \"{module}\""; + return Exec(); + } + + public bool Delete(string module) + { + Args = $"rm -rf \"{module}\""; + return Exec(); + } + } +} diff --git a/src/Commands/Tag.cs b/src/Commands/Tag.cs new file mode 100644 index 00000000..017afea0 --- /dev/null +++ b/src/Commands/Tag.cs @@ -0,0 +1,51 @@ +using System.IO; + +namespace SourceGit.Commands +{ + public static class Tag + { + public static bool Add(string repo, string name, string basedOn, Models.ICommandLog log) + { + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.Args = $"tag --no-sign {name} {basedOn}"; + cmd.Log = log; + return cmd.Exec(); + } + + public static bool Add(string repo, string name, string basedOn, string message, bool sign, Models.ICommandLog log) + { + var param = sign ? "--sign -a" : "--no-sign -a"; + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.Args = $"tag {param} {name} {basedOn} "; + cmd.Log = log; + + if (!string.IsNullOrEmpty(message)) + { + string tmp = Path.GetTempFileName(); + File.WriteAllText(tmp, message); + cmd.Args += $"-F \"{tmp}\""; + + var succ = cmd.Exec(); + File.Delete(tmp); + return succ; + } + + cmd.Args += $"-m {name}"; + return cmd.Exec(); + } + + public static bool Delete(string repo, string name, Models.ICommandLog log) + { + var cmd = new Command(); + cmd.WorkingDirectory = repo; + cmd.Context = repo; + cmd.Args = $"tag --delete {name}"; + cmd.Log = log; + return cmd.Exec(); + } + } +} diff --git a/src/Commands/UnstageChangesForAmend.cs b/src/Commands/UnstageChangesForAmend.cs new file mode 100644 index 00000000..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/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/BoolToCollapsed.cs b/src/Converters/BoolToCollapsed.cs deleted file mode 100644 index 47ce3c73..00000000 --- a/src/Converters/BoolToCollapsed.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; -using System.Globalization; -using System.Windows; -using System.Windows.Data; - -namespace SourceGit.Converters { - - /// - /// Same as BoolToVisibilityConverter. - /// - public class BoolToCollapsed : IValueConverter { - - /// - /// Implement IValueConverter.Convert - /// - /// - /// - /// - /// - /// - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - return (bool)value ? Visibility.Visible : Visibility.Collapsed; - } - - /// - /// Implement IValueConverter.ConvertBack - /// - /// - /// - /// - /// - /// - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { - throw new NotImplementedException(); - } - } -} 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/FileStatusToColor.cs b/src/Converters/FileStatusToColor.cs deleted file mode 100644 index e57940e2..00000000 --- a/src/Converters/FileStatusToColor.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System; -using System.Globalization; -using System.Windows.Data; -using System.Windows.Media; - -namespace SourceGit.Converters { - - /// - /// Convert file status to brush - /// - public class FileStatusToColor : IValueConverter { - - /// - /// Is only test local changes. - /// - public bool OnlyWorkTree { get; set; } = false; - - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - var change = value as Git.Change; - if (change == null) return Brushes.Transparent; - - var status = Git.Change.Status.None; - if (OnlyWorkTree) { - if (change.IsConflit) return Brushes.Yellow; - status = change.WorkTree; - } else { - status = change.Index; - } - - if (App.Setting.UI.UseLightTheme) { - switch (status) { - case Git.Change.Status.Modified: return Brushes.Goldenrod; - case Git.Change.Status.Added: return Brushes.Green; - case Git.Change.Status.Deleted: return Brushes.Red; - case Git.Change.Status.Renamed: return Brushes.Magenta; - case Git.Change.Status.Copied: return Brushes.Goldenrod; - case Git.Change.Status.Unmerged: return Brushes.Goldenrod; - case Git.Change.Status.Untracked: return Brushes.Green; - default: return Brushes.Transparent; - } - } else { - switch (status) { - case Git.Change.Status.Modified: return Brushes.DarkGoldenrod; - case Git.Change.Status.Added: return Brushes.DarkGreen; - case Git.Change.Status.Deleted: return Brushes.DarkRed; - case Git.Change.Status.Renamed: return Brushes.DarkMagenta; - case Git.Change.Status.Copied: return Brushes.DarkGoldenrod; - case Git.Change.Status.Unmerged: return Brushes.DarkGoldenrod; - case Git.Change.Status.Untracked: return Brushes.DarkGreen; - default: return Brushes.Transparent; - } - } - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { - throw new NotImplementedException(); - } - } -} diff --git a/src/Converters/FileStatusToIcon.cs b/src/Converters/FileStatusToIcon.cs deleted file mode 100644 index 85447770..00000000 --- a/src/Converters/FileStatusToIcon.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using System.Globalization; -using System.Windows.Data; - -namespace SourceGit.Converters { - - /// - /// Convert file status to icon. - /// - public class FileStatusToIcon : IValueConverter { - - /// - /// Is only test local changes. - /// - public bool OnlyWorkTree { get; set; } = false; - - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - var change = value as Git.Change; - if (change == null) return ""; - - var status = Git.Change.Status.None; - if (OnlyWorkTree) { - if (change.IsConflit) return "X"; - status = change.WorkTree; - } else { - status = change.Index; - } - - switch (status) { - case Git.Change.Status.Modified: return "M"; - case Git.Change.Status.Added: return "A"; - case Git.Change.Status.Deleted: return "D"; - case Git.Change.Status.Renamed: return "R"; - case Git.Change.Status.Copied: return "C"; - case Git.Change.Status.Unmerged: return "U"; - case Git.Change.Status.Untracked: return "?"; - default: return "?"; - } - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { - throw new NotImplementedException(); - } - } -} diff --git a/src/Converters/FilesDisplayModeToIcon.cs b/src/Converters/FilesDisplayModeToIcon.cs deleted file mode 100644 index 441c54c7..00000000 --- a/src/Converters/FilesDisplayModeToIcon.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using System.Globalization; -using System.Windows.Data; -using System.Windows.Media; - -namespace SourceGit.Converters { - - public class FilesDisplayModeToIcon : IValueConverter { - - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - var mode = (Preference.FilesDisplayMode)value; - switch (mode) { - case Preference.FilesDisplayMode.Grid: - return App.Current.FindResource("Icon.Grid") as Geometry; - case Preference.FilesDisplayMode.List: - return App.Current.FindResource("Icon.List") as Geometry; - default: - return App.Current.FindResource("Icon.Tree") as Geometry; - } - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { - throw new NotImplementedException(); - } - } -} diff --git a/src/Converters/FilesDisplayModeToVisibility.cs b/src/Converters/FilesDisplayModeToVisibility.cs deleted file mode 100644 index 2bea761f..00000000 --- a/src/Converters/FilesDisplayModeToVisibility.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System; -using System.Globalization; -using System.Windows; -using System.Windows.Data; - -namespace SourceGit.Converters { - - public class FilesDisplayModeToList : IValueConverter { - - public bool TreatGridAsList { get; set; } = true; - - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - var mode = (Preference.FilesDisplayMode)value; - if (mode == Preference.FilesDisplayMode.Tree) return Visibility.Collapsed; - if (mode == Preference.FilesDisplayMode.List) return Visibility.Visible; - if (TreatGridAsList) return Visibility.Visible; - return Visibility.Collapsed; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { - throw new NotImplementedException(); - } - } - - public class FilesDisplayModeToGrid : IValueConverter { - - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - return (Preference.FilesDisplayMode)value == Preference.FilesDisplayMode.Grid ? Visibility.Visible : Visibility.Collapsed; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { - throw new NotImplementedException(); - } - } - - public class FilesDisplayModeToTree : IValueConverter { - - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - return (Preference.FilesDisplayMode)value == Preference.FilesDisplayMode.Tree ? Visibility.Visible : Visibility.Collapsed; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { - throw new NotImplementedException(); - } - } -} 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/IndentToMargin.cs b/src/Converters/IndentToMargin.cs deleted file mode 100644 index 5214396b..00000000 --- a/src/Converters/IndentToMargin.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; -using System.Globalization; -using System.Windows; -using System.Windows.Data; - -namespace SourceGit.Converters { - - /// - /// Convert indent(horizontal offset) to Margin property - /// - public class IndentToMargin : IValueConverter { - - /// - /// Implement IValueConverter.Convert - /// - /// - /// - /// - /// - /// - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - return new Thickness((double)value, 0, 0, 0); - } - - /// - /// Implement IValueConverter.ConvertBack - /// - /// - /// - /// - /// - /// - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { - return ((Thickness)value).Left; - } - } -} 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/IntToRepoColor.cs b/src/Converters/IntToRepoColor.cs deleted file mode 100644 index 338f5403..00000000 --- a/src/Converters/IntToRepoColor.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using System.Globalization; -using System.Windows; -using System.Windows.Data; -using System.Windows.Media; - -namespace SourceGit.Converters { - - /// - /// Integer to color. - /// - public class IntToRepoColor : IValueConverter { - - /// - /// All supported colors. - /// - public static Brush[] Colors = new Brush[] { - Brushes.Transparent, - Brushes.White, - Brushes.Red, - Brushes.Orange, - Brushes.Yellow, - Brushes.ForestGreen, - Brushes.Purple, - Brushes.DeepSkyBlue, - Brushes.Magenta, - }; - - /// - /// Implement IValueConverter.Convert - /// - /// - /// - /// - /// - /// - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - return Colors[((int)value) % Colors.Length]; - } - - /// - /// Implement IValueConverter.ConvertBack - /// - /// - /// - /// - /// - /// - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { - return ((Thickness)value).Left; - } - } -} 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/InverseBool.cs b/src/Converters/InverseBool.cs deleted file mode 100644 index 932ae4ef..00000000 --- a/src/Converters/InverseBool.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.Globalization; -using System.Windows.Data; - -namespace SourceGit.Converters { - - /// - /// Inverse bool converter. - /// - public class InverseBool : IValueConverter { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - return !((bool)value); - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { - throw new NotImplementedException(); - } - } -} diff --git a/src/Converters/InverseBoolToCollapsed.cs b/src/Converters/InverseBoolToCollapsed.cs deleted file mode 100644 index 862bf913..00000000 --- a/src/Converters/InverseBoolToCollapsed.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; -using System.Globalization; -using System.Windows; -using System.Windows.Data; - -namespace SourceGit.Converters { - - /// - /// Inverse BoolToCollapsed. - /// - public class InverseBoolToCollapsed : IValueConverter { - - /// - /// Implement IValueConverter.Convert - /// - /// - /// - /// - /// - /// - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - return (bool)value ? Visibility.Collapsed : Visibility.Visible; - } - - /// - /// Implement IValueConverter.ConvertBack - /// - /// - /// - /// - /// - /// - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { - throw new NotImplementedException(); - } - } -} 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/Path.cs b/src/Converters/Path.cs deleted file mode 100644 index 8062b658..00000000 --- a/src/Converters/Path.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using System.Globalization; -using System.IO; -using System.Windows.Data; - -namespace SourceGit.Converters { - - public class PathToFileName : IValueConverter { - - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - return Path.GetFileName(value as string); - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { - throw new NotImplementedException(); - } - } - - public class PathToFolderName : IValueConverter { - - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - return Path.GetDirectoryName(value as string); - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { - throw new NotImplementedException(); - } - } -} 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/Converters/TreeViewItemDepthToMargin.cs b/src/Converters/TreeViewItemDepthToMargin.cs deleted file mode 100644 index 8c7eb856..00000000 --- a/src/Converters/TreeViewItemDepthToMargin.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System; -using System.Globalization; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Data; -using System.Windows.Media; - -namespace SourceGit.Converters { - - /// - /// Convert depth of a TreeViewItem to Margin property. - /// - public class TreeViewItemDepthToMargin : IValueConverter { - - /// - /// Indent length - /// - public double Indent { get; set; } = 19; - - /// - /// Implement IValueConverter.Convert - /// - /// - /// - /// - /// - /// - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - TreeViewItem item = value as TreeViewItem; - if (item == null) return new Thickness(0); - - TreeViewItem iterator = GetParent(item); - int depth = 0; - while (iterator != null) { - depth++; - iterator = GetParent(iterator); - } - - return new Thickness(Indent * depth, 0, 0, 0); - } - - /// - /// Implement IValueConvert.ConvertBack - /// - /// - /// - /// - /// - /// - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { - throw new NotImplementedException(); - } - - /// - /// Get parent item. - /// - /// - /// - private TreeViewItem GetParent(TreeViewItem item) { - var parent = VisualTreeHelper.GetParent(item); - - while (parent != null && !(parent is TreeView) && !(parent is TreeViewItem)) { - parent = VisualTreeHelper.GetParent(parent); - } - - return parent as TreeViewItem; - } - } -} diff --git a/src/Git/Blame.cs b/src/Git/Blame.cs deleted file mode 100644 index 7985aaa2..00000000 --- a/src/Git/Blame.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Collections.Generic; - -namespace SourceGit.Git { - - /// - /// Blame - /// - public class Blame { - - /// - /// Line content. - /// - public class Line { - public string CommitSHA { get; set; } - public string Author { get; set; } - public string Time { get; set; } - public string Content { get; set; } - } - - /// - /// Lines - /// - public List Lines { get; set; } = new List(); - - /// - /// Is binary file? - /// - public bool IsBinary { get; set; } = false; - } -} diff --git a/src/Git/Branch.cs b/src/Git/Branch.cs deleted file mode 100644 index 36e16af3..00000000 --- a/src/Git/Branch.cs +++ /dev/null @@ -1,203 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.RegularExpressions; - -namespace SourceGit.Git { - - /// - /// Git branch - /// - public class Branch { - private static readonly string PRETTY_FORMAT = @"$%(refname)$%(objectname)$%(HEAD)$%(upstream)$%(upstream:track)$%(contents:subject)"; - private static readonly Regex PARSE = new Regex(@"\$(.*)\$(.*)\$([\* ])\$(.*)\$(.*?)\$(.*)"); - private static readonly Regex AHEAD = new Regex(@"ahead (\d+)"); - private static readonly Regex BEHIND = new Regex(@"behind (\d+)"); - - /// - /// Branch type. - /// - public enum Type { - Normal, - Feature, - Release, - Hotfix, - } - - /// - /// Branch name - /// - public string Name { get; set; } = ""; - - /// - /// Full name. - /// - public string FullName { get; set; } = ""; - - /// - /// Head ref - /// - public string Head { get; set; } = ""; - - /// - /// Subject for head ref. - /// - public string HeadSubject { get; set; } = ""; - - /// - /// Is local branch - /// - public bool IsLocal { get; set; } = false; - - /// - /// Branch type. - /// - public Type Kind { get; set; } = Type.Normal; - - /// - /// Remote name. Only used for remote branch - /// - public string Remote { get; set; } = ""; - - /// - /// Upstream. Only used for local branches. - /// - public string Upstream { get; set; } - - /// - /// Track information for upstream. Only used for local branches. - /// - public string UpstreamTrack { get; set; } - - /// - /// Is current branch. Only used for local branches. - /// - public bool IsCurrent { get; set; } - - /// - /// Is this branch's HEAD same with upstream? - /// - public bool IsSameWithUpstream => string.IsNullOrEmpty(UpstreamTrack); - - /// - /// Enable filter in log histories. - /// - public bool IsFiltered { get; set; } - - /// - /// Load branches. - /// - /// - public static List Load(Repository repo) { - var localPrefix = "refs/heads/"; - var remotePrefix = "refs/remotes/"; - var branches = new List(); - var remoteBranches = new List(); - - repo.RunCommand("branch -l --all -v --format=\"" + PRETTY_FORMAT + "\"", line => { - var match = PARSE.Match(line); - if (!match.Success) return; - - var branch = new Branch(); - var refname = match.Groups[1].Value; - if (refname.EndsWith("/HEAD")) return; - - if (refname.StartsWith(localPrefix, StringComparison.Ordinal)) { - branch.Name = refname.Substring(localPrefix.Length); - branch.IsLocal = true; - } else if (refname.StartsWith(remotePrefix, StringComparison.Ordinal)) { - var name = refname.Substring(remotePrefix.Length); - branch.Remote = name.Substring(0, name.IndexOf('/')); - branch.Name = name; - branch.IsLocal = false; - remoteBranches.Add(refname); - } - - branch.FullName = refname; - branch.Head = match.Groups[2].Value; - branch.IsCurrent = match.Groups[3].Value == "*"; - branch.Upstream = match.Groups[4].Value; - branch.UpstreamTrack = ParseTrack(match.Groups[5].Value); - branch.HeadSubject = match.Groups[6].Value; - - branches.Add(branch); - }); - - // Fixed deleted remote branch - foreach (var b in branches) { - if (!string.IsNullOrEmpty(b.Upstream) && !remoteBranches.Contains(b.Upstream)) { - b.Upstream = null; - } - } - - return branches; - } - - /// - /// Create new branch. - /// - /// - /// - /// - public static void Create(Repository repo, string name, string startPoint) { - var errs = repo.RunCommand($"branch {name} {startPoint}", null); - if (errs != null) App.RaiseError(errs); - } - - /// - /// Rename branch - /// - /// - /// - public void Rename(Repository repo, string name) { - var errs = repo.RunCommand($"branch -M {Name} {name}", null); - if (errs != null) App.RaiseError(errs); - } - - /// - /// Change upstream - /// - /// - /// - public void SetUpstream(Repository repo, string upstream) { - var errs = repo.RunCommand($"branch {Name} -u {upstream}", null); - if (errs != null) App.RaiseError(errs); - - repo.Branches(true); - repo.OnBranchChanged?.Invoke(); - } - - /// - /// Delete branch. - /// - /// - public void Delete(Repository repo) { - string errs = null; - - if (!IsLocal) { - errs = repo.RunCommand($"-c credential.helper=manager push {Remote} --delete {Name.Substring(Name.IndexOf('/')+1)}", null); - } else { - errs = repo.RunCommand($"branch -D {Name}", null); - } - - if (errs != null) App.RaiseError(errs); - } - - private static string ParseTrack(string data) { - if (string.IsNullOrEmpty(data)) return ""; - - string track = ""; - - var ahead = AHEAD.Match(data); - if (ahead.Success) { - track += ahead.Groups[1].Value + "↑ "; - } - - var behind = BEHIND.Match(data); - if (behind.Success) { - track += behind.Groups[1].Value + "↓"; - } - - return track.Trim(); - } - } -} diff --git a/src/Git/Change.cs b/src/Git/Change.cs deleted file mode 100644 index c5f9ca4a..00000000 --- a/src/Git/Change.cs +++ /dev/null @@ -1,147 +0,0 @@ -using System.Text.RegularExpressions; - -namespace SourceGit.Git { - - /// - /// Changed file status. - /// - public class Change { - private static readonly Regex FORMAT = new Regex(@"^(\s?[\w\?]{1,4})\s+(.+)$"); - - /// - /// Status Code - /// - public enum Status { - None, - Modified, - Added, - Deleted, - Renamed, - Copied, - Unmerged, - Untracked, - } - - /// - /// Index status - /// - public Status Index { get; set; } - - /// - /// Work tree status. - /// - public Status WorkTree { get; set; } - - /// - /// Current file path. - /// - public string Path { get; set; } - - /// - /// Original file path before this revision. - /// - public string OriginalPath { get; set; } - - /// - /// Staged(added) in index? - /// - public bool IsAddedToIndex { - get { - if (Index == Status.None || Index == Status.Untracked) return false; - return true; - } - } - - /// - /// Is conflict? - /// - public bool IsConflit { - get { - if (Index == Status.Unmerged || WorkTree == Status.Unmerged) return true; - if (Index == Status.Added && WorkTree == Status.Added) return true; - if (Index == Status.Deleted && WorkTree == Status.Deleted) return true; - return false; - } - } - - /// - /// Parse change for `--name-status` data. - /// - /// Raw data. - /// Read from commit? - /// Parsed change instance. - public static Change Parse(string data, bool fromCommit = false) { - var match = FORMAT.Match(data); - if (!match.Success) return null; - - var change = new Change() { Path = match.Groups[2].Value }; - var status = match.Groups[1].Value; - - if (fromCommit) { - switch (status[0]) { - case 'M': change.Set(Status.Modified); break; - case 'A': change.Set(Status.Added); break; - case 'D': change.Set(Status.Deleted); break; - case 'R': change.Set(Status.Renamed); break; - case 'C': change.Set(Status.Copied); break; - default: return null; - } - } else { - switch (status) { - case " M": change.Set(Status.None, Status.Modified); break; - case " A": change.Set(Status.None, Status.Added); break; - case " D": change.Set(Status.None, Status.Deleted); break; - case " R": change.Set(Status.None, Status.Renamed); break; - case " C": change.Set(Status.None, Status.Copied); break; - case "M": change.Set(Status.Modified, Status.None); break; - case "MM": change.Set(Status.Modified, Status.Modified); break; - case "MD": change.Set(Status.Modified, Status.Deleted); break; - case "A": change.Set(Status.Added, Status.None); break; - case "AM": change.Set(Status.Added, Status.Modified); break; - case "AD": change.Set(Status.Added, Status.Deleted); break; - case "D": change.Set(Status.Deleted, Status.None); break; - case "R": change.Set(Status.Renamed, Status.None); break; - case "RM": change.Set(Status.Renamed, Status.Modified); break; - case "RD": change.Set(Status.Renamed, Status.Deleted); break; - case "C": change.Set(Status.Copied, Status.None); break; - case "CM": change.Set(Status.Copied, Status.Modified); break; - case "CD": change.Set(Status.Copied, Status.Deleted); break; - case "DR": change.Set(Status.Deleted, Status.Renamed); break; - case "DC": change.Set(Status.Deleted, Status.Copied); break; - case "DD": change.Set(Status.Deleted, Status.Deleted); break; - case "AU": change.Set(Status.Added, Status.Unmerged); break; - case "UD": change.Set(Status.Unmerged, Status.Deleted); break; - case "UA": change.Set(Status.Unmerged, Status.Added); break; - case "DU": change.Set(Status.Deleted, Status.Unmerged); break; - case "AA": change.Set(Status.Added, Status.Added); break; - case "UU": change.Set(Status.Unmerged, Status.Unmerged); break; - case "??": change.Set(Status.Untracked, Status.Untracked); break; - default: return null; - } - } - - if (change.Path[0] == '"') change.Path = change.Path.Substring(1, change.Path.Length - 2); - if (!string.IsNullOrEmpty(change.OriginalPath) && change.OriginalPath[0] == '"') change.OriginalPath = change.OriginalPath.Substring(1, change.OriginalPath.Length - 2); - return change; - } - - private void Set(Status index, Status workTree = Status.None) { - Index = index; - WorkTree = workTree; - - if (index == Status.Renamed || workTree == Status.Renamed) { - var idx = Path.IndexOf('\t'); - if (idx >= 0) { - OriginalPath = Path.Substring(0, idx); - Path = Path.Substring(idx + 1); - } else { - idx = Path.IndexOf(" -> "); - if (idx > 0) { - OriginalPath = Path.Substring(0, idx); - Path = Path.Substring(idx + 4); - } - } - } - } - } -} diff --git a/src/Git/Commit.cs b/src/Git/Commit.cs deleted file mode 100644 index ae3a326a..00000000 --- a/src/Git/Commit.cs +++ /dev/null @@ -1,365 +0,0 @@ -using System; -using System.IO; -using System.Collections.Generic; -using System.Diagnostics; -using System.Text.RegularExpressions; - -namespace SourceGit.Git { - - /// - /// Git commit information. - /// - public class Commit { - private static readonly string GPGSIG_START = "gpgsig -----BEGIN PGP SIGNATURE-----"; - private static readonly string GPGSIG_END = " -----END PGP SIGNATURE-----"; - private static readonly Regex REG_TESTBINARY = new Regex(@"^\-\s+\-\s+.*$"); - - /// - /// Object in commit. - /// - public class Object { - public enum Type { - Tag, - Blob, - Tree, - Commit, - } - - public string Path { get; set; } - public Type Kind { get; set; } - public string SHA { get; set; } - } - - /// - /// Line of text in file. - /// - public class Line { - public int No { get; set; } - public string Content { get; set; } - } - - /// - /// SHA - /// - public string SHA { get; set; } - - /// - /// Short SHA. - /// - public string ShortSHA => SHA.Substring(0, 8); - - /// - /// Parent commit SHAs. - /// - public List Parents { get; set; } = new List(); - - /// - /// Author - /// - public User Author { get; set; } = new User(); - - /// - /// Committer. - /// - public User Committer { get; set; } = new User(); - - /// - /// Subject - /// - public string Subject { get; set; } = ""; - - /// - /// Extra message. - /// - public string Message { get; set; } = ""; - - /// - /// HEAD commit? - /// - public bool IsHEAD { get; set; } = false; - - /// - /// Merged in current branch? - /// - public bool IsMerged { get; set; } = false; - - /// - /// X offset in graph - /// - public double GraphOffset { get; set; } = 0; - - /// - /// Has decorators. - /// - public bool HasDecorators => Decorators.Count > 0; - - /// - /// Decorators. - /// - public List Decorators { get; set; } = new List(); - - /// - /// Read commits. - /// - /// Repository - /// Limitations - /// Parsed commits. - public static List Load(Repository repo, string limit) { - List commits = new List(); - Commit current = null; - bool bSkippingGpgsig = false; - bool findHead = false; - - repo.RunCommand("log --date-order --decorate=full --pretty=raw " + limit, line => { - if (bSkippingGpgsig) { - if (line.StartsWith(GPGSIG_END, StringComparison.Ordinal)) bSkippingGpgsig = false; - return; - } else if (line.StartsWith(GPGSIG_START, StringComparison.Ordinal)) { - bSkippingGpgsig = true; - return; - } - - if (line.StartsWith("commit ", StringComparison.Ordinal)) { - if (current != null) { - current.Message = current.Message.TrimEnd(); - commits.Add(current); - } - - current = new Commit(); - ParseSHA(current, line.Substring("commit ".Length)); - if (!findHead) findHead = current.IsHEAD; - 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)) { - current.Author.Parse(line); - } else if (line.StartsWith("committer ", StringComparison.Ordinal)) { - current.Committer.Parse(line); - } else if (string.IsNullOrEmpty(current.Subject)) { - current.Subject = line.Trim(); - } else { - current.Message += (line.Trim() + "\n"); - } - }); - - if (current != null) { - current.Message = current.Message.TrimEnd(); - commits.Add(current); - } - - if (!findHead && commits.Count > 0) { - if (commits[commits.Count - 1].IsAncestorOfHead(repo)) { - if (commits.Count == 1) { - commits[0].IsMerged = true; - } else { - var head = FindFirstMerged(repo, commits, 0, commits.Count - 1); - if (head != null) head.IsMerged = true; - } - } - } - - return commits; - } - - /// - /// Get changed file list. - /// - /// - /// - public List GetChanges(Repository repo) { - var changes = new List(); - var regex = new Regex(@"^[MADRC]\d*\s*.*$"); - - var errs = repo.RunCommand($"show --name-status {SHA}", line => { - if (!regex.IsMatch(line)) return; - - var change = Change.Parse(line, true); - if (change != null) changes.Add(change); - }); - - if (errs != null) App.RaiseError(errs); - return changes; - } - - /// - /// Get revision files. - /// - /// - /// - public List GetFiles(Repository repo) { - var files = new List(); - var test = new Regex(@"^\d+\s+(\w+)\s+([0-9a-f]+)\s+(.*)$"); - - var errs = repo.RunCommand($"ls-tree -r {SHA}", line => { - var match = test.Match(line); - if (!match.Success) return; - - var obj = new Object(); - obj.Path = match.Groups[3].Value; - obj.Kind = Object.Type.Blob; - obj.SHA = match.Groups[2].Value; - - switch (match.Groups[1].Value) { - case "tag": obj.Kind = Object.Type.Tag; break; - case "blob": obj.Kind = Object.Type.Blob; break; - case "tree": obj.Kind = Object.Type.Tree; break; - case "commit": obj.Kind = Object.Type.Commit; break; - } - - files.Add(obj); - }); - - if (errs != null) App.RaiseError(errs); - return files; - } - - /// - /// Get file content. - /// - /// - /// - /// - public List GetTextFileContent(Repository repo, string file, out bool isBinary) { - var data = new List(); - var binary = false; - var count = 0; - - repo.RunCommand($"diff 4b825dc642cb6eb9a060e54bf8d69288fbee4904 {SHA} --numstat -- \"{file}\"", line => { - if (REG_TESTBINARY.IsMatch(line)) binary = true; - }); - - if (!binary) { - var errs = repo.RunCommand($"show {SHA}:\"{file}\"", line => { - if (binary) return; - - if (line.IndexOf('\0') >= 0) { - binary = true; - data.Clear(); - return; - } - - count++; - data.Add(new Line() { No = count, Content = line }); - }); - - if (errs != null) App.RaiseError(errs); - } - - isBinary = binary; - return data; - } - - /// - /// Save file to. - /// - /// - /// - /// - public void SaveFileTo(Repository repo, string file, string saveTo) { - var tmp = Path.GetTempFileName(); - var bat = tmp + ".bat"; - var cmd = ""; - - if (repo.IsLFSFiltered(file)) { - cmd += $"git --no-pager show {SHA}:\"{file}\" > {tmp}.lfs\n"; - cmd += $"git --no-pager lfs smudge < {tmp}.lfs > {saveTo}\n"; - } else { - cmd = $"git --no-pager show {SHA}:\"{file}\" > {saveTo}\n"; - } - - File.WriteAllText(bat, cmd); - - var starter = new ProcessStartInfo(); - starter.FileName = bat; - starter.WorkingDirectory = repo.Path; - starter.CreateNoWindow = true; - starter.WindowStyle = ProcessWindowStyle.Hidden; - - var proc = Process.Start(starter); - proc.WaitForExit(); - proc.Close(); - - File.Delete(bat); - } - - private bool IsAncestorOfHead(Repository repo) { - var startInfo = new ProcessStartInfo(); - startInfo.FileName = App.Setting.Tools.GitExecutable; - startInfo.Arguments = $"merge-base --is-ancestor {SHA} HEAD"; - startInfo.WorkingDirectory = repo.Path; - startInfo.UseShellExecute = false; - startInfo.CreateNoWindow = true; - startInfo.RedirectStandardOutput = false; - startInfo.RedirectStandardError = false; - - var proc = new Process() { StartInfo = startInfo }; - proc.Start(); - proc.WaitForExit(); - - var ret = proc.ExitCode; - proc.Close(); - return ret == 0; - } - - private static Commit FindFirstMerged(Repository repo, List commits, int start, int end) { - var isStartAncestor = commits[start].IsAncestorOfHead(repo); - if (isStartAncestor) return commits[start]; - - if (end - start <= 1) { - return commits[end]; - } else { - var mid = (int)Math.Floor((end + start) * 0.5f); - if (commits[mid].IsAncestorOfHead(repo)) { - return FindFirstMerged(repo, commits, start + 1, mid); - } else { - return FindFirstMerged(repo, commits, mid + 1, end); - } - } - } - - private static void ParseSHA(Commit commit, string data) { - var decoratorStart = data.IndexOf('('); - if (decoratorStart < 0) { - commit.SHA = data.Trim(); - return; - } - - commit.SHA = data.Substring(0, decoratorStart).Trim(); - - var subs = data.Substring(decoratorStart + 1).Split(new char[] { ',', ')', '(' }, StringSplitOptions.RemoveEmptyEntries); - foreach (var sub in subs) { - var d = sub.Trim(); - if (d.StartsWith("tag: refs/tags/", StringComparison.Ordinal)) { - commit.Decorators.Add(new Decorator() { - Type = DecoratorType.Tag, - Name = d.Substring(15).Trim() - }); - } else if (d.EndsWith("/HEAD")) { - continue; - } else if (d.StartsWith("HEAD -> refs/heads/", StringComparison.Ordinal)) { - commit.IsHEAD = true; - commit.Decorators.Add(new Decorator() { - Type = DecoratorType.CurrentBranchHead, - Name = d.Substring(19).Trim() - }); - } else if (d.StartsWith("refs/heads/", StringComparison.Ordinal)) { - commit.Decorators.Add(new Decorator() { - Type = DecoratorType.LocalBranchHead, - Name = d.Substring(11).Trim() - }); - } else if (d.StartsWith("refs/remotes/", StringComparison.Ordinal)) { - commit.Decorators.Add(new Decorator() { - Type = DecoratorType.RemoteBranchHead, - Name = d.Substring(13).Trim() - }); - } - } - } - } -} diff --git a/src/Git/Decorator.cs b/src/Git/Decorator.cs deleted file mode 100644 index d9131712..00000000 --- a/src/Git/Decorator.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace SourceGit.Git { - - /// - /// Decorator type. - /// - public enum DecoratorType { - None, - CurrentBranchHead, - LocalBranchHead, - RemoteBranchHead, - Tag, - } - - /// - /// Commit decorator. - /// - public class Decorator { - public DecoratorType Type { get; set; } - public string Name { get; set; } - } -} diff --git a/src/Git/Diff.cs b/src/Git/Diff.cs deleted file mode 100644 index c4cfd8e5..00000000 --- a/src/Git/Diff.cs +++ /dev/null @@ -1,188 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Text; -using System.Text.RegularExpressions; - -namespace SourceGit.Git { - - /// - /// Diff helper. - /// - public class Diff { - private static readonly Regex REG_INDICATOR = new Regex(@"^@@ \-(\d+),?\d* \+(\d+),?\d* @@", RegexOptions.None); - - /// - /// Line mode. - /// - public enum LineMode { - None, - Normal, - Indicator, - Added, - Deleted, - } - - /// - /// Line change. - /// - public class LineChange { - public LineMode Mode = LineMode.Normal; - public string Content = ""; - public string OldLine = ""; - public string NewLine = ""; - - public LineChange(LineMode mode, string content, string oldLine = "", string newLine = "") { - Mode = mode; - Content = content; - OldLine = oldLine; - NewLine = newLine; - } - } - - /// - /// Text change. - /// - public class TextChange { - public List Lines = new List(); - public bool IsBinary = false; - } - - /// - /// Binary change. - /// - public class BinaryChange { - public long Size = 0; - public long PreSize = 0; - } - - /// - /// Change for LFS object information. - /// - public class LFSChange { - public LFSObject Old; - public LFSObject New; - public bool IsValid => Old != null || New != null; - } - - /// - /// Run diff process. - /// - /// - /// - /// - public static TextChange GetTextChange(Repository repo, string args) { - var rs = new TextChange(); - var started = false; - var oldLine = 0; - var newLine = 0; - - repo.RunCommand($"diff --ignore-cr-at-eol {args}", line => { - if (rs.IsBinary) return; - - if (!started) { - var match = REG_INDICATOR.Match(line); - if (!match.Success) { - if (line.StartsWith("Binary ")) rs.IsBinary = true; - return; - } - - started = true; - oldLine = int.Parse(match.Groups[1].Value); - newLine = int.Parse(match.Groups[2].Value); - rs.Lines.Add(new LineChange(LineMode.Indicator, line)); - } else { - if (line[0] == '-') { - rs.Lines.Add(new LineChange(LineMode.Deleted, line.Substring(1), $"{oldLine}", "")); - oldLine++; - } else if (line[0] == '+') { - rs.Lines.Add(new LineChange(LineMode.Added, line.Substring(1), "", $"{newLine}")); - newLine++; - } else if (line[0] == '\\') { - // IGNORE \ No new line end of file. - } else { - var match = REG_INDICATOR.Match(line); - if (match.Success) { - oldLine = int.Parse(match.Groups[1].Value); - newLine = int.Parse(match.Groups[2].Value); - rs.Lines.Add(new LineChange(LineMode.Indicator, line)); - } else { - rs.Lines.Add(new LineChange(LineMode.Normal, line.Substring(1), $"{oldLine}", $"{newLine}")); - oldLine++; - newLine++; - } - } - } - }); - - if (rs.IsBinary) rs.Lines.Clear(); - return rs; - } - - /// - /// Get file size changes for binary file. - /// - /// - /// - /// - /// - /// - public static BinaryChange GetSizeChange(Repository repo, string[] revisions, string path, string orgPath = null) { - var change = new BinaryChange(); - - if (revisions.Length == 0) { // Compare working copy with HEAD - change.Size = new FileInfo(Path.Combine(repo.Path, path)).Length; - change.PreSize = repo.GetFileSize("HEAD", path); - } else if (revisions.Length == 1) { // Compare HEAD with given revision. - change.Size = repo.GetFileSize("HEAD", path); - if (!string.IsNullOrEmpty(orgPath)) { - change.PreSize = repo.GetFileSize(revisions[0], orgPath); - } else { - change.PreSize = repo.GetFileSize(revisions[0], path); - } - } else { - change.Size = repo.GetFileSize(revisions[1], path); - if (!string.IsNullOrEmpty(orgPath)) { - change.PreSize = repo.GetFileSize(revisions[0], orgPath); - } else { - change.PreSize = repo.GetFileSize(revisions[0], path); - } - } - - return change; - } - - /// - /// Get LFS object changes. - /// - /// - /// - /// - public static LFSChange GetLFSChange(Repository repo, string args) { - var rc = new LFSChange(); - - repo.RunCommand($"diff --ignore-cr-at-eol {args}", line => { - if (line[0] == '-') { - if (rc.Old == null) rc.Old = new LFSObject(); - line = line.Substring(1); - if (line.StartsWith("oid sha256:")) { - rc.Old.OID = line.Substring(11); - } else if (line.StartsWith("size ")) { - rc.Old.Size = int.Parse(line.Substring(5)); - } - } else if (line[0] == '+') { - if (rc.New == null) rc.New = new LFSObject(); - line = line.Substring(1); - if (line.StartsWith("oid sha256:")) { - rc.New.OID = line.Substring(11); - } else if (line.StartsWith("size ")) { - rc.New.Size = int.Parse(line.Substring(5)); - } - } else if (line.StartsWith(" size ")) { - rc.New.Size = rc.Old.Size = int.Parse(line.Substring(6)); - } - }); - - return rc; - } - } -} diff --git a/src/Git/LFS.cs b/src/Git/LFS.cs deleted file mode 100644 index 3b4c5a92..00000000 --- a/src/Git/LFS.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace SourceGit.Git { - - /// - /// Object filtered by LFS - /// - public class LFSObject { - - /// - /// Object id - /// - public string OID { get; set; } - - /// - /// Object size. - /// - public long Size { get; set; } - } -} diff --git a/src/Git/MergeTool.cs b/src/Git/MergeTool.cs deleted file mode 100644 index 8fda9492..00000000 --- a/src/Git/MergeTool.cs +++ /dev/null @@ -1,202 +0,0 @@ -using Microsoft.Win32; -using SourceGit.UI; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace SourceGit.Git { - - /// - /// External merge tool - /// - public class MergeTool { - - /// - /// Display name - /// - public string Name { get; set; } - - /// - /// Executable file name. - /// - public string ExecutableName { get; set; } - - /// - /// Command line parameter. - /// - public string Parameter { get; set; } - - /// - /// Auto finder. - /// - public Func Finder { get; set; } - - /// - /// Is this merge tool configured. - /// - public bool IsConfigured => !string.IsNullOrEmpty(ExecutableName); - - /// - /// Supported merge tools. - /// - public static List Supported = new List() { - new MergeTool("--", "", "", FindInvalid), - new MergeTool("Araxis Merge", "Compare.exe", "/wait /merge /3 /a1 \"$BASE\" \"$REMOTE\" \"$LOCAL\" \"$MERGED\"", FindAraxisMerge), - new MergeTool("Beyond Compare 4", "BComp.exe", "\"$REMOTE\" \"$LOCAL\" \"$BASE\" \"$MERGED\"", FindBCompare), - new MergeTool("KDiff3", "kdiff3.exe", "\"$REMOTE\" -b \"$BASE\" \"$LOCAL\" -o \"$MERGED\"", FindKDiff3), - new MergeTool("P4Merge", "p4merge.exe", "\"$BASE\" \"$REMOTE\" \"$LOCAL\" \"$MERGED\"", FindP4Merge), - new MergeTool("Tortoise Merge", "TortoiseMerge.exe", "-base:\"$BASE\" -theirs:\"$REMOTE\" -mine:\"$LOCAL\" -merged:\"$MERGED\"", FindTortoiseMerge), - new MergeTool("Visual Studio 2017/2019", "vsDiffMerge.exe", "\"$REMOTE\" \"$LOCAL\" \"$BASE\" \"$MERGED\" //m", FindVSMerge), - new MergeTool("Visual Studio Code", "Code.exe", "-n --wait \"$MERGED\"", FindVSCode), - }; - - /// - /// Finder for invalid merge tool. - /// - /// - public static string FindInvalid() { - return "--"; - } - - /// - /// Find araxis merge tool install path. - /// - /// - public static string FindAraxisMerge() { - var path = @"C:\Program Files\Araxis\Araxis Merge\Compare.exe"; - if (File.Exists(path)) return path; - return ""; - } - - /// - /// Find kdiff3.exe by registry. - /// - /// - public static string FindKDiff3() { - var root = RegistryKey.OpenBaseKey( - RegistryHive.LocalMachine, - Environment.Is64BitOperatingSystem ? RegistryView.Registry64 : RegistryView.Registry32); - - var kdiff = root.OpenSubKey(@"SOFTWARE\KDiff3\diff-ext"); - if (kdiff == null) return ""; - return kdiff.GetValue("diffcommand") as string; - } - - /// - /// Finder for p4merge - /// - /// - public static string FindP4Merge() { - var path = @"C:\Program Files\Perforce\p4merge.exe"; - if (File.Exists(path)) return path; - return ""; - } - - /// - /// Find BComp.exe by registry. - /// - /// - public static string FindBCompare() { - var root = RegistryKey.OpenBaseKey( - RegistryHive.LocalMachine, - Environment.Is64BitOperatingSystem ? RegistryView.Registry64 : RegistryView.Registry32); - - var bc = root.OpenSubKey(@"SOFTWARE\Scooter Software\Beyond Compare"); - if (bc == null) return ""; - - var exec = bc.GetValue("ExePath") as string; - var dir = Path.GetDirectoryName(exec); - return $"{dir}\\BComp.exe"; - } - - /// - /// Find TortoiseMerge.exe by registry. - /// - /// - public static string FindTortoiseMerge() { - var root = RegistryKey.OpenBaseKey( - RegistryHive.LocalMachine, - Environment.Is64BitOperatingSystem ? RegistryView.Registry64 : RegistryView.Registry32); - - var tortoiseSVN = root.OpenSubKey("SOFTWARE\\TortoiseSVN"); - if (tortoiseSVN == null) return ""; - return tortoiseSVN.GetValue("TMergePath") as string; - } - - /// - /// Find vsDiffMerge.exe. - /// - /// - public static string FindVSMerge() { - var dir = @"C:\Program Files (x86)\Microsoft Visual Studio"; - if (Directory.Exists($"{dir}\\2019")) { - dir += "\\2019"; - } else if (Directory.Exists($"{dir}\\2017")) { - dir += "\\2017"; - } else { - return ""; - } - - if (Directory.Exists($"{dir}\\Community")) { - dir += "\\Community"; - } else if (Directory.Exists($"{dir}\\Enterprise")) { - dir += "\\Enterprise"; - } else if (Directory.Exists($"{dir}\\Professional")) { - dir += "\\Professional"; - } else { - return ""; - } - - return $"{dir}\\Common7\\IDE\\CommonExtensions\\Microsoft\\TeamFoundation\\Team Explorer\\vsDiffMerge.exe"; - } - - /// - /// Find VSCode executable file path. - /// - /// - public static string FindVSCode() { - var root = RegistryKey.OpenBaseKey( - RegistryHive.LocalMachine, - Environment.Is64BitOperatingSystem ? RegistryView.Registry64 : RegistryView.Registry32); - - var vscode = root.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{C26E74D1-022E-4238-8B9D-1E7564A36CC9}_is1"); - if (vscode != null) { - return vscode.GetValue("DisplayIcon") as string; - } - - vscode = root.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{1287CAD5-7C8D-410D-88B9-0D1EE4A83FF2}_is1"); - if (vscode != null) { - return vscode.GetValue("DisplayIcon") as string; - } - - vscode = root.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{F8A2A208-72B3-4D61-95FC-8A65D340689B}_is1"); - if (vscode != null) { - return vscode.GetValue("DisplayIcon") as string; - } - - vscode = root.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{EA457B21-F73E-494C-ACAB-524FDE069978}_is1"); - if (vscode != null) { - return vscode.GetValue("DisplayIcon") as string; - } - - return ""; - } - - /// - /// Constructor. - /// - /// - /// - /// - /// - public MergeTool(string name, string exe, string param, Func finder) { - Name = name; - ExecutableName = exe; - Parameter = param; - Finder = finder; - } - } -} diff --git a/src/Git/Remote.cs b/src/Git/Remote.cs deleted file mode 100644 index 079bd2ea..00000000 --- a/src/Git/Remote.cs +++ /dev/null @@ -1,97 +0,0 @@ -using System.Collections.Generic; -using System.Text.RegularExpressions; - -namespace SourceGit.Git { - - /// - /// Git remote - /// - public class Remote { - private static readonly Regex FORMAT = new Regex(@"^([\w\.\-]+)\s*(\S+).*$"); - - /// - /// Name of this remote - /// - public string Name { get; set; } - - /// - /// URL - /// - public string URL { get; set; } - - /// - /// Parsing remote - /// - /// Repository - /// - public static List Load(Repository repo) { - var remotes = new List(); - var added = new List(); - - repo.RunCommand("remote -v", data => { - var match = FORMAT.Match(data); - if (!match.Success) return; - - var remote = new Remote() { - Name = match.Groups[1].Value, - URL = match.Groups[2].Value, - }; - - if (added.Contains(remote.Name)) return; - - added.Add(remote.Name); - remotes.Add(remote); - }); - - return remotes; - } - - /// - /// Add new remote - /// - /// - /// - /// - public static void Add(Repository repo, string name, string url) { - var errs = repo.RunCommand($"remote add {name} {url}", null); - if (errs != null) { - App.RaiseError(errs); - } else { - repo.Fetch(new Remote() { Name = name }, true, null); - } - } - - /// - /// Delete remote. - /// - /// - /// - public static void Delete(Repository repo, string remote) { - var errs = repo.RunCommand($"remote remove {remote}", null); - if (errs != null) App.RaiseError(errs); - } - - /// - /// Edit remote. - /// - /// - /// - /// - public void Edit(Repository repo, string name, string url) { - string errs = null; - - if (name != Name) { - errs = repo.RunCommand($"remote rename {Name} {name}", null); - if (errs != null) { - App.RaiseError(errs); - return; - } - } - - if (url != URL) { - errs = repo.RunCommand($"remote set-url {name} {url}", null); - if (errs != null) App.RaiseError(errs); - } - } - } -} diff --git a/src/Git/Repository.cs b/src/Git/Repository.cs deleted file mode 100644 index a1ca900d..00000000 --- a/src/Git/Repository.cs +++ /dev/null @@ -1,1201 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Text; -using System.Text.Json.Serialization; -using System.Text.RegularExpressions; -using System.Windows.Threading; - -namespace SourceGit.Git { - - /// - /// Git repository - /// - public class Repository { - - #region HOOKS - public Action OnNavigateCommit = null; - public Action OnWorkingCopyChanged = null; - public Action OnTagChanged = null; - public Action OnStashChanged = null; - public Action OnBranchChanged = null; - public Action OnCommitsChanged = null; - public Action OnSubmoduleChanged = null; - public Action OnClosing = null; - #endregion - - #region PROPERTIES_SAVED - /// - /// Storage path. - /// - public string Path { get; set; } - /// - /// Display name. - /// - public string Name { get; set; } - /// - /// Owner group. - /// - public string GroupId { get; set; } - /// - /// Custom color. - /// - public int Color { get; set; } = 0; - /// - /// Last open time(File time format). - /// - public long LastOpenTime { get; set; } - /// - /// Expand tags. - /// - public bool ExpandTags { get; set; } - /// - /// Filters for logs. - /// - public List LogFilters { get; set; } = new List(); - /// - /// Last 10 Commit message. - /// - public List CommitMsgRecords { get; set; } = new List(); - /// - /// Commit template. - /// - public string CommitTemplate { get; set; } - #endregion - - #region PROPERTIES_RUNTIME - [JsonIgnore] public Repository Parent = null; - [JsonIgnore] public string GitDir = null; - - private List cachedRemotes = new List(); - private List cachedBranches = new List(); - private List cachedTags = new List(); - private FileSystemWatcher gitDirWatcher = null; - private FileSystemWatcher workingCopyWatcher = null; - private DispatcherTimer timer = null; - private bool isWatcherDisabled = false; - private long nextUpdateTags = 0; - private long nextUpdateLocalChanges = 0; - private long nextUpdateStashes = 0; - private long nextUpdateTree = 0; - - private string featurePrefix = null; - private string releasePrefix = null; - private string hotfixPrefix = null; - #endregion - - #region METHOD_PROCESS - /// - /// Read git config - /// - /// - /// - public string GetConfig(string key) { - var startInfo = new ProcessStartInfo(); - startInfo.FileName = App.Setting.Tools.GitExecutable; - startInfo.Arguments = $"config {key}"; - startInfo.WorkingDirectory = Path; - startInfo.UseShellExecute = false; - startInfo.CreateNoWindow = true; - startInfo.RedirectStandardOutput = true; - startInfo.StandardOutputEncoding = Encoding.UTF8; - - var proc = new Process() { StartInfo = startInfo }; - proc.Start(); - var output = proc.StandardOutput.ReadToEnd(); - proc.WaitForExit(); - proc.Close(); - - return output.Trim(); - } - - /// - /// Configure git. - /// - /// - /// - public void SetConfig(string key, string value) { - var startInfo = new ProcessStartInfo(); - startInfo.FileName = App.Setting.Tools.GitExecutable; - startInfo.Arguments = $"config {key} \"{value}\""; - startInfo.WorkingDirectory = Path; - startInfo.UseShellExecute = false; - startInfo.CreateNoWindow = true; - - var proc = new Process() { StartInfo = startInfo }; - proc.Start(); - proc.WaitForExit(); - proc.Close(); - } - - /// - /// Run git command without repository. - /// - /// Working directory. - /// Arguments for running git command. - /// Handler for output. - /// Handle error as output. - /// Errors if exists. - public static string RunCommand(string cwd, string args, Action outputHandler, bool includeError = false) { - var startInfo = new ProcessStartInfo(); - startInfo.FileName = App.Setting.Tools.GitExecutable; - startInfo.Arguments = "--no-pager -c core.quotepath=off " + args; - startInfo.WorkingDirectory = cwd; - startInfo.UseShellExecute = false; - startInfo.CreateNoWindow = true; - startInfo.RedirectStandardOutput = true; - startInfo.RedirectStandardError = true; - startInfo.StandardOutputEncoding = Encoding.UTF8; - startInfo.StandardErrorEncoding = Encoding.UTF8; - - var progressFilter = new Regex(@"\d+\%"); - var errs = new List(); - var proc = new Process() { StartInfo = startInfo }; - - proc.OutputDataReceived += (o, e) => { - if (e.Data == null) return; - outputHandler?.Invoke(e.Data); - }; - proc.ErrorDataReceived += (o, e) => { - if (e.Data == null) return; - if (includeError) outputHandler?.Invoke(e.Data); - if (string.IsNullOrEmpty(e.Data)) return; - if (progressFilter.IsMatch(e.Data)) return; - if (e.Data.StartsWith("remote: Counting objects:", StringComparison.Ordinal)) return; - errs.Add(e.Data); - }; - - proc.Start(); - proc.BeginOutputReadLine(); - proc.BeginErrorReadLine(); - proc.WaitForExit(); - - int exitCode = proc.ExitCode; - proc.Close(); - - if (exitCode != 0 && errs.Count > 0) { - return string.Join("\n", errs); - } else { - return null; - } - } - - /// - /// Create process for reading outputs/errors using git.exe - /// - /// Arguments for running git command. - /// Handler for output. - /// Handle error as output. - /// Errors if exists. - public string RunCommand(string args, Action outputHandler, bool includeError = false) { - return RunCommand(Path, args, outputHandler, includeError); - } - - /// - /// Assert command result and then update branches and commits. - /// - /// - public void AssertCommand(string err) { - if (!string.IsNullOrEmpty(err)) App.RaiseError(err); - - Branches(true); - OnBranchChanged?.Invoke(); - OnCommitsChanged?.Invoke(); - OnWorkingCopyChanged?.Invoke(); - OnTagChanged?.Invoke(); - - isWatcherDisabled = false; - } - #endregion - - #region METHOD_VALIDATIONS - /// - /// Is valid git directory. - /// - /// Local path. - /// - public static bool IsValid(string path) { - var startInfo = new ProcessStartInfo(); - startInfo.FileName = App.Setting.Tools.GitExecutable; - startInfo.Arguments = "rev-parse --git-dir"; - startInfo.WorkingDirectory = path; - startInfo.UseShellExecute = false; - startInfo.CreateNoWindow = true; - - try { - var proc = new Process() { StartInfo = startInfo }; - proc.Start(); - proc.WaitForExit(); - - var test = proc.ExitCode == 0; - proc.Close(); - return test; - } catch { - return false; - } - } - - /// - /// Is remote url valid. - /// - /// - /// - public static bool IsValidUrl(string url) { - return !string.IsNullOrEmpty(url) - && (url.StartsWith("http://", StringComparison.Ordinal) - || url.StartsWith("https://", StringComparison.Ordinal) - || url.StartsWith("git@", StringComparison.Ordinal) - || url.StartsWith("file://", StringComparison.Ordinal)); - } - #endregion - - #region METHOD_OPEN_CLOSE - /// - /// Open repository. - /// - public void Open() { - LastOpenTime = DateTime.Now.ToFileTime(); - isWatcherDisabled = false; - - GitDir = ".git"; - RunCommand("rev-parse --git-dir", line => { - GitDir = line; - }); - if (!System.IO.Path.IsPathRooted(GitDir)) GitDir = System.IO.Path.Combine(Path, GitDir); - - var checkGitDir = new DirectoryInfo(GitDir); - if (!checkGitDir.Exists) { - App.RaiseError("GIT_DIR for this repository NOT FOUND!"); - return; - } else { - GitDir = checkGitDir.FullName; - } - - gitDirWatcher = new FileSystemWatcher(); - gitDirWatcher.Path = GitDir; - gitDirWatcher.Filter = "*"; - gitDirWatcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.DirectoryName | NotifyFilters.FileName; - gitDirWatcher.IncludeSubdirectories = true; - gitDirWatcher.Created += OnGitDirFSChanged; - gitDirWatcher.Renamed += OnGitDirFSChanged; - gitDirWatcher.Changed += OnGitDirFSChanged; - gitDirWatcher.Deleted += OnGitDirFSChanged; - gitDirWatcher.EnableRaisingEvents = true; - - workingCopyWatcher = new FileSystemWatcher(); - workingCopyWatcher.Path = Path; - workingCopyWatcher.Filter = "*"; - workingCopyWatcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.DirectoryName | NotifyFilters.FileName; - workingCopyWatcher.IncludeSubdirectories = true; - workingCopyWatcher.Created += OnWorkingCopyFSChanged; - workingCopyWatcher.Renamed += OnWorkingCopyFSChanged; - workingCopyWatcher.Changed += OnWorkingCopyFSChanged; - workingCopyWatcher.Deleted += OnWorkingCopyFSChanged; - workingCopyWatcher.EnableRaisingEvents = true; - - timer = new DispatcherTimer(); - timer.Tick += Tick; - timer.Interval = TimeSpan.FromSeconds(.1); - timer.Start(); - - featurePrefix = GetConfig("gitflow.prefix.feature"); - releasePrefix = GetConfig("gitflow.prefix.release"); - hotfixPrefix = GetConfig("gitflow.prefix.hotfix"); - } - - /// - /// Close repository. - /// - public void Close() { - OnClosing?.Invoke(); - - OnBranchChanged = null; - OnCommitsChanged = null; - OnTagChanged = null; - OnStashChanged = null; - OnWorkingCopyChanged = null; - OnNavigateCommit = null; - OnSubmoduleChanged = null; - OnClosing = null; - - cachedBranches.Clear(); - cachedRemotes.Clear(); - cachedTags.Clear(); - - gitDirWatcher.EnableRaisingEvents = false; - workingCopyWatcher.EnableRaisingEvents = false; - gitDirWatcher.Dispose(); - workingCopyWatcher.Dispose(); - timer.Stop(); - - gitDirWatcher = null; - workingCopyWatcher = null; - timer = null; - featurePrefix = null; - releasePrefix = null; - hotfixPrefix = null; - - GC.Collect(); - } - #endregion - - #region METHOD_WATCHER - public void SetWatcherEnabled(bool enabled) { - isWatcherDisabled = !enabled; - } - - private void Tick(object sender, EventArgs e) { - if (isWatcherDisabled) { - nextUpdateLocalChanges = 0; - nextUpdateStashes = 0; - nextUpdateTags = 0; - nextUpdateTree = 0; - return; - } - - var now = DateTime.Now.ToFileTime(); - if (nextUpdateLocalChanges > 0 && now >= nextUpdateLocalChanges) { - nextUpdateLocalChanges = 0; - OnWorkingCopyChanged?.Invoke(); - } - - if (nextUpdateTags > 0 && now >= nextUpdateTags) { - nextUpdateTags = 0; - OnTagChanged?.Invoke(); - } - - if (nextUpdateStashes > 0 && now >= nextUpdateStashes) { - nextUpdateStashes = 0; - OnStashChanged?.Invoke(); - } - - if (nextUpdateTree > 0 && now >= nextUpdateTree) { - nextUpdateTree = 0; - - var branches = Branches(true); - var badFilters = new List(); - foreach (var filter in LogFilters) { - if (filter.StartsWith("refs/heads/") || filter.StartsWith("refs/remotes/")) { - var idx = branches.FindIndex(b => b.FullName == filter); - if (idx < 0) badFilters.Add(filter); - } - } - foreach (var bad in badFilters) LogFilters.Remove(bad); - - OnBranchChanged?.Invoke(); - OnCommitsChanged?.Invoke(); - } - } - - private void OnGitDirFSChanged(object sender, FileSystemEventArgs e) { - if (string.IsNullOrEmpty(e.Name)) return; - if (e.Name.StartsWith("index")) return; - - if (e.Name.StartsWith("refs\\tags", StringComparison.Ordinal)) { - nextUpdateTags = DateTime.Now.AddSeconds(.5).ToFileTime(); - } else if (e.Name.StartsWith("refs\\stash", StringComparison.Ordinal)) { - nextUpdateStashes = DateTime.Now.AddSeconds(.5).ToFileTime(); - } else if (e.Name.EndsWith("_HEAD", StringComparison.Ordinal) || - e.Name.StartsWith("refs\\heads", StringComparison.Ordinal) || - e.Name.StartsWith("refs\\remotes", StringComparison.Ordinal)) { - nextUpdateTree = DateTime.Now.AddSeconds(.5).ToFileTime(); - } - } - - private void OnWorkingCopyFSChanged(object sender, FileSystemEventArgs e) { - if (string.IsNullOrEmpty(e.Name)) return; - if (e.Name == ".git" || e.Name.StartsWith(".git\\")) return; - - nextUpdateLocalChanges = DateTime.Now.AddSeconds(1.5).ToFileTime(); - } - #endregion - - #region METHOD_GITCOMMANDS - /// - /// Clone repository. - /// - /// Remote repository URL - /// Folder to clone into - /// Local name - /// - /// - public static bool Clone(string url, string folder, string rName, string lName, Action onProgress) { - string RemoteName; - if (rName != null) { - RemoteName = $" --origin {rName}"; - } else { - RemoteName = null; - } - - var errs = RunCommand(folder, $"-c credential.helper=manager clone --progress --verbose {RemoteName} --recurse-submodules {url} {lName}", line => { - if (line != null) onProgress?.Invoke(line); - }, true); - - if (errs != null) { - App.RaiseError(errs); - return false; - } - - return true; - } - - /// - /// Fetch remote changes - /// - /// - /// - /// - public void Fetch(Remote remote, bool prune, Action onProgress) { - isWatcherDisabled = true; - - var args = "-c credential.helper=manager fetch --progress --verbose "; - - if (prune) args += "--prune "; - - if (remote == null) { - args += "--all"; - } else { - args += remote.Name; - } - - var errs = RunCommand(args, line => { - if (line != null) onProgress?.Invoke(line); - }, true); - - OnSubmoduleChanged?.Invoke(); - AssertCommand(errs); - } - - /// - /// Pull remote changes. - /// - /// remote - /// branch - /// Progress message handler. - /// Use rebase instead of merge. - /// Auto stash local changes. - /// Progress message handler. - public void Pull(string remote, string branch, Action onProgress, bool rebase = false, bool autostash = false) { - isWatcherDisabled = true; - - var args = "-c credential.helper=manager pull --verbose --progress "; - var needPopStash = false; - - if (rebase) args += "--rebase "; - if (autostash) { - if (rebase) { - args += "--autostash "; - } else { - var changes = LocalChanges(); - if (changes.Count > 0) { - var fatal = RunCommand("stash push -u -m \"PULL_AUTO_STASH\"", null); - if (fatal != null) { - App.RaiseError(fatal); - isWatcherDisabled = false; - return; - } - needPopStash = true; - } - } - } - - var errs = RunCommand(args + remote + " " + branch, line => { - if (line != null) onProgress?.Invoke(line); - }, true); - - OnSubmoduleChanged?.Invoke(); - AssertCommand(errs); - if (needPopStash && errs == null) RunCommand("stash pop -q stash@{0}", null); - } - - /// - /// Push local branch to remote. - /// - /// Remote - /// Local branch name - /// Remote branch name - /// Progress message handler. - /// Push tags - /// Create track reference - /// Force push - public void Push(string remote, string localBranch, string remoteBranch, Action onProgress, bool withTags = false, bool track = false, bool force = false) { - isWatcherDisabled = true; - - var args = "-c credential.helper=manager push --progress --verbose "; - - if (withTags) args += "--tags "; - if (track) args += "-u "; - if (force) args += "--force-with-lease "; - - var errs = RunCommand(args + remote + " " + localBranch + ":" + remoteBranch, line => { - if (line != null) onProgress?.Invoke(line); - }, true); - - AssertCommand(errs); - } - - /// - /// Apply patch. - /// - /// - /// - /// - public void Apply(string patch, bool ignoreSpaceChanges, string whitespaceMode) { - isWatcherDisabled = true; - - var args = "apply "; - if (ignoreSpaceChanges) args += "--ignore-whitespace "; - else args += $"--whitespace={whitespaceMode} "; - - var errs = RunCommand($"{args} \"{patch}\"", null); - if (errs != null) { - App.RaiseError(errs); - } else { - OnWorkingCopyChanged?.Invoke(); - } - - isWatcherDisabled = false; - } - - /// - /// Revert given commit. - /// - /// - /// - public void Revert(string commit, bool autoCommit) { - isWatcherDisabled = true; - - var errs = RunCommand($"revert {commit} --no-edit" + (autoCommit ? "" : " --no-commit"), null); - AssertCommand(errs); - } - - /// - /// Checkout - /// - /// Options. - public void Checkout(string option) { - isWatcherDisabled = true; - - var errs = RunCommand($"checkout {option}", null); - if (errs != null) { - App.RaiseError(errs); - } else { - Branches(true); - OnBranchChanged?.Invoke(); - OnCommitsChanged?.Invoke(); - OnWorkingCopyChanged?.Invoke(); - OnTagChanged?.Invoke(); - } - - isWatcherDisabled = false; - } - - /// - /// Merge given branch into current. - /// - /// - /// - public void Merge(string branch, string option) { - isWatcherDisabled = true; - - var errs = RunCommand($"merge {branch} {option}", null); - AssertCommand(errs); - } - - /// - /// Rebase current branch to revision - /// - /// - /// - public void Rebase(string revision, bool autoStash) { - isWatcherDisabled = true; - - var args = $"rebase "; - if (autoStash) args += "--autostash "; - args += revision; - - var errs = RunCommand(args, null); - AssertCommand(errs); - } - - /// - /// Reset. - /// - /// - /// - public void Reset(string revision, string mode = "") { - isWatcherDisabled = true; - - var errs = RunCommand($"reset {mode} {revision}", null); - AssertCommand(errs); - } - - /// - /// Cherry pick commit. - /// - /// - /// - public void CherryPick(string commit, bool noCommit) { - isWatcherDisabled = true; - - var args = "cherry-pick "; - args += noCommit ? "-n " : "--ff "; - args += commit; - - var errs = RunCommand(args, null); - AssertCommand(errs); - } - - /// - /// Stage(add) files to index. - /// - /// - public void Stage(params string[] files) { - isWatcherDisabled = true; - - var args = "add"; - if (files == null || files.Length == 0) { - args += " ."; - } else { - args += " --"; - foreach (var file in files) args += $" \"{file}\""; - } - - var errs = RunCommand(args, null); - if (errs != null) App.RaiseError(errs); - - OnWorkingCopyChanged?.Invoke(); - isWatcherDisabled = false; - } - - /// - /// Unstage files from index - /// - /// - public void Unstage(params string[] files) { - isWatcherDisabled = true; - - var args = "reset"; - if (files != null && files.Length > 0) { - args += " --"; - foreach (var file in files) args += $" \"{file}\""; - } - - var errs = RunCommand(args, null); - if (errs != null) App.RaiseError(errs); - - OnWorkingCopyChanged?.Invoke(); - isWatcherDisabled = false; - } - - /// - /// Discard changes. - /// - /// - public void Discard(List changes) { - isWatcherDisabled = true; - - if (changes == null || changes.Count == 0) { - var errs = RunCommand("reset --hard HEAD", null); - if (errs != null) { - App.RaiseError(errs); - isWatcherDisabled = false; - return; - } - - RunCommand("clean -qfd", null); - } else { - foreach (var change in changes) { - if (change.WorkTree == Change.Status.Untracked || change.WorkTree == Change.Status.Added) { - RunCommand($"clean -qfd -- \"{change.Path}\"", null); - } else { - RunCommand($"checkout -f -- \"{change.Path}\"", null); - } - } - } - - OnWorkingCopyChanged?.Invoke(); - isWatcherDisabled = false; - } - - /// - /// Commit - /// - /// - /// - public bool DoCommit(string message, bool amend) { - isWatcherDisabled = true; - - var file = System.IO.Path.GetTempFileName(); - File.WriteAllText(file, message); - - var args = $"commit --file=\"{file}\""; - if (amend) args += " --amend --no-edit"; - var errs = RunCommand(args, null); - AssertCommand(errs); - - var branch = CurrentBranch(); - OnNavigateCommit?.Invoke(branch.Head); - return string.IsNullOrEmpty(errs); - } - - /// - /// Get all remotes of this repository. - /// - /// Force reload - /// Remote collection - public List Remotes(bool bForceReload = false) { - if (cachedRemotes.Count == 0 || bForceReload) { - cachedRemotes = Remote.Load(this); - } - - return cachedRemotes; - } - - /// - /// Local changes in working copy. - /// - /// Changes. - public List LocalChanges() { - List changes = new List(); - RunCommand("status -uall --ignore-submodules=dirty --porcelain", line => { - if (!string.IsNullOrEmpty(line)) { - var change = Change.Parse(line); - if (change != null) changes.Add(change); - } - }); - return changes; - } - - /// - /// Get total commit count. - /// - /// Number of total commits. - public int TotalCommits() { - int count = 0; - RunCommand("rev-list --all --count", line => { - if (!string.IsNullOrEmpty(line)) count = int.Parse(line.Trim()); - }); - return count; - } - - /// - /// Load commits. - /// - /// Extra limit arguments for `git log` - /// Commit collection - public List Commits(string limit = null) { - return Commit.Load(this, (limit == null ? "" : limit)); ; - } - - /// - /// Load all branches. - /// - /// Force reload. - /// Branches collection. - public List Branches(bool bForceReload = false) { - if (cachedBranches.Count == 0 || bForceReload) { - cachedBranches = Branch.Load(this); - } - - if (IsGitFlowEnabled()) { - foreach (var b in cachedBranches) { - if (b.IsLocal) { - if (b.Name.StartsWith(featurePrefix)) { - b.Kind = Branch.Type.Feature; - } else if (b.Name.StartsWith(releasePrefix)) { - b.Kind = Branch.Type.Release; - } else if (b.Name.StartsWith(hotfixPrefix)) { - b.Kind = Branch.Type.Hotfix; - } - } - } - } - - return cachedBranches; - } - - /// - /// Get all remote branches - /// - /// All remote branches - public List RemoteBranches() { - var ret = new List(); - foreach (var b in cachedBranches) { - if (!b.IsLocal) ret.Add(b); - } - return ret; - } - - /// - /// Get current branch - /// - /// - public Branch CurrentBranch() { - foreach (var b in cachedBranches) { - if (b.IsCurrent) return b; - } - - return null; - } - - /// - /// Load all tags. - /// - /// - /// - public List Tags(bool bForceReload = false) { - if (cachedTags.Count == 0 || bForceReload) { - cachedTags = Tag.Load(this); - } - - return cachedTags; - } - - /// - /// Get all stashes - /// - /// - public List Stashes() { - var reflog = new Regex(@"^Reflog: refs/(stash@\{\d+\}).*$"); - var stashes = new List(); - var current = null as Stash; - - var errs = RunCommand("stash list --pretty=raw", line => { - if (line.StartsWith("commit ")) { - if (current != null && !string.IsNullOrEmpty(current.Name)) stashes.Add(current); - current = new Stash() { SHA = line.Substring(7, 8) }; - return; - } - - if (current == null) return; - - if (line.StartsWith("Reflog: refs/stash@")) { - var match = reflog.Match(line); - if (match.Success) current.Name = match.Groups[1].Value; - } else if (line.StartsWith("Reflog message: ")) { - current.Message = line.Substring(16); - } else if (line.StartsWith("author ")) { - current.Author.Parse(line); - } - }); - - if (current != null) stashes.Add(current); - if (errs != null) App.RaiseError(errs); - return stashes; - } - - /// - /// Get all submodules - /// - /// - public List Submodules() { - var test = new Regex(@"^[\-\+ ][0-9a-f]+\s(.*)\(.*\)$"); - var modules = new List(); - - var errs = RunCommand("submodule status", line => { - var match = test.Match(line); - if (!match.Success) return; - - modules.Add(match.Groups[1].Value); - }); - - return modules; - } - - /// - /// Add submodule - /// - /// - /// - /// - /// - public void AddSubmodule(string url, string localPath, bool recursive, Action onProgress) { - isWatcherDisabled = true; - - var errs = RunCommand($"submodule add {url} {localPath}", onProgress, true); - if (errs == null) { - if (recursive) RunCommand($"submodule update --init --recursive -- {localPath}", onProgress, true); - OnWorkingCopyChanged?.Invoke(); - OnSubmoduleChanged?.Invoke(); - } else { - App.RaiseError(errs); - } - - isWatcherDisabled = false; - } - - /// - /// Update submodule. - /// - public void UpdateSubmodule() { - isWatcherDisabled = true; - - var errs = RunCommand("submodule update --rebase --remote", null); - if (errs != null) { - App.RaiseError(errs); - } else { - OnWorkingCopyChanged?.Invoke(); - OnSubmoduleChanged?.Invoke(); - } - - isWatcherDisabled = false; - } - - /// - /// Blame file. - /// - /// - /// - /// - public Blame BlameFile(string file, string revision) { - var regex = new Regex(@"^\^?([0-9a-f]+)\s+.*\((.*)\s+(\d+)\s+[\-\+]?\d+\s+\d+\) (.*)"); - var blame = new Blame(); - - var errs = RunCommand($"blame -t {revision} -- \"{file}\"", line => { - if (blame.IsBinary) return; - if (string.IsNullOrEmpty(line)) return; - - if (line.IndexOf('\0') >= 0) { - blame.IsBinary = true; - blame.Lines.Clear(); - return; - } - - var match = regex.Match(line); - if (!match.Success) return; - - var commit = match.Groups[1].Value; - var author = match.Groups[2].Value; - var timestamp = int.Parse(match.Groups[3].Value); - var data = match.Groups[4].Value; - var when = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddSeconds(timestamp).ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"); - - var blameLine = new Blame.Line() { - CommitSHA = commit, - Author = author, - Time = when, - Content = data, - }; - - blame.Lines.Add(blameLine); - }); - - if (errs != null) App.RaiseError(errs); - return blame; - } - - /// - /// Get file size. - /// - /// - /// - /// - public long GetFileSize(string sha, string path) { - long size = 0; - - RunCommand($"cat-file -s {sha}:\"{path}\"", line => { - if (!long.TryParse(line, out size)) size = 0; - }); - - return size; - } - - /// - /// Detect if a file is managed by LFS. - /// - /// File path - /// - public bool IsLFSFiltered(string path) { - bool ok = false; - RunCommand($"check-attr -a -z \"{path}\"", line => { - ok = ok || line.Contains("filter\0lfs"); - }); - return ok; - } - - /// - /// Get LFS object information. - /// - /// - /// - /// - public LFSObject GetLFSObject(string sha, string path) { - LFSObject obj = new LFSObject(); - - RunCommand($"show {sha}:\"{path}\"", line => { - if (line.StartsWith("oid")) { - obj.OID = line.Substring(3).Replace("sha256:", "").Trim(); - } else if (line.StartsWith("size")) { - obj.Size = int.Parse(line.Substring(4).Trim()); - } - }); - - return obj; - } - #endregion - - #region METHOD_GITFLOW - /// - /// Check if git-flow feature enabled - /// - /// - public bool IsGitFlowEnabled() { - return !string.IsNullOrEmpty(featurePrefix) - && !string.IsNullOrEmpty(releasePrefix) - && !string.IsNullOrEmpty(hotfixPrefix); - } - - /// - /// Get git-flow branch prefix. - /// - /// - public string GetFeaturePrefix() { return featurePrefix; } - public string GetReleasePrefix() { return releasePrefix; } - public string GetHotfixPrefix() { return hotfixPrefix; } - - /// - /// Enable git-flow - /// - /// - /// - /// - /// - /// - /// - public void EnableGitFlow(string master, string develop, string feature, string release, string hotfix, string version = "") { - isWatcherDisabled = true; - - var branches = Branches(); - var masterBranch = branches.Find(b => b.Name == master); - var devBranch = branches.Find(b => b.Name == develop); - var refreshBranches = false; - - if (masterBranch == null) { - var errs = RunCommand($"branch --no-track {master}", null); - if (errs != null) { - App.RaiseError(errs); - isWatcherDisabled = false; - return; - } - - refreshBranches = true; - } - - if (devBranch == null) { - var errs = RunCommand($"branch --no-track {develop}", null); - if (errs != null) { - App.RaiseError(errs); - if (refreshBranches) { - Branches(true); - OnBranchChanged?.Invoke(); - OnCommitsChanged?.Invoke(); - OnWorkingCopyChanged?.Invoke(); - } - isWatcherDisabled = false; - return; - } - - refreshBranches = true; - } - - SetConfig("gitflow.branch.master", master); - SetConfig("gitflow.branch.develop", develop); - SetConfig("gitflow.prefix.feature", feature); - SetConfig("gitflow.prefix.bugfix", "bugfix"); - SetConfig("gitflow.prefix.release", release); - SetConfig("gitflow.prefix.hotfix", hotfix); - SetConfig("gitflow.prefix.support", "support"); - SetConfig("gitflow.prefix.versiontag", version); - - RunCommand("flow init -d", null); - - featurePrefix = GetConfig("gitflow.prefix.feature"); - releasePrefix = GetConfig("gitflow.prefix.release"); - hotfixPrefix = GetConfig("gitflow.prefix.hotfix"); - - if (!IsGitFlowEnabled()) App.RaiseError("Initialize Git-flow failed!"); - - if (refreshBranches) { - Branches(true); - OnBranchChanged?.Invoke(); - OnCommitsChanged?.Invoke(); - OnWorkingCopyChanged?.Invoke(); - } - - isWatcherDisabled = false; - } - - /// - /// Start git-flow branch - /// - /// - /// - public void StartGitFlowBranch(Branch.Type type, string name) { - isWatcherDisabled = true; - - string args; - switch (type) { - case Branch.Type.Feature: args = $"flow feature start {name}"; break; - case Branch.Type.Release: args = $"flow release start {name}"; break; - case Branch.Type.Hotfix: args = $"flow hotfix start {name}"; break; - default: - App.RaiseError("Bad git-flow branch type!"); - return; - } - - var errs = RunCommand(args, null); - AssertCommand(errs); - } - - /// - /// Finish git-flow branch - /// - /// - public void FinishGitFlowBranch(Branch branch) { - isWatcherDisabled = true; - - string args; - switch (branch.Kind) { - case Branch.Type.Feature: - args = $"flow feature finish {branch.Name.Substring(featurePrefix.Length)}"; - break; - case Branch.Type.Release: - var releaseName = branch.Name.Substring(releasePrefix.Length); - args = $"flow release finish {releaseName} -m \"Release done\""; - break; - case Branch.Type.Hotfix: - var hotfixName = branch.Name.Substring(hotfixPrefix.Length); - args = $"flow hotfix finish {hotfixName} -m \"Hotfix done\""; - break; - default: - App.RaiseError("Bad git-flow branch type!"); - return; - } - - var errs = RunCommand(args, null); - AssertCommand(errs); - OnTagChanged?.Invoke(); - } - #endregion - - #region METHOD_COMMITMSG - public void RecordCommitMessage(string message) { - if (string.IsNullOrEmpty(message)) return; - - int exists = CommitMsgRecords.Count; - if (exists > 0) { - var last = CommitMsgRecords[0]; - if (last == message) return; - } - - if (exists >= 10) { - CommitMsgRecords.RemoveRange(9, exists - 9); - } - - CommitMsgRecords.Insert(0, message); - } - #endregion - } -} diff --git a/src/Git/Stash.cs b/src/Git/Stash.cs deleted file mode 100644 index d83f85f0..00000000 --- a/src/Git/Stash.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace SourceGit.Git { - - /// - /// Git stash - /// - public class Stash { - - /// - /// SHA for this stash - /// - public string SHA { get; set; } - - /// - /// Name - /// - public string Name { get; set; } - - /// - /// Author - /// - public User Author { get; set; } = new User(); - - /// - /// Message - /// - public string Message { get; set; } - - /// - /// Stash push. - /// - /// - /// - /// - /// - public static void Push(Repository repo, bool includeUntracked, string message, List files) { - string specialFiles = ""; - - if (files.Count > 0) { - specialFiles = " --"; - foreach (var f in files) specialFiles += $" \"{f}\""; - } - - string args = "stash push "; - if (includeUntracked) args += "-u "; - if (!string.IsNullOrEmpty(message)) args += $"-m \"{message}\" "; - - var errs = repo.RunCommand(args + specialFiles, null); - if (errs != null) App.RaiseError(errs); - } - - /// - /// Get changed file list in this stash. - /// - /// - /// - public List GetChanges(Repository repo) { - List changes = new List(); - - var errs = repo.RunCommand($"diff --name-status --pretty=format: {SHA}^ {SHA}", line => { - var change = Change.Parse(line); - if (change != null) changes.Add(change); - }); - - if (errs != null) App.RaiseError(errs); - return changes; - } - - /// - /// Apply stash. - /// - /// - public void Apply(Repository repo) { - var errs = repo.RunCommand($"stash apply -q {Name}", null); - if (errs != null) App.RaiseError(errs); - } - - /// - /// Pop stash - /// - /// - public void Pop(Repository repo) { - var errs = repo.RunCommand($"stash pop -q {Name}", null); - if (errs != null) App.RaiseError(errs); - } - - /// - /// Drop stash - /// - /// - public void Drop(Repository repo) { - var errs = repo.RunCommand($"stash drop -q {Name}", null); - if (errs != null) App.RaiseError(errs); - } - } -} diff --git a/src/Git/Tag.cs b/src/Git/Tag.cs deleted file mode 100644 index fbfbf432..00000000 --- a/src/Git/Tag.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Text.RegularExpressions; - -namespace SourceGit.Git { - - /// - /// Git tag. - /// - public class Tag { - private static readonly Regex FORMAT = new Regex(@"\$(.*)\$(.*)\$(.*)"); - - /// - /// SHA - /// - public string SHA { get; set; } - - /// - /// Display name. - /// - public string Name { get; set; } - - /// - /// Enable filter in log histories. - /// - public bool IsFiltered { get; set; } - - /// - /// Load all tags - /// - /// - /// - public static List Load(Repository repo) { - var args = "for-each-ref --sort=-creatordate --format=\"$%(refname:short)$%(objectname)$%(*objectname)\" refs/tags"; - var tags = new List(); - - repo.RunCommand(args, line => { - var match = 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)) { - tags.Add(new Tag() { - Name = name, - SHA = commit, - }); - } else { - tags.Add(new Tag() { - Name = name, - SHA = dereference, - }); - } - }); - - return tags; - } - - /// - /// Add new tag. - /// - /// - /// - /// - /// - public static void Add(Repository repo, string name, string startPoint, string message) { - var args = $"tag -a {name} {startPoint} "; - - if (!string.IsNullOrEmpty(message)) { - string temp = Path.GetTempFileName(); - File.WriteAllText(temp, message); - args += $"-F \"{temp}\""; - } else { - args += $"-m {name}"; - } - - var errs = repo.RunCommand(args, null); - if (errs != null) App.RaiseError(errs); - else repo.OnCommitsChanged?.Invoke(); - } - - /// - /// Delete tag. - /// - /// - /// - /// - public static void Delete(Repository repo, string name, bool push) { - var errs = repo.RunCommand($"tag --delete {name}", null); - if (errs != null) { - App.RaiseError(errs); - return; - } - - if (push) { - var remotes = repo.Remotes(); - foreach (var r in remotes) { - repo.RunCommand($"-c credential.helper=manager push --delete {r.Name} refs/tags/{name}", null); - } - } - - repo.LogFilters.Remove(name); - repo.OnCommitsChanged?.Invoke(); - } - - /// - /// Push tag to remote. - /// - /// - /// - /// - public static void Push(Repository repo, string name, string remote) { - var errs = repo.RunCommand($"-c credential.helper=manager push {remote} refs/tags/{name}", null); - if (errs != null) App.RaiseError(errs); - } - } -} diff --git a/src/Git/User.cs b/src/Git/User.cs deleted file mode 100644 index 0e1b4120..00000000 --- a/src/Git/User.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System; -using System.Text.RegularExpressions; - -namespace SourceGit.Git { - - /// - /// Git user. - /// - public class User { - private static readonly Regex FORMAT = new Regex(@"\w+ (.*) <([\w\.\-_]+@[\w\.\-_]+)> (\d{10}) [\+\-]\d+"); - - /// - /// Name. - /// - public string Name { get; set; } = ""; - - /// - /// Email. - /// - public string Email { get; set; } = ""; - - /// - /// Operation time. - /// - public string Time { get; set; } = ""; - - /// - /// Parse user from raw string. - /// - /// Raw string - public void Parse(string data) { - var match = FORMAT.Match(data); - if (!match.Success) return; - - var time = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddSeconds(int.Parse(match.Groups[3].Value)); - - Name = match.Groups[1].Value; - Email = match.Groups[2].Value; - Time = time.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"); - } - } -} diff --git a/src/Git/Version.cs b/src/Git/Version.cs deleted file mode 100644 index 02020610..00000000 --- a/src/Git/Version.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using System.Text.Json.Serialization; - -namespace SourceGit.Git { - - /// - /// Version information. - /// - public class Version { - [JsonPropertyName("id")] - public ulong Id { get; set; } - [JsonPropertyName("tag_name")] - public string TagName { get; set; } - [JsonPropertyName("target_commitish")] - public string CommitSHA { get; set; } - [JsonPropertyName("prerelease")] - public bool PreRelease { get; set; } - [JsonPropertyName("name")] - public string Name { get; set; } - [JsonPropertyName("body")] - public string Body { get; set; } - [JsonPropertyName("created_at")] - public DateTime CreatedAt { get; set; } - } -} diff --git a/src/Helpers/CommitGraph.cs b/src/Helpers/CommitGraph.cs deleted file mode 100644 index 4acc3ed7..00000000 --- a/src/Helpers/CommitGraph.cs +++ /dev/null @@ -1,379 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Windows; -using System.Windows.Media; - -namespace SourceGit.Helpers { - - /// - /// Tools to parse commit graph. - /// - public class CommitGraphMaker { - /// - /// Sizes - /// - public static readonly double UNIT_WIDTH = 12; - public static readonly double HALF_WIDTH = 6; - public static readonly double DOUBLE_WIDTH = 24; - public static readonly double UNIT_HEIGHT = 24; - public static readonly double HALF_HEIGHT = 12; - - /// - /// Colors - /// - public static Brush[] Colors = new Brush[] { - Brushes.Orange, - Brushes.ForestGreen, - Brushes.Gold, - Brushes.Magenta, - Brushes.Red, - Brushes.Gray, - Brushes.Turquoise, - Brushes.Olive, - }; - - /// - /// Helpers to draw lines. - /// - public class LineHelper { - private double lastX = 0; - private double lastY = 0; - - /// - /// Parent commit id. - /// - public string Next { get; set; } - - /// - /// Is merged into this tree. - /// - public bool IsMerged { get; set; } - - /// - /// Points in line - /// - public List Points { get; set; } - - /// - /// Brush to draw line - /// - public Brush Brush { get; set; } - - /// - /// Current horizontal offset. - /// - public double HorizontalOffset => lastX; - - /// - /// Constructor. - /// - /// Parent commit id - /// Is merged in tree - /// Color index - /// Start point - public LineHelper(string nextCommitId, bool isMerged, int colorIdx, Point startPoint) { - Next = nextCommitId; - IsMerged = isMerged; - Points = new List() { startPoint }; - Brush = Colors[colorIdx % Colors.Length]; - - lastX = startPoint.X; - lastY = startPoint.Y; - } - - /// - /// Line to. - /// - /// - /// - /// - public void AddPoint(double x, double y, bool isEnd = false) { - if (x > lastX) { - Points.Add(new Point(lastX, lastY)); - Points.Add(new Point(x, y - HALF_HEIGHT)); - } else if (x < lastX) { - Points.Add(new Point(lastX, lastY + HALF_HEIGHT)); - Points.Add(new Point(x, y)); - } - - lastX = x; - lastY = y; - - if (isEnd) { - var last = Points.Last(); - if (last.X != lastX || last.Y != lastY) Points.Add(new Point(lastX, lastY)); - } - } - } - - /// - /// Short link between two commits. - /// - public struct ShortLink { - public Point Start; - public Point Control; - public Point End; - public Brush Brush; - } - - /// - /// Dot - /// - public struct Dot { - public Point Center; - public Brush Color; - } - - /// - /// Independent lines in graph - /// - public List Lines { get; set; } = new List(); - - /// - /// Short links. - /// - public List Links { get; set; } = new List(); - - /// - /// All dots. - /// - public List Dots { get; set; } = new List(); - - /// - /// Parse commits. - /// - /// - /// - public static CommitGraphMaker Parse(List commits) { - CommitGraphMaker maker = new CommitGraphMaker(); - - List unsolved = new List(); - List ended = new List(); - Dictionary currentMap = new Dictionary(); - double offsetY = -HALF_HEIGHT; - int colorIdx = 0; - - for (int i = 0; i < commits.Count; i++) { - Git.Commit commit = commits[i]; - LineHelper major = null; - bool isMerged = commit.IsHEAD || commit.IsMerged; - int oldCount = unsolved.Count; - - // 更新Y坐标 - offsetY += UNIT_HEIGHT; - - // 找到第一个依赖于本提交的树,将其他依赖于本提交的树标记为终止,并对已存在的线路调整(防止线重合) - double offsetX = -HALF_WIDTH; - foreach (var l in unsolved) { - if (l.Next == commit.SHA) { - if (major == null) { - offsetX += UNIT_WIDTH; - major = l; - - if (commit.Parents.Count > 0) { - major.Next = commit.Parents[0]; - if (!currentMap.ContainsKey(major.Next)) currentMap.Add(major.Next, major); - } else { - major.Next = "ENDED"; - ended.Add(l); - } - - major.AddPoint(offsetX, offsetY); - } else { - ended.Add(l); - } - - isMerged = isMerged || l.IsMerged; - } else { - if (!currentMap.ContainsKey(l.Next)) currentMap.Add(l.Next, l); - offsetX += UNIT_WIDTH; - l.AddPoint(offsetX, offsetY); - } - } - - // 处理本提交为非当前分支HEAD的情况(创建新依赖线路) - if (major == null && commit.Parents.Count > 0) { - offsetX += UNIT_WIDTH; - major = new LineHelper(commit.Parents[0], isMerged, colorIdx, new Point(offsetX, offsetY)); - unsolved.Add(major); - colorIdx++; - } - - // 确定本提交的点的位置 - Point position = new Point(offsetX, offsetY); - if (major != null) { - major.IsMerged = isMerged; - position.X = major.HorizontalOffset; - position.Y = offsetY; - maker.Dots.Add(new Dot() { Center = position, Color = major.Brush }); - } else { - maker.Dots.Add(new Dot() { Center = position, Color = Brushes.Orange }); - } - - // 处理本提交的其他依赖 - for (int j = 1; j < commit.Parents.Count; j++) { - var parent = commit.Parents[j]; - if (currentMap.ContainsKey(parent)) { - var l = currentMap[parent]; - var link = new ShortLink(); - - link.Start = position; - link.End = new Point(l.HorizontalOffset, offsetY + HALF_HEIGHT); - link.Control = new Point(link.End.X, link.Start.Y); - link.Brush = l.Brush; - maker.Links.Add(link); - } else { - offsetX += UNIT_WIDTH; - unsolved.Add(new LineHelper(commit.Parents[j], isMerged, colorIdx, position)); - colorIdx++; - } - } - - // 处理已终止的线 - foreach (var l in ended) { - l.AddPoint(position.X, position.Y, true); - maker.Lines.Add(l); - unsolved.Remove(l); - } - - // 加入本次提交 - commit.IsMerged = isMerged; - commit.GraphOffset = Math.Max(offsetX + HALF_WIDTH, oldCount * UNIT_WIDTH); - - // 清理临时数据 - ended.Clear(); - currentMap.Clear(); - } - - // 处理尚未终结的线 - for (int i = 0; i < unsolved.Count; i++) { - var path = unsolved[i]; - var endY = (commits.Count - 0.5) * UNIT_HEIGHT; - - if (path.Points.Count == 1 && path.Points[0].Y == endY) continue; - - path.AddPoint((i + 0.5) * UNIT_WIDTH, endY, true); - maker.Lines.Add(path); - } - unsolved.Clear(); - - maker.Lines.Sort((l, h) => l.Points[0].Y.CompareTo(h.Points[0].Y)); - return maker; - } - } - - /// - /// Visual element to render commit graph - /// - public class CommitGraph : FrameworkElement { - private double offsetY; - private CommitGraphMaker maker; - - public CommitGraph() { - Clear(); - } - - public void Clear() { - offsetY = 0; - maker = null; - } - - public void SetCommits(List commits) { - maker = CommitGraphMaker.Parse(commits); - Dispatcher.Invoke(() => InvalidateVisual()); - } - - public void SetOffset(double y) { - offsetY = y * CommitGraphMaker.UNIT_HEIGHT; - InvalidateVisual(); - } - - protected override void OnRender(DrawingContext dc) { - if (maker == null) return; - - var startY = offsetY; - var endY = offsetY + ActualHeight; - - dc.PushTransform(new TranslateTransform(0, -offsetY)); - - // Draw all visible lines. - foreach (var path in maker.Lines) { - var last = path.Points[0]; - var size = path.Points.Count; - - if (last.Y > endY) break; - if (path.Points[size - 1].Y < startY) continue; - - var geo = new StreamGeometry(); - var pen = new Pen(path.Brush, 2); - - using (var geoCtx = geo.Open()) { - geoCtx.BeginFigure(last, false, false); - - var ended = false; - for (int i = 1; i < size; i++) { - var cur = path.Points[i]; - - // Fix line NOT shown in graph if cur.Y is too large than current. - if (cur.Y > endY) { - cur.Y = endY; - ended = true; - } - - if (cur.X > last.X) { - geoCtx.QuadraticBezierTo(new Point(cur.X, last.Y), cur, true, false); - } else if (cur.X < last.X) { - if (i < size - 1) { - cur.Y += CommitGraphMaker.HALF_HEIGHT; - - var midY = (last.Y + cur.Y) / 2; - var midX = (last.X + cur.X) / 2; - geoCtx.PolyQuadraticBezierTo(new Point[] { - new Point(last.X, midY), - new Point(midX, midY), - new Point(cur.X, midY), - cur}, true, false); - } else { - geoCtx.QuadraticBezierTo(new Point(last.X, cur.Y), cur, true, false); - } - } else { - geoCtx.LineTo(cur, true, false); - } - - if (ended) break; - last = cur; - } - } - - geo.Freeze(); - dc.DrawGeometry(null, pen, geo); - } - - // Draw short links - foreach (var link in maker.Links) { - if (link.Start.Y > endY) break; - if (link.End.Y < startY) continue; - - var geo = new StreamGeometry(); - var pen = new Pen(link.Brush, 2); - - using (var geoCtx = geo.Open()) { - geoCtx.BeginFigure(link.Start, false, false); - geoCtx.QuadraticBezierTo(link.Control, link.End, true, false); - } - - geo.Freeze(); - dc.DrawGeometry(null, pen, geo); - } - - // Draw visible points - foreach (var dot in maker.Dots) { - if (dot.Center.Y > endY) break; - if (dot.Center.Y < startY) continue; - - dc.DrawEllipse(dot.Color, null, dot.Center, 3, 3); - } - } - } -} diff --git a/src/Helpers/TextBoxHelper.cs b/src/Helpers/TextBoxHelper.cs deleted file mode 100644 index dc3c2fb5..00000000 --- a/src/Helpers/TextBoxHelper.cs +++ /dev/null @@ -1,224 +0,0 @@ -using System.Windows; -using System.Windows.Controls; -using System.Windows.Input; -using System.Windows.Media; - -namespace SourceGit.Helpers { - - /// - /// Attached properties to TextBox. - /// - public static class TextBoxHelper { - - /// - /// Auto scroll on text changed or selection changed. - /// - public static readonly DependencyProperty AutoScrollProperty = DependencyProperty.RegisterAttached( - "AutoScroll", - typeof(bool), - typeof(TextBoxHelper), - new PropertyMetadata(false, OnAutoScrollChanged)); - - /// - /// Placeholder property - /// - public static readonly DependencyProperty PlaceholderProperty = DependencyProperty.RegisterAttached( - "Placeholder", - typeof(string), - typeof(TextBoxHelper), - new PropertyMetadata(string.Empty, OnPlaceholderChanged)); - - /// - /// Vertical alignment for placeholder. - /// - public static readonly DependencyProperty PlaceholderBaselineProperty = DependencyProperty.RegisterAttached( - "PlaceholderBaseline", - typeof(AlignmentY), - typeof(TextBoxHelper), - new PropertyMetadata(AlignmentY.Center)); - - /// - /// Property to store generated placeholder brush. - /// - public static readonly DependencyProperty PlaceholderBrushProperty = DependencyProperty.RegisterAttached( - "PlaceholderBrush", - typeof(Brush), - typeof(TextBoxHelper), - new PropertyMetadata(Brushes.Transparent)); - - /// - /// Setter for AutoScrollProperty - /// - /// - /// - public static void SetAutoScroll(UIElement element, bool enabled) { - element.SetValue(AutoScrollProperty, enabled); - } - - /// - /// Getter for AutoScrollProperty - /// - /// - /// - public static bool GetAutoScroll(UIElement element) { - return (bool)element.GetValue(AutoScrollProperty); - } - - /// - /// Triggered when AutoScroll property changed. - /// - /// - /// - public static void OnAutoScrollChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { - var textBox = d as TextBox; - if (textBox == null) return; - - textBox.SelectionChanged -= UpdateScrollOnSelectionChanged; - if ((bool)e.NewValue == true) { - textBox.SelectionChanged += UpdateScrollOnSelectionChanged; - } - } - - /// - /// Triggered when placeholder changed. - /// - /// - /// - private static void OnPlaceholderChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { - var textBox = d as TextBox; - if (textBox != null) textBox.Loaded += OnTextLoaded; - } - - /// - /// Setter for Placeholder property - /// - /// - /// - public static void SetPlaceholder(UIElement element, string value) { - element.SetValue(PlaceholderProperty, value); - } - - /// - /// Getter for Placeholder property - /// - /// - /// - public static string GetPlaceholder(UIElement element) { - return (string)element.GetValue(PlaceholderProperty); - } - - /// - /// Setter for PlaceholderBaseline property - /// - /// - /// - public static void SetPlaceholderBaseline(UIElement element, AlignmentY align) { - element.SetValue(PlaceholderBaselineProperty, align); - } - - /// - /// Setter for PlaceholderBaseline property. - /// - /// - /// - public static AlignmentY GetPlaceholderBaseline(UIElement element) { - return (AlignmentY)element.GetValue(PlaceholderBaselineProperty); - } - - /// - /// Setter for PlaceholderBrush property. - /// - /// - /// - public static void SetPlaceholderBrush(UIElement element, Brush value) { - element.SetValue(PlaceholderBrushProperty, value); - } - - /// - /// Getter for PlaceholderBrush property. - /// - /// - /// - public static Brush GetPlaceholderBrush(UIElement element) { - return (Brush)element.GetValue(PlaceholderBrushProperty); - } - - /// - /// Set placeholder as background when TextBox was loaded. - /// - /// - /// - private static void OnTextLoaded(object sender, RoutedEventArgs e) { - var textBox = sender as TextBox; - if (textBox == null) return; - - Label placeholder = new Label(); - placeholder.Content = textBox.GetValue(PlaceholderProperty); - - VisualBrush brush = new VisualBrush(); - brush.AlignmentX = AlignmentX.Left; - brush.AlignmentY = GetPlaceholderBaseline(textBox); - brush.TileMode = TileMode.None; - brush.Stretch = Stretch.None; - brush.Opacity = 0.3; - brush.Visual = placeholder; - - textBox.SetValue(PlaceholderBrushProperty, brush); - textBox.Background = brush; - textBox.TextChanged += UpdatePlaceholder; - UpdatePlaceholder(textBox, null); - } - - /// - /// Dynamically hide/show placeholder. - /// - /// - /// - private static void UpdatePlaceholder(object sender, RoutedEventArgs e) { - var textBox = sender as TextBox; - if (string.IsNullOrEmpty(textBox.Text)) { - textBox.Background = textBox.GetValue(PlaceholderBrushProperty) as Brush; - } else { - textBox.Background = Brushes.Transparent; - } - } - - /// - /// - /// - /// - /// - private static void UpdateScrollOnSelectionChanged(object sender, RoutedEventArgs e) { - var textBox = sender as TextBox; - if (textBox != null && textBox.IsFocused) { - if (Mouse.LeftButton == MouseButtonState.Pressed && textBox.SelectionLength > 0) { - var p = Mouse.GetPosition(textBox); - if (p.X <= 8) { - textBox.LineLeft(); - } else if (p.X >= textBox.ActualWidth - 8) { - textBox.LineRight(); - } - - if (p.Y <= 8) { - textBox.LineUp(); - } else if (p.Y >= textBox.ActualHeight - 8) { - textBox.LineDown(); - } - } else { - var rect = textBox.GetRectFromCharacterIndex(textBox.CaretIndex); - if (rect.Left <= 0) { - textBox.ScrollToHorizontalOffset(textBox.HorizontalOffset + rect.Left); - } else if (rect.Right >= textBox.ActualWidth) { - textBox.ScrollToHorizontalOffset(textBox.HorizontalOffset + rect.Right); - } - - if (rect.Top <= 0) { - textBox.ScrollToVerticalOffset(textBox.VerticalOffset + rect.Top); - } else if (rect.Bottom >= textBox.ActualHeight) { - textBox.ScrollToVerticalOffset(textBox.VerticalOffset + rect.Bottom); - } - } - } - } - } -} diff --git a/src/Helpers/TreeViewHelper.cs b/src/Helpers/TreeViewHelper.cs deleted file mode 100644 index 605fc4f4..00000000 --- a/src/Helpers/TreeViewHelper.cs +++ /dev/null @@ -1,348 +0,0 @@ -using System.Collections.ObjectModel; -using System.Linq; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Input; -using System.Windows.Media; - -namespace SourceGit.Helpers { - - /// - /// Helper class to enable multi-selection of TreeView - /// - public static class TreeViewHelper { - - /// - /// Definition of EnableMultiSelection property. - /// - public static readonly DependencyProperty EnableMultiSelectionProperty = - DependencyProperty.RegisterAttached( - "EnableMultiSelection", - typeof(bool), - typeof(TreeViewHelper), - new FrameworkPropertyMetadata(false, OnEnableMultiSelectionChanged)); - - /// - /// Getter of EnableMultiSelection - /// - /// - /// - public static bool GetEnableMultiSelection(DependencyObject obj) { - return (bool)obj.GetValue(EnableMultiSelectionProperty); - } - - /// - /// Setter of EnableMultiSelection - /// - /// - /// - public static void SetEnableMultiSelection(DependencyObject obj, bool value) { - obj.SetValue(EnableMultiSelectionProperty, value); - } - - /// - /// Definition of SelectedItems - /// - public static readonly DependencyProperty SelectedItemsProperty = - DependencyProperty.RegisterAttached( - "SelectedItems", - typeof(ObservableCollection), - typeof(TreeViewHelper), - new FrameworkPropertyMetadata(null)); - - /// - /// Getter of SelectedItems - /// - /// - /// - public static ObservableCollection GetSelectedItems(DependencyObject obj) { - return (ObservableCollection)obj.GetValue(SelectedItemsProperty); - } - - /// - /// Setter of SelectedItems - /// - /// - /// - public static void SetSelectedItems(DependencyObject obj, ObservableCollection value) { - obj.SetValue(SelectedItemsProperty, value); - } - - /// - /// Definition of IsChecked property. - /// - public static readonly DependencyProperty IsCheckedProperty = - DependencyProperty.RegisterAttached( - "IsChecked", - typeof(bool), - typeof(TreeViewHelper), - new FrameworkPropertyMetadata(false)); - - /// - /// Getter of IsChecked Property. - /// - /// - /// - public static bool GetIsChecked(DependencyObject obj) { - return (bool)obj.GetValue(IsCheckedProperty); - } - - /// - /// Setter of IsChecked property - /// - /// - /// - public static void SetIsChecked(DependencyObject obj, bool value) { - obj.SetValue(IsCheckedProperty, value); - } - - /// - /// Definition of MultiSelectionChangedEvent - /// - public static readonly RoutedEvent MultiSelectionChangedEvent = - EventManager.RegisterRoutedEvent("MultiSelectionChanged", RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(TreeViewHelper)); - - /// - /// Add handler for MultiSelectionChanged event. - /// - /// - /// - public static void AddMultiSelectionChangedHandler(DependencyObject d, RoutedEventHandler handler) { - var tree = d as TreeView; - if (tree != null) tree.AddHandler(MultiSelectionChangedEvent, handler); - } - - /// - /// Remove handler for MultiSelectionChanged event. - /// - /// - /// - public static void RemoveMultiSelectionChangedHandler(DependencyObject d, RoutedEventHandler handler) { - var tree = d as TreeView; - if (tree != null) tree.RemoveHandler(MultiSelectionChangedEvent, handler); - } - - /// - /// Find ScrollViewer of a tree view - /// - /// - /// - public static ScrollViewer GetScrollViewer(FrameworkElement owner) { - if (owner == null) return null; - if (owner is ScrollViewer) return owner as ScrollViewer; - - int n = VisualTreeHelper.GetChildrenCount(owner); - for (int i = 0; i < n; i++) { - var child = VisualTreeHelper.GetChild(owner, i) as FrameworkElement; - var deep = GetScrollViewer(child); - if (deep != null) return deep; - } - - return null; - } - - /// - /// Select all items in tree. - /// - /// - public static void SelectWholeTree(TreeView tree) { - var selected = GetSelectedItems(tree); - selected.Clear(); - SelectAll(selected, tree); - tree.RaiseEvent(new RoutedEventArgs(MultiSelectionChangedEvent)); - } - - /// - /// Selected one item by DataContext - /// - /// - /// - public static void SelectOneByContext(TreeView tree, object obj) { - var item = FindTreeViewItemByDataContext(tree, obj); - if (item != null) { - var selected = GetSelectedItems(tree); - selected.Add(item); - item.SetValue(IsCheckedProperty, true); - tree.RaiseEvent(new RoutedEventArgs(MultiSelectionChangedEvent)); - } - } - - /// - /// Unselect the whole tree. - /// - /// - public static void UnselectTree(TreeView tree) { - var selected = GetSelectedItems(tree); - if (selected.Count == 0) return; - - foreach (var old in selected) old.SetValue(IsCheckedProperty, false); - selected.Clear(); - tree.RaiseEvent(new RoutedEventArgs(MultiSelectionChangedEvent)); - } - - /// - /// Hooks when EnableMultiSelection changed. - /// - /// - /// - private static void OnEnableMultiSelectionChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { - var tree = d as TreeView; - if (tree != null && (bool)e.NewValue) { - tree.SetValue(SelectedItemsProperty, new ObservableCollection()); - tree.PreviewMouseDown += OnTreeMouseDown; - } - } - - ///
Open-source GUI client for git users