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)); + } + } +}