diff --git a/common/CMakeLists.txt b/common/CMakeLists.txt index 1a7e4e9efc..7831e4e93f 100644 --- a/common/CMakeLists.txt +++ b/common/CMakeLists.txt @@ -168,6 +168,7 @@ set( KICOMMON_SRCS text_eval/text_eval_wrapper.cpp text_eval/text_eval_parser.cpp + text_eval/text_eval_vcs.cpp # This is basically a settings object, but for the toolbar tool/ui/toolbar_configuration.cpp diff --git a/common/text_eval/text_eval_parser.cpp b/common/text_eval/text_eval_parser.cpp index ef8c499ec8..1b3144d25d 100644 --- a/common/text_eval/text_eval_parser.cpp +++ b/common/text_eval/text_eval_parser.cpp @@ -18,6 +18,7 @@ */ #include +#include #include #include #include @@ -675,6 +676,163 @@ auto EVAL_VISITOR::evaluateFunction( const FUNC_DATA& aFunc ) const -> Result( DATE_UTILS::GetWeekdayName( days ) ); } + // VCS functions (return strings!) + else if( name == "vcsidentifier" && argc <= 1 ) + { + int length = 40; // Full identifier by default + if( argc == 1 ) + { + auto lenResult = VALUE_UTILS::ToDouble( argValues[0] ); + if( lenResult ) + length = static_cast( lenResult.GetValue() ); + } + + return MakeValue( TEXT_EVAL_VCS::GetCommitHash( ".", length ) ); + } + else if( name == "vcsnearestlabel" && argc <= 2 ) + { + std::string match; + bool anyTags = false; + + if( argc >= 1 ) + match = VALUE_UTILS::ToString( argValues[0] ); + if( argc >= 2 ) + { + auto tagsResult = VALUE_UTILS::ToDouble( argValues[1] ); + if( tagsResult ) + anyTags = tagsResult.GetValue() != 0.0; + } + + return MakeValue( TEXT_EVAL_VCS::GetNearestTag( match, anyTags ) ); + } + else if( name == "vcslabeldistance" && argc <= 2 ) + { + std::string match; + bool anyTags = false; + + if( argc >= 1 ) + match = VALUE_UTILS::ToString( argValues[0] ); + if( argc >= 2 ) + { + auto tagsResult = VALUE_UTILS::ToDouble( argValues[1] ); + if( tagsResult ) + anyTags = tagsResult.GetValue() != 0.0; + } + + return MakeValue( std::to_string( TEXT_EVAL_VCS::GetDistanceFromTag( match, anyTags ) ) ); + } + else if( name == "vcsdirty" && argc <= 1 ) + { + bool includeUntracked = false; + if( argc == 1 ) + { + auto utResult = VALUE_UTILS::ToDouble( argValues[0] ); + if( utResult ) + includeUntracked = utResult.GetValue() != 0.0; + } + + return MakeValue( TEXT_EVAL_VCS::IsDirty( includeUntracked ) ? "1" : "0" ); + } + else if( name == "vcsdirtysuffix" && argc <= 2 ) + { + std::string suffix = "-dirty"; + bool includeUntracked = false; + + if( argc >= 1 ) + suffix = VALUE_UTILS::ToString( argValues[0] ); + if( argc >= 2 ) + { + auto utResult = VALUE_UTILS::ToDouble( argValues[1] ); + if( utResult ) + includeUntracked = utResult.GetValue() != 0.0; + } + + return MakeValue( TEXT_EVAL_VCS::IsDirty( includeUntracked ) ? suffix : "" ); + } + else if( name == "vcsauthor" && argc == 0 ) + { + return MakeValue( TEXT_EVAL_VCS::GetAuthor( "." ) ); + } + else if( name == "vcsauthoremail" && argc == 0 ) + { + return MakeValue( TEXT_EVAL_VCS::GetAuthorEmail( "." ) ); + } + else if( name == "vcscommitter" && argc == 0 ) + { + return MakeValue( TEXT_EVAL_VCS::GetCommitter( "." ) ); + } + else if( name == "vcscommitteremail" && argc == 0 ) + { + return MakeValue( TEXT_EVAL_VCS::GetCommitterEmail( "." ) ); + } + else if( name == "vcsbranch" && argc == 0 ) + { + return MakeValue( TEXT_EVAL_VCS::GetBranch() ); + } + else if( name == "vcscommitdate" && argc <= 1 ) + { + std::string format = "ISO"; + if( argc == 1 ) + format = VALUE_UTILS::ToString( argValues[0] ); + + int64_t timestamp = TEXT_EVAL_VCS::GetCommitTimestamp( "." ); + if( timestamp == 0 ) + return MakeValue( "" ); + + int days = static_cast( timestamp / ( 24 * 3600 ) ); + return MakeValue( DATE_UTILS::FormatDate( days, format ) ); + } + + // VCS file functions (file-specific versions) + else if( name == "vcsfileidentifier" && argc >= 1 && argc <= 2 ) + { + std::string filePath = VALUE_UTILS::ToString( argValues[0] ); + int length = 40; + + if( argc == 2 ) + { + auto lenResult = VALUE_UTILS::ToDouble( argValues[1] ); + if( lenResult ) + length = static_cast( lenResult.GetValue() ); + } + + return MakeValue( TEXT_EVAL_VCS::GetCommitHash( filePath, length ) ); + } + else if( name == "vcsfileauthor" && argc == 1 ) + { + std::string filePath = VALUE_UTILS::ToString( argValues[0] ); + return MakeValue( TEXT_EVAL_VCS::GetAuthor( filePath ) ); + } + else if( name == "vcsfileauthoremail" && argc == 1 ) + { + std::string filePath = VALUE_UTILS::ToString( argValues[0] ); + return MakeValue( TEXT_EVAL_VCS::GetAuthorEmail( filePath ) ); + } + else if( name == "vcsfilecommitter" && argc == 1 ) + { + std::string filePath = VALUE_UTILS::ToString( argValues[0] ); + return MakeValue( TEXT_EVAL_VCS::GetCommitter( filePath ) ); + } + else if( name == "vcsfilecommitteremail" && argc == 1 ) + { + std::string filePath = VALUE_UTILS::ToString( argValues[0] ); + return MakeValue( TEXT_EVAL_VCS::GetCommitterEmail( filePath ) ); + } + else if( name == "vcsfilecommitdate" && argc >= 1 && argc <= 2 ) + { + std::string filePath = VALUE_UTILS::ToString( argValues[0] ); + std::string format = "ISO"; + if( argc == 2 ) + format = VALUE_UTILS::ToString( argValues[1] ); + + int64_t timestamp = TEXT_EVAL_VCS::GetCommitTimestamp( filePath ); + if( timestamp == 0 ) + return MakeValue( "" ); + + int days = static_cast( timestamp / ( 24 * 3600 ) ); + return MakeValue( DATE_UTILS::FormatDate( days, format ) ); + } + // String functions (return strings!) else if( name == "upper" && argc == 1 ) { diff --git a/common/text_eval/text_eval_vcs.cpp b/common/text_eval/text_eval_vcs.cpp new file mode 100644 index 0000000000..2325c991d1 --- /dev/null +++ b/common/text_eval/text_eval_vcs.cpp @@ -0,0 +1,388 @@ +/* + * This program source code file is part of KiCad, a free EDA CAD application. + * + * Copyright The KiCad Developers, see AUTHORS.txt for contributors. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +#include +#include +#include +#include +#include +#include +#include + +namespace TEXT_EVAL_VCS +{ +// Private implementation details +namespace +{ + auto OpenRepo( const std::string& aPath ) -> git_repository* + { + // Check if git backend is available (may be nullptr in tests or non-GUI contexts) + if( !GetGitBackend() ) + return nullptr; + + return KIGIT::PROJECT_GIT_UTILS::GetRepositoryForFile( aPath.c_str() ); + } + + auto CloseRepo( git_repository* aRepo ) -> void + { + if( aRepo ) + git_repository_free( aRepo ); + } + + auto MakeZeroOid() -> git_oid + { + git_oid oid; + git_oid_fromstrn( &oid, "0000000000000000000000000000000000000000", 40 ); + return oid; + } + + auto GetFileCommit( git_repository* aRepo, const std::string& aPath ) -> git_oid + { + if( !aRepo ) + return MakeZeroOid(); + + // Get HEAD commit + git_oid head_oid; + if( git_reference_name_to_id( &head_oid, aRepo, "HEAD" ) != 0 ) + return MakeZeroOid(); + + // For repo-level query (empty or "."), just return HEAD + if( aPath.empty() || aPath == "." ) + return head_oid; + + // For file-specific query, walk history to find last commit that touched this file + git_revwalk* walker = nullptr; + if( git_revwalk_new( &walker, aRepo ) != 0 ) + return MakeZeroOid(); + + git_revwalk_sorting( walker, GIT_SORT_TIME ); + git_revwalk_push( walker, &head_oid ); + + // Walk through commits to find when the file was last modified + git_oid result = MakeZeroOid(); + git_oid commit_oid; + git_oid prev_blob_oid = MakeZeroOid(); + bool first_commit = true; + + while( git_revwalk_next( &commit_oid, walker ) == 0 ) + { + git_commit* commit = nullptr; + if( git_commit_lookup( &commit, aRepo, &commit_oid ) != 0 ) + continue; + + // Get the tree for this commit + git_tree* tree = nullptr; + if( git_commit_tree( &tree, commit ) == 0 ) + { + // Try to find the file in this tree + git_tree_entry* entry = nullptr; + if( git_tree_entry_bypath( &entry, tree, aPath.c_str() ) == 0 ) + { + const git_oid* blob_oid = git_tree_entry_id( entry ); + + if( first_commit ) + { + // First time we see this file, remember its blob ID + git_oid_cpy( &prev_blob_oid, blob_oid ); + git_oid_cpy( &result, &commit_oid ); + first_commit = false; + } + else if( git_oid_cmp( blob_oid, &prev_blob_oid ) != 0 ) + { + // File content changed - previous commit is where it changed + git_tree_entry_free( entry ); + git_tree_free( tree ); + git_commit_free( commit ); + break; + } + else + { + // File unchanged, keep looking + git_oid_cpy( &result, &commit_oid ); + } + + git_tree_entry_free( entry ); + } + else if( !first_commit ) + { + // File doesn't exist in this commit, but existed before + // So the previous commit is where it was added/last modified + git_tree_free( tree ); + git_commit_free( commit ); + break; + } + + git_tree_free( tree ); + } + git_commit_free( commit ); + } + + git_revwalk_free( walker ); + return result; + } + + struct DescribeInfo + { + std::string tag; + int distance; + }; + + auto GetDescribeInfo( const std::string& aMatch, bool aAnyTags ) -> DescribeInfo + { + auto repo = OpenRepo( "." ); + if( !repo ) + return { "", 0 }; + + git_oid head_oid; + if( git_reference_name_to_id( &head_oid, repo, "HEAD" ) != 0 ) + { + CloseRepo( repo ); + return { "", 0 }; + } + + git_strarray tag_names; + if( git_tag_list_match( &tag_names, aMatch.empty() ? "*" : aMatch.c_str(), repo ) != 0 ) + { + CloseRepo( repo ); + return { "", 0 }; + } + + // Build map of commit OID -> tag name upfront + std::map commit_to_tag; + + for( size_t i = 0; i < tag_names.count; ++i ) + { + git_object* tag_obj = nullptr; + if( git_revparse_single( &tag_obj, repo, tag_names.strings[i] ) == 0 ) + { + git_object_t type = git_object_type( tag_obj ); + + if( type == GIT_OBJECT_TAG ) + { + git_object* target = nullptr; + if( git_tag_peel( &target, (git_tag*) tag_obj ) == 0 ) + { + commit_to_tag[*git_object_id( target )] = tag_names.strings[i]; + git_object_free( target ); + } + } + else if( aAnyTags && type == GIT_OBJECT_COMMIT ) + { + commit_to_tag[*git_object_id( tag_obj )] = tag_names.strings[i]; + } + git_object_free( tag_obj ); + } + } + + git_strarray_dispose( &tag_names ); + + git_revwalk* walker = nullptr; + if( git_revwalk_new( &walker, repo ) != 0 ) + { + CloseRepo( repo ); + return { "", 0 }; + } + + git_revwalk_sorting( walker, GIT_SORT_TOPOLOGICAL | GIT_SORT_TIME ); + git_revwalk_push( walker, &head_oid ); + + DescribeInfo result{ "", 0 }; + int distance = 0; + git_oid commit_oid; + + while( git_revwalk_next( &commit_oid, walker ) == 0 ) + { + auto it = commit_to_tag.find( commit_oid ); + if( it != commit_to_tag.end() ) + { + result.tag = it->second; + result.distance = distance; + break; + } + distance++; + } + + git_revwalk_free( walker ); + CloseRepo( repo ); + return result; + } + + auto GetCommitSignatureField( const std::string& aPath, bool aUseCommitter, bool aGetEmail ) -> std::string + { + auto repo = OpenRepo( aPath ); + if( !repo ) + return ""; + + git_oid oid = GetFileCommit( repo, aPath ); + + if( git_oid_is_zero( &oid ) ) + { + CloseRepo( repo ); + return ""; + } + + git_commit* commit = nullptr; + std::string result; + + if( git_commit_lookup( &commit, repo, &oid ) == 0 ) + { + const git_signature* sig = aUseCommitter ? git_commit_committer( commit ) : git_commit_author( commit ); + + if( sig ) + { + const char* field = aGetEmail ? sig->email : sig->name; + if( field ) + result = field; + } + + git_commit_free( commit ); + } + + CloseRepo( repo ); + return result; + } + +} // anonymous namespace + +std::string GetCommitHash( const std::string& aPath, int aLength ) +{ + auto repo = OpenRepo( aPath ); + if( !repo ) + return ""; + + git_oid oid = GetFileCommit( repo, aPath ); + + if( git_oid_is_zero( &oid ) ) + { + CloseRepo( repo ); + return ""; + } + + int length = std::max( 4, std::min( aLength, GIT_OID_HEXSZ ) ); + char hash[GIT_OID_HEXSZ + 1]; + git_oid_tostr( hash, length + 1, &oid ); + + CloseRepo( repo ); + return hash; +} + +std::string GetNearestTag( const std::string& aMatch, bool aAnyTags ) +{ + return GetDescribeInfo( aMatch, aAnyTags ).tag; +} + +int GetDistanceFromTag( const std::string& aMatch, bool aAnyTags ) +{ + return GetDescribeInfo( aMatch, aAnyTags ).distance; +} + +bool IsDirty( bool aIncludeUntracked ) +{ + auto repo = OpenRepo( "." ); + if( !repo ) + return false; + + git_status_list* status = nullptr; + git_status_options statusOpts; + git_status_options_init( &statusOpts, GIT_STATUS_OPTIONS_VERSION ); + + statusOpts.show = GIT_STATUS_SHOW_INDEX_AND_WORKDIR; + statusOpts.flags = aIncludeUntracked ? GIT_STATUS_OPT_INCLUDE_UNTRACKED : GIT_STATUS_OPT_EXCLUDE_SUBMODULES; + + bool isDirty = false; + + if( git_status_list_new( &status, repo, &statusOpts ) == 0 ) + { + isDirty = git_status_list_entrycount( status ) > 0; + git_status_list_free( status ); + } + + CloseRepo( repo ); + return isDirty; +} + +std::string GetAuthor( const std::string& aPath ) +{ + return GetCommitSignatureField( aPath, false, false ); +} + +std::string GetAuthorEmail( const std::string& aPath ) +{ + return GetCommitSignatureField( aPath, false, true ); +} + +std::string GetCommitter( const std::string& aPath ) +{ + return GetCommitSignatureField( aPath, true, false ); +} + +std::string GetCommitterEmail( const std::string& aPath ) +{ + return GetCommitSignatureField( aPath, true, true ); +} + +std::string GetBranch() +{ + auto repo = OpenRepo( "." ); + if( !repo ) + return ""; + + KIGIT_COMMON common( repo ); + wxString branchName = common.GetCurrentBranchName(); + + CloseRepo( repo ); + return branchName.ToStdString(); +} + +int64_t GetCommitTimestamp( const std::string& aPath ) +{ + auto repo = OpenRepo( aPath ); + if( !repo ) + return 0; + + git_oid oid = GetFileCommit( repo, aPath ); + + if( git_oid_is_zero( &oid ) ) + { + CloseRepo( repo ); + return 0; + } + + git_commit* commit = nullptr; + int64_t timestamp = 0; + + if( git_commit_lookup( &commit, repo, &oid ) == 0 ) + { + timestamp = static_cast( git_commit_time( commit ) ); + git_commit_free( commit ); + } + + CloseRepo( repo ); + return timestamp; +} + +std::string GetCommitDate( const std::string& aPath ) +{ + int64_t timestamp = GetCommitTimestamp( aPath ); + return timestamp > 0 ? std::to_string( timestamp ) : ""; +} + +} // namespace TEXT_EVAL_VCS diff --git a/common/text_eval/text_eval_vcs.h b/common/text_eval/text_eval_vcs.h new file mode 100644 index 0000000000..1b9e24603b --- /dev/null +++ b/common/text_eval/text_eval_vcs.h @@ -0,0 +1,123 @@ +/* + * This program source code file is part of KiCad, a free EDA CAD application. + * + * Copyright The KiCad Developers, see AUTHORS.txt for contributors. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +#pragma once + +#include +#include + +/** + * @namespace TEXT_EVAL_VCS + * VCS (Version Control System) utility functions for text evaluation. + * These functions provide generic VCS operations that are currently implemented + * for Git repositories, but can be extended to support other VCS systems in the future. + */ +namespace TEXT_EVAL_VCS +{ +/** + * Get the current HEAD commit identifier (hash). + * + * @param aPath Optional file path for file-specific queries. Defaults to "." for repo HEAD. + * @param aLength Optional hash length (4-40). Defaults to 40 for full SHA-1. + * @return Commit hash as hex string, or empty string if not in a VCS repository. + */ +std::string GetCommitHash( const std::string& aPath = ".", int aLength = 40 ); + +/** + * Get the nearest tag/label from HEAD. + * + * @param aMatch Optional glob pattern to filter tags. Defaults to "*" for all tags. + * @param aAnyTags If true, includes lightweight tags. If false, annotated tags only. + * @return Tag name, or empty string if no matching tag found. + */ +std::string GetNearestTag( const std::string& aMatch = "", bool aAnyTags = false ); + +/** + * Get the number of commits since the nearest matching tag. + * + * @param aMatch Optional glob pattern to filter tags. + * @param aAnyTags If true, includes lightweight tags. If false, annotated tags only. + * @return Distance in commits, or 0 if no tag found or at a tag. + */ +int GetDistanceFromTag( const std::string& aMatch = "", bool aAnyTags = false ); + +/** + * Check if the repository has uncommitted changes. + * + * @param aIncludeUntracked If true, untracked files count as dirty. If false, only staged/modified. + * @return true if repository is dirty, false if clean or not in a VCS repository. + */ +bool IsDirty( bool aIncludeUntracked = false ); + +/** + * Get the author name of the HEAD commit. + * + * @param aPath Optional file path for file-specific queries. + * @return Author name, or empty string if not available. + */ +std::string GetAuthor( const std::string& aPath = "." ); + +/** + * Get the author email of the HEAD commit. + * + * @param aPath Optional file path for file-specific queries. + * @return Author email, or empty string if not available. + */ +std::string GetAuthorEmail( const std::string& aPath = "." ); + +/** + * Get the committer name of the HEAD commit. + * + * @param aPath Optional file path for file-specific queries. + * @return Committer name, or empty string if not available. + */ +std::string GetCommitter( const std::string& aPath = "." ); + +/** + * Get the committer email of the HEAD commit. + * + * @param aPath Optional file path for file-specific queries. + * @return Committer email, or empty string if not available. + */ +std::string GetCommitterEmail( const std::string& aPath = "." ); + +/** + * Get the current branch name. + * + * @return Branch name (e.g., "main", "develop"), or empty string if detached or not in VCS. + */ +std::string GetBranch(); + +/** + * Get the commit timestamp (Unix time) of the HEAD commit. + * + * @param aPath Optional file path for file-specific queries. + * @return Unix timestamp in seconds, or 0 if not available. + */ +int64_t GetCommitTimestamp( const std::string& aPath = "." ); + +/** + * Get the commit date of the HEAD commit as a timestamp string. + * + * @param aPath Optional file path for file-specific queries. + * @return Timestamp as string (to be formatted by caller), or empty string if not available. + */ +std::string GetCommitDate( const std::string& aPath = "." ); + +} // namespace TEXT_EVAL_VCS diff --git a/qa/tests/common/CMakeLists.txt b/qa/tests/common/CMakeLists.txt index 4f53d9e6c5..5833616e43 100644 --- a/qa/tests/common/CMakeLists.txt +++ b/qa/tests/common/CMakeLists.txt @@ -71,6 +71,7 @@ set( QA_COMMON_SRCS text_eval/test_text_eval_parser.cpp text_eval/test_text_eval_parser_core.cpp text_eval/test_text_eval_parser_datetime.cpp + text_eval/test_text_eval_parser_vcs.cpp text_eval/test_text_eval_parser_integration.cpp text_eval/test_text_eval_numeric_compat.cpp text_eval/test_text_eval_render.cpp diff --git a/qa/tests/common/text_eval/README.md b/qa/tests/common/text_eval/README.md index 6c5a44c558..1df82af6b9 100644 --- a/qa/tests/common/text_eval/README.md +++ b/qa/tests/common/text_eval/README.md @@ -41,6 +41,17 @@ Specialized tests for date and time functionality: - **Weekday Calculations**: Day-of-week determination and cycling - **Performance**: Date operation timing validation +### `test_text_eval_parser_vcs.cpp` +Specialized tests for version control system (VCS) functionality: + +- **Identifier Formatting**: Commit identifiers with various lengths (4-40 chars), hex validation +- **Branch and Author Info**: Branch names, author/committer names and emails (repository and file-specific) +- **Dirty Status**: Repository dirty state detection, untracked file handling +- **Labels and Distance**: Nearest label/tag finding, commit distance calculations, pattern matching, annotated vs lightweight tags +- **Commit Dates**: Date formatting (ISO, US, EU, etc.), timestamp validation (repository and file-specific) +- **Performance**: VCS operation timing validation (100 operations < 2000ms) +- **Mixed Expressions**: VCS functions combined with variables and text + ### `test_text_eval_parser_integration.cpp` Integration tests simulating real-world KiCad usage scenarios: @@ -93,6 +104,27 @@ Integration tests simulating real-world KiCad usage scenarios: - Formats: "ISO", "US", "EU", "Chinese", "Japanese", "Korean", "long", "short" - `weekdayname(days)` - Get weekday name for date +### VCS Functions +- `vcsidentifier([length])` - Current HEAD commit identifier (default: 40 chars, min: 4) +- `vcsbranch()` - Current branch name +- `vcsauthor()` - HEAD commit author name +- `vcsauthoremail()` - HEAD commit author email +- `vcscommitter()` - HEAD commit committer name +- `vcscommitteremail()` - HEAD commit committer email +- `vcscommitdate([format])` - HEAD commit date (formats: "ISO", "US", "EU", "Chinese", "Japanese", "Korean", "long", "short") +- `vcsnearestlabel([match], [anyTags])` - Nearest label from HEAD (match pattern, anyTags: 0=annotated only, 1=all tags) +- `vcslabeldistance([match], [anyTags])` - Number of commits since nearest label +- `vcsdirty([includeUntracked])` - Repository dirty status: "1" if dirty, "0" if clean +- `vcsdirtysuffix([suffix], [includeUntracked])` - Custom suffix if dirty (default: "-dirty"), empty if clean +- `vcsfileidentifier(file, [length])` - Last commit identifier that modified file +- `vcsfileauthor(file)` - Author of last commit that modified file +- `vcsfileauthoremail(file)` - Author email of last commit that modified file +- `vcsfilecommitter(file)` - Committer of last commit that modified file +- `vcsfilecommitteremail(file)` - Committer email of last commit that modified file +- `vcsfilecommitdate(file, [format])` - Date of last commit that modified file + +Note: All VCS functions return empty strings when not in a VCS repository. File paths are relative to repository root. + ### Conditional Functions - `if(condition, true_value, false_value)` - Conditional evaluation diff --git a/qa/tests/common/text_eval/test_text_eval_parser.cpp b/qa/tests/common/text_eval/test_text_eval_parser.cpp index 82917744ad..e0be834094 100644 --- a/qa/tests/common/text_eval/test_text_eval_parser.cpp +++ b/qa/tests/common/text_eval/test_text_eval_parser.cpp @@ -397,6 +397,57 @@ BOOST_AUTO_TEST_CASE( DateTimeFunctions ) BOOST_CHECK_EQUAL( result2, "Thursday" ); // Unix epoch was a Thursday } +/** + * Test git functions + */ +BOOST_AUTO_TEST_CASE( GitFunctions ) +{ + EXPRESSION_EVALUATOR evaluator; + + // Note: These tests work whether in a git repo or not. + // Git functions return empty strings if not in a repo. + + struct TestCase + { + std::string expression; + bool shouldError; + }; + + const std::vector cases = { + // Basic VCS functions + { "@{vcsidentifier()}", false }, + { "@{vcsidentifier(7)}", false }, + { "@{vcsbranch()}", false }, + { "@{vcsauthor()}", false }, + { "@{vcsauthoremail()}", false }, + { "@{vcscommitter()}", false }, + { "@{vcscommitteremail()}", false }, + { "@{vcsnearestlabel()}", false }, + { "@{vcslabeldistance()}", false }, + { "@{vcsdirty()}", false }, + { "@{vcsdirtysuffix()}", false }, + { "@{vcscommitdate()}", false }, + + // VCS file functions with current directory + { "@{vcsfileidentifier(\".\")}", false }, + }; + + for( const auto& testCase : cases ) + { + auto result = evaluator.Evaluate( testCase.expression ); + + if( testCase.shouldError ) + { + BOOST_CHECK( evaluator.HasErrors() ); + } + else + { + BOOST_CHECK( !evaluator.HasErrors() ); + // Result can be empty if not in a git repo - that's valid + } + } +} + /** * Test conditional functions */ diff --git a/qa/tests/common/text_eval/test_text_eval_parser_vcs.cpp b/qa/tests/common/text_eval/test_text_eval_parser_vcs.cpp new file mode 100644 index 0000000000..0320a0e529 --- /dev/null +++ b/qa/tests/common/text_eval/test_text_eval_parser_vcs.cpp @@ -0,0 +1,399 @@ +/* + * This program source code file is part of KiCad, a free EDA CAD application. + * + * Copyright The KiCad Developers, see AUTHORS.TXT for contributors. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, you may find one here: + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.html + * or you may search the http://www.gnu.org website for the version 2 license, + * or you may write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + */ + +/** + * @file + * Test suite for text_eval_parser VCS functionality + */ + +#include +#include +#include +#include + +#include +#include + +/** + * Fixture to set up and tear down the git backend for VCS tests. + */ +struct VCS_TEST_FIXTURE +{ + VCS_TEST_FIXTURE() + { + m_backend = new LIBGIT_BACKEND(); + m_backend->Init(); + SetGitBackend( m_backend ); + } + + ~VCS_TEST_FIXTURE() + { + SetGitBackend( nullptr ); + m_backend->Shutdown(); + delete m_backend; + } + + LIBGIT_BACKEND* m_backend; +}; + +BOOST_FIXTURE_TEST_SUITE( TextEvalParserVcs, VCS_TEST_FIXTURE ) + +/** + * Test VCS identifier functions with various lengths + */ +BOOST_AUTO_TEST_CASE( VcsIdentifierFormatting ) +{ + EXPRESSION_EVALUATOR evaluator; + + struct TestCase + { + std::string expression; + int expectedLength; + bool shouldError; + }; + + const std::vector cases = { + // Different identifier lengths + { "@{vcsidentifier()}", 40, false }, // Full SHA-1 + { "@{vcsidentifier(40)}", 40, false }, // Explicit full + { "@{vcsidentifier(7)}", 7, false }, // Short + { "@{vcsidentifier(8)}", 8, false }, // 8 chars + { "@{vcsidentifier(12)}", 12, false }, // Medium + { "@{vcsidentifier(4)}", 4, false }, // Minimum (clamped) + + // File variants + { "@{vcsfileidentifier(\".\")}", 40, false }, + { "@{vcsfileidentifier(\".\", 8)}", 8, false }, + }; + + for( const auto& testCase : cases ) + { + auto result = evaluator.Evaluate( wxString::FromUTF8( testCase.expression ) ); + + if( testCase.shouldError ) + { + BOOST_CHECK( evaluator.HasErrors() ); + } + else + { + BOOST_CHECK_MESSAGE( !evaluator.HasErrors(), "Error in expression: " + testCase.expression + " Errors: " + + evaluator.GetErrorSummary().ToStdString() ); + + // If in a git repo, validate format + if( !result.IsEmpty() ) + { + BOOST_CHECK_EQUAL( result.Length(), testCase.expectedLength ); + + // Should be valid hex + std::regex hexPattern( "^[0-9a-f]+$" ); + BOOST_CHECK( std::regex_match( result.ToStdString(), hexPattern ) ); + } + } + } +} + +/** + * Test VCS branch and author information + */ +BOOST_AUTO_TEST_CASE( VcsBranchAndAuthorInfo ) +{ + EXPRESSION_EVALUATOR evaluator; + + auto branch = evaluator.Evaluate( "@{vcsbranch()}" ); + auto author = evaluator.Evaluate( "@{vcsauthor()}" ); + auto authorEmail = evaluator.Evaluate( "@{vcsauthoremail()}" ); + auto committer = evaluator.Evaluate( "@{vcscommitter()}" ); + auto committerEmail = evaluator.Evaluate( "@{vcscommitteremail()}" ); + + BOOST_CHECK( !evaluator.HasErrors() ); + + // If in a VCS repo, validate email format + if( !authorEmail.IsEmpty() ) + { + BOOST_CHECK( authorEmail.Contains( "@" ) ); + } + + if( !committerEmail.IsEmpty() ) + { + BOOST_CHECK( committerEmail.Contains( "@" ) ); + } + + // File variants + auto fileAuthor = evaluator.Evaluate( "@{vcsfileauthor(\".\")}" ); + auto fileAuthorEmail = evaluator.Evaluate( "@{vcsfileauthoremail(\".\")}" ); + auto fileCommitter = evaluator.Evaluate( "@{vcsfilecommitter(\".\")}" ); + auto fileCommitterEmail = evaluator.Evaluate( "@{vcsfilecommitteremail(\".\")}" ); + + BOOST_CHECK( !evaluator.HasErrors() ); + + // Validate file variant email formats + if( !fileAuthorEmail.IsEmpty() ) + { + BOOST_CHECK( fileAuthorEmail.Contains( "@" ) ); + } + + if( !fileCommitterEmail.IsEmpty() ) + { + BOOST_CHECK( fileCommitterEmail.Contains( "@" ) ); + } +} + +/** + * Test VCS dirty status functions + */ +BOOST_AUTO_TEST_CASE( VcsDirtyStatus ) +{ + EXPRESSION_EVALUATOR evaluator; + + struct TestCase + { + std::string expression; + bool shouldError; + }; + + const std::vector cases = { + // Dirty status (returns 0 or 1) + { "@{vcsdirty()}", false }, + { "@{vcsdirty(0)}", false }, // Exclude untracked + { "@{vcsdirty(1)}", false }, // Include untracked + }; + + for( const auto& testCase : cases ) + { + auto result = evaluator.Evaluate( wxString::FromUTF8( testCase.expression ) ); + + if( testCase.shouldError ) + { + BOOST_CHECK( evaluator.HasErrors() ); + } + else + { + BOOST_CHECK( !evaluator.HasErrors() ); + + // If in a repo, validate format - dirty status should be 0 or 1 + if( !result.IsEmpty() ) + { + BOOST_CHECK( result == "0" || result == "1" ); + } + } + } +} + +/** + * Test VCS dirty suffix functions + */ +BOOST_AUTO_TEST_CASE( VcsDirtySuffix ) +{ + EXPRESSION_EVALUATOR evaluator; + + struct TestCase + { + std::string expression; + bool shouldError; + }; + + const std::vector cases = { + // Dirty suffix (returns string or empty) + { "@{vcsdirtysuffix()}", false }, + { "@{vcsdirtysuffix(\"-modified\")}", false }, + { "@{vcsdirtysuffix(\"+\", 1)}", false }, + }; + + for( const auto& testCase : cases ) + { + auto result = evaluator.Evaluate( wxString::FromUTF8( testCase.expression ) ); + + if( testCase.shouldError ) + { + BOOST_CHECK( evaluator.HasErrors() ); + } + else + { + BOOST_CHECK( !evaluator.HasErrors() ); + // Suffix can be any string or empty, just validate no errors + } + } +} + +/** + * Test VCS label and distance functions + */ +BOOST_AUTO_TEST_CASE( VcsLabelsAndDistance ) +{ + EXPRESSION_EVALUATOR evaluator; + + struct TestCase + { + std::string expression; + bool shouldError; + }; + + const std::vector cases = { + // Label functions + { "@{vcsnearestlabel()}", false }, + { "@{vcsnearestlabel(\"\")}", false }, + { "@{vcsnearestlabel(\"v*\")}", false }, // Match v* labels + { "@{vcsnearestlabel(\"\", 0)}", false }, // Annotated only + { "@{vcsnearestlabel(\"\", 1)}", false }, // Any labels + + // Distance functions + { "@{vcslabeldistance()}", false }, + { "@{vcslabeldistance(\"v*\")}", false }, + { "@{vcslabeldistance(\"\", 1)}", false }, + }; + + for( const auto& testCase : cases ) + { + auto result = evaluator.Evaluate( wxString::FromUTF8( testCase.expression ) ); + + if( testCase.shouldError ) + { + BOOST_CHECK( evaluator.HasErrors() ); + } + else + { + BOOST_CHECK( !evaluator.HasErrors() ); + + // Distance should be a valid number if not empty + if( !result.IsEmpty() && testCase.expression.find( "distance" ) != std::string::npos ) + { + std::regex numberPattern( "^[0-9]+$" ); + BOOST_CHECK( std::regex_match( result.ToStdString(), numberPattern ) ); + } + } + } +} + +/** + * Test VCS commit date formatting + */ +BOOST_AUTO_TEST_CASE( VcsCommitDate ) +{ + EXPRESSION_EVALUATOR evaluator; + + struct TestCase + { + std::string expression; + std::regex pattern; + bool shouldError; + }; + + const std::vector cases = { + // Different date formats + { "@{vcscommitdate()}", std::regex( "^([0-9]{4}-[0-9]{2}-[0-9]{2}|)$" ), false }, + { "@{vcscommitdate(\"ISO\")}", std::regex( "^([0-9]{4}-[0-9]{2}-[0-9]{2}|)$" ), false }, + { "@{vcscommitdate(\"US\")}", std::regex( "^([0-9]{2}/[0-9]{2}/[0-9]{4}|)$" ), false }, + { "@{vcscommitdate(\"EU\")}", std::regex( "^([0-9]{2}/[0-9]{2}/[0-9]{4}|)$" ), false }, + + // File variant + { "@{vcsfilecommitdate(\".\")}", std::regex( "^([0-9]{4}-[0-9]{2}-[0-9]{2}|)$" ), false }, + }; + + for( const auto& testCase : cases ) + { + auto result = evaluator.Evaluate( wxString::FromUTF8( testCase.expression ) ); + + if( testCase.shouldError ) + { + BOOST_CHECK( evaluator.HasErrors() ); + } + else + { + BOOST_CHECK_MESSAGE( !evaluator.HasErrors(), "Error in expression: " + testCase.expression + " Errors: " + + evaluator.GetErrorSummary().ToStdString() ); + + // Validate format if result is not empty + BOOST_CHECK( std::regex_match( result.ToStdString(), testCase.pattern ) ); + } + } +} + +/** + * Test performance of VCS operations + */ +BOOST_AUTO_TEST_CASE( VcsPerformance ) +{ + EXPRESSION_EVALUATOR evaluator; + + // Test that VCS operations are reasonably fast + auto start = std::chrono::high_resolution_clock::now(); + + // Perform many VCS operations + for( int i = 0; i < 100; ++i ) + { + auto result = evaluator.Evaluate( "@{vcsidentifier(7)}" ); + BOOST_CHECK( !evaluator.HasErrors() ); + } + + auto end = std::chrono::high_resolution_clock::now(); + auto duration = std::chrono::duration_cast( end - start ); + + // Should complete in reasonable time (less than 2 seconds for 100 operations) + BOOST_CHECK_LT( duration.count(), 2000 ); +} + +/** + * Test mixed VCS and non-VCS expressions + */ +BOOST_AUTO_TEST_CASE( VcsMixedExpressions ) +{ + EXPRESSION_EVALUATOR evaluator; + evaluator.SetVariable( wxString( "PROJECT" ), wxString( "MyProject" ) ); + + struct TestCase + { + std::string expression; + bool shouldError; + }; + + const std::vector cases = { + // Mixed with text + { "Version: @{vcsbranch()}", false }, + { "Commit: @{vcsidentifier(7)}", false }, + { "Author: @{vcsauthor()} <@{vcsauthoremail()}>", false }, + + // Mixed with variables + { "${PROJECT} @{vcsbranch()}", false }, + { "Built from @{vcsnearestlabel()}@{vcsdirtysuffix()}", false }, + + // Mixed with calculations + { "Distance: @{vcslabeldistance() + 0}", false }, + }; + + for( const auto& testCase : cases ) + { + auto result = evaluator.Evaluate( wxString::FromUTF8( testCase.expression ) ); + + if( testCase.shouldError ) + { + BOOST_CHECK( evaluator.HasErrors() ); + } + else + { + BOOST_CHECK( !evaluator.HasErrors() ); + BOOST_CHECK( !result.IsEmpty() ); + } + } +} + +BOOST_AUTO_TEST_SUITE_END()