diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15332a9..52a089b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -190,3 +190,64 @@ jobs: build/test/ build/Testing/ retention-days: 7 + + coverage: + name: Coverage (debian:stable / clang / Debug) + + runs-on: ubuntu-latest + + container: + image: debian:stable + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install dependencies + env: + DEBIAN_FRONTEND: noninteractive + run: bash dependencies.sh --compiler=clang + + - name: Cache CMake FetchContent (spdlog, clipp, fmt) + uses: actions/cache@v5 + with: + path: build/_deps + key: fetchcontent-coverage-${{ hashFiles('CMakeLists.txt') }} + restore-keys: | + fetchcontent-coverage- + + - name: Configure git identity (needed by tests that create commits) + run: | + git config --global user.email "ci@github-actions" + git config --global user.name "GitHub Actions" + git config --global init.defaultBranch master + + - name: Build with coverage + env: + CC: clang + CXX: clang++ + CI: "1" + COVERAGE: "1" + run: make TYPE=Debug test + + - name: Collect coverage data + run: | + gcovr --sonarqube coverage.xml \ + --gcov-executable 'llvm-cov gcov' \ + --gcov-ignore-errors=no_working_dir_found \ + --exclude 'build/_deps/' \ + --exclude 'test/' \ + --root . + + - name: Install codecov dependencies + env: + DEBIAN_FRONTEND: noninteractive + run: apt-get update && apt-get install -y curl gpg + + - name: Upload to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage.xml + flags: unittests + name: debian-stable-clang-debug diff --git a/.gitignore b/.gitignore index a099e18..0fc9d21 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ build/ .opencode/ /git-wip +coverage-report/ +coverage.xml + ### C++ # Prerequisites *.d diff --git a/CMakeLists.txt b/CMakeLists.txt index bf6a9d3..f913582 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,6 +8,13 @@ set(CMAKE_CXX_EXTENSIONS OFF) # Enable generation of compile_commands.json set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +# Code coverage options +option(WIP_COVERAGE "Enable code coverage instrumentation" OFF) +if(WIP_COVERAGE) + add_compile_options(-fprofile-arcs -ftest-coverage) + add_link_options(-fprofile-arcs -ftest-coverage) +endif() + include(FetchContent) include(CheckCXXSourceCompiles) diff --git a/Makefile b/Makefile index 99f9eda..82a300e 100644 --- a/Makefile +++ b/Makefile @@ -31,12 +31,16 @@ TYPE ?= $(if ${CURRENT_TYPE},${CURRENT_TYPE},${DEFAULT_TYPE}) NPROC ?= $(shell nproc || echo 1) CC ?= $(shell which clang gcc cc | head -n1) CXX ?= $(shell which clang g++ c++ | head -n1) -$(info ## TYPE=${TYPE} CC=${CC} CXX=${CXX}) +COVERAGE ?= false +$(info ## TYPE=${TYPE} CC=${CC} CXX=${CXX} COVERAGE=${COVERAGE}) + +# Coverage flag for CMake +COVERAGE_FLAG = $(if $(filter 1 yes true YES TRUE,${COVERAGE}),-DWIP_COVERAGE=ON,) GIT_WIP = ${BUILD}/src/git-wip -all: ## [default] build the project (uses TYPE={Release,Debug}) - ${Q}${CMAKE} -G ${GENERATOR} -S. -B${BUILD} -DCMAKE_INSTALL_PREFIX="$(PREFIX)" -DCMAKE_BUILD_TYPE="${TYPE}" +all: ## [default] build the project (uses TYPE={Release,Debug}, COVERAGE={true,false}) + ${Q}${CMAKE} -G ${GENERATOR} -S. -B${BUILD} -DCMAKE_INSTALL_PREFIX="$(PREFIX)" -DCMAKE_BUILD_TYPE="${TYPE}" ${COVERAGE_FLAG} ${Q}${CMAKE} --build "${BUILD}" --config "${TYPE}" --parallel "${NPROC}" ${Q}ln -fs "${BUILD}"/compile_commands.json compile_commands.json ${Q}ln -fs "${GIT_WIP}" . @@ -50,19 +54,21 @@ distclean: ## remove build directory completely help: ${Q}python3 -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) -test: ## run unit tests (with ctest, uses REBUILD={true,false}) +test: ## run unit tests (with ctest, uses REBUILD={true,false}, COVERAGE={true,false}) ${Q}$(if $(filter 1 yes true YES TRUE,${REBUILD}),rm -rf "${BUILD}"/) - ${Q}${CMAKE} -G ${GENERATOR} -S. -B${BUILD} -DCMAKE_INSTALL_PREFIX="$(PREFIX)" -DCMAKE_BUILD_TYPE="${TYPE}" + ${Q}${CMAKE} -G ${GENERATOR} -S. -B${BUILD} -DCMAKE_INSTALL_PREFIX="$(PREFIX)" -DCMAKE_BUILD_TYPE="${TYPE}" ${COVERAGE_FLAG} ${Q}${CMAKE} --build "${BUILD}" --config "${TYPE}" --parallel "${NPROC}" ${Q}cd "${BUILD}"/ && ctest -C "${TYPE}" $(if ${CI},--output-on-failure -VV) ${Q}echo " ✅ Unit tests complete." -coverage: ## check code coverage (with gcov, uses REBUILD={true,false}) +coverage: ## check code coverage (with gcovr, uses REBUILD={true,false}) ${Q}$(if $(filter 1 yes true YES TRUE,${REBUILD}),rm -rf "${BUILD}"/) - ${Q}${CMAKE} -G ${GENERATOR} -S. -B${BUILD} -DCMAKE_INSTALL_PREFIX="$(PREFIX)" -DCMAKE_BUILD_TYPE="${TYPE}" + ${Q}${CMAKE} -G ${GENERATOR} -S. -B${BUILD} -DCMAKE_INSTALL_PREFIX="$(PREFIX)" -DCMAKE_BUILD_TYPE="${TYPE}" -DWIP_COVERAGE=ON ${Q}${CMAKE} --build "${BUILD}" --config "${TYPE}" --parallel "${NPROC}" ${Q}cd "${BUILD}"/ && ctest -C "${TYPE}" -VV - ${Q}find "${BUILD}"/ -type f -name '*.gcno' -exec gcov -pb {} + + ${Q}mkdir -p coverage-report + ${Q}gcovr --html coverage-report/index.html --root . "${BUILD}" + ${Q}echo " ✅ Coverage report generated in coverage-report/" install: ## install the package (to the `PREFIX`, uses REBUILD={true,false}) ${Q}$(if $(filter 1 yes true YES TRUE,${REBUILD}),rm -rf "${BUILD}"/) diff --git a/README.md b/README.md index 8452fa0..182fc02 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # About +[![CI](https://github.com/bartman/git-wip/actions/workflows/ci.yml/badge.svg)](https://github.com/bartman/git-wip/actions) +[![Codecov](https://codecov.io/gh/bartman/git-wip/branch/main/graph/badge.svg)](https://codecov.io/gh/bartman/git-wip) + `git-wip` manages **Work In Progress** (or **WIP**) branches. WIP branches are mostly throw-away but capture points of development between commits. The intent is to tie `git-wip` into your editor so @@ -78,6 +81,15 @@ Old WIP commits are never deleted; they remain reachable through Snapshot the working tree with the default message `"WIP"`. Equivalent to `git wip save "WIP"`. +### `git wip [--version | -v | version]` + +Show the version string (from `git describe --tags --dirty=-dirty` at build time). + +``` +$ git wip --version +v0.2-83-g95a6648-dirty +``` + ### `git wip save [] [options] [-- ...]` Create a new WIP commit. diff --git a/cmake/GitVersion.cmake b/cmake/GitVersion.cmake new file mode 100644 index 0000000..55bb746 --- /dev/null +++ b/cmake/GitVersion.cmake @@ -0,0 +1,44 @@ +# GitVersion.cmake - Integration for GitVersion.sh +# +# Usage: +# include(GitVersion) +# gitversion_generate(PREFIX GIT_WIP_ OUTPUT ${CMAKE_BINARY_DIR}/git_wip_version.h) + +function(gitversion_generate) + set(options) + set(oneValueArgs PREFIX OUTPUT) + set(multiValueArgs) + + cmake_parse_arguments(GV "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) + + if(NOT GV_PREFIX) + message(FATAL_ERROR "gitversion_generate: PREFIX is required") + endif() + if(NOT GV_OUTPUT) + message(FATAL_ERROR "gitversion_generate: OUTPUT is required") + endif() + + # Run GitVersion.sh to generate the version header + execute_process( + COMMAND ${CMAKE_SOURCE_DIR}/cmake/GitVersion.sh ${GV_PREFIX} ${GV_OUTPUT} + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + RESULT_VARIABLE GV_RESULT + ) + + if(NOT GV_RESULT EQUAL 0) + message(WARNING "gitversion_generate: Failed to run GitVersion.sh") + endif() + + # Add a custom command to regenerate the version header + add_custom_command( + OUTPUT ${GV_OUTPUT} + COMMAND ${CMAKE_SOURCE_DIR}/cmake/GitVersion.sh ${GV_PREFIX} ${GV_OUTPUT} + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + DEPENDS ${CMAKE_SOURCE_DIR}/cmake/GitVersion.sh + COMMENT "Generating version header: ${GV_OUTPUT}" + VERBATIM + ) + + # Add a custom target that depends on the version header + add_custom_target(gitversion DEPENDS ${GV_OUTPUT}) +endfunction() diff --git a/cmake/GitVersion.sh b/cmake/GitVersion.sh new file mode 100755 index 0000000..69f0a20 --- /dev/null +++ b/cmake/GitVersion.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# GitVersion.sh - Generate version header from git describe +# Usage: GitVersion.sh PREFIX OUTPUT + +set -e + +PREFIX="$1" +OUTPUT="$2" + +if [ -z "$PREFIX" ] || [ -z "$OUTPUT" ]; then + echo "Usage: $0 PREFIX OUTPUT" >&2 + exit 1 +fi + +# Get git describe output +DESCRIBE="$(git describe --tags --dirty=-dirty 2>/dev/null || echo "unknown")" + +# Generate temporary output file +OUTPUT_TMP="${OUTPUT}.tmp" + +cat > "$OUTPUT_TMP" << EOF +#pragma once +#define ${PREFIX}VERSION "${DESCRIBE}" +EOF + +# Only update the file if it changed +if [ -f "$OUTPUT" ] && cmp -s "$OUTPUT" "$OUTPUT_TMP"; then + rm -f "$OUTPUT_TMP" +else + mv "$OUTPUT_TMP" "$OUTPUT" +fi diff --git a/dependencies.sh b/dependencies.sh index 02f9183..795ebc9 100755 --- a/dependencies.sh +++ b/dependencies.sh @@ -205,6 +205,7 @@ case "$pkg_mgr" in libgmock-dev libgtest-dev libgit2-dev + gcovr ) ;; dnf) @@ -212,12 +213,14 @@ case "$pkg_mgr" in gtest-devel gmock-devel libgit2-devel + gcovr ) ;; pacman) # Arch uses different package names packages+=( libgit2 + gcovr ) # Replace base packages with Arch equivalents packages=( "${packages[@]/ninja-build/ninja}" ) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 43a146b..2325093 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,3 +1,7 @@ +# Generate version header +include(${CMAKE_SOURCE_DIR}/cmake/GitVersion.cmake) +gitversion_generate(PREFIX GIT_WIP_ OUTPUT ${CMAKE_BINARY_DIR}/git_wip_version.h) + add_executable(git-wip color.cpp main.cpp @@ -8,11 +12,15 @@ add_executable(git-wip cmd_status.cpp ) +# Ensure the executable is rebuilt when the version header changes +add_dependencies(git-wip gitversion) + install(TARGETS git-wip RUNTIME DESTINATION bin ) target_include_directories(git-wip PRIVATE + ${CMAKE_BINARY_DIR} ${LIBGIT2_INCLUDE_DIRS} ) diff --git a/src/main.cpp b/src/main.cpp index d005663..4199946 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -8,6 +8,7 @@ #include #include +#include "git_wip_version.h" #include "print_compat.hpp" #include #include @@ -19,7 +20,7 @@ bool g_wip_debug = false; void print_main_help(const std::vector>& commands, std::ostream &os = std::cout) { std::println(os, "Manage Work In Progress\n"); - std::println(os, "git-wip [ --help | command options ]\n"); + std::println(os, "git-wip [ --help | --version | command options ]\n"); for (const auto& cmd : commands) { std::println(" git-wip {:20} # {}", cmd->name(), cmd->desc()); } @@ -69,6 +70,11 @@ int main(int argc, char *argv[]) { return 0; } + if (command_name == "version" || command_name == "--version" || command_name == "-v") { + std::cout << GIT_WIP_VERSION << std::endl; + return 0; + } + // If the first argument looks like a file (not a known command and not an // option), treat the whole invocation as "save WIP [files...]" — matching // the old script behaviour where bare file paths fall through to save.