diff --git a/.editorconfig b/.editorconfig
index a8a5b95..a49f60d 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -1,6 +1,301 @@
+; Visual Studio Extension : http://visualstudiogallery.msdn.microsoft.com/c8bccfe2-650c-4b42-bc5c-845e21f96328
+; See http://editorconfig.org/ for more informations
+; Top-most EditorConfig file
root = true
+; 4-column space indentation
[*]
-charset = utf-8
indent_style = space
-indent_size = 4
\ No newline at end of file
+indent_size = 4
+trim_trailing_whitespace = true
+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_prefer_conditional_expression_over_return = true:silent
+dotnet_style_explicit_tuple_names = true:suggestion
+dotnet_style_prefer_inferred_tuple_names = true:suggestion
+dotnet_style_prefer_inferred_anonymous_type_member_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_locals_parameters_members = true:none
+dotnet_style_predefined_type_for_member_access = true:none
+dotnet_style_require_accessibility_modifiers = for_non_interface_members:none
+dotnet_style_allow_multiple_blank_lines_experimental = true:silent
+dotnet_style_allow_statement_immediately_after_block_experimental = true:silent
+dotnet_code_quality_unused_parameters = all:suggestion
+dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:none
+dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:none
+dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:none
+dotnet_style_parentheses_in_other_operators = never_if_unnecessary:none
+dotnet_style_qualification_for_field = false:none
+dotnet_style_qualification_for_property = false:none
+dotnet_style_qualification_for_method = false:none
+dotnet_style_qualification_for_event = false:none
+
+[*.{csproj,vcxproj}]
+indent_style = space
+indent_size = 2
+insert_final_newline = false
+
+[*.{props,targets}]
+indent_size = 2
+
+[*.Designer.cs]
+trim_trailing_whitespace = false
+
+[*.{xml,config,nuspec,xslt,wxs}]
+indent_style = space
+indent_size = 2
+trim_trailing_whitespace = true
+
+[*.{patch,diff}]
+trim_trailing_whitespace = false
+
+[*.blame]
+trim_trailing_whitespace = false
+
+[*.resx]
+indent_style = space
+indent_size = 2
+trim_trailing_whitespace = false
+
+# C# files
+[*.cs]
+
+#### Core EditorConfig Options ####
+
+# New line preferences
+insert_final_newline = true
+
+#### .NET Coding Conventions ####
+
+# Organize usings
+dotnet_separate_import_directive_groups = false
+dotnet_sort_system_directives_first = true
+
+# this. and Me. preferences
+dotnet_style_qualification_for_event = false:none
+dotnet_style_qualification_for_field = false:none
+dotnet_style_qualification_for_method = false:none
+dotnet_style_qualification_for_property = false:none
+
+# Language keywords vs BCL types preferences
+dotnet_style_predefined_type_for_locals_parameters_members = true:none
+dotnet_style_predefined_type_for_member_access = true:none
+
+# Parentheses preferences
+dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:none
+dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:none
+dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:none
+dotnet_style_parentheses_in_other_operators = never_if_unnecessary:none
+
+# Modifier preferences
+dotnet_style_require_accessibility_modifiers = for_non_interface_members:none
+
+# Expression-level preferences
+dotnet_style_coalesce_expression = true:suggestion
+dotnet_style_collection_initializer = true:suggestion
+dotnet_style_explicit_tuple_names = true:suggestion
+dotnet_style_null_propagation = true:suggestion
+dotnet_style_object_initializer = true:suggestion
+dotnet_style_prefer_auto_properties = true:silent
+dotnet_style_prefer_compound_assignment = true:suggestion
+dotnet_style_prefer_conditional_expression_over_assignment = true:silent
+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_is_null_check_over_reference_equality_method = true:suggestion
+
+# Field preferences
+dotnet_style_readonly_field = true:suggestion
+
+# Parameter preferences
+dotnet_code_quality_unused_parameters = all:suggestion
+
+#### C# Coding Conventions ####
+
+# Avoid var
+csharp_style_var_for_built_in_types = false:warning
+csharp_style_var_when_type_is_apparent = false:warning
+csharp_style_var_elsewhere = false:warning
+
+# Expression-bodied members
+csharp_style_expression_bodied_accessors = true:silent
+csharp_style_expression_bodied_constructors = false:silent
+csharp_style_expression_bodied_indexers = true:silent
+csharp_style_expression_bodied_lambdas = true:silent
+csharp_style_expression_bodied_local_functions = false:silent
+csharp_style_expression_bodied_methods = false:silent
+csharp_style_expression_bodied_operators = false:silent
+csharp_style_expression_bodied_properties = false:silent
+
+# Pattern matching preferences
+csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
+csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
+csharp_style_prefer_switch_expression = true:silent
+
+# Null-checking preferences
+csharp_style_conditional_delegate_call = true:suggestion
+
+# Modifier preferences
+csharp_prefer_static_local_function = true:suggestion
+csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async
+
+# Code-block preferences
+csharp_prefer_braces = true:none
+csharp_prefer_simple_using_statement = true:suggestion
+
+# Expression-level preferences
+csharp_prefer_simple_default_expression = true:suggestion
+csharp_style_deconstructed_variable_declaration = true:suggestion
+csharp_style_inlined_variable_declaration = true:suggestion
+csharp_style_pattern_local_over_anonymous_function = true:suggestion
+csharp_style_prefer_index_operator = true:suggestion
+csharp_style_prefer_range_operator = true:suggestion
+csharp_style_throw_expression = true:suggestion
+csharp_style_unused_value_assignment_preference = discard_variable:suggestion
+csharp_style_unused_value_expression_statement_preference = discard_variable:silent
+
+# 'using' directive preferences
+csharp_using_directive_placement = outside_namespace:none
+
+# Use new(...)
+dotnet_diagnostic.IDE0090.severity = warning
+
+#### C# Formatting Rules ####
+
+# New line preferences
+csharp_new_line_before_catch = true
+csharp_new_line_before_else = true
+csharp_new_line_before_finally = true
+csharp_new_line_before_members_in_anonymous_types = true
+csharp_new_line_before_members_in_object_initializers = true
+csharp_new_line_before_open_brace = all
+csharp_new_line_between_query_expression_clauses = true
+
+# Indentation preferences
+csharp_indent_block_contents = true
+csharp_indent_braces = false
+csharp_indent_case_contents = true
+csharp_indent_case_contents_when_block = true
+csharp_indent_labels = no_change
+csharp_indent_switch_labels = true
+
+# 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
+
+# Wrapping preferences
+csharp_preserve_single_line_blocks = true
+csharp_preserve_single_line_statements = true
+
+#### Naming styles ####
+
+# Naming rules
+
+dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion
+dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
+dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
+
+dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
+dotnet_naming_rule.types_should_be_pascal_case.symbols = types
+dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
+
+dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
+dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
+dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
+
+dotnet_naming_rule.private_members_with_underscore.symbols = private_fields
+dotnet_naming_rule.private_members_with_underscore.style = prefix_underscore
+dotnet_naming_rule.private_members_with_underscore.severity = suggestion
+
+dotnet_naming_symbols.private_fields.applicable_kinds = field
+dotnet_naming_symbols.private_fields.applicable_accessibilities = private
+
+dotnet_naming_style.prefix_underscore.capitalization = camel_case
+dotnet_naming_style.prefix_underscore.required_prefix = _
+
+# Symbol specifications
+
+dotnet_naming_symbols.interface.applicable_kinds = interface
+dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.interface.required_modifiers =
+
+dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
+dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.types.required_modifiers =
+
+dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
+dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.non_field_members.required_modifiers =
+
+# Naming styles
+
+dotnet_naming_style.pascal_case.required_prefix =
+dotnet_naming_style.pascal_case.required_suffix =
+dotnet_naming_style.pascal_case.word_separator =
+dotnet_naming_style.pascal_case.capitalization = pascal_case
+
+dotnet_naming_style.begins_with_i.required_prefix = I
+dotnet_naming_style.begins_with_i.required_suffix =
+dotnet_naming_style.begins_with_i.word_separator =
+dotnet_naming_style.begins_with_i.capitalization = pascal_case
+
+# SA0001: XML comment analysis is disabled due to project configuration
+dotnet_diagnostic.SA0001.severity = none
+# SA1135: Using directives should be qualified
+dotnet_diagnostic.SA1135.severity = none
+# SA1629: Documentation text should end with a period
+dotnet_diagnostic.SA1629.severity = none
+
+
+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_prefer_null_check_over_type_check = true:suggestion
+csharp_style_prefer_local_over_anonymous_function = 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_prefer_readonly_struct = true:suggestion
+csharp_style_prefer_readonly_struct_member = true:suggestion
+csharp_style_allow_embedded_statements_on_same_line_experimental = true:silent
+csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true:silent
+csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent
+csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true:silent
+csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent
+csharp_style_prefer_pattern_matching = true:silent
+csharp_style_prefer_not_pattern = true:suggestion
+csharp_style_prefer_extended_property_pattern = true:suggestion
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..f6cbefe
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,19 @@
+* text=auto
+*.xml diff -text
+*.patch diff -text
+*.blame diff -text
+*.xlf text
+*.cs text diff=csharp
+*.sln text eol=crlf
+*.targets text eol=crlf
+*.bat text eol=crlf
+*.cmd text eol=crlf
+*.yml text eol=lf
+*.sh text eol=lf
+*.py text eol=lf
+.gitattributes text eol=lf
+
+# Used in unit tests - try text diff
+*.bin binary diff=text
+*RevisionReaderTests.*.approved.* binary diff=text
+*DoAutoCRLF*.approved.* binary diff=text
diff --git a/.gitignore b/.gitignore
index 8897d11..ec92c17 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,79 @@
-# Git Extensions specific files
+#basic visual studio directories
+_UpgradeReport_Files/
+[Dd]ebug*/
+[Rr]elease*/
+!ReleaseNotesGenerator*
+ipch/
+.vs/
+_ReSharper*/
+TestResults/
+*.DS_Store*
+
+obj/
+bin/
+
+#ignore output mild compiler
+GitExtensionsShellEx/Generated/
+
+#ignore some unwanted files
+*.ncb
+*.suo
+*.csproj.user
+*.orig
+*.msi
+*.user
+*.opendb
+*.sdf
+*.opensdf
+*.ipch
+*.iml
+*.VC.db
+*.sqlite
+*.aps
+*.bak
+*.[Cc]ache
+.idea/
+Thumbs.db
+GitPlugin/bin/*
+GitPlugin/Properties/Resources.resources
+*.pidb
+*.resources
+*.userprefs
+*.dotCover
+*.ncrunchproject
+*.ncrunchsolution
+test-results/*
+GitCommandsTests/test-results/*
+/!runTests.bat
+TestResult.xml
+libgit2sharp
+Setup/GitExtensions/
+Setup/GitExtensions-pdbs/
+Setup/GitExtensions-Portable-*.zip
+Setup/GitExtensions-pdbs-*.zip
+Setup/tools/tx.exe
+Plugins/GitExtensions.PluginManager/*
+
+# Backup & report files from converting an old project file to a newer
+# Visual Studio version. Backup files are not needed, because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm*
+
+#nuget
+packages/
+GitExtensions.*.sln.VisualState.xml
GitExtensions.settings.backup
+/Setup/*.zip
+/Setup/Changelog.md
+*.received.*
+Directory.Build.rsp
+GitStatus.txt
+OpenCover.GitExtensions.xml
+tree.txt
+*.binlog
+artifacts/
+.tools/vswhere/
+.dotnet/
+*.svclog
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..8d03643
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,48 @@
+
+
+
+
+
+ latest
+ $(NoWarn);1573;1591;1712
+ true
+ annotations
+ true
+
+ net8.0-windows
+ enable
+ true
+ true
+ true
+ $(MSBuildThisFileDirectory)\eng\GitExtensions.ruleset
+
+
+ false
+
+
+ snupkg
+ embedded
+
+
+ true
+ true
+
+
+
+
+ true
+
+
+
+ PackageReference
+
+ false
+
+
+
+
+ en-US
+
+
+
diff --git a/Directory.Build.targets b/Directory.Build.targets
new file mode 100644
index 0000000..ddf909e
--- /dev/null
+++ b/Directory.Build.targets
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
new file mode 100644
index 0000000..d186332
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/GitExtensions.Extensibility.sln b/GitExtensions.Extensibility.sln
new file mode 100644
index 0000000..bd15d5d
--- /dev/null
+++ b/GitExtensions.Extensibility.sln
@@ -0,0 +1,44 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.9.34321.82
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitExtensions.Extensibility", "src\GitExtensions.Extensibility\GitExtensions.Extensibility.csproj", "{B75368EA-3C47-4916-BA2E-92EF976BED9F}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitExtensions.Extensibility.Tests", "tests\GitExtensions.Extensibility.Tests\GitExtensions.Extensibility.Tests.csproj", "{4A4F8091-3847-43EE-A8DE-500D41DF08BB}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{393BBB52-AAB0-48CD-B939-6EF21266C333}"
+ ProjectSection(SolutionItems) = preProject
+ .editorconfig = .editorconfig
+ .gitattributes = .gitattributes
+ .gitignore = .gitignore
+ appveyor.yml = appveyor.yml
+ Directory.Build.props = Directory.Build.props
+ Directory.Build.targets = Directory.Build.targets
+ Directory.Packages.props = Directory.Packages.props
+ GitExtensions.ruleset = GitExtensions.ruleset
+ GitExtensionsTest.ruleset = GitExtensionsTest.ruleset
+ EndProjectSection
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {B75368EA-3C47-4916-BA2E-92EF976BED9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B75368EA-3C47-4916-BA2E-92EF976BED9F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B75368EA-3C47-4916-BA2E-92EF976BED9F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B75368EA-3C47-4916-BA2E-92EF976BED9F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4A4F8091-3847-43EE-A8DE-500D41DF08BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4A4F8091-3847-43EE-A8DE-500D41DF08BB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4A4F8091-3847-43EE-A8DE-500D41DF08BB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4A4F8091-3847-43EE-A8DE-500D41DF08BB}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {39E4BE2E-815C-4946-91E7-B24A85F6FEDA}
+ EndGlobalSection
+EndGlobal
diff --git a/eng/GitExtensions.ruleset b/eng/GitExtensions.ruleset
new file mode 100644
index 0000000..c9513e3
--- /dev/null
+++ b/eng/GitExtensions.ruleset
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/eng/GitExtensionsTest.ruleset b/eng/GitExtensionsTest.ruleset
new file mode 100644
index 0000000..1fb1405
--- /dev/null
+++ b/eng/GitExtensionsTest.ruleset
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/eng/RepoLayout.props b/eng/RepoLayout.props
new file mode 100644
index 0000000..138de56
--- /dev/null
+++ b/eng/RepoLayout.props
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+ 8.0.0
+ net8.0-windows
+ Debug
+ AnyCPU
+ $(Platform)
+
+
+
+ $([MSBuild]::NormalizeDirectory('$([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildProjectDirectory), 'global.json'))'))
+
+
+
+ $(MSBuildToolsPath)\msbuild.exe
+
+
+
+ $([MSBuild]::NormalizeDirectory('$(RepoRoot)', '.tools'))
+
+ $([MSBuild]::NormalizeDirectory('$(RepoRoot)', 'artifacts', '$(Configuration)'))
+ $([MSBuild]::NormalizeDirectory('$(ArtifactsDir)', 'toolset'))
+ $([MSBuild]::NormalizeDirectory('$(ArtifactsDir)', 'obj'))
+ $([MSBuild]::NormalizeDirectory('$(ArtifactsDir)', 'bin'))
+ $([MSBuild]::NormalizeDirectory('$(ArtifactsBinDir)', 'GitExtensions', '$(SolutionTargetFramework)', 'Plugins'))
+ $([MSBuild]::NormalizeDirectory('$(ArtifactsBinDir)', 'tests'))
+ $([MSBuild]::NormalizeDirectory('$(ArtifactsDir)', 'log'))
+ $([MSBuild]::NormalizeDirectory('$(ArtifactsDir)', 'tmp'))
+ $([MSBuild]::NormalizeDirectory('$(ArtifactsDir)', 'TestResults'))
+ $([MSBuild]::NormalizeDirectory('$(ArtifactsDir)', 'packages'))
+ $([MSBuild]::NormalizeDirectory('$(ArtifactsTestsDir)', 'UnitTests'))
+ $([MSBuild]::NormalizeDirectory('$(ArtifactsTestsDir)', 'IntegrationTests'))
+
+ $([MSBuild]::NormalizeDirectory('$(ArtifactsDir)', 'publish'))
+ $([MSBuild]::NormalizeDirectory('$(ArtifactsPublishDir)', 'GitExtensions'))
+
+
+
+ $(MSBuildProjectName)
+
+ $([System.IO.Path]::GetFullPath('$(ArtifactsBinDir)$(OutDirName)\'))
+ $(BaseOutputPath)
+ $(BaseOutputPath)$(PlatformName)\
+
+ $([System.IO.Path]::GetFullPath('$(ArtifactsObjDir)$(OutDirName)\'))
+ $(BaseIntermediateOutputPath)
+ $(BaseIntermediateOutputPath)$(PlatformName)\
+
+ $([System.IO.Path]::GetFullPath('$(ArtifactsBinDir)GitExtensions\'))
+
+
diff --git a/eng/Tests.props b/eng/Tests.props
new file mode 100644
index 0000000..ffc73ae
--- /dev/null
+++ b/eng/Tests.props
@@ -0,0 +1,24 @@
+
+
+
+
+ false
+ true
+
+
+
+ false
+ true
+
+
+
+ false
+ true
+
+
+
+ false
+ true
+
+
+
diff --git a/eng/Tests.targets b/eng/Tests.targets
new file mode 100644
index 0000000..948714b
--- /dev/null
+++ b/eng/Tests.targets
@@ -0,0 +1,30 @@
+
+
+
+
+ <_TestResultsDir>$([MSBuild]::NormalizeDirectory('$(ArtifactsDir)', 'TestsResults'))
+ trx
+ $(_TestResultsDir)
+
+
+
+ $(PlatformTarget)
+ x64
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/GitExtensions.Extensibility/ArgumentBuilder.cs b/src/GitExtensions.Extensibility/ArgumentBuilder.cs
new file mode 100644
index 0000000..48a8b34
--- /dev/null
+++ b/src/GitExtensions.Extensibility/ArgumentBuilder.cs
@@ -0,0 +1,109 @@
+using System.Collections;
+using System.Diagnostics.CodeAnalysis;
+using System.Text;
+
+namespace GitExtensions.Extensibility;
+
+///
+/// Builds a command line argument string from zero or more arguments.
+///
+///
+/// To retrieve the constructed argument list string, call .
+///
+/// Arguments are separated by a single space character.
+///
+/// Adding null or white-space strings has no effect on the output, which can be
+/// useful in some calling constructions.
+///
+/// This class has been designed to work with C# collection initialiser syntax which makes
+/// its use quite ergonomic. See the example for more information.
+///
+/// The type accepts strings, however conversion from other types is achieved via extension
+/// methods by adding a method named Add that accepts the required type.
+///
+///
+///
+/// ArgumentBuilder args = new
+/// {
+/// "commit", // adds the string unconditionally
+/// { isAmend, "--amend" }, // adds the option only if isAmend == true
+/// { isUp, "--up", "--down" }, // selects the option based on the value of isUp
+/// };
+///
+///
+public class ArgumentBuilder : IEnumerable
+{
+ private readonly StringBuilder _arguments = new(capacity: 16);
+
+ public bool IsEmpty => _arguments.Length == 0;
+
+ ///
+ /// Adds to the argument list.
+ ///
+ ///
+ /// If is null or white-space, then no change is made
+ /// to the argument list.
+ ///
+ /// The string to add.
+ public void Add(string? s)
+ {
+ if (string.IsNullOrWhiteSpace(s))
+ {
+ return;
+ }
+
+ if (_arguments.Length != 0)
+ {
+ _arguments.Append(' ');
+ }
+
+ _arguments.Append(s);
+ }
+
+ ///
+ /// Adds a range of arguments.
+ ///
+ /// The arguments to add to this builder.
+ public void AddRange(IEnumerable args)
+ {
+ args = args.Where(a => !string.IsNullOrEmpty(a));
+ foreach (string s in args)
+ {
+ Add(s);
+ }
+ }
+
+ ///
+ /// Returns the composed argument list as a string.
+ ///
+ public override string ToString()
+ {
+ return _arguments.ToString();
+ }
+
+ ///
+ /// This method is only implemented to support collection initialiser syntax, and always
+ /// throws if called.
+ ///
+ /// Always thrown.
+ [DoesNotReturn]
+ IEnumerator IEnumerable.GetEnumerator()
+ {
+ throw new InvalidOperationException($"{nameof(IEnumerable)} only implemented to support collection initialiser syntax.");
+ }
+
+ internal TestAccessor GetTestAccessor()
+ => new(this);
+
+ internal readonly struct TestAccessor
+ {
+ private readonly ArgumentBuilder _builder;
+
+ public TestAccessor(ArgumentBuilder builder)
+ {
+ _builder = builder;
+ }
+
+ public StringBuilder Arguments => _builder._arguments;
+ }
+}
diff --git a/src/GitExtensions.Extensibility/ArgumentString.cs b/src/GitExtensions.Extensibility/ArgumentString.cs
new file mode 100644
index 0000000..6d7ae34
--- /dev/null
+++ b/src/GitExtensions.Extensibility/ArgumentString.cs
@@ -0,0 +1,21 @@
+using System.Diagnostics;
+
+namespace GitExtensions.Extensibility;
+
+[DebuggerDisplay("{" + nameof(Arguments) + "}")]
+public readonly struct ArgumentString
+{
+ public string? Arguments { get; }
+ public int Length { get => Arguments?.Length ?? 0; }
+
+ private ArgumentString(string arguments)
+ {
+ ArgumentNullException.ThrowIfNull(nameof(arguments));
+ Arguments = arguments;
+ }
+
+ public static implicit operator ArgumentString(string? args) => new(args ?? "");
+ public static implicit operator ArgumentString(ArgumentBuilder args) => new(args.ToString());
+ public static implicit operator string(ArgumentString args) => args.Arguments ?? "";
+ public override string ToString() => Arguments ?? "";
+}
diff --git a/src/GitExtensions.Extensibility/BuildServerIntegration/BuildInfo.cs b/src/GitExtensions.Extensibility/BuildServerIntegration/BuildInfo.cs
new file mode 100644
index 0000000..f30f235
--- /dev/null
+++ b/src/GitExtensions.Extensibility/BuildServerIntegration/BuildInfo.cs
@@ -0,0 +1,27 @@
+using GitExtensions.Extensibility.Git;
+
+namespace GitExtensions.Extensibility.BuildServerIntegration;
+
+public class BuildInfo
+{
+ public string? Id { get; set; }
+ public DateTime StartDate { get; set; }
+ public long? Duration { get; set; }
+ public BuildStatus Status { get; set; }
+ public string? Description { get; set; }
+ public IReadOnlyList CommitHashList { get; set; } = Array.Empty();
+ public string? Url { get; set; }
+ public bool ShowInBuildReportTab { get; set; } = true;
+ public string? Tooltip { get; set; }
+ public string? PullRequestUrl { get; set; }
+
+ public string StatusSymbol => Status switch
+ {
+ BuildStatus.Success => "✔",
+ BuildStatus.Failure => "❌",
+ BuildStatus.InProgress => "▶️",
+ BuildStatus.Stopped => "⏹️",
+ BuildStatus.Unstable => "❗",
+ _ => "❓",
+ };
+}
diff --git a/src/GitExtensions.Extensibility/BuildServerIntegration/BuildStatus.cs b/src/GitExtensions.Extensibility/BuildServerIntegration/BuildStatus.cs
new file mode 100644
index 0000000..3f44bfc
--- /dev/null
+++ b/src/GitExtensions.Extensibility/BuildServerIntegration/BuildStatus.cs
@@ -0,0 +1,11 @@
+namespace GitExtensions.Extensibility.BuildServerIntegration;
+
+public enum BuildStatus
+{
+ Unknown,
+ InProgress,
+ Success,
+ Failure,
+ Unstable,
+ Stopped
+}
diff --git a/src/GitExtensions.Extensibility/Configurations/IConfigFile.cs b/src/GitExtensions.Extensibility/Configurations/IConfigFile.cs
new file mode 100644
index 0000000..cbca371
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Configurations/IConfigFile.cs
@@ -0,0 +1,34 @@
+namespace GitExtensions.Extensibility.Configurations;
+
+///
+/// Provides the ability to access the config file.
+///
+public interface IConfigFile : ISubmodulesConfigFile
+{
+ string FileName { get; }
+
+ void AddConfigSection(IConfigSection configSection);
+
+ IConfigSection? FindConfigSection(string name);
+
+ IConfigSection FindOrCreateConfigSection(string name);
+
+ string GetAsString();
+
+ IEnumerable GetConfigSections(string sectionName);
+
+ string GetValue(string setting, string defaultValue);
+
+ ///
+ /// Gets all configured values for a git setting that accepts multiple values for the same key.
+ ///
+ /// The git setting key
+ /// The collection of all the values.
+ IReadOnlyList GetValues(string setting);
+
+ void LoadFromString(string str);
+
+ void Save(string fileName);
+
+ void SetValue(string setting, string value);
+}
diff --git a/src/GitExtensions.Extensibility/Configurations/IConfigFileSettings.cs b/src/GitExtensions.Extensibility/Configurations/IConfigFileSettings.cs
new file mode 100644
index 0000000..6ad1081
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Configurations/IConfigFileSettings.cs
@@ -0,0 +1,32 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace GitExtensions.Extensibility.Configurations;
+
+public interface IConfigFileSettings : IConfigValueStore
+{
+ ///
+ /// Adds the specific configuration section to the .git/config file.
+ ///
+ /// The configuration section.
+ void AddConfigSection(IConfigSection configSection);
+
+ ///
+ /// Retrieves configuration sections the .git/config file.
+ ///
+ IReadOnlyList GetConfigSections();
+
+ [return: NotNullIfNotNull("defaultValue")]
+ string? GetString(string name, string? defaultValue);
+
+ ///
+ /// Removes the specific configuration section from the .git/config file.
+ ///
+ /// The name of the configuration section.
+ /// If the configuration changes will be saved immediately.
+ void RemoveConfigSection(string configSectionName, bool performSave = false);
+
+ ///
+ /// Save pending changes.
+ ///
+ void Save();
+}
diff --git a/src/GitExtensions.Extensibility/Configurations/IConfigSection.cs b/src/GitExtensions.Extensibility/Configurations/IConfigSection.cs
new file mode 100644
index 0000000..c80e861
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Configurations/IConfigSection.cs
@@ -0,0 +1,15 @@
+namespace GitExtensions.Extensibility.Configurations;
+
+public interface IConfigSection
+{
+ string SectionName { get; set; }
+ string? SubSection { get; set; }
+
+ IDictionary> AsDictionary();
+ void AddValue(string key, string value);
+ bool Equals(IConfigSection other);
+ string GetValue(string key, string defaultValue = "");
+ IReadOnlyList GetValues(string key);
+ bool HasValue(string key);
+ void SetValue(string key, string? value);
+}
diff --git a/src/GitExtensions.Extensibility/Configurations/IConfigValueStore.cs b/src/GitExtensions.Extensibility/Configurations/IConfigValueStore.cs
new file mode 100644
index 0000000..bcc454c
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Configurations/IConfigValueStore.cs
@@ -0,0 +1,9 @@
+using GitExtensions.Extensibility.Settings;
+
+namespace GitExtensions.Extensibility.Configurations;
+
+public interface IConfigValueStore : ISettingsValueGetter
+{
+ void SetPathValue(string setting, string? value);
+ void SetValue(string setting, string? value);
+}
diff --git a/src/GitExtensions.Extensibility/Configurations/ISubmodulesConfigFile.cs b/src/GitExtensions.Extensibility/Configurations/ISubmodulesConfigFile.cs
new file mode 100644
index 0000000..8338a5e
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Configurations/ISubmodulesConfigFile.cs
@@ -0,0 +1,15 @@
+namespace GitExtensions.Extensibility.Configurations;
+
+///
+/// Provides the ability to access the .gitmodules config file.
+///
+public interface ISubmodulesConfigFile
+{
+ IReadOnlyList ConfigSections { get; }
+
+ string GetPathValue(string setting);
+
+ void RemoveConfigSection(string configSectionName);
+
+ void Save();
+}
diff --git a/src/GitExtensions.Extensibility/ExecutionResult.cs b/src/GitExtensions.Extensibility/ExecutionResult.cs
new file mode 100644
index 0000000..d6d5e37
--- /dev/null
+++ b/src/GitExtensions.Extensibility/ExecutionResult.cs
@@ -0,0 +1,21 @@
+namespace GitExtensions.Extensibility;
+
+public readonly struct ExecutionResult
+{
+ public const int Success = 0;
+
+ public string StandardOutput { get; }
+ public string StandardError { get; }
+ public int? ExitCode { get; }
+
+ public ExecutionResult(string standardOutput, string standardError, int? exitCode)
+ {
+ StandardOutput = standardOutput;
+ StandardError = standardError;
+ ExitCode = exitCode;
+ }
+
+ public bool ExitedSuccessfully => ExitCode == Success;
+
+ public string AllOutput => string.Concat(StandardOutput, Environment.NewLine, StandardError);
+}
diff --git a/src/GitExtensions.Extensibility/Extensions/DateTimeExtensions.cs b/src/GitExtensions.Extensibility/Extensions/DateTimeExtensions.cs
new file mode 100644
index 0000000..ef5f4fa
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Extensions/DateTimeExtensions.cs
@@ -0,0 +1,19 @@
+namespace GitExtensions.Extensibility.Extensions;
+
+public static class DateTimeExtensions
+{
+ public static DateTimeOffset ToDateTimeOffset(this DateTime dateTime)
+ {
+ if (dateTime.ToUniversalTime() <= DateTimeOffset.MinValue.UtcDateTime)
+ {
+ return DateTimeOffset.MinValue;
+ }
+
+ if (dateTime.ToUniversalTime() >= DateTimeOffset.MaxValue.UtcDateTime)
+ {
+ return DateTimeOffset.MaxValue;
+ }
+
+ return new DateTimeOffset(dateTime);
+ }
+}
diff --git a/src/GitExtensions.Extensibility/FontParser.cs b/src/GitExtensions.Extensibility/FontParser.cs
new file mode 100644
index 0000000..4e0e3e7
--- /dev/null
+++ b/src/GitExtensions.Extensibility/FontParser.cs
@@ -0,0 +1,52 @@
+using System.Globalization;
+
+namespace GitExtensions.Extensibility;
+
+public static class FontParser
+{
+ private const string InvariantCultureId = "_IC_";
+
+ public static string AsString(this Font value)
+ {
+ ArgumentNullException.ThrowIfNull(nameof(value));
+ return string.Format(CultureInfo.InvariantCulture,
+ "{0};{1};{2};{3};{4}", value.FontFamily.Name, value.Size, InvariantCultureId, value.Bold ? 1 : 0, value.Italic ? 1 : 0);
+ }
+
+ public static Font Parse(this string value, Font defaultValue)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return defaultValue;
+ }
+
+ string[] parts = value.Split(';');
+ if (parts.Length < 2)
+ {
+ return defaultValue;
+ }
+
+ try
+ {
+ string fontSize;
+ if (parts.Length == 3 && parts[2] == InvariantCultureId)
+ {
+ fontSize = parts[1];
+ }
+ else
+ {
+ fontSize = parts[1].Replace(",", CultureInfo.InvariantCulture.NumberFormat.NumberDecimalSeparator);
+ fontSize = fontSize.Replace(".", CultureInfo.InvariantCulture.NumberFormat.NumberDecimalSeparator);
+ }
+
+ FontStyle fontStyle = parts.Length > 3 && parts[3] == "1" ? FontStyle.Bold : FontStyle.Regular;
+ fontStyle |= parts.Length > 4 && parts[4] == "1" ? FontStyle.Italic : FontStyle.Regular;
+
+ return new Font(parts[0], float.Parse(fontSize, CultureInfo.InvariantCulture), fontStyle);
+ }
+ catch
+ {
+ return defaultValue;
+ }
+ }
+}
diff --git a/src/GitExtensions.Extensibility/Git/BatchProgressEventArgs.cs b/src/GitExtensions.Extensibility/Git/BatchProgressEventArgs.cs
new file mode 100644
index 0000000..75a53e6
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/BatchProgressEventArgs.cs
@@ -0,0 +1,23 @@
+namespace GitExtensions.Extensibility.Git;
+
+///
+/// Event arguments for batch progress updating
+///
+public sealed class BatchProgressEventArgs : EventArgs
+{
+ public BatchProgressEventArgs(int batchItemsProcessed, bool executionResult)
+ {
+ ProcessedCount = batchItemsProcessed;
+ ExecutionResult = executionResult;
+ }
+
+ ///
+ /// Number of items processed in this batch event
+ ///
+ public int ProcessedCount { get; }
+
+ ///
+ /// Batch execution result
+ ///
+ public bool ExecutionResult { get; }
+}
diff --git a/src/GitExtensions.Extensibility/Git/CommitData.cs b/src/GitExtensions.Extensibility/Git/CommitData.cs
new file mode 100644
index 0000000..04c2148
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/CommitData.cs
@@ -0,0 +1,40 @@
+using GitExtensions.Extensibility.Extensions;
+
+namespace GitExtensions.Extensibility.Git;
+
+public sealed class CommitData
+{
+ public CommitData(
+ ObjectId objectId,
+ IReadOnlyList? parentIds,
+ string author,
+ DateTime authorDate,
+ string committer,
+ DateTime commitDate,
+ string body)
+ {
+ ObjectId = objectId;
+ ParentIds = parentIds;
+ Author = author;
+ AuthorDate = authorDate.ToDateTimeOffset();
+ Committer = committer;
+ CommitDate = commitDate.ToDateTimeOffset();
+ Body = body;
+ }
+
+ public ObjectId ObjectId { get; }
+ public IReadOnlyList? ParentIds { get; }
+ public string Author { get; }
+ public DateTimeOffset AuthorDate { get; }
+ public string Committer { get; }
+ public DateTimeOffset CommitDate { get; }
+
+ // TODO mutable properties need review
+
+ public IReadOnlyList? ChildIds { get; set; }
+
+ ///
+ /// Gets and sets the commit message.
+ ///
+ public string Body { get; set; }
+}
diff --git a/src/GitExtensions.Extensibility/Git/ConflictData.cs b/src/GitExtensions.Extensibility/Git/ConflictData.cs
new file mode 100644
index 0000000..35b9b62
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/ConflictData.cs
@@ -0,0 +1,23 @@
+using System.Diagnostics;
+
+namespace GitExtensions.Extensibility.Git;
+
+[DebuggerDisplay("{" + nameof(Filename) + "}")]
+public readonly struct ConflictData
+{
+ public ConflictData(
+ ConflictedFileData @base,
+ ConflictedFileData local,
+ ConflictedFileData remote)
+ {
+ Base = @base;
+ Local = local;
+ Remote = remote;
+ }
+
+ public ConflictedFileData Base { get; }
+ public ConflictedFileData Local { get; }
+ public ConflictedFileData Remote { get; }
+
+ public string Filename => Local.Filename ?? Base.Filename ?? Remote.Filename;
+}
diff --git a/src/GitExtensions.Extensibility/Git/ConflictedFileData.cs b/src/GitExtensions.Extensibility/Git/ConflictedFileData.cs
new file mode 100644
index 0000000..005847a
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/ConflictedFileData.cs
@@ -0,0 +1,13 @@
+namespace GitExtensions.Extensibility.Git;
+
+public readonly struct ConflictedFileData
+{
+ public ConflictedFileData(ObjectId objectId, string filename)
+ {
+ ObjectId = objectId;
+ Filename = filename;
+ }
+
+ public ObjectId ObjectId { get; }
+ public string Filename { get; }
+}
diff --git a/src/GitExtensions.Extensibility/Git/DiffBranchStatus.cs b/src/GitExtensions.Extensibility/Git/DiffBranchStatus.cs
new file mode 100644
index 0000000..a945c7f
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/DiffBranchStatus.cs
@@ -0,0 +1,12 @@
+namespace GitExtensions.Extensibility.Git;
+
+public enum DiffBranchStatus
+{
+ Unknown = 0,
+ OnlyAChange,
+ OnlyBChange,
+ SameChange,
+
+ // Concurrent changes, different in first(A) and second(B)
+ UnequalChange
+}
diff --git a/src/GitExtensions.Extensibility/Git/FilteredGitRefsProvider.cs b/src/GitExtensions.Extensibility/Git/FilteredGitRefsProvider.cs
new file mode 100644
index 0000000..b1b80b3
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/FilteredGitRefsProvider.cs
@@ -0,0 +1,36 @@
+using GitExtensions.Extensibility;
+
+namespace GitExtensions.Extensibility.Git;
+
+// TODO: This should not be in Extensibility!
+
+public sealed class FilteredGitRefsProvider : IFilteredGitRefsProvider
+{
+ public FilteredGitRefsProvider(IGitModule module)
+ {
+ _getRefs = new(() => module.GetRefs(RefsFilter.NoFilter));
+ }
+
+ public FilteredGitRefsProvider(Lazy> getRefs)
+ {
+ _getRefs = getRefs;
+ }
+
+ private readonly Lazy> _getRefs;
+
+ ///
+ public IReadOnlyList GetRefs(RefsFilter filter)
+ {
+ if (filter == RefsFilter.NoFilter)
+ {
+ return _getRefs.Value;
+ }
+
+ return _getRefs.Value
+ .Where(r =>
+ ((filter & RefsFilter.Tags) != 0 && r.IsTag)
+ || ((filter & RefsFilter.Remotes) != 0 && r.IsRemote)
+ || ((filter & RefsFilter.Heads) != 0 && r.IsHead))
+ .ToList();
+ }
+}
diff --git a/src/GitExtensions.Extensibility/Git/GitBlame.cs b/src/GitExtensions.Extensibility/Git/GitBlame.cs
new file mode 100644
index 0000000..05b8ed5
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/GitBlame.cs
@@ -0,0 +1,90 @@
+using System.Globalization;
+using System.Text;
+
+namespace GitExtensions.Extensibility.Git;
+
+public sealed class GitBlame
+{
+ public IReadOnlyList Lines { get; }
+
+ public GitBlame(IReadOnlyList lines)
+ {
+ Lines = lines;
+ }
+}
+
+public sealed class GitBlameLine
+{
+ public GitBlameCommit Commit { get; }
+ public int FinalLineNumber { get; }
+ public int OriginLineNumber { get; }
+ public string Text { get; }
+
+ public GitBlameLine(GitBlameCommit commit, int finalLineNumber, int originLineNumber, string text)
+ {
+ Commit = commit;
+ FinalLineNumber = finalLineNumber;
+ OriginLineNumber = originLineNumber;
+ Text = text;
+ }
+}
+
+public sealed class GitBlameCommit
+{
+ public ObjectId ObjectId { get; }
+ public string Author { get; }
+ public string AuthorMail { get; }
+ public DateTime AuthorTime { get; }
+ public string AuthorTimeZone { get; }
+ public string Committer { get; }
+ public string CommitterMail { get; }
+ public DateTime CommitterTime { get; }
+ public string CommitterTimeZone { get; }
+ public string Summary { get; }
+ public string FileName { get; }
+
+ public GitBlameCommit(ObjectId objectId, string author, string authorMail, DateTime authorTime, string authorTimeZone, string committer, string committerMail, DateTime committerTime, string committerTimeZone, string summary, string fileName)
+ {
+ ObjectId = objectId;
+ Author = author;
+ AuthorMail = authorMail;
+ AuthorTime = authorTime;
+ AuthorTimeZone = authorTimeZone;
+ Committer = committer;
+ CommitterMail = committerMail;
+ CommitterTime = committerTime;
+ CommitterTimeZone = committerTimeZone;
+ Summary = summary;
+ FileName = fileName;
+ }
+
+ public string ToString(Func summaryBuilderFunc)
+ {
+ return ToString(summaryBuilderFunc(Summary) ?? Summary);
+ }
+
+ public override string ToString()
+ {
+ return ToString(Summary);
+ }
+
+ private string ToString(string summary)
+ {
+ StringBuilder s = new();
+
+ s.Append("Author: ").AppendLine(Author);
+ s.Append("Author date: ").AppendLine(AuthorTime.ToString(CultureInfo.CurrentCulture));
+ if (Author != Committer || AuthorTime != CommitterTime)
+ {
+ s.Append("Committer: ").AppendLine(Committer);
+ s.Append("Commit date: ").AppendLine(CommitterTime.ToString(CultureInfo.CurrentCulture));
+ }
+
+ s.Append("Commit hash: ").AppendLine(ObjectId.ToShortString());
+ s.Append("Summary: ").AppendLine(summary);
+ s.AppendLine();
+ s.Append("FileName: ").Append(FileName);
+
+ return s.ToString();
+ }
+}
diff --git a/src/GitExtensions.Extensibility/Git/GitItemStatus.cs b/src/GitExtensions.Extensibility/Git/GitItemStatus.cs
new file mode 100644
index 0000000..3358f1e
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/GitItemStatus.cs
@@ -0,0 +1,318 @@
+using System.Text;
+using Microsoft;
+using Microsoft.VisualStudio.Threading;
+
+namespace GitExtensions.Extensibility.Git;
+
+///
+/// Status if the file can be staged (worktree->index), unstaged or None (normal commits).
+/// The status may not be available or unset for some commands.
+///
+public enum StagedStatus
+{
+ Unset = 0,
+ None,
+ WorkTree,
+ Index,
+ Unknown
+}
+
+public sealed class GitItemStatus
+{
+ [Flags]
+ private enum Flags
+ {
+ IsTracked = 1 << 1,
+ IsDeleted = 1 << 2,
+ IsChanged = 1 << 3,
+ IsNew = 1 << 4,
+ IsIgnored = 1 << 5,
+ IsRenamed = 1 << 6,
+ IsCopied = 1 << 7,
+ IsUnmerged = 1 << 8,
+ IsAssumeUnchanged = 1 << 9,
+ IsSkipWorktree = 1 << 10,
+ IsSubmodule = 1 << 11,
+ IsDirty = 1 << 12,
+
+ // Other flags are parsed from Git status, set fake flags for special uses
+ IsStatusOnly = 1 << 13,
+ IsRangeDiff = 1 << 14
+ }
+
+ private JoinableTask? _submoduleStatus;
+
+ private Flags _flags;
+
+ public GitItemStatus(string name)
+ {
+ Requires.NotNull(name, nameof(name));
+ Name = name;
+ }
+
+ ///
+ /// Get a default object for an item unchanged in the WorkTree.
+ ///
+ /// The file name for the item.
+ /// The default GitItemStatus object.
+ public static GitItemStatus GetDefaultStatus(string name)
+ {
+ return GitItemStatusConverter.FromStatusCharacter(StagedStatus.WorkTree, name, GitItemStatusConverter.UnusedCharacter);
+ }
+
+ public string Name { get; set; }
+ public string? OldName { get; set; }
+ public string? ErrorMessage { get; set; }
+ public ObjectId? TreeGuid { get; set; }
+ public string? RenameCopyPercentage { get; set; }
+
+ public StagedStatus Staged { get; set; }
+ public DiffBranchStatus DiffStatus { get; set; } = DiffBranchStatus.Unknown;
+
+ #region Flags
+
+ public bool IsTracked
+ {
+ get => HasFlag(Flags.IsTracked);
+ set => SetFlag(value, Flags.IsTracked);
+ }
+
+ public bool IsDeleted
+ {
+ get => HasFlag(Flags.IsDeleted);
+ set => SetFlag(value, Flags.IsDeleted);
+ }
+
+ ///
+ /// For files, the file is modified
+ /// For submodules, the commit is changed.
+ ///
+ public bool IsChanged
+ {
+ get => HasFlag(Flags.IsChanged);
+ set => SetFlag(value, Flags.IsChanged);
+ }
+
+ public bool IsNew
+ {
+ get => HasFlag(Flags.IsNew);
+ set => SetFlag(value, Flags.IsNew);
+ }
+
+ public bool IsIgnored
+ {
+ get => HasFlag(Flags.IsIgnored);
+ set => SetFlag(value, Flags.IsIgnored);
+ }
+
+ public bool IsRenamed
+ {
+ get => HasFlag(Flags.IsRenamed);
+ set => SetFlag(value, Flags.IsRenamed);
+ }
+
+ public bool IsCopied
+ {
+ get => HasFlag(Flags.IsCopied);
+ set => SetFlag(value, Flags.IsCopied);
+ }
+
+ public bool IsUnmerged
+ {
+ get => HasFlag(Flags.IsUnmerged);
+ set => SetFlag(value, Flags.IsUnmerged);
+ }
+
+ // Flags below are not set from git-status parsing, but from other sources
+ // (IsSubmodule and IsDirty can be parsed in git-status porcelain=2 mode)
+
+ public bool IsAssumeUnchanged
+ {
+ get => HasFlag(Flags.IsAssumeUnchanged);
+ set => SetFlag(value, Flags.IsAssumeUnchanged);
+ }
+
+ public bool IsSkipWorktree
+ {
+ get => HasFlag(Flags.IsSkipWorktree);
+ set => SetFlag(value, Flags.IsSkipWorktree);
+ }
+
+ public bool IsSubmodule
+ {
+ get => HasFlag(Flags.IsSubmodule);
+ set => SetFlag(value, Flags.IsSubmodule);
+ }
+
+ ///
+ /// Submodule is dirty
+ /// Info from git-status, may be available before GetSubmoduleStatusAsync is evaluated.
+ ///
+ public bool IsDirty
+ {
+ get => HasFlag(Flags.IsDirty);
+ set => SetFlag(value, Flags.IsDirty);
+ }
+
+ ///
+ /// This item is not a Git item, just status information
+ /// If ErrorMessage is set, this is an error from Git, otherwise just a marker that nothing is changed.
+ ///
+ public bool IsStatusOnly
+ {
+ get => HasFlag(Flags.IsStatusOnly);
+ set => SetFlag(value, Flags.IsStatusOnly);
+ }
+
+ ///
+ /// This item is not a native git item, but a status information
+ /// calculated with git range-diff command.
+ ///
+ public bool IsRangeDiff
+ {
+ get => HasFlag(Flags.IsRangeDiff);
+ set => SetFlag(value, Flags.IsRangeDiff);
+ }
+
+ private bool HasFlag(Flags flags)
+ {
+ // NOTE Enum.HasFlag boxes its argument
+ return (flags & _flags) == flags;
+ }
+
+ private void SetFlag(bool isSet, Flags flag)
+ {
+ if (isSet)
+ {
+ _flags |= flag;
+ }
+ else
+ {
+ _flags &= ~flag;
+ }
+ }
+
+ #endregion
+
+ #region Derived Flags
+
+ ///
+ /// Indicates whether the Git item was added in the (artificial or real) commit.
+ ///
+ public bool IsAdded
+ => IsNew || IsCopied;
+
+ ///
+ /// Indicates whether the Git item is yet to be committed;
+ /// that is it belongs to either WorkTree or Index ( must be set).
+ ///
+ public bool IsUncommitted
+ => Staged is StagedStatus.WorkTree or StagedStatus.Index;
+
+ ///
+ /// Indicates whether the Git item is new or copied () and has not been committed yet ().
+ ///
+ public bool IsUncommittedAdded
+ => IsUncommitted && IsAdded;
+
+ #endregion
+
+ ///
+ /// Gets a task whose result is the submodule status.
+ ///
+ ///
+ /// A null task when has not been called on this object, or
+ /// a task whose result is the status. The task may also return null if the status could not be
+ /// determined.
+ ///
+ public Task GetSubmoduleStatusAsync()
+ {
+ if (_submoduleStatus is null)
+ {
+ return Task.FromResult((GitSubmoduleStatus?)null);
+ }
+
+ return _submoduleStatus.JoinAsync();
+ }
+
+ public void SetSubmoduleStatus(JoinableTask status)
+ {
+ _submoduleStatus = status;
+ }
+
+ ///
+ /// Return an object with the status as if the item was created
+ /// with first and second commit reverse selected.
+ ///
+ /// An inverted copy of the status.
+ public GitItemStatus InvertStatus()
+ {
+ GitItemStatus gitItemStatus = new(Name)
+ {
+ Name = IsRenamed ? OldName : Name,
+ OldName = IsRenamed ? Name : OldName,
+ ErrorMessage = ErrorMessage,
+ TreeGuid = TreeGuid,
+ RenameCopyPercentage = RenameCopyPercentage,
+ Staged = Staged,
+ DiffStatus = DiffStatus,
+ _flags = _flags,
+ IsNew = IsDeleted,
+ IsDeleted = IsNew
+ };
+
+ return gitItemStatus;
+ }
+
+ public int CompareName(GitItemStatus other)
+ {
+ int value = StringComparer.InvariantCulture.Compare(Name, other.Name);
+
+ if (value == 0)
+ {
+ value = StringComparer.InvariantCulture.Compare(OldName, other.OldName);
+ }
+
+ return value;
+ }
+
+ public override string ToString()
+ {
+ StringBuilder str = new();
+
+ if (!string.IsNullOrWhiteSpace(ErrorMessage))
+ {
+ str.Append(ErrorMessage);
+ }
+
+ if (IsRenamed)
+ {
+ str.Append("Renamed\n ").Append(OldName).Append("\nto\n ").Append(Name);
+ }
+ else if (IsCopied)
+ {
+ str.Append("Copied\n ").Append(OldName).Append("\nto\n ").Append(Name);
+ }
+ else
+ {
+ str.Append(Name);
+ }
+
+ if (IsUnmerged)
+ {
+ str.Append(" (Unmerged)");
+ }
+
+ if (Staged is not (StagedStatus.None or StagedStatus.Unset))
+ {
+ str.Append($" {Staged}");
+ }
+
+ if (!string.IsNullOrEmpty(RenameCopyPercentage))
+ {
+ str.Append("\nSimilarity ").Append(RenameCopyPercentage).Append('%');
+ }
+
+ return str.ToString();
+ }
+}
diff --git a/src/GitExtensions.Extensibility/Git/GitItemStatusConverter.cs b/src/GitExtensions.Extensibility/Git/GitItemStatusConverter.cs
new file mode 100644
index 0000000..35ae6a1
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/GitItemStatusConverter.cs
@@ -0,0 +1,40 @@
+namespace GitExtensions.Extensibility.Git;
+
+public static class GitItemStatusConverter
+{
+ // https://git-scm.com/docs/git-status#_short_format
+ // const instead of static readonly:
+ // These are external constants not expected to be changed or be used as a standalone library
+ public const char AddedStatus = 'A';
+ public const char CopiedStatus = 'C';
+ public const char DeletedStatus = 'D';
+ public const char ModifiedStatus = 'M';
+ public const char RenamedStatus = 'R';
+ public const char TypeChangedStatus = 'T';
+ public const char UnmergedStatus = 'U';
+ public const char UnmodifiedStatus_v1 = ' ';
+ public const char UnmodifiedStatus_v2 = '.';
+ public const char IgnoredStatus = '!';
+ public const char UntrackedStatus = '?';
+
+ // Unused char, to be used to get a default object
+ public const char UnusedCharacter = '&';
+
+ public static GitItemStatus FromStatusCharacter(StagedStatus staged, string fileName, char x)
+ {
+ bool isNew = x is AddedStatus or UntrackedStatus or IgnoredStatus;
+
+ return new GitItemStatus(fileName)
+ {
+ IsNew = isNew,
+ IsChanged = x is ModifiedStatus or TypeChangedStatus,
+ IsDeleted = x is DeletedStatus,
+ IsRenamed = x is RenamedStatus,
+ IsCopied = x is CopiedStatus,
+ IsTracked = !(x is UntrackedStatus or IgnoredStatus or UnmodifiedStatus_v1) || !isNew,
+ IsIgnored = x is IgnoredStatus,
+ IsUnmerged = x is UnmergedStatus,
+ Staged = staged
+ };
+ }
+}
diff --git a/src/GitExtensions.Extensibility/Git/GitRemoteCommandCompletedEventArgs.cs b/src/GitExtensions.Extensibility/Git/GitRemoteCommandCompletedEventArgs.cs
new file mode 100644
index 0000000..55db028
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/GitRemoteCommandCompletedEventArgs.cs
@@ -0,0 +1,17 @@
+namespace GitExtensions.Extensibility.Git;
+
+public class GitRemoteCommandCompletedEventArgs : EventArgs
+{
+ public IGitRemoteCommand Command { get; }
+
+ public bool IsError { get; }
+
+ public bool Handled { get; }
+
+ public GitRemoteCommandCompletedEventArgs(IGitRemoteCommand command, bool isError, bool handled)
+ {
+ Command = command;
+ IsError = isError;
+ Handled = handled;
+ }
+}
diff --git a/src/GitExtensions.Extensibility/Git/GitRevision.cs b/src/GitExtensions.Extensibility/Git/GitRevision.cs
new file mode 100644
index 0000000..b6ed470
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/GitRevision.cs
@@ -0,0 +1,147 @@
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+using System.Text.RegularExpressions;
+using GitExtensions.Extensibility.BuildServerIntegration;
+using GitExtensions.Extensibility.Git;
+
+namespace GitUIPluginInterfaces;
+
+public sealed partial class GitRevision : IGitItem, INotifyPropertyChanged
+{
+ /// 40 characters of 1's
+ public const string WorkTreeGuid = "1111111111111111111111111111111111111111";
+
+ /// 40 characters of 2's
+ public const string IndexGuid = "2222222222222222222222222222222222222222";
+
+ /// 40 characters of 2's
+ /// Artificial commit for the combined diff
+ public const string CombinedDiffGuid = "3333333333333333333333333333333333333333";
+
+ [GeneratedRegex(@"^[a-f\d]{40}$")]
+ public static partial Regex Sha1HashRegex();
+ [GeneratedRegex(@"\b[a-f\d]{7,40}\b(?![^@\s]*@)")]
+ public static partial Regex Sha1HashShortRegex();
+
+ private BuildInfo? _buildStatus;
+ private string? _body;
+
+ public GitRevision(ObjectId objectId)
+ {
+ ObjectId = objectId ?? throw new ArgumentNullException(nameof(objectId));
+ }
+
+ ///
+ /// Make a shallow clone of the object.
+ ///
+ /// A shallow copy.
+ public GitRevision Clone()
+ {
+ return (GitRevision)MemberwiseClone();
+ }
+
+ public ObjectId ObjectId { get; }
+
+ public string Guid => ObjectId.ToString();
+
+ // TODO this should probably be null when not yet populated, similar to how ParentIds works
+ public IReadOnlyList Refs { get; set; } = Array.Empty();
+
+ ///
+ /// Gets the revision's parent IDs.
+ ///
+ ///
+ /// Can return null in cases where the data has not been populated
+ /// for whatever reason.
+ ///
+ public IReadOnlyList? ParentIds { get; set; }
+
+ public ObjectId? TreeGuid { get; set; }
+
+ public string? Author { get; set; }
+ public string? AuthorEmail { get; set; }
+
+ // Git native datetime format
+ public long AuthorUnixTime { get; set; }
+ public DateTime AuthorDate => FromUnixTimeSeconds(AuthorUnixTime);
+ public string? Committer { get; set; }
+ public string? CommitterEmail { get; set; }
+ public long CommitUnixTime { get; set; }
+ public DateTime CommitDate => FromUnixTimeSeconds(CommitUnixTime);
+
+ private static DateTime FromUnixTimeSeconds(long unixTime)
+ => unixTime == 0 ? DateTime.MaxValue : DateTimeOffset.FromUnixTimeSeconds(unixTime).LocalDateTime;
+
+ public BuildInfo? BuildStatus
+ {
+ get => _buildStatus;
+ set
+ {
+ if (Equals(value, _buildStatus))
+ {
+ return;
+ }
+
+ _buildStatus = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public string Subject { get; set; } = "";
+
+ public string? Body
+ {
+ // Body is not stored by default for older commits to reduce memory usage
+ // Body do not have to be stored explicitly if same as subject and not multiline
+ get => _body ?? (!HasMultiLineMessage ? Subject : null);
+ set => _body = value;
+ }
+
+ public bool HasMultiLineMessage { get; set; }
+ public bool HasNotes { get; set; }
+
+ public override string ToString() => $"{ObjectId.ToShortString()}:{Subject}";
+
+ ///
+ /// Indicates whether the commit is an artificial commit.
+ ///
+ public bool IsArtificial => ObjectId.IsArtificial;
+
+ ///
+ /// Indicates whether the commit is a main stash commit.
+ ///
+ public bool IsStash => ReflogSelector is not null;
+
+ ///
+ /// The reflog selector, contains the stash name like "stash{0}"
+ ///
+ public string? ReflogSelector { get; set; }
+
+ public bool HasParent => ParentIds?.Count > 0;
+
+ public ObjectId? FirstParentId => HasParent ? ParentIds[0] : null;
+
+ #region INotifyPropertyChanged
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
+ {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ }
+
+ #endregion
+
+ ///
+ /// Returns a value indicating whether is a valid SHA-1 hash.
+ ///
+ ///
+ /// To be valid the string must contain exactly 40 lower-case hexadecimal characters.
+ ///
+ /// The string to validate.
+ /// true if is a valid SHA-1 hash, otherwise false.
+ public static bool IsFullSha1Hash(string id)
+ {
+ return Sha1HashRegex().IsMatch(id);
+ }
+}
diff --git a/src/GitExtensions.Extensibility/Git/GitStash.cs b/src/GitExtensions.Extensibility/Git/GitStash.cs
new file mode 100644
index 0000000..dfbe807
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/GitStash.cs
@@ -0,0 +1,52 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.RegularExpressions;
+
+namespace GitUIPluginInterfaces;
+
+/// Stored local modifications.
+public sealed partial class GitStash
+{
+ [GeneratedRegex(@"^stash@\{(?\d+)\}: (?.+)$")]
+ private static partial Regex StashRegex();
+
+ public static bool TryParse(string s, [NotNullWhen(returnValue: true)] out GitStash? stash)
+ {
+ // "stash@{i}: WIP on {branch}: {PreviousCommitMiniSHA} {PreviousCommitMessage}"
+ // "stash@{i}: On {branch}: {Message}"
+ // "stash@{i}: autostash"
+
+ Match match = StashRegex().Match(s);
+
+ if (!match.Success)
+ {
+ stash = default;
+ return false;
+ }
+
+ stash = new GitStash(
+ int.Parse(match.Groups["index"].Value),
+ match.Groups["message"].Value);
+
+ return true;
+ }
+
+ /// Short description of the commit the stash was based on.
+ public string Message { get; }
+
+ /// Gets the index of the stash in the list.
+ public int Index { get; }
+
+ public GitStash(int index, string message)
+ {
+ Index = index;
+ Message = message;
+ }
+
+ /// Name of the stash.
+ /// "stash@{n}"
+ public string Name => $"stash@{{{Index}}}";
+
+ public string Summary => Index == -1 ? Message : $"@{{{Index}}}: {Message}";
+
+ public override string ToString() => Message;
+}
diff --git a/src/GitExtensions.Extensibility/Git/GitSubmoduleStatus.cs b/src/GitExtensions.Extensibility/Git/GitSubmoduleStatus.cs
new file mode 100644
index 0000000..b9cf5a2
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/GitSubmoduleStatus.cs
@@ -0,0 +1,56 @@
+namespace GitExtensions.Extensibility.Git;
+
+public sealed class GitSubmoduleStatus
+{
+ public string Name { get; }
+ public string? OldName { get; }
+ public bool IsDirty { get; }
+ public ObjectId? Commit { get; }
+ public ObjectId? OldCommit { get; }
+ public int? AddedCommits { get; }
+ public int? RemovedCommits { get; }
+
+ public SubmoduleStatus Status { get; set; } = SubmoduleStatus.Unknown;
+
+ public GitSubmoduleStatus(string name, string? oldName, bool isDirty, ObjectId? commit, ObjectId? oldCommit, int? addedCommits, int? removedCommits)
+ {
+ ArgumentNullException.ThrowIfNull(nameof(name));
+ Name = name;
+ OldName = oldName;
+ IsDirty = isDirty;
+ Commit = commit;
+ OldCommit = oldCommit;
+ AddedCommits = addedCommits;
+ RemovedCommits = removedCommits;
+ }
+
+ public IGitModule GetSubmodule(IGitModule module)
+ {
+ return module.GetSubmodule(Name);
+ }
+
+ public void CheckSubmoduleStatus(IGitModule? submodule)
+ {
+ if (submodule is null)
+ {
+ Status = SubmoduleStatus.NewSubmodule;
+ return;
+ }
+
+ Status = submodule.CheckSubmoduleStatus(Commit, OldCommit, data: null, oldData: null, loadData: true);
+ }
+
+ public string AddedAndRemovedString()
+ {
+ if (RemovedCommits is null || AddedCommits is null ||
+ (RemovedCommits == 0 && AddedCommits == 0))
+ {
+ return "";
+ }
+
+ return " (" +
+ (RemovedCommits == 0 ? "" : "-" + RemovedCommits) +
+ (AddedCommits == 0 ? "" : "+" + AddedCommits) +
+ ")";
+ }
+}
diff --git a/src/GitExtensions.Extensibility/Git/GitUIEventArgs.cs b/src/GitExtensions.Extensibility/Git/GitUIEventArgs.cs
new file mode 100644
index 0000000..6346c5b
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/GitUIEventArgs.cs
@@ -0,0 +1,31 @@
+using System.ComponentModel;
+
+namespace GitExtensions.Extensibility.Git;
+
+public class GitUIEventArgs : CancelEventArgs
+{
+ private readonly IFilteredGitRefsProvider _getRefs;
+
+ public GitUIEventArgs(IWin32Window? ownerForm, IGitUICommands gitUICommands, Lazy>? getRefs = null)
+ : base(cancel: false)
+ {
+ OwnerForm = ownerForm;
+ GitUICommands = gitUICommands;
+ if (getRefs is null)
+ {
+ _getRefs = new FilteredGitRefsProvider(GitModule);
+ }
+ else
+ {
+ _getRefs = new FilteredGitRefsProvider(getRefs);
+ }
+ }
+
+ public IGitUICommands GitUICommands { get; }
+
+ public IWin32Window? OwnerForm { get; }
+
+ public IGitModule GitModule => GitUICommands.Module;
+
+ public IReadOnlyList GetRefs(RefsFilter filter) => _getRefs.GetRefs(filter);
+}
diff --git a/src/GitExtensions.Extensibility/Git/GitUIPostActionEventArgs.cs b/src/GitExtensions.Extensibility/Git/GitUIPostActionEventArgs.cs
new file mode 100644
index 0000000..2f73dcd
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/GitUIPostActionEventArgs.cs
@@ -0,0 +1,12 @@
+namespace GitExtensions.Extensibility.Git;
+
+public class GitUIPostActionEventArgs : GitUIEventArgs
+{
+ public bool ActionDone { get; }
+
+ public GitUIPostActionEventArgs(IWin32Window? ownerForm, IGitUICommands gitUICommands, bool actionDone)
+ : base(ownerForm, gitUICommands)
+ {
+ ActionDone = actionDone;
+ }
+}
diff --git a/src/GitExtensions.Extensibility/Git/IFilteredGitRefsProvider.cs b/src/GitExtensions.Extensibility/Git/IFilteredGitRefsProvider.cs
new file mode 100644
index 0000000..3f7aaf7
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/IFilteredGitRefsProvider.cs
@@ -0,0 +1,14 @@
+namespace GitExtensions.Extensibility.Git;
+
+///
+/// A lazy provider for GitRefs() that can be shared for instance when a repository is changed.
+///
+public interface IFilteredGitRefsProvider
+{
+ ///
+ /// Returns the IGitRefs matching the filter.
+ ///
+ /// The filter
+ /// The filtered GitRefs
+ IReadOnlyList GetRefs(RefsFilter filter);
+}
diff --git a/src/GitExtensions.Extensibility/Git/IGitCommand.cs b/src/GitExtensions.Extensibility/Git/IGitCommand.cs
new file mode 100644
index 0000000..7d7f5e6
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/IGitCommand.cs
@@ -0,0 +1,13 @@
+namespace GitExtensions.Extensibility.Git;
+
+public interface IGitCommand
+{
+ /// if command accesses remote repository
+ bool AccessesRemote { get; }
+
+ /// true if repo state changes after executing this command
+ bool ChangesRepoState { get; }
+
+ /// git command arguments as single line
+ string Arguments { get; }
+}
diff --git a/src/GitExtensions.Extensibility/Git/IGitCommandRunner.cs b/src/GitExtensions.Extensibility/Git/IGitCommandRunner.cs
new file mode 100644
index 0000000..ff29fa8
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/IGitCommandRunner.cs
@@ -0,0 +1,29 @@
+using System.Text;
+
+namespace GitExtensions.Extensibility.Git;
+
+public interface IGitCommandRunner
+{
+ ///
+ /// Starts git with the given arguments in the background cancellably.
+ /// The process exit is awaited on dispose of the IProcess instance.
+ ///
+ IProcess RunDetached(
+ CancellationToken cancellationToken,
+ ArgumentString arguments = default,
+ bool createWindow = false,
+ bool redirectInput = false,
+ bool redirectOutput = false,
+ Encoding? outputEncoding = null);
+
+ ///
+ /// Starts git with the given arguments in the background.
+ /// The process exit or exceptions are awaited in the background.
+ ///
+ void RunDetached(
+ ArgumentString arguments = default,
+ bool createWindow = false,
+ bool redirectInput = false,
+ bool redirectOutput = false,
+ Encoding? outputEncoding = null);
+}
diff --git a/src/GitExtensions.Extensibility/Git/IGitItem.cs b/src/GitExtensions.Extensibility/Git/IGitItem.cs
new file mode 100644
index 0000000..0ff6d5b
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/IGitItem.cs
@@ -0,0 +1,8 @@
+namespace GitExtensions.Extensibility.Git;
+
+public interface IGitItem
+{
+ ObjectId? ObjectId { get; }
+
+ string? Guid { get; }
+}
diff --git a/src/GitExtensions.Extensibility/Git/IGitModule.cs b/src/GitExtensions.Extensibility/Git/IGitModule.cs
new file mode 100644
index 0000000..1a551d9
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/IGitModule.cs
@@ -0,0 +1,440 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text;
+using GitExtensions.Extensibility.Configurations;
+using GitExtensions.Extensibility.Settings;
+using GitUIPluginInterfaces;
+
+namespace GitExtensions.Extensibility.Git;
+
+///
+/// Provides the ability to manipulate the git module.
+///
+public interface IGitModule
+{
+ IConfigFileSettings LocalConfigFile { get; }
+
+ string AddRemote(string remoteName, string? path);
+ IReadOnlyList GetRefs(RefsFilter getRef);
+ IEnumerable GetSettings(string setting);
+ IEnumerable GetTree(ObjectId? commitId, bool full);
+
+ ///
+ /// Removes the registered remote by running git remote rm command.
+ ///
+ /// The remote name.
+ string RemoveRemote(string remoteName);
+
+ ///
+ /// Renames the registered remote by running git remote rename command.
+ ///
+ /// The current remote name.
+ /// The new remote name.
+ string RenameRemote(string remoteName, string newName);
+
+ ///
+ /// Parses the revisionExpression as a git reference and returns an .
+ ///
+ /// An expression like HEAD or commit hash that can be parsed as a git reference.
+ /// An ObjectID representing that git reference
+ ObjectId? RevParse(string revisionExpression);
+
+ void SetSetting(string setting, string value);
+ void UnsetSetting(string setting);
+
+ Encoding CommitEncoding { get; }
+
+ IConfigFileSettings EffectiveConfigFile { get; }
+
+ Encoding FilesEncoding { get; }
+
+ ///
+ /// Returns git common directory.
+ /// https://git-scm.com/docs/git-rev-parse#Documentation/git-rev-parse.txt---git-common-dir.
+ ///
+ string GitCommonDirectory { get; }
+
+ ///
+ /// Gets the default Git executable associated with this module.
+ /// This executable can be non-native (i.e. WSL).
+ ///
+ IExecutable GitExecutable { get; }
+
+ ///
+ /// Gets the access to the current git executable associated with this module.
+ /// This command runner can be non-native (i.e. WSL).
+ ///
+ IGitCommandRunner GitCommandRunner { get; }
+
+ ///
+ /// Encoding for commit header (message, notes, author, committer, emails).
+ ///
+ Encoding LogOutputEncoding { get; }
+
+ ///
+ /// If this module is a submodule, returns its path, otherwise null.
+ ///
+ string? SubmodulePath { get; }
+
+ ///
+ /// Gets the super-project of the current git module, if any.
+ ///
+ ///
+ /// If this module is a submodule, returns its super-project , otherwise null.
+ ///
+ public IGitModule? SuperprojectModule { get; }
+
+ ///
+ /// Gets the directory which contains the git repository.
+ ///
+ string WorkingDir { get; }
+
+ ///
+ /// Gets the location of .git directory for the current working folder.
+ ///
+ string WorkingDirGitDir { get; }
+
+ ///
+ /// Asks git to resolve the given relativePath
+ /// git special folders are located in different directories depending on the kind of repo: submodule, worktree, main
+ /// See https://git-scm.com/docs/git-rev-parse#Documentation/git-rev-parse.txt---git-pathltpathgt
+ ///
+ /// A path relative to the .git directory
+ string ResolveGitInternalPath(string relativePath);
+
+ /// Indicates whether the specified directory contains a git repository.
+ bool IsValidGitWorkingDir();
+
+ /// Indicates HEAD is not pointing to a branch (i.e. it is detached).
+ bool IsDetachedHead();
+
+ ///
+ /// Convert the path for the Git executable. For WSL Git, the path will be adjusted.
+ ///
+ /// The Windows (native) path as seen by the application.
+ /// The Posix path if Windows Git, WSL path for WSL Git.
+ public string GetPathForGitExecution(string? path);
+
+ ///
+ /// Convert a path to Windows application (native) format.
+ ///
+ /// Path as seen by the Git executable, possibly WSL Git.
+ /// The path in Windows format with native file separators.
+ public string GetWindowsPath(string path);
+
+ bool TryResolvePartialCommitId(string objectIdPrefix, [NotNullWhen(returnValue: true)] out ObjectId? objectId);
+
+ string GetSubmoduleFullPath(string localPath);
+
+ IEnumerable GetSubmodulesInfo();
+
+ ///
+ /// Gets the local paths of any submodules of this git module.
+ ///
+ ///
+ /// This method obtains its results by parsing the .gitmodules file.
+ ///
+ /// This approach is a faster than which
+ /// invokes the git submodule command.
+ ///
+ IReadOnlyList GetSubmodulesLocalPaths(bool recursive = true);
+
+ IGitModule GetSubmodule(string submoduleName);
+
+ ///
+ /// Retrieves registered remotes by running git remote show command.
+ ///
+ /// Registered remotes.
+ IReadOnlyList GetRemoteNames();
+
+ ///
+ /// Gets the commit ID of the currently checked out commit.
+ /// If the repo is bare or has no commits, null is returned.
+ ///
+ ObjectId? GetCurrentCheckout();
+
+ /// Gets the remote of the current branch; or "" if no remote is configured.
+ string GetCurrentRemote();
+
+ GitRevision GetRevision(ObjectId? objectId = null, bool shortFormat = false, bool loadRefs = false);
+
+ Task> GetRemotesAsync();
+
+ string GetSetting(string setting);
+
+ ///
+ /// Gets the config setting from git converted in an expected C# value type (bool, int, etc.).
+ ///
+ /// The expected type of the git setting.
+ /// The git setting key.
+ /// The value converted to the type; if the settings is not set.
+ ///
+ /// The value of the git setting cannot be converted in the specified type .
+ ///
+ T? GetSetting(string setting) where T : struct;
+
+ string GetEffectiveSetting(string setting);
+
+ ///
+ /// Gets the config setting from git converted in an expected C# value type (bool, int, etc.).
+ ///
+ /// The expected type of the git setting.
+ /// The git setting key.
+ /// The value converted to the type; if the settings is not set.
+ ///
+ /// The value of the git setting cannot be converted in the specified type .
+ ///
+ T? GetEffectiveSetting(string setting) where T : struct;
+
+ ///
+ /// Get the effective config setting from git.
+ ///
+ /// The setting key.
+ /// if the result shall be cached.
+ /// The value of the setting or if the value is not set.
+ string? GetEffectiveGitSetting(string setting, bool cache = true);
+
+ SettingsSource GetEffectiveSettingsByPath(string path);
+
+ ///
+ /// Gets the name of the currently checked out branch.
+ ///
+ /// Defines the value returned if HEAD is detached. to return ; to return "(no branch)".
+ ///
+ /// The name of the branch (for example: "main"); the value requested by , if HEAD is detached.
+ ///
+ string GetSelectedBranch(bool emptyIfDetached = false);
+
+ /// true if ".git" directory does NOT exist.
+ bool IsBareRepository();
+
+ bool IsRunningGitProcess();
+
+ SettingsSource GetEffectiveSettings();
+ SettingsSource GetLocalSettings();
+
+ string? ReEncodeStringFromLossless(string? s);
+
+ string ReEncodeCommitMessage(string s);
+
+ string? GetDescribe(ObjectId commitId);
+
+ (int TotalCount, Dictionary CountByName) GetCommitsByContributor(DateTime? since = null, DateTime? until = null);
+
+ void SaveBlobAs(string saveAs, string blob);
+ Task<(char Code, ObjectId CommitId)> GetSuperprojectCurrentCheckoutAsync();
+ Task GetCurrentChangesAsync(string? fileName, string? oldFileName, bool staged, string extraDiffArguments, Encoding? encoding = null, bool noLocks = false);
+ Task GetFileContentsAsync(GitItemStatus file);
+ IReadOnlyList GetStashes(bool noLocks);
+ IReadOnlyList GetWorkTreeFiles();
+ SubmoduleStatus CheckSubmoduleStatus(ObjectId? commit, ObjectId? oldCommit, CommitData? data, CommitData? oldData, bool loadData);
+ bool ResetAllChanges(bool clean, bool onlyWorkTree = false);
+
+ ///
+ /// Get a list of diff/merge tools known by Git.
+ /// This normally requires long time (up to tenths of seconds)
+ ///
+ /// diff or merge.
+ /// the Git output.
+ string GetCustomDiffMergeTools(bool isDiff, CancellationToken cancellationToken);
+ Task<(Patch? Patch, string? ErrorMessage)> GetSingleDiffAsync(ObjectId? firstId, ObjectId? secondId, string? fileName, string? oldFileName, string extraDiffArguments, Encoding encoding, bool cacheResult, bool isTracked);
+ int? GetCommitCount(string parent, string child, bool cache, bool throwOnErrorExit);
+
+ ///
+ /// Gets the top-most parent module of the current git submodule.
+ ///
+ ///
+ /// If this module is a submodule, returns its top-most parent module, otherwise .
+ ///
+ IGitModule GetTopModule();
+ string? GetCurrentSubmoduleLocalPath();
+ ISubmodulesConfigFile GetSubmodulesConfigFile();
+ string GetStatusText(bool untracked);
+ ExecutionResult GetDiffFiles(string? firstRevision, string? secondRevision, bool noCache, bool nullSeparated, CancellationToken cancellationToken);
+ bool InTheMiddleOfBisect();
+ IReadOnlyList GetDiffFilesWithUntracked(string? firstRevision, string? secondRevision, StagedStatus stagedStatus, bool noCache, CancellationToken cancellationToken);
+ bool IsDirtyDir();
+ Task GetRangeDiffAsync(ObjectId firstId, ObjectId secondId, ObjectId? firstBase, ObjectId? secondBase, string extraDiffArguments, string? pathFilter, CancellationToken cancellationToken);
+ bool InTheMiddleOfPatch();
+ bool InTheMiddleOfConflictedMerge(bool throwOnErrorExit = true);
+ bool InTheMiddleOfAction();
+ string ApplyPatch(string dirText, ArgumentString arguments);
+ bool InTheMiddleOfRebase();
+ bool InTheMiddleOfMerge();
+ IReadOnlyList GetDiffFilesWithSubmodulesStatus(ObjectId? firstId, ObjectId? secondId, ObjectId? parentToSecond, CancellationToken cancellationToken);
+ IReadOnlyList GetIndexFilesWithSubmodulesStatus();
+ ObjectId? GetFileBlobHash(string fileName, ObjectId objectId);
+ void OpenFilesWithDifftool(string? firstGitCommit, string? secondGitCommit, string? customTool);
+ IReadOnlyList GetIgnoredFiles(IEnumerable ignorePatterns);
+ void UnlockIndex(bool includeSubmodules);
+ bool EditNotes(ObjectId objectId);
+ ArgumentString FetchCmd(string? remote, string? remoteBranch, string? localBranch, bool? fetchTags = false, bool isUnshallow = false, bool pruneRemoteBranches = false, bool pruneRemoteBranchesAndTags = false);
+ void RunGui();
+ void RunGitK();
+ ObjectId? GetMergeBase(ObjectId a, ObjectId b);
+ (int? First, int? Second) GetCommitRangeDiffCount(ObjectId firstId, ObjectId secondId);
+ IReadOnlyList GetCombinedDiffFileList(ObjectId mergeCommitObjectId);
+ IReadOnlyList GetTreeFiles(ObjectId treeGuid, bool full);
+ IReadOnlyList GetFullTree(string id);
+
+ ///
+ /// Gets branches which contain the given commit.
+ /// If both local and remote branches are requested, remote branches are prefixed with "remotes/"
+ /// (as returned by git branch -a).
+ ///
+ /// The sha1.
+ /// Pass true to include local branches.
+ /// Pass true to include remote branches.
+ IReadOnlyList GetAllBranchesWhichContainGivenCommit(ObjectId objectId, bool getLocal, bool getRemote);
+
+ ///
+ /// Uses check-ref-format to ensure that a branch name is well formed.
+ ///
+ /// Branch name to test.
+ /// true if is valid reference name, otherwise false.
+ bool CheckBranchFormat(string branchName);
+ string? GetLocalTrackingBranchName(string remoteName, string branch);
+ string GetCommitCountString(string from, string to);
+ IReadOnlyList GetAllChangedFilesWithSubmodulesStatus(CancellationToken cancellationToken);
+ IReadOnlyList GetAllChangedFilesWithSubmodulesStatus(bool excludeIgnoredFiles, bool excludeAssumeUnchangedFiles, bool excludeSkipWorktreeFiles, UntrackedFilesMode untrackedFiles, CancellationToken cancellationToken);
+ bool ResetChanges(ObjectId? resetId, IReadOnlyList selectedItems, bool resetAndDelete, IFullPathResolver fullPathResolver, out StringBuilder output, Action? progressAction);
+ bool HasSubmodules();
+ void OpenWithDifftool(string? filename, string? oldFileName = "", string? firstRevision = GitRevision.IndexGuid, string? secondRevision = GitRevision.WorkTreeGuid, string? extraDiffArguments = null, bool isTracked = true, string? customTool = null);
+ void OpenWithDifftoolDirDiff(string? firstRevision, string? secondRevision, string? customTool);
+ IReadOnlyList GetParents(ObjectId objectId);
+ IReadOnlyList GetParentRevisions(ObjectId objectId);
+ Task GetConflictAsync(string? filename);
+ Task> GetSubmoduleItemsForEachRefAsync(string? filename, bool noLocks);
+
+ ///
+ /// Returns tag's message. If the lightweight tag is passed, corresponding commit message
+ /// is returned.
+ ///
+ string? GetTagMessage(string? tag);
+ void UnstageFile(string file);
+ bool UnstageFiles(IReadOnlyList files, out string allOutput);
+ bool StageFile(string file);
+ bool StageFiles(IReadOnlyList files, out string allOutput);
+ IEnumerable GetRemoteBranches();
+ IEnumerable GetPreviousCommitMessages(int count, string revision, string authorPattern);
+ Task AddInteractiveAsync(GitItemStatus file);
+ string GetRebaseDir();
+
+ ///
+ /// Unstage files in batch.
+ ///
+ /// The list of files to unstage.
+ /// The progress update callback.
+ /// if changes should be rescanned; , otherwise..
+ public bool BatchUnstageFiles(IEnumerable files, Action? progressCallback = null);
+
+ bool StopTrackingFile(string filename);
+
+ ///
+ /// Set/unset whether given files are assumed unchanged by git-status.
+ ///
+ /// The list of files to set the status for.
+ /// The status value.
+ /// stdout and stderr output.
+ /// if no errors occurred; otherwise; , otherwise.
+ bool AssumeUnchangedFiles(IReadOnlyList files, bool assumeUnchanged, out string allOutput);
+
+ ///
+ /// Performs git-checkout for the given files.
+ ///
+ /// The list of files to checkout.
+ /// The revision to checkout; is handled as HEAD.
+ /// Indicates whether to perform a forced checkout.
+ string CheckoutFiles(IReadOnlyList files, ObjectId? revision, bool force);
+
+ void DeleteTag(string tagName);
+
+ string? ShowObject(ObjectId objectId);
+
+ IReadOnlyList GetStashDiffFiles(string stashName);
+
+ IReadOnlyList GetAllChangedFiles(bool excludeIgnoredFiles = true,
+ bool excludeAssumeUnchangedFiles = true, bool excludeSkipWorktreeFiles = true,
+ UntrackedFilesMode untrackedFiles = UntrackedFilesMode.Default, CancellationToken cancellationToken = default);
+
+ bool HandleConflictsSaveSide(string fileName, string saveAsFileName, string side);
+
+ void RunMergeTool(string? fileName = "", string? customTool = null);
+
+ bool HandleConflictSelectSide(string fileName, string side);
+
+ void Reset(ResetMode mode, string? file = null);
+
+ (string? BaseFile, string? LocalFile, string? RemoteFile) CheckoutConflictedFiles(ConflictData unmergedData);
+
+ bool IsSubmodule(string submodulePath);
+
+ Task> GetConflictsAsync(string? filename = "");
+ string FormatPatch(string from, string to, string output, int? start = null);
+
+ // TODO: convert to IGitCommand
+ ArgumentString PullCmd(string source, string curRemoteBranch, bool checked1, bool? v, bool checked2);
+
+ bool ExistsMergeCommit(string? startRev, string? endRev);
+
+ string GetFileText(ObjectId id, Encoding encoding);
+
+ MemoryStream? GetFileStream(string blob);
+
+ IReadOnlyList GitStatus(UntrackedFilesMode untrackedFilesMode, IgnoreSubmodulesMode ignoreSubmodulesMode = IgnoreSubmodulesMode.None);
+
+ ///
+ /// Tries to start Pageant for the specified remote repo (using the remote's PuTTY key file).
+ ///
+ /// if the remote has a PuTTY key file; , otherwise.
+ string GetPuttyKeyFileForRemote(string? remote);
+
+ ///
+ /// GitVersion for the default GitExecutable.
+ ///
+ IGitVersion GitVersion { get; }
+
+ string? GetCombinedDiffContent(ObjectId revisionOfMergeCommit, string filePath, string extraArgs, Encoding encoding);
+ bool IsMerge(ObjectId objectId);
+ IEnumerable GetMergedBranches(bool includeRemote = false);
+ Task GetMergedBranchesAsync(bool includeRemote = false, bool fullRefname = false, string? commit = null);
+ IReadOnlyList GetMergedRemoteBranches();
+ IReadOnlyList GetRemoteServerRefs(string remote, bool tags, bool branches, out string? errorOutput, CancellationToken cancellationToken);
+
+ ///
+ /// Format branch name, check if name is valid for repository.
+ ///
+ /// Branch name to test.
+ /// Well formed branch name.
+ string FormatBranchName(string branchName);
+
+ ///
+ /// Set/unset whether given items are not flagged as changed by git-status.
+ ///
+ /// The files to set the status for.
+ /// The status value.
+ /// stdout and stderr output.
+ /// if no errors occurred; otherwise, .
+ bool SkipWorktreeFiles(IReadOnlyList files, bool skipWorktree, out string allOutput);
+
+ Task ResetInteractiveAsync(GitItemStatus file);
+
+ IReadOnlyList ParseRefs(string refList);
+
+ ///
+ /// Gets all tags which contain the given commit.
+ ///
+ /// The sha1.
+ IReadOnlyList GetAllTagsWhichContainGivenCommit(ObjectId objectId);
+
+ ///
+ /// Gets the remote branch.
+ ///
+ ///
+ /// The remote branch of the specified local branch; or "" if none is configured.
+ ///
+ string GetRemoteBranch(string branch);
+
+ string RenameBranch(string name, string newName);
+
+ GitBlame Blame(string? fileName, string from, Encoding encoding, string? lines = null, CancellationToken cancellationToken = default);
+}
diff --git a/src/GitExtensions.Extensibility/Git/IGitRef.cs b/src/GitExtensions.Extensibility/Git/IGitRef.cs
new file mode 100644
index 0000000..02fefce
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/IGitRef.cs
@@ -0,0 +1,52 @@
+using GitExtensions.Extensibility.Settings;
+
+namespace GitExtensions.Extensibility.Git;
+
+public interface IGitRef : INamedGitItem
+{
+ string CompleteName { get; }
+ bool IsBisect { get; }
+ bool IsBisectGood { get; }
+ bool IsBisectBad { get; }
+ bool IsStash { get; }
+
+ ///
+ /// True when Guid is a checksum of an object (e.g. commit) to which another object
+ /// with Name (e.g. annotated tag) is applied.
+ /// False when Name and Guid are denoting the same object.
+ ///
+ bool IsDereference { get; }
+
+ bool IsHead { get; }
+ bool IsRemote { get; }
+ bool IsTag { get; }
+ string LocalName { get; }
+ string MergeWith { get; set; }
+ IGitModule Module { get; }
+ string Remote { get; }
+ string TrackingRemote { get; set; }
+ bool IsSelected { get; set; }
+ bool IsSelectedHeadMergeSource { get; set; }
+
+ ///
+ /// This method is a faster than the property above. The property reads the config file
+ /// every time it is accessed. This method accepts a config file what makes it faster when loading
+ /// the revision graph.
+ ///
+ string GetTrackingRemote(ISettingsValueGetter configFile);
+
+ ///
+ /// This method is a faster than the property above. The property reads the config file
+ /// every time it is accessed. This method accepts a config file which makes it faster when loading
+ /// the revision graph.
+ ///
+ string GetMergeWith(ISettingsValueGetter configFile);
+
+ ///
+ /// Return if the current `GitRef` is tracking another `GitRef` as a remote.
+ ///
+ /// the expected remote ref tracked
+ /// true if the current ref is tracking the expected remote ref
+ /// false otherwise
+ bool IsTrackingRemote(IGitRef? remote);
+}
diff --git a/src/GitExtensions.Extensibility/Git/IGitRemoteCommand.cs b/src/GitExtensions.Extensibility/Git/IGitRemoteCommand.cs
new file mode 100644
index 0000000..1475ef6
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/IGitRemoteCommand.cs
@@ -0,0 +1,15 @@
+namespace GitExtensions.Extensibility.Git;
+
+public interface IGitRemoteCommand
+{
+ object? OwnerForm { get; set; }
+ string? Remote { get; set; }
+ string? Title { get; set; }
+ string? CommandText { get; set; }
+ bool ErrorOccurred { get; }
+ string? CommandOutput { get; }
+
+ event EventHandler Completed;
+
+ void Execute();
+}
diff --git a/src/GitExtensions.Extensibility/Git/IGitSubmoduleInfo.cs b/src/GitExtensions.Extensibility/Git/IGitSubmoduleInfo.cs
new file mode 100644
index 0000000..4136e6c
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/IGitSubmoduleInfo.cs
@@ -0,0 +1,13 @@
+namespace GitExtensions.Extensibility.Git;
+
+public interface IGitSubmoduleInfo
+{
+ string Branch { get; }
+ ObjectId CurrentCommitId { get; }
+ bool IsInitialized { get; }
+ string LocalPath { get; }
+ string Name { get; }
+ string RemotePath { get; }
+ string Status { get; }
+ bool IsUpToDate { get; }
+}
diff --git a/src/GitExtensions.Extensibility/Git/IGitUICommands.cs b/src/GitExtensions.Extensibility/Git/IGitUICommands.cs
new file mode 100644
index 0000000..277ecbd
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/IGitUICommands.cs
@@ -0,0 +1,41 @@
+using GitExtensions.Extensibility.Plugins;
+
+namespace GitExtensions.Extensibility.Git;
+
+public interface IGitUICommands : IServiceProvider
+{
+ event EventHandler PostCommit;
+ event EventHandler PostRepositoryChanged;
+ event EventHandler PostSettings;
+ event EventHandler PostUpdateSubmodules;
+ event EventHandler PostBrowseInitialize;
+ event EventHandler PostRegisterPlugin;
+ event EventHandler PreCommit;
+
+ IBrowseRepo? BrowseRepo { get; set; }
+
+ IGitModule Module { get; }
+
+ ///
+ /// RepoChangedNotifier.Notify() should be called after each action that changes repo state
+ ///
+ ILockableNotifier RepoChangedNotifier { get; }
+
+ IGitRemoteCommand CreateRemoteCommand();
+
+ bool StartCommandLineProcessDialog(IWin32Window? owner, string? command, ArgumentString arguments);
+ bool StartCommandLineProcessDialog(IWin32Window? owner, IGitCommand command);
+ void StartBatchFileProcessDialog(string batchFile);
+
+ ///
+ /// Opens the FormRemotes.
+ ///
+ /// Makes the FormRemotes initially select the given remote.
+ /// Makes the FormRemotes initially show the tab "Default push behavior" and select the given local.
+ bool StartRemotesDialog(IWin32Window? owner, string? preselectRemote = null, string? preselectLocal = null);
+
+ bool StartSettingsDialog(Type pageType);
+ bool StartSettingsDialog(IGitPlugin gitPlugin);
+ void AddCommitTemplate(string key, Func addingText, Image? icon);
+ void RemoveCommitTemplate(string key);
+}
diff --git a/src/GitExtensions.Extensibility/Git/IGitVersion.cs b/src/GitExtensions.Extensibility/Git/IGitVersion.cs
new file mode 100644
index 0000000..61a684a
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/IGitVersion.cs
@@ -0,0 +1,20 @@
+namespace GitExtensions.Extensibility.Git;
+
+public interface IGitVersion : IComparable
+{
+ bool IsUnknown { get; }
+ bool SupportAmendCommits { get; }
+ bool SupportGuiMergeTool { get; }
+ bool SupportRangeDiffPath { get; }
+ bool SupportRangeDiffTool { get; }
+ bool SupportRebaseMerges { get; }
+ bool SupportStashStaged { get; }
+ bool SupportUpdateRefs { get; }
+
+ string ToString();
+
+ public static bool operator >(IGitVersion left, IGitVersion? right) => left.CompareTo(right) > 0;
+ public static bool operator <(IGitVersion left, IGitVersion? right) => left.CompareTo(right) < 0;
+ public static bool operator >=(IGitVersion left, IGitVersion? right) => left.CompareTo(right) >= 0;
+ public static bool operator <=(IGitVersion left, IGitVersion? right) => left.CompareTo(right) <= 0;
+}
diff --git a/src/GitExtensions.Extensibility/Git/INamedGitItem.cs b/src/GitExtensions.Extensibility/Git/INamedGitItem.cs
new file mode 100644
index 0000000..d276582
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/INamedGitItem.cs
@@ -0,0 +1,6 @@
+namespace GitExtensions.Extensibility.Git;
+
+public interface INamedGitItem : IGitItem
+{
+ string Name { get; }
+}
diff --git a/src/GitExtensions.Extensibility/Git/IgnoreSubmodulesMode.cs b/src/GitExtensions.Extensibility/Git/IgnoreSubmodulesMode.cs
new file mode 100644
index 0000000..0305eeb
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/IgnoreSubmodulesMode.cs
@@ -0,0 +1,25 @@
+namespace GitExtensions.Extensibility.Git;
+
+/// Specifies whether to ignore changes to submodules when looking for changes (e.g. via 'git status').
+public enum IgnoreSubmodulesMode
+{
+ /// Default is (hides all changes to submodules).
+ Default = 0,
+
+ /// Consider a submodule modified when it either:
+ /// contains untracked or modified files,
+ /// or its HEAD differs from the commit recorded in the superproject.
+ None,
+
+ /// Submodules NOT considered dirty when they only contain untracked content
+ /// (but they are still scanned for modified content).
+ Untracked,
+
+ /// Ignores all changes to the work tree of submodules,
+ /// only changes to the commits stored in the superproject are shown.
+ Dirty,
+
+ /// Hides all changes to submodules
+ /// (and suppresses the output of submodule summaries when the config option status.submodulesummary is set).
+ All
+}
diff --git a/src/GitExtensions.Extensibility/Git/ObjectId.cs b/src/GitExtensions.Extensibility/Git/ObjectId.cs
new file mode 100644
index 0000000..b2be8fe
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/ObjectId.cs
@@ -0,0 +1,333 @@
+using System.Buffers.Binary;
+using System.Buffers.Text;
+using System.Diagnostics.CodeAnalysis;
+using System.Diagnostics.Contracts;
+using System.Globalization;
+using System.Text.RegularExpressions;
+
+namespace GitExtensions.Extensibility.Git;
+
+///
+/// Models a SHA1 hash.
+///
+///
+/// Instances are immutable and are guaranteed to contain valid, 160-bit (20-byte) SHA1 hashes.
+/// String forms of this object must be in lower case.
+///
+public sealed class ObjectId : IEquatable, IComparable
+{
+ private static readonly ThreadLocal _buffer = new(() => new byte[_sha1ByteCount], trackAllValues: false);
+ private static readonly Random _random = new();
+
+ ///
+ /// Gets the artificial ObjectId used to represent working directory tree (unstaged) changes.
+ ///
+ public static ObjectId WorkTreeId { get; } = new(0x1111_1111_1111_1111, 0x1111_1111_1111_1111, 0x1111_1111);
+
+ ///
+ /// Gets the artificial ObjectId used to represent changes staged to the index.
+ ///
+ public static ObjectId IndexId { get; } = new(0x2222_2222_2222_2222, 0x2222_2222_2222_2222, 0x2222_2222);
+
+ ///
+ /// Gets the artificial ObjectId used to represent combined diff for merge commits.
+ ///
+ public static ObjectId CombinedDiffId { get; } = new(0x3333_3333_3333_3333, 0x3333_3333_3333_3333, 0x3333_3333);
+
+ ///
+ /// Produces an populated with random bytes.
+ ///
+ [Pure]
+ public static ObjectId Random()
+ {
+ return new ObjectId(
+ unchecked((ulong)_random.NextInt64()),
+ unchecked((ulong)_random.NextInt64()),
+ unchecked((uint)_random.Next()));
+ }
+
+ public bool IsArtificial => this == WorkTreeId || this == IndexId || this == CombinedDiffId;
+
+ private const int _sha1ByteCount = 20;
+ public const int Sha1CharCount = 40;
+
+ #region Parsing
+
+ ///
+ /// Parses an from .
+ ///
+ ///
+ /// For parsing to succeed, must be a valid 40-character SHA-1 string.
+ /// Any extra characters at the end will cause parsing to fail.
+ ///
+ /// The string to try parsing from.
+ /// The parsed .
+ /// did not contain a valid 40-character SHA-1 hash, or is .
+ [Pure]
+ public static ObjectId Parse(string s)
+ {
+ if (s?.Length is not Sha1CharCount || !TryParse(s.AsSpan(), out ObjectId? id))
+ {
+ throw new FormatException($"Unable to parse object ID \"{s}\".");
+ }
+
+ return id;
+ }
+
+ ///
+ /// Parses an from a regex that was produced by matching against .
+ ///
+ ///
+ /// This method avoids the temporary string created by calling .
+ /// For parsing to succeed, must be a valid 40-character SHA-1 string.
+ ///
+ /// The string that the regex was produced from.
+ /// The regex capture/group that describes the location of the SHA-1 hash within .
+ /// The parsed .
+ /// did not contain a valid 40-character SHA-1 hash.
+ [Pure]
+ public static ObjectId Parse(string s, Capture capture)
+ {
+ if (s is null || capture?.Length is not Sha1CharCount || !TryParse(s.AsSpan(capture.Index, capture.Length), out ObjectId? id))
+ {
+ throw new FormatException($"Unable to parse object ID \"{s}\".");
+ }
+
+ return id;
+ }
+
+ ///
+ /// Attempts to parse an from .
+ ///
+ ///
+ /// For parsing to succeed, must be a valid 40-character SHA-1 string.
+ /// Any extra characters at the end will cause parsing to fail, unlike for
+ /// overload .
+ ///
+ /// The string to try parsing from.
+ /// The parsed , or null if parsing was unsuccessful.
+ /// true if parsing was successful, otherwise false.
+ public static bool TryParse(string? s, [NotNullWhen(returnValue: true)] out ObjectId? objectId)
+ {
+ if (s is null)
+ {
+ objectId = default;
+ return false;
+ }
+
+ return TryParse(s.AsSpan(), out objectId);
+ }
+
+ ///
+ /// Attempts to parse an from , starting at .
+ ///
+ ///
+ /// For parsing to succeed, must contain a valid 40-character SHA-1 starting at .
+ /// Any extra characters before or after this substring will be ignored, unlike for
+ /// overload .
+ ///
+ /// The string to try parsing from.
+ /// The position within to start parsing from.
+ /// The parsed , or null if parsing was unsuccessful.
+ /// true if parsing was successful, otherwise false.
+ public static bool TryParse(string? s, int offset, [NotNullWhen(returnValue: true)] out ObjectId? objectId)
+ {
+ if (s is null || s.Length - offset < Sha1CharCount)
+ {
+ objectId = default;
+ return false;
+ }
+
+ return TryParse(s.AsSpan(offset, Sha1CharCount), out objectId);
+ }
+
+ ///
+ /// Parses an from a span of chars .
+ ///
+ ///
+ /// This method reads human-readable chars.
+ /// Several git commands emit them in this form.
+ /// For parsing to succeed, must contain 40 chars.
+ ///
+ /// The char span to parse.
+ /// The parsed .
+ /// true if parsing succeeded, otherwise false.
+ [Pure]
+ [SuppressMessage("Style", "IDE0057:Use range operator", Justification = "Performance")]
+ public static bool TryParse(in ReadOnlySpan array, [NotNullWhen(returnValue: true)] out ObjectId? objectId)
+ {
+ if (array.Length != Sha1CharCount)
+ {
+ objectId = default;
+ return false;
+ }
+
+ if (!ulong.TryParse(array.Slice(0, 16), NumberStyles.AllowHexSpecifier, provider: null, out ulong i1)
+ || !ulong.TryParse(array.Slice(16, 16), NumberStyles.AllowHexSpecifier, provider: null, out ulong i2)
+ || !uint.TryParse(array.Slice(32, 8), NumberStyles.AllowHexSpecifier, provider: null, out uint i3))
+ {
+ objectId = default;
+ return false;
+ }
+
+ objectId = new ObjectId(i1, i2, i3);
+ return true;
+ }
+
+ ///
+ /// Parses an from a span of bytes containing ASCII characters.
+ ///
+ ///
+ /// This method reads human-readable ASCII-encoded bytes (more verbose than raw values).
+ /// Several git commands emit them in this form.
+ /// For parsing to succeed, must contain 40 bytes.
+ ///
+ /// The byte span to parse.
+ /// The parsed .
+ /// true if parsing succeeded, otherwise false.
+ [Pure]
+ [SuppressMessage("Style", "IDE0057:Use range operator", Justification = "Performance")]
+ public static bool TryParse(in ReadOnlySpan array, [NotNullWhen(returnValue: true)] out ObjectId? objectId)
+ {
+ if (array.Length != Sha1CharCount)
+ {
+ objectId = default;
+ return false;
+ }
+
+ if (!Utf8Parser.TryParse(array.Slice(0, 16), out ulong i1, out int _, standardFormat: 'X')
+ || !Utf8Parser.TryParse(array.Slice(16, 16), out ulong i2, out int _, standardFormat: 'X')
+ || !Utf8Parser.TryParse(array.Slice(32, 8), out uint i3, out int _, standardFormat: 'X'))
+ {
+ objectId = default;
+ return false;
+ }
+
+ objectId = new ObjectId(i1, i2, i3);
+ return true;
+ }
+
+ #endregion
+
+ ///
+ /// Identifies whether contains a valid 40-character SHA-1 hash.
+ ///
+ /// The string to validate.
+ /// true if is a valid SHA-1 hash, otherwise false.
+ [Pure]
+ public static bool IsValid(string s) => s.Length == Sha1CharCount && IsValidCharacters(s);
+
+ ///
+ /// Identifies whether contains between and 40 valid SHA-1 hash characters.
+ ///
+ /// The string to validate.
+ /// true if is a valid partial SHA-1 hash, otherwise false.
+ [Pure]
+ public static bool IsValidPartial(string s, int minLength) => s.Length >= minLength && s.Length <= Sha1CharCount && IsValidCharacters(s);
+
+ private static bool IsValidCharacters(string s)
+ {
+ // ReSharper disable once LoopCanBeConvertedToQuery
+ // ReSharper disable once ForCanBeConvertedToForeach
+ for (int i = 0; i < s.Length; i++)
+ {
+ char c = s[i];
+ if (!char.IsDigit(c) && (c < 'a' || c > 'f'))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private readonly ulong _i1;
+ private readonly ulong _i2;
+ private readonly uint _i3;
+
+ private ObjectId(ulong i1, ulong i2, uint i3)
+ {
+ _i1 = i1;
+ _i2 = i2;
+ _i3 = i3;
+ }
+
+ #region IComparable
+
+ public int CompareTo(ObjectId? other)
+ {
+ int result = _i1.CompareTo(other?._i1);
+ if (result != 0)
+ {
+ return result;
+ }
+
+ result = _i2.CompareTo(other?._i2);
+ if (result != 0)
+ {
+ return result;
+ }
+
+ return _i3.CompareTo(other?._i3);
+ }
+
+ #endregion
+
+ ///
+ /// Returns the SHA-1 hash.
+ ///
+ public override string ToString()
+ {
+ return ToShortString(Sha1CharCount);
+ }
+
+ ///
+ /// Returns the first characters of the SHA-1 hash.
+ ///
+ /// The length of the returned string. Defaults to 8.
+ /// is less than zero, or more than 40.
+ [Pure]
+ [SuppressMessage("Style", "IDE0057:Use range operator", Justification = "Performance")]
+ public unsafe string ToShortString(int length = 8)
+ {
+ if (length < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(length), length, "Cannot be less than zero.");
+ }
+
+ if (length > Sha1CharCount)
+ {
+ throw new ArgumentOutOfRangeException(nameof(length), length, $"Cannot be greater than {Sha1CharCount}.");
+ }
+
+ Span buffer = stackalloc byte[_sha1ByteCount];
+
+ BinaryPrimitives.WriteUInt64BigEndian(buffer.Slice(0, 8), _i1);
+ BinaryPrimitives.WriteUInt64BigEndian(buffer.Slice(8, 8), _i2);
+ BinaryPrimitives.WriteUInt32BigEndian(buffer.Slice(16, 4), _i3);
+
+ return Convert.ToHexString(buffer).Substring(0, length).ToLowerInvariant();
+ }
+
+ #region Equality and hashing
+
+ ///
+ public bool Equals(ObjectId? other)
+ {
+ return other is not null &&
+ _i1 == other._i1 &&
+ _i2 == other._i2 &&
+ _i3 == other._i3;
+ }
+
+ ///
+ public override bool Equals(object? obj) => obj is ObjectId id && Equals(id);
+
+ ///
+ public override int GetHashCode() => unchecked((int)_i2);
+
+ public static bool operator ==(ObjectId? left, ObjectId? right) => Equals(left, right);
+ public static bool operator !=(ObjectId? left, ObjectId? right) => !Equals(left, right);
+
+ #endregion
+}
diff --git a/src/GitExtensions.Extensibility/Git/Patch.cs b/src/GitExtensions.Extensibility/Git/Patch.cs
new file mode 100644
index 0000000..94f287f
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/Patch.cs
@@ -0,0 +1,57 @@
+namespace GitExtensions.Extensibility.Git;
+
+public sealed class Patch
+{
+ public string Header { get; }
+
+ public string? Index { get; }
+
+ public PatchFileType FileType { get; }
+
+ public string FileNameA { get; }
+
+ public string? FileNameB { get; }
+
+ public bool IsCombinedDiff { get; }
+
+ public PatchChangeType ChangeType { get; }
+
+ public string? Text { get; }
+
+ public Patch(
+ string header,
+ string? index,
+ PatchFileType fileType,
+ string fileNameA,
+ string? fileNameB,
+ bool isCombinedDiff,
+ PatchChangeType changeType,
+ string? text)
+ {
+ ArgumentNullException.ThrowIfNull(nameof(header));
+ ArgumentNullException.ThrowIfNull(nameof(fileNameA));
+
+ Header = header;
+ Index = index;
+ FileType = fileType;
+ FileNameA = fileNameA;
+ FileNameB = fileNameB;
+ IsCombinedDiff = isCombinedDiff;
+ ChangeType = changeType;
+ Text = text;
+ }
+}
+
+public enum PatchChangeType
+{
+ NewFile,
+ DeleteFile,
+ ChangeFile,
+ ChangeFileMode
+}
+
+public enum PatchFileType
+{
+ Binary,
+ Text
+}
diff --git a/src/GitExtensions.Extensibility/Git/Remote.cs b/src/GitExtensions.Extensibility/Git/Remote.cs
new file mode 100644
index 0000000..b7456a9
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/Remote.cs
@@ -0,0 +1,17 @@
+namespace GitExtensions.Extensibility.Git;
+
+public readonly struct Remote
+{
+ public string Name { get; }
+ public string FetchUrl { get; }
+ public List PushUrls { get; }
+
+ public Remote(string name, string fetchUrl, string? firstPushUrl)
+ {
+ Name = name ?? throw new ArgumentNullException(nameof(name));
+ FetchUrl = fetchUrl ?? throw new ArgumentNullException(nameof(fetchUrl));
+
+ // At least one push URL must be added
+ PushUrls = firstPushUrl is not null ? [firstPushUrl] : throw new ArgumentNullException(nameof(firstPushUrl));
+ }
+}
diff --git a/src/GitExtensions.Extensibility/Git/ResetMode.cs b/src/GitExtensions.Extensibility/Git/ResetMode.cs
new file mode 100644
index 0000000..21a742b
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/ResetMode.cs
@@ -0,0 +1,25 @@
+namespace GitExtensions.Extensibility.Git;
+
+/// Arguments to 'git reset'.
+public enum ResetMode
+{
+ /// (no option)
+ ResetIndex = 0,
+
+ /// --soft
+ Soft,
+
+ /// --mixed
+ Mixed,
+
+ /// --hard
+ Hard,
+
+ /// --merge
+ Merge,
+
+ /// --keep
+ Keep
+
+ // All options are not implemented, like --patch
+}
diff --git a/src/GitExtensions.Extensibility/Git/SubmoduleStatus.cs b/src/GitExtensions.Extensibility/Git/SubmoduleStatus.cs
new file mode 100644
index 0000000..38feabb
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/SubmoduleStatus.cs
@@ -0,0 +1,12 @@
+namespace GitExtensions.Extensibility.Git;
+
+public enum SubmoduleStatus
+{
+ Unknown = 0,
+ NewSubmodule,
+ FastForward,
+ Rewind,
+ NewerTime,
+ OlderTime,
+ SameTime
+}
diff --git a/src/GitExtensions.Extensibility/Git/UntrackedFilesMode.cs b/src/GitExtensions.Extensibility/Git/UntrackedFilesMode.cs
new file mode 100644
index 0000000..a4d95cb
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Git/UntrackedFilesMode.cs
@@ -0,0 +1,26 @@
+namespace GitExtensions.Extensibility.Git;
+
+/// Specifies whether to check untracked files/directories (e.g. via 'git status')
+public enum UntrackedFilesMode
+{
+ ///
+ /// Default is ; when is NOT used,
+ /// git status' uses .
+ ///
+ Default = 0,
+
+ ///
+ /// Show no untracked files.
+ ///
+ No,
+
+ ///
+ /// Shows untracked files and directories.
+ ///
+ Normal,
+
+ ///
+ /// Shows untracked files and directories, and individual files in untracked directories.
+ ///
+ All
+}
diff --git a/src/GitExtensions.Extensibility/GitConfigFormatException.cs b/src/GitExtensions.Extensibility/GitConfigFormatException.cs
new file mode 100644
index 0000000..57f4163
--- /dev/null
+++ b/src/GitExtensions.Extensibility/GitConfigFormatException.cs
@@ -0,0 +1,19 @@
+namespace GitExtensions.Extensibility;
+
+///
+/// The exception that is thrown when a git setting value is converted in an incompatible format.
+///
+public class GitConfigFormatException : Exception
+{
+ public GitConfigFormatException()
+ {
+ }
+
+ public GitConfigFormatException(string? message) : base(message)
+ {
+ }
+
+ public GitConfigFormatException(string? message, Exception? innerException) : base(message, innerException)
+ {
+ }
+}
diff --git a/src/GitExtensions.Extensibility/GitExtensions.Extensibility.csproj b/src/GitExtensions.Extensibility/GitExtensions.Extensibility.csproj
new file mode 100644
index 0000000..3066529
--- /dev/null
+++ b/src/GitExtensions.Extensibility/GitExtensions.Extensibility.csproj
@@ -0,0 +1,8 @@
+
+
+
+
+ false
+
+
+
diff --git a/src/GitExtensions.Extensibility/IBrowseRepo.cs b/src/GitExtensions.Extensibility/IBrowseRepo.cs
new file mode 100644
index 0000000..00ee67d
--- /dev/null
+++ b/src/GitExtensions.Extensibility/IBrowseRepo.cs
@@ -0,0 +1,13 @@
+using GitExtensions.Extensibility.Git;
+using GitUIPluginInterfaces;
+
+namespace GitExtensions.Extensibility;
+
+public interface IBrowseRepo
+{
+ GitRevision? GetLatestSelectedRevision();
+ IReadOnlyList GetSelectedRevisions();
+ Point GetQuickItemSelectorLocation();
+ void GoToRef(string refName, bool showNoRevisionMsg, bool toggleSelection = false);
+ void SetWorkingDir(string? path, ObjectId? selectedId = null, ObjectId? firstId = null);
+}
diff --git a/src/GitExtensions.Extensibility/IExecutable.cs b/src/GitExtensions.Extensibility/IExecutable.cs
new file mode 100644
index 0000000..3a88187
--- /dev/null
+++ b/src/GitExtensions.Extensibility/IExecutable.cs
@@ -0,0 +1,36 @@
+using System.Diagnostics.Contracts;
+using System.Text;
+
+namespace GitExtensions.Extensibility;
+
+///
+/// Defines an executable that can be launched to create processes.
+///
+public interface IExecutable
+{
+ ///
+ /// Starts a process of this executable.
+ ///
+ ///
+ /// This is a low level means of starting a process. Most code will want to use one of the extension methods
+ /// provided by ExecutableExtensions.
+ ///
+ /// Any command line arguments to be passed to the executable when it is started.
+ /// Whether to create a window for the process or not.
+ /// Whether the standard input stream of the process will be written to.
+ /// Whether the standard output stream of the process will be read from.
+ /// The to use when interpreting standard output and standard
+ /// error, or null if is false.
+ /// The value for the flag ProcessStartInfo.UseShellExecute.
+ /// A flag configuring whether to throw an exception if the exit code is not 0.
+ /// The started process.
+ [Pure]
+ IProcess Start(ArgumentString arguments = default,
+ bool createWindow = false,
+ bool redirectInput = false,
+ bool redirectOutput = false,
+ Encoding? outputEncoding = null,
+ bool useShellExecute = false,
+ bool throwOnErrorExit = true,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/GitExtensions.Extensibility/IFullPathResolver.cs b/src/GitExtensions.Extensibility/IFullPathResolver.cs
new file mode 100644
index 0000000..ebe353f
--- /dev/null
+++ b/src/GitExtensions.Extensibility/IFullPathResolver.cs
@@ -0,0 +1,18 @@
+using GitExtensions.Extensibility.Git;
+
+namespace GitExtensions.Extensibility;
+
+///
+/// Provides the ability to resolve full path.
+///
+public interface IFullPathResolver
+{
+ ///
+ /// Resolves the provided path (folder or file) against the current working directory.
+ ///
+ /// Folder or file path to resolve.
+ ///
+ /// if is rooted; otherwise resolved path from .
+ ///
+ string? Resolve(string? path);
+}
diff --git a/src/GitExtensions.Extensibility/ILockableNotifier.cs b/src/GitExtensions.Extensibility/ILockableNotifier.cs
new file mode 100644
index 0000000..dd8fe07
--- /dev/null
+++ b/src/GitExtensions.Extensibility/ILockableNotifier.cs
@@ -0,0 +1,26 @@
+namespace GitExtensions.Extensibility;
+
+public interface ILockableNotifier
+{
+ ///
+ /// notifies if is unlocked
+ ///
+ void Notify();
+
+ ///
+ /// locks raising notification
+ ///
+ void Lock();
+
+ ///
+ /// unlocks raising notification
+ /// to unlock raising notification, UnLock has to be called as many times as Lock was called
+ ///
+ /// true if Notify has to be called
+ void UnLock(bool requestNotify);
+
+ ///
+ /// true if raising notification is locked
+ ///
+ bool IsLocked { get; }
+}
diff --git a/src/GitExtensions.Extensibility/IProcess.cs b/src/GitExtensions.Extensibility/IProcess.cs
new file mode 100644
index 0000000..29e0525
--- /dev/null
+++ b/src/GitExtensions.Extensibility/IProcess.cs
@@ -0,0 +1,72 @@
+using System.Diagnostics;
+
+namespace GitExtensions.Extensibility;
+
+///
+/// Defines a process instance.
+///
+///
+/// This process will either be running or exited.
+///
+public interface IProcess : IDisposable
+{
+ ///
+ /// Gets an object that facilitates writing to the process's standard input stream.
+ ///
+ ///
+ /// To access the underlying , dereference .
+ ///
+ /// This process's input was not redirected
+ /// when calling .
+ StreamWriter StandardInput { get; }
+
+ ///
+ /// Gets an object that facilitates writing to the process's standard output stream.
+ ///
+ ///
+ /// To access the underlying , dereference .
+ ///
+ /// This process's output was not redirected
+ /// when calling .
+ StreamReader StandardOutput { get; }
+
+ ///
+ /// Gets an object that facilitates writing to the process's standard error stream.
+ ///
+ ///
+ /// To access the underlying , dereference .
+ ///
+ /// This process's output was not redirected
+ /// when calling .
+ StreamReader StandardError { get; }
+
+ ///
+ /// Kill the process at once.
+ ///
+ /// Specifies whether to kill all child processes, too.
+ void Kill(bool entireProcessTree = false);
+
+ ///
+ /// Blocks the calling thread until the process exits, or when this object is disposed.
+ ///
+ /// The process's exit code, or null if this object was disposed before the process exited.
+ int WaitForExit();
+
+ ///
+ /// Returns a task that completes when the process exits, or when this object is disposed.
+ ///
+ /// A task that yields the process's exit code, or throws an exception if this object was disposed before the process exited.
+ Task WaitForExitAsync();
+
+ ///
+ /// Returns a cancellable task that completes when the process exits, or when this object is disposed.
+ ///
+ /// A task that yields the process's exit code, or throws an exception if this object was disposed before the process exited.
+ Task WaitForExitAsync(CancellationToken token);
+
+ ///
+ /// Waits for the process to reach an idle state.
+ ///
+ ///
+ void WaitForInputIdle();
+}
diff --git a/src/GitExtensions.Extensibility/LazyStringSplit.cs b/src/GitExtensions.Extensibility/LazyStringSplit.cs
new file mode 100644
index 0000000..0125aff
--- /dev/null
+++ b/src/GitExtensions.Extensibility/LazyStringSplit.cs
@@ -0,0 +1,170 @@
+using System.Collections;
+using Microsoft;
+
+namespace GitExtensions.Extensibility;
+
+///
+/// Splits a string by a delimiter, producing substrings lazily during enumeration.
+///
+///
+/// Unlike and overloads,
+/// does not allocate an array for the return, and allocates strings on demand during
+/// enumeration. A custom enumerator type is used so that the only allocations made are
+/// the substrings themselves. We also avoid the large internal arrays assigned by the
+/// methods on .
+///
+public readonly struct LazyStringSplit : IEnumerable
+{
+ private readonly string _input;
+ private readonly char _delimiter;
+ private readonly StringSplitOptions _options;
+
+ public LazyStringSplit(string input, char delimiter, StringSplitOptions options = StringSplitOptions.None)
+ {
+ Requires.NotNull(input, nameof(input));
+
+ _input = input;
+ _delimiter = delimiter;
+ _options = options;
+ }
+
+ public Enumerator GetEnumerator() => new(this, _options);
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+
+ public struct Enumerator : IEnumerator
+ {
+ private readonly StringSplitOptions _options;
+ private readonly string _input;
+ private readonly char _delimiter;
+ private int _index;
+
+ internal Enumerator(in LazyStringSplit split, StringSplitOptions options)
+ {
+ _options = options;
+ _index = 0;
+ _input = split._input;
+ _delimiter = split._delimiter;
+ Current = null!;
+ }
+
+ public string Current { get; private set; }
+
+ public bool MoveNext()
+ {
+ while (_index < _input.Length)
+ {
+ int delimiterIndex = _input.IndexOf(_delimiter, _index);
+
+ if (delimiterIndex == -1)
+ {
+ Current = _input[_index..];
+ _index = _input.Length + 1;
+ return true;
+ }
+
+ int length = delimiterIndex - _index;
+
+ if (length == 0)
+ {
+ _index++;
+ if (_options == StringSplitOptions.RemoveEmptyEntries)
+ {
+ continue;
+ }
+ else
+ {
+ Current = "";
+ return true;
+ }
+ }
+
+ Current = _input.Substring(_index, length);
+ _index = delimiterIndex + 1;
+
+ return true;
+ }
+
+ if (_options == StringSplitOptions.None && _index == _input.Length)
+ {
+ _index++;
+ Current = "";
+ return true;
+ }
+
+ return false;
+ }
+
+ object IEnumerator.Current => Current;
+
+ void IEnumerator.Reset()
+ {
+ _index = 0;
+ Current = null!;
+ }
+
+ void IDisposable.Dispose()
+ {
+ }
+ }
+}
+
+public static class LazyStringSplitExtensions
+{
+ public static LazyStringSplit LazySplit(this string s, char delimiter, StringSplitOptions options = StringSplitOptions.None)
+ {
+ return new(s, delimiter, options);
+ }
+
+ public static IEnumerable Select(this LazyStringSplit split, Func func)
+ {
+ foreach (string value in split)
+ {
+ yield return func(value);
+ }
+ }
+
+ public static string First(this LazyStringSplit split)
+ {
+ return split.FirstOrDefault() ?? throw new InvalidOperationException("Sequence is empty.");
+ }
+
+ public static string? FirstOrDefault(this LazyStringSplit split)
+ {
+ using LazyStringSplit.Enumerator enumerator = split.GetEnumerator();
+ return enumerator.MoveNext() ? enumerator.Current : null;
+ }
+
+ public static string? LastOrDefault(this LazyStringSplit split)
+ {
+ using LazyStringSplit.Enumerator enumerator = split.GetEnumerator();
+
+ if (!enumerator.MoveNext())
+ {
+ return null;
+ }
+
+ while (true)
+ {
+ string last = enumerator.Current;
+
+ if (!enumerator.MoveNext())
+ {
+ return last;
+ }
+ }
+ }
+
+ public static IEnumerable Where(this LazyStringSplit split, Func predicate)
+ {
+ foreach (string s in split)
+ {
+ if (predicate(s))
+ {
+ yield return s;
+ }
+ }
+ }
+}
diff --git a/src/GitExtensions.Extensibility/Plugins/IGitPlugin.cs b/src/GitExtensions.Extensibility/Plugins/IGitPlugin.cs
new file mode 100644
index 0000000..58e9483
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Plugins/IGitPlugin.cs
@@ -0,0 +1,30 @@
+using GitExtensions.Extensibility.Git;
+using GitExtensions.Extensibility.Settings;
+
+namespace GitExtensions.Extensibility.Plugins;
+
+public interface IGitPlugin
+{
+ Guid Id { get; }
+
+ string? Name { get; }
+
+ string? Description { get; }
+
+ Image? Icon { get; }
+
+ IGitPluginSettingsContainer? SettingsContainer { get; set; }
+
+ bool HasSettings { get; }
+
+ IEnumerable GetSettings();
+
+ void Register(IGitUICommands gitUiCommands);
+
+ void Unregister(IGitUICommands gitUiCommands);
+
+ ///
+ /// Runs the plugin and returns whether the RevisionGrid should be refreshed.
+ ///
+ bool Execute(GitUIEventArgs args);
+}
diff --git a/src/GitExtensions.Extensibility/Plugins/IGitPluginForRepository.cs b/src/GitExtensions.Extensibility/Plugins/IGitPluginForRepository.cs
new file mode 100644
index 0000000..aa30d4b
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Plugins/IGitPluginForRepository.cs
@@ -0,0 +1,5 @@
+namespace GitExtensions.Extensibility.Plugins;
+
+public interface IGitPluginForRepository : IGitPlugin
+{
+}
diff --git a/src/GitExtensions.Extensibility/Plugins/IGitPluginSettingsContainer.cs b/src/GitExtensions.Extensibility/Plugins/IGitPluginSettingsContainer.cs
new file mode 100644
index 0000000..6d57738
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Plugins/IGitPluginSettingsContainer.cs
@@ -0,0 +1,10 @@
+using GitExtensions.Extensibility.Settings;
+
+namespace GitExtensions.Extensibility.Plugins;
+
+public interface IGitPluginSettingsContainer
+{
+ SettingsSource GetSettingsSource();
+
+ void SetSettingsSource(SettingsSource? settingsSource);
+}
diff --git a/src/GitExtensions.Extensibility/Properties/AssemblyInfo.cs b/src/GitExtensions.Extensibility/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000..afa858f
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Properties/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("GitExtensions.Extensibility.Tests")]
diff --git a/src/GitExtensions.Extensibility/RefsFilter.cs b/src/GitExtensions.Extensibility/RefsFilter.cs
new file mode 100644
index 0000000..e2f2163
--- /dev/null
+++ b/src/GitExtensions.Extensibility/RefsFilter.cs
@@ -0,0 +1,22 @@
+namespace GitExtensions.Extensibility;
+
+///
+/// Enums requestable in GetRefs() (multiple names can be appended)
+/// Compare to for actual values of parsed GitRefs
+///
+[Flags]
+public enum RefsFilter
+{
+ // No filter, include those below but also (at least) stash, notes and bisect
+ // Note that if NoFilter is combined with a filter, the filter takes precedence
+ NoFilter = 0,
+
+ // Local Branches
+ Heads = 1 << 0,
+
+ // Remote branches
+ Remotes = 1 << 1,
+
+ // Tags
+ Tags = 1 << 2
+}
diff --git a/src/GitExtensions.Extensibility/Settings/IDetachedSettings.cs b/src/GitExtensions.Extensibility/Settings/IDetachedSettings.cs
new file mode 100644
index 0000000..c1f81da
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Settings/IDetachedSettings.cs
@@ -0,0 +1,8 @@
+namespace GitExtensions.Extensibility.Settings;
+
+public interface IDetachedSettings
+{
+ string Dictionary { get; set; }
+
+ bool NoFastForwardMerge { get; set; }
+}
diff --git a/src/GitExtensions.Extensibility/Settings/IDetailedSettings.cs b/src/GitExtensions.Extensibility/Settings/IDetailedSettings.cs
new file mode 100644
index 0000000..8a31782
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Settings/IDetailedSettings.cs
@@ -0,0 +1,16 @@
+namespace GitExtensions.Extensibility.Settings;
+
+public interface IDetailedSettings
+{
+ string SmtpServer { get; set; }
+
+ int SmtpPort { get; set; }
+
+ bool SmtpUseSsl { get; set; }
+
+ bool GetRemoteBranchesDirectlyFromRemote { get; set; }
+
+ bool AddMergeLogMessages { get; set; }
+
+ int MergeLogMessagesCount { get; set; }
+}
diff --git a/src/GitExtensions.Extensibility/Settings/ISetting.cs b/src/GitExtensions.Extensibility/Settings/ISetting.cs
new file mode 100644
index 0000000..c60c728
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Settings/ISetting.cs
@@ -0,0 +1,16 @@
+namespace GitExtensions.Extensibility.Settings;
+
+public interface ISetting
+{
+ ///
+ /// Name of the setting
+ ///
+ string Name { get; }
+
+ ///
+ /// Caption of the setting
+ ///
+ string Caption { get; }
+
+ ISettingControlBinding CreateControlBinding();
+}
diff --git a/src/GitExtensions.Extensibility/Settings/ISettingControlBinding.cs b/src/GitExtensions.Extensibility/Settings/ISettingControlBinding.cs
new file mode 100644
index 0000000..cafa06a
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Settings/ISettingControlBinding.cs
@@ -0,0 +1,28 @@
+namespace GitExtensions.Extensibility.Settings;
+
+public interface ISettingControlBinding
+{
+ ///
+ /// Creates a control to be placed on FormSettings to edit this setting value
+ /// Control should take care of scalability and resizability of its sub-controls
+ ///
+ Control GetControl();
+
+ ///
+ /// Loads setting value from settings to Control
+ ///
+ void LoadSetting(SettingsSource settings);
+
+ ///
+ /// Saves value from Control to settings
+ ///
+ void SaveSetting(SettingsSource settings);
+
+ ///
+ /// returns caption associated with this control or null if the control layouts
+ /// the caption by itself
+ ///
+ string Caption();
+
+ ISetting GetSetting();
+}
diff --git a/src/GitExtensions.Extensibility/Settings/ISettingsValueGetter.cs b/src/GitExtensions.Extensibility/Settings/ISettingsValueGetter.cs
new file mode 100644
index 0000000..ef008ab
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Settings/ISettingsValueGetter.cs
@@ -0,0 +1,17 @@
+namespace GitExtensions.Extensibility.Settings;
+
+public interface ISettingsValueGetter
+{
+ string GetValue(string setting);
+
+ ///
+ /// Gets the config setting from git converted in an expected C# value type (bool, int, etc.).
+ ///
+ /// The expected type of the git setting.
+ /// The git setting key.
+ /// The value converted to the type; if the settings is not set.
+ ///
+ /// The value of the git setting cannot be converted in the specified type .
+ ///
+ T? GetValue(string setting) where T : struct;
+}
diff --git a/src/GitExtensions.Extensibility/Settings/NumberSetting.cs b/src/GitExtensions.Extensibility/Settings/NumberSetting.cs
new file mode 100644
index 0000000..12ce14c
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Settings/NumberSetting.cs
@@ -0,0 +1,182 @@
+namespace GitExtensions.Extensibility.Settings;
+
+public class NumberSetting : ISetting
+{
+ public NumberSetting(string name, T defaultValue)
+ : this(name, name, defaultValue)
+ {
+ }
+
+ public NumberSetting(string name, string caption, T defaultValue)
+ {
+ Name = name;
+ Caption = caption;
+ DefaultValue = defaultValue;
+ }
+
+ public string Name { get; }
+ public string Caption { get; }
+ public T DefaultValue { get; }
+ public Control? CustomControl { get; set; }
+
+ public ISettingControlBinding CreateControlBinding()
+ {
+ if (typeof(T) == typeof(int))
+ {
+ return new NumericUpDownBinding(this as NumberSetting, CustomControl as NumericUpDown);
+ }
+ else
+ {
+ return new TextBoxBinding(this, CustomControl as TextBox);
+ }
+ }
+
+ // TODO: honestly, NumericUpDownBinding might be a better choice than TextBox in general since its internal type is `decimal`.
+ // We would just need to appropriately choose an increment based on NumberSetting's type.
+ private class NumericUpDownBinding : SettingControlBinding, NumericUpDown>
+ {
+ public NumericUpDownBinding(NumberSetting setting, NumericUpDown? customControl)
+ : base(setting, customControl)
+ {
+ }
+
+ public override NumericUpDown CreateControl()
+ {
+ NumericUpDown numericUpDown = new();
+
+ // TODO: if we need negative values, int.MinValue should be the Minimum.
+ // Or, we can attempt to introduce a NumberSetting constructor that accepts a min and max value parameter.
+ numericUpDown.Minimum = 0;
+ numericUpDown.Maximum = int.MaxValue;
+
+ Setting.CustomControl = numericUpDown;
+ return Setting.CustomControl as NumericUpDown;
+ }
+
+ public override void LoadSetting(SettingsSource settings, NumericUpDown control)
+ {
+ control.Value = Setting.ValueOrDefault(settings);
+ }
+
+ public override void SaveSetting(SettingsSource settings, NumericUpDown control)
+ {
+ decimal controlValue = control.Value;
+
+ if (Setting.ValueOrDefault(settings) == controlValue)
+ {
+ return;
+ }
+
+ Setting[settings] = controlValue;
+ }
+ }
+
+ private class TextBoxBinding : SettingControlBinding, TextBox>
+ {
+ public TextBoxBinding(NumberSetting setting, TextBox? customControl)
+ : base(setting, customControl)
+ {
+ }
+
+ public override TextBox CreateControl()
+ {
+ Setting.CustomControl = new TextBox();
+ return Setting.CustomControl as TextBox;
+ }
+
+ public override void LoadSetting(SettingsSource settings, TextBox control)
+ {
+ object? settingVal = settings.SettingLevel == SettingLevel.Effective
+ ? Setting.ValueOrDefault(settings)
+ : Setting[settings];
+
+ control.Text = ConvertToString(settingVal);
+ }
+
+ public override void SaveSetting(SettingsSource settings, TextBox control)
+ {
+ string controlValue = control.Text;
+
+ if (settings.SettingLevel == SettingLevel.Effective)
+ {
+ if (ConvertToString(Setting.ValueOrDefault(settings)) == controlValue)
+ {
+ return;
+ }
+ }
+
+ Setting[settings] = ConvertFromString(controlValue);
+ }
+ }
+
+ private static string ConvertToString(object? value)
+ {
+ if (value is null)
+ {
+ return string.Empty;
+ }
+
+ return value.ToString();
+ }
+
+ private static object? ConvertFromString(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return null;
+ }
+
+ Type type = typeof(T);
+ if (type == typeof(int))
+ {
+ return int.Parse(value);
+ }
+
+ if (type == typeof(float))
+ {
+ return float.Parse(value);
+ }
+
+ if (type == typeof(double))
+ {
+ return double.Parse(value);
+ }
+
+ if (type == typeof(long))
+ {
+ return long.Parse(value);
+ }
+
+ return null;
+ }
+
+ public object? this[SettingsSource settings]
+ {
+ get
+ {
+ string? stringValue = settings.GetValue(Name);
+
+ return ConvertFromString(stringValue);
+ }
+
+ set
+ {
+ string? stringValue = ConvertToString(value);
+
+ settings.SetValue(Name, stringValue);
+ }
+ }
+
+ public T ValueOrDefault(SettingsSource settings)
+ {
+ object? settingVal = this[settings];
+ if (settingVal is null)
+ {
+ return DefaultValue;
+ }
+ else
+ {
+ return (T)settingVal;
+ }
+ }
+}
diff --git a/src/GitExtensions.Extensibility/Settings/PasswordSetting.cs b/src/GitExtensions.Extensibility/Settings/PasswordSetting.cs
new file mode 100644
index 0000000..3079d7b
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Settings/PasswordSetting.cs
@@ -0,0 +1,74 @@
+namespace GitExtensions.Extensibility.Settings;
+
+public class PasswordSetting : ISetting
+{
+ public PasswordSetting(string name, string defaultValue)
+ : this(name, name, defaultValue)
+ {
+ }
+
+ public PasswordSetting(string name, string caption, string defaultValue)
+ {
+ Name = name;
+ Caption = caption;
+ DefaultValue = defaultValue;
+ }
+
+ public string Name { get; }
+ public string Caption { get; }
+ public string DefaultValue { get; }
+ public TextBox? CustomControl { get; set; }
+
+ public ISettingControlBinding CreateControlBinding()
+ {
+ return new TextBoxBinding(this, CustomControl);
+ }
+
+ private class TextBoxBinding : SettingControlBinding
+ {
+ public TextBoxBinding(PasswordSetting setting, TextBox? customControl)
+ : base(setting, customControl)
+ {
+ }
+
+ public override TextBox CreateControl()
+ {
+ Setting.CustomControl = new TextBox { PasswordChar = '\u25CF' };
+ return Setting.CustomControl;
+ }
+
+ public override void LoadSetting(SettingsSource settings, TextBox control)
+ {
+ string? settingVal = settings.SettingLevel == SettingLevel.Effective
+ ? Setting.ValueOrDefault(settings)
+ : Setting[settings];
+
+ control.Text = settingVal;
+ }
+
+ public override void SaveSetting(SettingsSource settings, TextBox control)
+ {
+ string controlValue = control.Text;
+ if (settings.SettingLevel == SettingLevel.Effective)
+ {
+ if (Setting.ValueOrDefault(settings) == controlValue)
+ {
+ return;
+ }
+ }
+
+ Setting[settings] = controlValue;
+ }
+ }
+
+ public string? this[SettingsSource settings]
+ {
+ get => settings.GetString(Name, null);
+ set => settings.SetString(Name, value);
+ }
+
+ public string ValueOrDefault(SettingsSource settings)
+ {
+ return this[settings] ?? DefaultValue;
+ }
+}
diff --git a/src/GitExtensions.Extensibility/Settings/PseudoSetting.cs b/src/GitExtensions.Extensibility/Settings/PseudoSetting.cs
new file mode 100644
index 0000000..f11e28c
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Settings/PseudoSetting.cs
@@ -0,0 +1,73 @@
+using Microsoft;
+
+namespace GitExtensions.Extensibility.Settings;
+
+///
+/// Not a real setting (as it save no setting value). It is used to display a control that is not a setting (linklabel, text,...)
+///
+public class PseudoSetting : ISetting
+{
+ private readonly Func? _textBoxCreator;
+
+ public PseudoSetting(Control control, string caption = "")
+ {
+ Caption = caption;
+ CustomControl = control;
+ }
+
+ public PseudoSetting(string text, string caption = " ", int? height = null, Action? textboxSettings = null)
+ {
+ Caption = caption;
+
+ _textBoxCreator = () =>
+ {
+ TextBox textbox = new() { ReadOnly = true, BorderStyle = BorderStyle.None, Text = text };
+
+ if (height.HasValue)
+ {
+ textbox.Multiline = true;
+ textbox.Height = height.Value;
+ }
+
+ textboxSettings?.Invoke(textbox);
+ return textbox;
+ };
+
+ CustomControl = _textBoxCreator();
+ }
+
+ public string Name { get; } = "PseusoSetting";
+ public string Caption { get; }
+ public Control? CustomControl { get; set; }
+
+ public ISettingControlBinding CreateControlBinding()
+ {
+ return new PseudoBinding(this, CustomControl, _textBoxCreator);
+ }
+
+ private class PseudoBinding : SettingControlBinding
+ {
+ private readonly Func? _textBoxCreator;
+
+ public PseudoBinding(PseudoSetting setting, Control? customControl, Func? textBoxCreator)
+ : base(setting, customControl)
+ {
+ _textBoxCreator = textBoxCreator;
+ }
+
+ public override Control CreateControl()
+ {
+ ArgumentNullException.ThrowIfNull(_textBoxCreator);
+ Setting.CustomControl = _textBoxCreator();
+ return Setting.CustomControl;
+ }
+
+ public override void LoadSetting(SettingsSource settings, Control control)
+ {
+ }
+
+ public override void SaveSetting(SettingsSource settings, Control control)
+ {
+ }
+ }
+}
diff --git a/src/GitExtensions.Extensibility/Settings/SettingControlBinding.cs b/src/GitExtensions.Extensibility/Settings/SettingControlBinding.cs
new file mode 100644
index 0000000..5440370
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Settings/SettingControlBinding.cs
@@ -0,0 +1,70 @@
+namespace GitExtensions.Extensibility.Settings;
+
+public abstract class SettingControlBinding : ISettingControlBinding where TControl : Control where TSetting : ISetting
+{
+ private TControl? _control;
+ protected readonly TSetting Setting;
+
+ protected SettingControlBinding(TSetting setting, TControl? customControl)
+ {
+ Setting = setting;
+ _control = customControl;
+ }
+
+ private TControl Control
+ {
+ get
+ {
+ if (_control?.IsDisposed is not false)
+ {
+ _control = CreateControl();
+ }
+
+ return _control;
+ }
+ }
+
+ public Control GetControl()
+ {
+ return Control;
+ }
+
+ public void LoadSetting(SettingsSource settings)
+ {
+ LoadSetting(settings, Control);
+ }
+
+ ///
+ /// Saves value from Control to settings
+ ///
+ public void SaveSetting(SettingsSource settings)
+ {
+ SaveSetting(settings, Control);
+ }
+
+ public virtual string Caption()
+ {
+ return Setting.Caption;
+ }
+
+ public ISetting GetSetting()
+ {
+ return Setting;
+ }
+
+ ///
+ /// Creates a control to be placed on FormSettings to edit this setting value
+ /// Control should take care of scalability and resizability of its sub-controls
+ ///
+ public abstract TControl CreateControl();
+
+ ///
+ /// Loads setting value from settings to Control
+ ///
+ public abstract void LoadSetting(SettingsSource settings, TControl control);
+
+ ///
+ /// Saves value from Control to settings
+ ///
+ public abstract void SaveSetting(SettingsSource settings, TControl control);
+}
diff --git a/src/GitExtensions.Extensibility/Settings/SettingLevel.cs b/src/GitExtensions.Extensibility/Settings/SettingLevel.cs
new file mode 100644
index 0000000..8ed6814
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Settings/SettingLevel.cs
@@ -0,0 +1,11 @@
+namespace GitExtensions.Extensibility.Settings;
+
+public enum SettingLevel
+{
+ Unknown,
+ SystemWide,
+ Global,
+ Distributed,
+ Local,
+ Effective
+}
diff --git a/src/GitExtensions.Extensibility/Settings/SettingsSource.cs b/src/GitExtensions.Extensibility/Settings/SettingsSource.cs
new file mode 100644
index 0000000..ffb5f2d
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Settings/SettingsSource.cs
@@ -0,0 +1,173 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+
+namespace GitExtensions.Extensibility.Settings;
+
+public abstract class SettingsSource
+{
+ public virtual SettingLevel SettingLevel { get; set; } = SettingLevel.Unknown;
+
+ public abstract string? GetValue(string name);
+
+ public abstract void SetValue(string name, string? value);
+
+ [return: NotNullIfNotNull(nameof(defaultValue))]
+ public string? GetString(string name, string? defaultValue) => GetValue(name) ?? defaultValue;
+
+ public void SetString(string name, string? value) => SetValue(name, value);
+
+ public bool? GetBool(string name)
+ {
+ string? stringValue = GetValue(name);
+
+ if (string.Equals(stringValue, "true", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ if (string.Equals(stringValue, "false", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ return null;
+ }
+
+ public bool GetBool(string name, bool defaultValue) => GetBool(name) ?? defaultValue;
+
+ public void SetBool(string name, bool? value)
+ {
+ string? stringValue = value.HasValue ? value.Value ? "true" : "false" : null;
+
+ SetValue(name, stringValue);
+ }
+
+ public int? GetInt(string name)
+ {
+ string? stringValue = GetValue(name);
+
+ if (int.TryParse(stringValue, out int result))
+ {
+ return result;
+ }
+
+ return null;
+ }
+
+ public int GetInt(string name, int defaultValue) => GetInt(name) ?? defaultValue;
+
+ public void SetInt(string name, int? value)
+ {
+ string? stringValue = value.HasValue ? value.ToString() : null;
+
+ SetValue(name, stringValue);
+ }
+
+ public float? GetFloat(string name)
+ {
+ string? stringValue = GetValue(name);
+
+ if (float.TryParse(stringValue, NumberStyles.Float, CultureInfo.InvariantCulture, out float result))
+ {
+ return result;
+ }
+
+ if (float.TryParse(stringValue, out result))
+ {
+ return result;
+ }
+
+ return null;
+ }
+
+ public float GetFloat(string name, float defaultValue) => GetFloat(name) ?? defaultValue;
+
+ public void SetFloat(string name, float? value)
+ {
+ string? stringValue = value.HasValue ? value.Value.ToString(CultureInfo.InvariantCulture) : null;
+
+ SetValue(name, stringValue);
+ }
+
+ public DateTime? GetDate(string name)
+ {
+ string? stringValue = GetValue(name);
+
+ if (DateTime.TryParseExact(stringValue, "yyyy/M/dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime result))
+ {
+ return result;
+ }
+
+ return null;
+ }
+
+ public DateTime GetDate(string name, DateTime defaultValue) => GetDate(name) ?? defaultValue;
+
+ public void SetDate(string name, DateTime? value)
+ {
+ string? stringValue = value?.ToString("yyyy/M/dd", CultureInfo.InvariantCulture);
+
+ SetValue(name, stringValue);
+ }
+
+ public Font? GetFont(string name, Font defaultValue) => GetValue(name)?.Parse(defaultValue);
+
+ public void SetFont(string name, Font value) => SetValue(name, value.AsString());
+
+ public Color GetColor(string name, Color defaultValue)
+ {
+ string? stringValue = GetValue(name);
+
+ try
+ {
+ return ColorTranslator.FromHtml(stringValue!);
+ }
+ catch
+ {
+ return defaultValue;
+ }
+ }
+
+ public T GetEnum(string name, T defaultValue) where T : struct, Enum
+ {
+ string? stringValue = GetValue(name);
+
+ if (Enum.TryParse(stringValue, true, out T result))
+ {
+ return result;
+ }
+
+ return defaultValue;
+ }
+
+ public void SetEnum(string name, T value) where T : Enum
+ {
+ string? stringValue = value.ToString();
+
+ SetValue(name, stringValue);
+ }
+
+ public T? GetNullableEnum(string name) where T : struct
+ {
+ string? stringValue = GetValue(name);
+
+ if (string.IsNullOrEmpty(stringValue))
+ {
+ return null;
+ }
+
+ if (Enum.TryParse(stringValue, true, out T result))
+ {
+ return result;
+ }
+
+ return null;
+ }
+
+ public void SetNullableEnum(string name, T? value) where T : struct, Enum
+ {
+ string? stringValue = value.HasValue ? value.ToString() : string.Empty;
+
+ SetValue(name, stringValue);
+ }
+}
diff --git a/src/GitExtensions.Extensibility/Settings/StringSetting.cs b/src/GitExtensions.Extensibility/Settings/StringSetting.cs
new file mode 100644
index 0000000..3efac45
--- /dev/null
+++ b/src/GitExtensions.Extensibility/Settings/StringSetting.cs
@@ -0,0 +1,93 @@
+namespace GitExtensions.Extensibility.Settings;
+
+public class StringSetting : ISetting
+{
+ public StringSetting(string name, string defaultValue)
+ : this(name, name, defaultValue)
+ {
+ }
+
+ public StringSetting(string name, string caption, string defaultValue, bool useDefaultValueIfBlank = false)
+ {
+ Name = name;
+ Caption = caption;
+ DefaultValue = defaultValue;
+ UseDefaultValueIfBlank = useDefaultValueIfBlank;
+ }
+
+ public string Name { get; }
+ public string Caption { get; }
+ public string DefaultValue { get; }
+ public TextBox? CustomControl { get; set; }
+ public bool UseDefaultValueIfBlank { get; }
+
+ public ISettingControlBinding CreateControlBinding()
+ {
+ return new TextBoxBinding(this, CustomControl, UseDefaultValueIfBlank);
+ }
+
+ private class TextBoxBinding : SettingControlBinding
+ {
+ private readonly bool _useDefaultValueIfBlank;
+
+ public TextBoxBinding(StringSetting setting, TextBox? customControl, bool useDefaultValueIfBlank)
+ : base(setting, customControl)
+ {
+ _useDefaultValueIfBlank = useDefaultValueIfBlank;
+ }
+
+ public override TextBox CreateControl()
+ {
+ Setting.CustomControl = new TextBox();
+ return Setting.CustomControl;
+ }
+
+ public override void LoadSetting(SettingsSource settings, TextBox control)
+ {
+ if (control.ReadOnly)
+ {
+ // readonly controls can't be changed by the user, so there is no need to load settings
+ return;
+ }
+
+ string? settingVal = settings.SettingLevel == SettingLevel.Effective
+ ? Setting.ValueOrDefault(settings)
+ : Setting[settings];
+
+ if (settingVal is null && _useDefaultValueIfBlank)
+ {
+ settingVal = Setting.ValueOrDefault(settings);
+ }
+
+ // for multiline control, transform "\n" in "\r\n" but prevent "\r\n" to be transformed in "\r\r\n"
+ control.Text = control.Multiline
+ ? settingVal?.Replace(Environment.NewLine, "\n").Replace("\n", Environment.NewLine)
+ : settingVal;
+ }
+
+ public override void SaveSetting(SettingsSource settings, TextBox control)
+ {
+ string controlValue = control.Text;
+ if (settings.SettingLevel == SettingLevel.Effective)
+ {
+ if (Setting.ValueOrDefault(settings) == controlValue)
+ {
+ return;
+ }
+ }
+
+ Setting[settings] = controlValue;
+ }
+ }
+
+ public string? this[SettingsSource settings]
+ {
+ get => settings.GetString(Name, null);
+ set => settings.SetString(Name, value);
+ }
+
+ public string ValueOrDefault(SettingsSource settings)
+ {
+ return this[settings] ?? DefaultValue;
+ }
+}
diff --git a/src/GitExtensions.Extensibility/tools/Download-GitExtensions.ps1 b/src/GitExtensions.Extensibility/tools/Download-GitExtensions.ps1
deleted file mode 100644
index 91aa979..0000000
--- a/src/GitExtensions.Extensibility/tools/Download-GitExtensions.ps1
+++ /dev/null
@@ -1,232 +0,0 @@
-param(
- [Parameter(Mandatory=$true)]
- [string] $ExtractRootPath,
- [Parameter(Mandatory=$true)]
- [string] $Version,
- [ValidateSet('GitHub','AppVeyor', ignorecase=$False)]
- [string] $Source = "GitHub"
-)
-
-$LatestVersionName = "latest";
-
-function Test-LocalCopy
-{
- Param(
- [Parameter(Mandatory=$true, Position=0)]
- [string] $ExtractPath,
- [Parameter(Mandatory=$true, Position=1)]
- [string] $FileName
- )
-
- $FilePath = [System.IO.Path]::Combine($ExtractPath, $FileName);
- if (Test-Path $FilePath)
- {
- Write-Host "Download '$FileName' already exists.";
- return $true;
- }
-
- return $false;
-}
-
-function Find-ArchiveUrl
-{
- param (
- [Parameter(Mandatory=$true, Position=0)]
- [string] $Version,
- [Parameter(Mandatory=$true, Position=1)]
- [ValidateSet('GitHub','AppVeyor', ignorecase=$False)]
- [string] $Source
- )
-
- Write-Host "Searching for Git Extensions release '$Version' on '$Source'.";
- if ($Source -eq "GitHub")
- {
- return Find-ArchiveUrlFromGitHub -Version $Version;
- }
-
- if ($Source -eq "AppVeyor")
- {
- return Find-ArchiveUrlFromAppVeyor -Version $Version;
- }
-
- throw "Unable to find download URL for 'Git Extensions $Version'";
-}
-
-function Find-ArchiveUrlFromGitHub
-{
- param (
- [Parameter(Mandatory=$true, Position=0)]
- [string] $Version
- )
-
- $BaseUrl = 'https://api.github.com/repos/gitextensions/gitextensions/releases';
- $SelectedRelease = $null;
- if ($Version -eq $LatestVersionName)
- {
- $SelectedRelease = Invoke-RestMethod -Uri "$BaseUrl/latest";
- $Version = $SelectedRelease.tag_name;
- Write-Host "Selected release '$($SelectedRelease.name)'.";
- }
- else
- {
- $Releases = Invoke-RestMethod -Uri $BaseUrl;
- foreach ($Release in $Releases)
- {
- if ($Release.tag_name -eq $Version)
- {
- Write-Host "Selected release '$($Release.name)'.";
- $SelectedRelease = $Release;
- break;
- }
- }
- }
-
- if (!($null -eq $SelectedRelease))
- {
- foreach ($Asset in $SelectedRelease.assets)
- {
- if ($Asset.name.ToLower().Contains('portable') -and $Asset.name.ToLower().EndsWith('.zip'))
- {
- Write-Host "Selected asset '$($Asset.name)'.";
- return $Version,$Asset.browser_download_url;
- }
- }
- }
-
- throw "Unable to find download URL for 'Git Extensions $Version' on GitHub";
-}
-
-function Find-ArchiveUrlFromAppVeyor
-{
- param (
- [Parameter(Mandatory=$true, Position=0)]
- [string] $Version
- )
-
- $UrlVersion = $Version;
- if ($UrlVersion.StartsWith("v"))
- {
- $UrlVersion = $UrlVersion.Substring(1);
- }
-
- $UrlBase = "https://ci.appveyor.com/api";
-
- try
- {
- if ($Version -eq $LatestVersionName)
- {
- $Url = "$UrlBase/projects/gitextensions/gitextensions/branch/master";
- }
- else
- {
- $Url = "$UrlBase/projects/gitextensions/gitextensions/build/$UrlVersion";
- }
-
- $BuildInfo = Invoke-RestMethod -Uri $Url;
- $Version = "v$($BuildInfo.build.version)";
- $Job = $BuildInfo.build.jobs[0];
- if ($Job.Status -eq "success")
- {
- $JobId = $Job.jobId;
- Write-Host "Selected build job '$JobId'.";
-
- $AssetsUrl = "$UrlBase/buildjobs/$JobId/artifacts";
- $Assets = Invoke-RestMethod -Method Get -Uri $AssetsUrl;
- foreach ($Asset in $Assets)
- {
- if ($Asset.type.ToLower() -eq "zip" -and $Asset.FileName.ToLower().Contains('portable'))
- {
- Write-Host "Selected asset '$($Asset.FileName)'.";
- return $Version,($AssetsUrl + "/" + $Asset.FileName);
- }
- }
- }
- }
- catch
- {
- if (!($_.Exception.Response.StatusCode -eq 404))
- {
- throw;
- }
- }
-
- throw "Unable to find download URL for 'Git Extensions $Version' on AppVeyor";
-}
-
-function Get-Application
-{
- param (
- [Parameter(Mandatory=$true, Position=0)]
- [string] $ArchiveUrl,
- [Parameter(Mandatory=$true, Position=1)]
- [string] $ExtractPath,
- [Parameter(Mandatory=$true, Position=2)]
- [string] $FileName,
- [Parameter(Mandatory=$true, Position=3)]
- [ValidateSet('GitHub','AppVeyor', ignorecase=$False)]
- [string] $Source
- )
-
- if (!(Test-Path $ExtractPath))
- {
- New-Item -ItemType directory -Path $ExtractPath | Out-Null;
- }
-
- $FilePath = [System.IO.Path]::Combine($ExtractPath, $FileName);
-
- Write-Host "Downloading '$ArchiveUrl'...";
-
- Invoke-WebRequest -Uri $ArchiveUrl -OutFile $FilePath;
- Expand-Archive $FilePath -DestinationPath $ExtractPath -Force;
-
- Write-Host "Application extracted to '$ExtractPath'.";
-}
-
-function Get-ZipFileName {
- param (
- [Parameter(Mandatory=$true, Position=0)]
- [string] $Version
- )
-
- return "GitExtensions-$Version.zip";
-}
-
-
-Push-Location $PSScriptRoot;
-try
-{
- $ExtractRootPath = Resolve-Path $ExtractRootPath;
- Write-Host "Extraction root path is '$ExtractRootPath'.";
-
- [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12;
-
- if (!($Version -eq $LatestVersionName))
- {
- $FileName = Get-ZipFileName -Version $Version;
- if (Test-LocalCopy -ExtractPath $ExtractRootPath -FileName $FileName)
- {
- exit 0;
- }
- }
-
- $SelectedVersion,$DownloadUrl = Find-ArchiveUrl -Version $Version -Source $Source;
- if ($Version -eq $LatestVersionName)
- {
- $FileName = Get-ZipFileName -Version $SelectedVersion;
- if (Test-LocalCopy -ExtractPath $ExtractRootPath -FileName $FileName)
- {
- exit 0;
- }
- }
-
- Get-Application -ArchiveUrl $DownloadUrl -ExtractPath $ExtractRootPath -FileName $FileName -Source $Source;
-}
-catch
-{
- Write-Host $_.Exception -ForegroundColor Red;
- exit -1;
-}
-finally
-{
- Pop-Location;
-}
diff --git a/tests/GitExtensions.Extensibility.Tests/ArgumentBuilderTests.cs b/tests/GitExtensions.Extensibility.Tests/ArgumentBuilderTests.cs
new file mode 100644
index 0000000..35df83b
--- /dev/null
+++ b/tests/GitExtensions.Extensibility.Tests/ArgumentBuilderTests.cs
@@ -0,0 +1,82 @@
+using FluentAssertions;
+using NUnit.Framework;
+
+namespace GitExtensions.Extensibility.Tests
+{
+ [TestFixture]
+ public sealed class ArgumentBuilderTests
+ {
+ [Test]
+ public void Adds_simple_parameters()
+ {
+ Test(
+ "",
+ []);
+
+ Test(
+ "foo",
+ ["foo"]);
+
+ Test(
+ "foo bar",
+ ["foo", "bar"]);
+
+ Test(
+ "foo bar",
+ ["foo", null, "bar"]);
+
+ Test(
+ "foo bar",
+ ["foo", "", "bar"]);
+
+ Test(
+ "",
+ [null]);
+
+ void Test(string expected, ArgumentBuilder command)
+ {
+ Assert.AreEqual(expected, command.ToString());
+ }
+ }
+
+ [Test]
+ public void IsEmpty()
+ {
+ ArgumentBuilder builder = [];
+ builder.IsEmpty.Should().BeTrue();
+
+ builder.Add("test");
+ builder.IsEmpty.Should().BeFalse();
+ }
+
+ [Test]
+ public void Length()
+ {
+ ArgumentBuilder builder = [];
+ builder.GetTestAccessor().Arguments.Length.Should().Be(0);
+
+ builder.Add("test");
+ builder.GetTestAccessor().Arguments.Length.Should().Be(4);
+
+ string args = "Lorem ipsum dolor sit amet, solet soleat option mel no.";
+ int expectedLength = args.Length;
+ builder.AddRange(args.LazySplit(' '));
+ builder.GetTestAccessor().Arguments.Length.Should().Be(expectedLength + /* 'test ' */5);
+ }
+
+ [TestCase(new[] { (string)null }, 0, "")]
+ [TestCase(new[] { "" }, 0, "")]
+ [TestCase(new[] { "", null }, 0, "")]
+ [TestCase(new[] { "test" }, 4, "test")]
+ [TestCase(new[] { "test", "test" }, 9, "test test")]
+ [TestCase(new[] { "", "test" }, 4, "test")]
+ [TestCase(new[] { "test", null, "test" }, 9, "test test")]
+ public void Add(string[] args, int expectedLength, string expected)
+ {
+ ArgumentBuilder builder = [.. args];
+
+ builder.GetTestAccessor().Arguments.Length.Should().Be(expectedLength);
+ builder.ToString().Should().Be(expected);
+ }
+ }
+}
diff --git a/tests/GitExtensions.Extensibility.Tests/AssertEx.cs b/tests/GitExtensions.Extensibility.Tests/AssertEx.cs
new file mode 100644
index 0000000..e401bed
--- /dev/null
+++ b/tests/GitExtensions.Extensibility.Tests/AssertEx.cs
@@ -0,0 +1,113 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+#nullable enable
+
+using System.Collections;
+using NUnit.Framework;
+using NUnit.Framework.Constraints;
+
+namespace GitExtensions.Extensibility.Tests
+{
+ public static class AssertEx
+ {
+ public static async Task ThrowsAsync(IResolveConstraint expression, AsyncTestDelegate code, string message, params object?[]? args)
+ {
+ Exception? caughtException = null;
+ try
+ {
+ await code();
+ }
+ catch (Exception e)
+ {
+ caughtException = e;
+ }
+
+ Assert.That(caughtException, expression, message, args);
+ return caughtException;
+ }
+
+ public static async Task ThrowsAsync(IResolveConstraint expression, AsyncTestDelegate code)
+ {
+ return await ThrowsAsync(expression, code, string.Empty, null);
+ }
+
+ public static async Task ThrowsAsync(Type expectedExceptionType, AsyncTestDelegate code, string message, params object?[]? args)
+ {
+ return await ThrowsAsync(new ExceptionTypeConstraint(expectedExceptionType), code, message, args);
+ }
+
+ public static async Task ThrowsAsync(Type expectedExceptionType, AsyncTestDelegate code)
+ {
+ return await ThrowsAsync(new ExceptionTypeConstraint(expectedExceptionType), code, string.Empty, null);
+ }
+
+ public static async Task ThrowsAsync(AsyncTestDelegate code, string message, params object?[]? args)
+ where TActual : Exception
+ {
+ return (TActual?)await ThrowsAsync(typeof(TActual), code, message, args);
+ }
+
+ public static async Task ThrowsAsync(AsyncTestDelegate code)
+ where TActual : Exception
+ {
+ return await ThrowsAsync(code, string.Empty, null);
+ }
+
+ public static void SequenceEqual(
+ IEnumerable expected,
+ IEnumerable actual,
+ IEqualityComparer? comparer = null)
+ {
+ comparer ??= EqualityComparer.Default;
+
+ if (expected is ICollection c1 && actual is ICollection c2)
+ {
+ Assert.AreEqual(c1.Count, c2.Count, "Invalid collection count");
+ }
+
+ int index = 0;
+
+ using IEnumerator expectedEnumerator = expected.GetEnumerator();
+ using IEnumerator actualEnumerator = actual.GetEnumerator();
+
+ while (true)
+ {
+ bool expectedHasNext = expectedEnumerator.MoveNext();
+ bool actualHasNext = actualEnumerator.MoveNext();
+
+ switch (expectedHasNext, actualHasNext)
+ {
+ case (false, false):
+ {
+ // Both sequences end at the same point. We are finished.
+ return;
+ }
+
+ case (false, true):
+ {
+ throw new($"Expected sequence ended at index {index} while actual sequence has more items.");
+ }
+
+ case (true, false):
+ {
+ throw new($"Actual sequence ended at index {index} while expected sequence has more items.");
+ }
+
+ case (true, true):
+ {
+ if (!comparer.Equals(expectedEnumerator.Current, actualEnumerator.Current))
+ {
+ throw new($"Sequences differ at index {index}.\nExpect: {expectedEnumerator.Current}\nActual: {actualEnumerator.Current}");
+ }
+
+ break;
+ }
+ }
+
+ index++;
+ }
+ }
+ }
+}
diff --git a/tests/GitExtensions.Extensibility.Tests/FontParserTests.cs b/tests/GitExtensions.Extensibility.Tests/FontParserTests.cs
new file mode 100644
index 0000000..1dd336b
--- /dev/null
+++ b/tests/GitExtensions.Extensibility.Tests/FontParserTests.cs
@@ -0,0 +1,70 @@
+using FluentAssertions;
+using NUnit.Framework;
+
+namespace GitExtensions.Extensibility.Tests
+{
+ [TestFixture]
+ public class FontParserTests
+ {
+ private Font _defaultFont;
+
+ [SetUp]
+ public void Setup()
+ {
+ _defaultFont = new Font("Arial", 9);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ _defaultFont.Dispose();
+ }
+
+ [TestCase(FontStyle.Regular, "Arial;9;_IC_;0;0")]
+ [TestCase(FontStyle.Bold, "Arial;9;_IC_;1;0")]
+ [TestCase(FontStyle.Italic, "Arial;9;_IC_;0;1")]
+ [TestCase(FontStyle.Bold | FontStyle.Italic, "Arial;9;_IC_;1;1")]
+ public void AsString_should_persist_font_with_styles(FontStyle fontStyle, string serialised)
+ {
+ using Font font = new("Arial", 9, fontStyle);
+ font.AsString().Should().Be(serialised);
+ }
+
+ [TestCase(null)]
+ [TestCase("")]
+ [TestCase("\t")]
+ public void Parse_should_return_default_if_null_or_empty(string serialised)
+ {
+ Font font = serialised.Parse(_defaultFont);
+
+ font.Should().Be(_defaultFont);
+ }
+
+ [TestCase("Arial")]
+ [TestCase("Arial;")]
+ public void Parse_should_return_default_if_less_then_two_parts(string serialised)
+ {
+ Font font = serialised.Parse(_defaultFont);
+
+ font.Should().Be(_defaultFont);
+ }
+
+ [TestCase("Courier;8.25;", "Courier", 8.25f, FontStyle.Regular)]
+ [TestCase("Courier;12;_IC_", "Courier", 12f, FontStyle.Regular)]
+ [TestCase("Courier;11,3;", "Courier", 11.3f, FontStyle.Regular)]
+ [TestCase("Courier;11,3;ru", "Courier", 11.3f, FontStyle.Regular)]
+ [TestCase("Courier;12;_IC_;0;0", "Courier", 12f, FontStyle.Regular)]
+ [TestCase("Courier;12;_IC_;1;0", "Courier", 12f, FontStyle.Bold)]
+ [TestCase("Courier;12;_IC_;0;1", "Courier", 12f, FontStyle.Italic)]
+ [TestCase("Courier;12;_IC_;1;1", "Courier", 12f, FontStyle.Italic | FontStyle.Bold)]
+ public void Parse_should_parse(string serialised, string name, float size, FontStyle style)
+ {
+ Font font = serialised.Parse(_defaultFont);
+
+ font.Should().NotBe(_defaultFont);
+ font.OriginalFontName.Should().Be(name);
+ font.Size.Should().Be(size);
+ font.Style.Should().Be(style);
+ }
+ }
+}
diff --git a/tests/GitExtensions.Extensibility.Tests/GitExtensions.Extensibility.Tests.csproj b/tests/GitExtensions.Extensibility.Tests/GitExtensions.Extensibility.Tests.csproj
new file mode 100644
index 0000000..8a6522f
--- /dev/null
+++ b/tests/GitExtensions.Extensibility.Tests/GitExtensions.Extensibility.Tests.csproj
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/tests/GitExtensions.Extensibility.Tests/LazyStringSplitTests.cs b/tests/GitExtensions.Extensibility.Tests/LazyStringSplitTests.cs
new file mode 100644
index 0000000..919fd7c
--- /dev/null
+++ b/tests/GitExtensions.Extensibility.Tests/LazyStringSplitTests.cs
@@ -0,0 +1,86 @@
+using NUnit.Framework;
+
+namespace GitExtensions.Extensibility.Tests
+{
+ [TestFixture]
+ public sealed class LazyStringSplitTests
+ {
+ [TestCase("a;b;c", ';', new[] { "a", "b", "c" })]
+ [TestCase("a_b_c", '_', new[] { "a", "b", "c" })]
+ [TestCase("aa;bb;cc", ';', new[] { "aa", "bb", "cc" })]
+ [TestCase("aaa;bbb;ccc", ';', new[] { "aaa", "bbb", "ccc" })]
+ [TestCase(";a", ';', new[] { "", "a" })]
+ [TestCase("a;", ';', new[] { "a", "" })]
+ [TestCase(";a;b;c", ';', new[] { "", "a", "b", "c" })]
+ [TestCase("a;b;c;", ';', new[] { "a", "b", "c", "" })]
+ [TestCase(";a;b;c;", ';', new[] { "", "a", "b", "c", "" })]
+ [TestCase(";;a;;b;;c;;", ';', new[] { "", "", "a", "", "b", "", "c", "", "" })]
+ [TestCase("", ';', new[] { "" })]
+ [TestCase(";", ';', new[] { "", "" })]
+ [TestCase(";;", ';', new[] { "", "", "" })]
+ [TestCase(";;;", ';', new[] { "", "", "", "" })]
+ [TestCase(";;;a", ';', new[] { "", "", "", "a" })]
+ [TestCase("a;;;", ';', new[] { "a", "", "", "" })]
+ [TestCase(";a;;", ';', new[] { "", "a", "", "" })]
+ [TestCase(";;a;", ';', new[] { "", "", "a", "" })]
+ [TestCase("a", ';', new[] { "a" })]
+ [TestCase("aa", ';', new[] { "aa" })]
+ public void None(string input, char delimiter, string[] expected)
+ {
+ // This boxes
+ IEnumerable actual = new LazyStringSplit(input, delimiter, StringSplitOptions.None);
+
+ AssertEx.SequenceEqual(expected, actual);
+
+ // Non boxing foreach
+ List list = [.. new LazyStringSplit(input, delimiter, StringSplitOptions.None)];
+
+ AssertEx.SequenceEqual(expected, list);
+
+ // Equivalence with string.Split
+ AssertEx.SequenceEqual(expected, input.Split(new[] { delimiter }, StringSplitOptions.None));
+ }
+
+ [TestCase("a;b;c", ';', new[] { "a", "b", "c" })]
+ [TestCase("a_b_c", '_', new[] { "a", "b", "c" })]
+ [TestCase("aa;bb;cc", ';', new[] { "aa", "bb", "cc" })]
+ [TestCase("aaa;bbb;ccc", ';', new[] { "aaa", "bbb", "ccc" })]
+ [TestCase(";a", ';', new[] { "a" })]
+ [TestCase("a;", ';', new[] { "a" })]
+ [TestCase(";a;b;c", ';', new[] { "a", "b", "c" })]
+ [TestCase("a;b;c;", ';', new[] { "a", "b", "c" })]
+ [TestCase(";a;b;c;", ';', new[] { "a", "b", "c" })]
+ [TestCase(";;a;;b;;c;;", ';', new[] { "a", "b", "c" })]
+ [TestCase("", ';', new string[0])]
+ [TestCase(";", ';', new string[0])]
+ [TestCase(";;", ';', new string[0])]
+ [TestCase(";;;", ';', new string[0])]
+ [TestCase(";;;a", ';', new[] { "a" })]
+ [TestCase("a;;;", ';', new[] { "a" })]
+ [TestCase(";a;;", ';', new[] { "a" })]
+ [TestCase(";;a;", ';', new[] { "a" })]
+ [TestCase("a", ';', new[] { "a" })]
+ [TestCase("aa", ';', new[] { "aa" })]
+ public void RemoveEmptyEntries(string input, char delimiter, string[] expected)
+ {
+ // This boxes
+ IEnumerable actual = new LazyStringSplit(input, delimiter, StringSplitOptions.RemoveEmptyEntries);
+
+ AssertEx.SequenceEqual(expected, actual);
+
+ // Non boxing foreach
+ List list = [.. new LazyStringSplit(input, delimiter, StringSplitOptions.RemoveEmptyEntries)];
+
+ AssertEx.SequenceEqual(expected, list);
+
+ // Equivalence with string.Split
+ AssertEx.SequenceEqual(expected, input.Split(new[] { delimiter }, StringSplitOptions.RemoveEmptyEntries));
+ }
+
+ [Test]
+ public void Constructor_WithNullInput_Throws()
+ {
+ Assert.Throws(() => _ = new LazyStringSplit(null!, ' ', StringSplitOptions.RemoveEmptyEntries));
+ }
+ }
+}