diff --git a/.gitea/workflows/poc-dispatch.yml b/.gitea/workflows/poc-dispatch.yml deleted file mode 100644 index 6783fb3..0000000 --- a/.gitea/workflows/poc-dispatch.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: POC GitOps E2E -run-name: "POC E2E (${{ inputs.dispatch_id || 'orchestrator' }})" -on: - push: - branches: - - feature/gitops - workflow_dispatch: - inputs: - dispatch_id: - required: false - type: string - -jobs: - e2e: - if: github.event_name == 'push' - runs-on: ubuntu-latest - steps: - - name: Generate dispatch_id - id: gen - run: | - ID=$(date +%s | md5sum | head -c 8) - echo "dispatch_id=$ID" >> "$GITHUB_OUTPUT" - - - name: Dispatch to gitea-ci-gitops-tests - run: | - INPUTS=$(jq -nc \ - --arg dispatch_id "${{ steps.gen.outputs.dispatch_id }}" \ - --arg file "dev/Chart.yaml" \ - --arg yq_tpl '.version = "{{VERSION}}"' \ - --arg version "0.2.0" \ - --arg source_repo "${{ github.repository }}" \ - --arg source_commit "${{ github.sha }}" \ - --arg git_tag_prefix "poc-test" \ - '{dispatch_id: $dispatch_id, file: $file, yq_tpl: $yq_tpl, version: $version, source_repo: $source_repo, source_commit: $source_commit, git_tag_prefix: $git_tag_prefix}') - curl -s -X POST \ - "https://gitea.app.keskikuja.site/api/v1/repos/niko/gitea-ci-gitops-tests/actions/workflows/gitops-service.yaml/dispatches" \ - -H "Authorization: token ${{ secrets.GITOPS_DISPATCH_TOKEN }}" \ - -H "Content-Type: application/json" \ - -d "$(jq -nc --arg ref "main" --argjson inputs "$INPUTS" '{ref: "main", inputs: $inputs}')" - - - name: Poll for completion - run: | - ID=${{ steps.gen.outputs.dispatch_id }} - for i in $(seq 1 60); do - RUNS=$(curl -s "https://gitea.app.keskikuja.site/api/v1/repos/niko/gitea-ci-gitops-tests/actions/runs?event=workflow_dispatch&limit=10" \ - -H "Authorization: token ${{ secrets.GITOPS_DISPATCH_TOKEN }}") - FOUND=$(echo "$RUNS" | jq -r --arg id "$ID" \ - '[.workflow_runs[] | select(.display_title | contains($id))] | .[0]') - if [ -n "$FOUND" ] && [ "$FOUND" != "null" ]; then - STATUS=$(echo "$FOUND" | jq -r '.status') - CONCLUSION=$(echo "$FOUND" | jq -r '.conclusion // ""') - RUN_NUM=$(echo "$FOUND" | jq -r '.run_number') - echo "run found: id=$(echo $FOUND | jq -r '.id') number=$RUN_NUM display_title=$(echo $FOUND | jq -r '.display_title') status=$STATUS" - if [ "$STATUS" = "completed" ]; then - if [ "$CONCLUSION" = "success" ]; then - echo "POC PASS: gitops workflow completed successfully" - exit 0 - else - echo "POC FAIL: gitops workflow completed with conclusion=$CONCLUSION" - exit 1 - fi - fi - fi - sleep 5 - done - echo "POC FAIL: timeout waiting for gitops workflow" - exit 124 diff --git a/scripts/poc-dispatch-match.sh b/scripts/poc-dispatch-match.sh deleted file mode 100755 index e182f6e..0000000 --- a/scripts/poc-dispatch-match.sh +++ /dev/null @@ -1,189 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# -# POC: testaa display_title-matchingia dispatchatuille workflow runeille. -# -# Ajaa kaksi testiä: -# A — dispatchaa gitea-ci-library:n poc-dispatch.yml (tämä repo, feature-haara) -# B — dispatchaa gitea-ci-gitops-tests:n gitops-service.yaml -# -# Käyttö: -# export GITEA_API_URL=https://gitea.app.keskikuja.site -# export GITEA_TOKEN=... -# bash scripts/poc-dispatch-match.sh -# - -GITEA_API_URL="${GITEA_API_URL:?GITEA_API_URL is required}" -GITEA_TOKEN="${GITEA_TOKEN:?GITEA_TOKEN is required}" -BRANCH="${BRANCH:-feature/gitops}" -TIMEOUT_SECONDS="${TIMEOUT_SECONDS:-60}" -POLL_INTERVAL="${POLL_INTERVAL:-5}" -GITOPS_REPO="${GITOPS_REPO:-niko/gitea-ci-gitops-tests}" -GITOPS_WORKFLOW="${GITOPS_WORKFLOW:-gitops-service.yaml}" -LIB_REPO="${LIB_REPO:-niko/gitea-ci-library}" -LIB_WORKFLOW="${LIB_WORKFLOW:-poc-dispatch.yml}" - -PASS=0 -FAIL=0 - -_ts() { - date -u +%Y-%m-%dT%H:%M:%SZ -} - -_report() { - local label="$1" status="$2" detail="$3" - if [ "$status" = "PASS" ]; then - PASS=$((PASS + 1)) - echo " ✅ $label: $detail" - else - FAIL=$((FAIL + 1)) - echo " ❌ $label: $detail" - fi -} - -_dispatch_and_poll() { - local label="$1" target_repo="$2" workflow_file="$3" ref="$4" dispatch_id="$5" - local inputs_json="$6" - - echo "" - echo "=== Test: $label ===" - echo " target: $target_repo" - echo " workflow: $workflow_file" - echo " ref: $ref" - echo " dispatch_id: $dispatch_id" - - # 1. Ennen dispatchia: ota snapshot viimeisimmästä run_number:sta - local before_resp before_run before_count - before_resp=$(curl -s --connect-timeout 5 --max-time 10 \ - "$GITEA_API_URL/api/v1/repos/$target_repo/actions/runs?event=workflow_dispatch&limit=1" \ - -H "Authorization: token $GITEA_TOKEN") - - before_run=$(echo "$before_resp" | jq -r '.workflow_runs[0].run_number // 0') - before_count=$(echo "$before_resp" | jq -r '.total_count // 0') - echo " before: $before_count runs, latest run_number=$before_run" - - # 2. Dispatch - local dispatch_url="$GITEA_API_URL/api/v1/repos/$target_repo/actions/workflows/$workflow_file/dispatches" - local dispatch_body - dispatch_body=$(jq -nc --arg ref "$ref" --argjson inputs "$inputs_json" '{ref: $ref, inputs: $inputs}') - - local dispatch_code - dispatch_code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 --max-time 10 \ - -X POST "$dispatch_url" \ - -H "Authorization: token $GITEA_TOKEN" \ - -H "Content-Type: application/json" \ - -d "$dispatch_body") - - if [ "$dispatch_code" != "201" ] && [ "$dispatch_code" != "204" ]; then - _report "$label" "FAIL" "Dispatch failed with HTTP $dispatch_code" - return - fi - echo " dispatch: HTTP $dispatch_code — started" - - # 3. Pollaa: etsi run jossa display_title sisältää dispatch_id:n - local start_time elapsed found="" - start_time=$(date +%s) - - while true; do - elapsed=$(( $(date +%s) - start_time )) - if [ "$elapsed" -ge "$TIMEOUT_SECONDS" ]; then - break - fi - - local runs_resp found_id found_display found_status found_run_num - runs_resp=$(curl -s --connect-timeout 5 --max-time 10 \ - "$GITEA_API_URL/api/v1/repos/$target_repo/actions/runs?event=workflow_dispatch&limit=20" \ - -H "Authorization: token $GITEA_TOKEN") - - found_id=$(echo "$runs_resp" | jq -r --arg id "$dispatch_id" \ - '[.workflow_runs[] | select(.display_title | contains($id))] | .[0].id // empty') - - if [ -n "$found_id" ] && [ "$found_id" != "null" ]; then - found_display=$(echo "$runs_resp" | jq -r --arg id "$dispatch_id" \ - '[.workflow_runs[] | select(.display_title | contains($id))] | .[0].display_title // "?"') - found_status=$(echo "$runs_resp" | jq -r --arg id "$dispatch_id" \ - '[.workflow_runs[] | select(.display_title | contains($id))] | .[0].status // "?"') - found_run_num=$(echo "$runs_resp" | jq -r --arg id "$dispatch_id" \ - '[.workflow_runs[] | select(.display_title | contains($id))] | .[0].run_number // "?"') - - echo " found: id=$found_id run_number=$found_run_num status=$found_status display_title=\"$found_display\" (after ${elapsed}s)" - - # Odota että run on completed - local poll_url="$GITEA_API_URL/api/v1/repos/$target_repo/actions/runs/$found_id" - while [ "$elapsed" -lt "$TIMEOUT_SECONDS" ]; do - local run_resp run_status run_conclusion - run_resp=$(curl -s --connect-timeout 5 --max-time 10 \ - "$poll_url" -H "Authorization: token $GITEA_TOKEN") - run_status=$(echo "$run_resp" | jq -r '.status // "unknown"') - - if [ "$run_status" = "completed" ]; then - run_conclusion=$(echo "$run_resp" | jq -r '.conclusion // "?"') - elapsed=$(( $(date +%s) - start_time )) - _report "$label" "PASS" "display_title matched, status=$run_status conclusion=$run_conclusion run_number=$found_run_num (${elapsed}s)" - echo " run detail: id=$found_id display_title=\"$found_display\"" - echo " endpoint: $GITEA_API_URL/$target_repo/actions/runs/$found_id" - return - fi - - sleep "$POLL_INTERVAL" - elapsed=$(( $(date +%s) - start_time )) - done - - _report "$label" "FAIL" "Run found but didn't complete within ${TIMEOUT_SECONDS}s" - return - fi - - sleep "$POLL_INTERVAL" - done - - _report "$label" "FAIL" "No run with display_title containing \"$dispatch_id\" found within ${TIMEOUT_SECONDS}s" -} - -# Tallenna aloitusaika -POC_START=$(_ts) -echo "==============================================" -echo "POC: dispatch-workflow display_title matching" -echo "Started: $POC_START" -echo "API URL: $GITEA_API_URL" -echo "==============================================" - -# Testi A: dispatchaa tämän repon poc-dispatch.yml -DISPATCH_ID_A=$(xxd -l 4 -p /dev/urandom 2>/dev/null || openssl rand -hex 4 2>/dev/null || date +%s | md5sum | head -c 8) -INPUTS_A=$(jq -nc \ - --arg dispatch_id "$DISPATCH_ID_A" \ - --arg type "local" \ - '{dispatch_id: $dispatch_id, type: $type}') - -_dispatch_and_poll \ - "A: local poc-dispatch" \ - "$LIB_REPO" "$LIB_WORKFLOW" "$BRANCH" \ - "$DISPATCH_ID_A" "$INPUTS_A" - -# Testi B: dispatchaa gitea-ci-gitops-tests gitops-service.yaml -DISPATCH_ID_B=$(xxd -l 4 -p /dev/urandom 2>/dev/null || openssl rand -hex 4 2>/dev/null || date +%s | md5sum | head -c 8) -INPUTS_B=$(jq -nc \ - --arg dispatch_id "$DISPATCH_ID_B" \ - --arg file "dev/Chart.yaml" \ - --arg yq_tpl '.version = "{{VERSION}}"' \ - --arg version "0.1.1" \ - --arg source_repo "$LIB_REPO" \ - --arg source_commit "poc-test-$(date +%s)" \ - '{dispatch_id: $dispatch_id, file: $file, yq_tpl: $yq_tpl, version: $version, source_repo: $source_repo, source_commit: $source_commit}') - -_dispatch_and_poll \ - "B: gitops test" \ - "$GITOPS_REPO" "$GITOPS_WORKFLOW" "main" \ - "$DISPATCH_ID_B" "$INPUTS_B" - -# Loppuraportti -echo "" -echo "==============================================" -echo "POC complete" -echo " PASS: $PASS" -echo " FAIL: $FAIL" -echo "==============================================" - -if [ "$FAIL" -gt 0 ]; then - exit 1 -fi diff --git a/tests/features/gitops-update.feature b/tests/features/gitops-update.feature index 409df30..83c200e 100644 --- a/tests/features/gitops-update.feature +++ b/tests/features/gitops-update.feature @@ -1,6 +1,6 @@ Feature: GitOps update As a GitOps repository - I want to update version references and report results back to the caller + I want to update version references and report results to the caller So that the deployment chain is traceable from source to GitOps commit Background: @@ -8,37 +8,35 @@ Feature: GitOps update And a commit has been pushed to the repository @mock - Scenario: Not enough env vars — caller commit gets failure status + Scenario: Not enough env vars — script fails, no status set Given insufficient environment variables are provided for the GitOps update When the GitOps update script runs - Then the caller commit shows a failure status with the missing variable name - And the script exits with error + Then the script exits with error @mock - Scenario: GitOps job fails — caller commit gets failure status + Scenario: GitOps job fails — no status set (SHA not yet known) Given the GitOps repository clone will fail When the GitOps update script runs - Then the caller commit shows a failure status - And the script exits with error + Then the script exits with error @mock - Scenario: Everything succeeds — caller and GitOps get success + Scenario: Everything succeeds — GitOps repo gets success status with link to caller Given a valid GitOps update dispatch When the GitOps update script runs Then the script exits successfully - And the caller commit shows a success status with a link to the GitOps commit - And the GitOps repo commit shows a success status with a link to the caller + And the GitOps repo commit shows a success status with a link to the caller commit @mock - Scenario: GitOps push fails — both repos get failure status + Scenario: GitOps push fails — GitOps repo gets failure status Given the GitOps repo push will fail after the version is committed When the GitOps update script runs Then the script exits with error - And the caller commit shows a failure status - And the GitOps repo commit shows a failure status linking to the caller + And the GitOps repo commit shows a failure status linking to the caller commit @mock - Scenario: GitOps update succeeds — this repo commit status links to caller - Given a valid GitOps update dispatch + Scenario: No changes — GitOps repo gets "no change" status + Given the version file already has the target version When the GitOps update script runs - Then the GitOps repo commit shows a source context status linking to the caller commit + Then the script exits successfully + And the GitOps repo commit shows a "no change" status + And no Git commit or push was performed diff --git a/tests/features/step_definitions/gitops-update.steps.js b/tests/features/step_definitions/gitops-update.steps.js index e690433..4a033ba 100644 --- a/tests/features/step_definitions/gitops-update.steps.js +++ b/tests/features/step_definitions/gitops-update.steps.js @@ -41,7 +41,7 @@ Before({ tags: '@mock' }, function () { After({ tags: '@mock' }, function () { spawnSync('bash', ['-c', `source "${MOCK_SCRIPT}" && mock_stop 2>/dev/null`], { stdio: 'ignore' }); - try { execSync('rm -f /tmp/gitops-mock-requests.log', { stdio: 'ignore' }); } catch (_) {} + try { execSync('rm -f /tmp/gitops-mock-requests.log /tmp/gitops-git-calls.log', { stdio: 'ignore' }); } catch (_) {} }); function bash(cmd) { @@ -73,6 +73,11 @@ function requestCount() { return parseInt(bash(`grep -c '^POST ' "${REQ_FILE}" 2>/dev/null || echo 0`).stdout.trim(), 10) || 0; } +function gitCalls() { + const out = bash(`cat "${GIT_CALLS_FILE:-/dev/null}" 2>/dev/null || echo ""`).stdout; + return out.split('\n').filter(l => l.length > 0); +} + function runScript(envOverrides) { const env = { ...BASE_ENV, ...envOverrides }; const scriptPath = `/tmp/gitops-run-${Date.now()}.sh`; @@ -88,7 +93,6 @@ function runScript(envOverrides) { } Given('insufficient environment variables are provided for the GitOps update', function () { - this.missingVar = 'INPUT_FILE'; this.envOverrides = { INPUT_FILE: '' }; }); @@ -104,29 +108,17 @@ Given('the GitOps repo push will fail after the version is committed', function this.envOverrides = { GIT_MOCK_FAIL_PUSH: '1' }; }); +Given('the version file already has the target version', function () { + this.envOverrides = { + GIT_MOCK_DIFF_NO_CHANGES: '1', + GIT_CALLS_FILE: '/tmp/gitops-git-calls.log', + }; +}); + When('the GitOps update script runs', function () { this.result = runScript(this.envOverrides || {}); }); -Then('the caller commit shows a failure status with the missing variable name', function () { - const body = getFirstBody(); - if (!body.includes('"state":"failure"')) throw new Error(`Expected failure state, body: ${body.substring(0,200)}`); - if (!body.includes('"context":"gitops/niko/app"')) throw new Error(`Expected gitops context, body: ${body.substring(0,200)}`); - if (!body.includes(`"description":"${this.missingVar} is required`)) { - throw new Error(`Expected description mentioning ${this.missingVar}, body: ${body.substring(0,200)}`); - } - const pathStr = getFirstPath(); - if (!pathStr.includes('/repos/niko/app/statuses/')) throw new Error(`Expected source repo path, got: ${pathStr}`); -}); - -Then('the caller commit shows a failure status', function () { - const body = getFirstBody(); - if (!body.includes('"state":"failure"')) throw new Error(`Expected failure state, body: ${body.substring(0,200)}`); - if (!body.includes('"context":"gitops/niko/app"')) throw new Error(`Expected gitops context, body: ${body.substring(0,200)}`); - const pathStr = getFirstPath(); - if (!pathStr.includes('/repos/niko/app/statuses/')) throw new Error(`Expected source repo path, got: ${pathStr}`); -}); - Then('the script exits with error', function () { if (this.result.status === 0) throw new Error(`Expected non-zero exit, got 0. stderr: ${this.result.stderr.substring(0,200)}`); }); @@ -135,41 +127,45 @@ Then('the script exits successfully', function () { if (this.result.status !== 0) throw new Error(`Expected exit 0, got ${this.result.status}: ${this.result.stderr.substring(0,200)}`); }); -Then('the caller commit shows a success status with a link to the GitOps commit', function () { +Then('the GitOps repo commit shows a success status with a link to the caller commit', function () { + const count = requestCount(); + if (count < 1) throw new Error(`Expected at least 1 request, got ${count}`); const body = getFirstBody(); if (!body.includes('"state":"success"')) throw new Error(`Expected success state, body: ${body.substring(0,200)}`); - if (!body.includes('"context":"gitops/niko/app"')) throw new Error(`Expected gitops context, body: ${body.substring(0,200)}`); - if (!body.includes('niko/app-gitops/commits/')) throw new Error(`Expected link to GitOps commit, body: ${body.substring(0,200)}`); + if (!body.includes('"context":"app unknown"')) throw new Error(`Expected context "app unknown", body: ${body.substring(0,200)}`); + if (!body.includes('"description":"Install to dev 0.2.0"')) throw new Error(`Expected description, body: ${body.substring(0,200)}`); + if (!body.includes('niko/app/commit/abc123def456')) throw new Error(`Expected link to caller commit, body: ${body.substring(0,200)}`); const pathStr = getFirstPath(); - if (!pathStr.includes('/repos/niko/app/statuses/')) throw new Error(`Expected source repo path, got: ${pathStr}`); -}); - -Then('the GitOps repo commit shows a success status with a link to the caller', function () { - if (requestCount() < 2) throw new Error(`Expected at least 2 requests, got ${requestCount()}`); - const body = getLastBody(); - if (!body.includes('"state":"success"')) throw new Error(`Expected success state, body: ${body.substring(0,200)}`); - if (!body.includes('"context":"source/niko/app"')) throw new Error(`Expected source context, body: ${body.substring(0,200)}`); - if (!body.includes('niko/app/commits/abc123def456')) throw new Error(`Expected link to caller commit, body: ${body.substring(0,200)}`); - const pathStr = getLastPath(); if (!pathStr.includes('/repos/niko/app-gitops/statuses/')) throw new Error(`Expected gitops repo path, got: ${pathStr}`); }); -Then('the GitOps repo commit shows a failure status linking to the caller', function () { - if (requestCount() < 2) throw new Error(`Expected at least 2 requests, got ${requestCount()}`); - const body = getLastBody(); +Then('the GitOps repo commit shows a failure status linking to the caller commit', function () { + const count = requestCount(); + if (count < 1) throw new Error(`Expected at least 1 request, got ${count}`); + const body = getFirstBody(); if (!body.includes('"state":"failure"')) throw new Error(`Expected failure state, body: ${body.substring(0,200)}`); - if (!body.includes('"context":"source/niko/app"')) throw new Error(`Expected source context, body: ${body.substring(0,200)}`); - if (!body.includes('niko/app/commits/abc123def456')) throw new Error(`Expected link to caller commit, body: ${body.substring(0,200)}`); - const pathStr = getLastPath(); + if (!body.includes('"context":"app unknown"')) throw new Error(`Expected context "app unknown", body: ${body.substring(0,200)}`); + if (!body.includes('"description":"Install to dev 0.2.0"')) throw new Error(`Expected description, body: ${body.substring(0,200)}`); + if (!body.includes('niko/app/commit/abc123def456')) throw new Error(`Expected link to caller commit, body: ${body.substring(0,200)}`); + const pathStr = getFirstPath(); if (!pathStr.includes('/repos/niko/app-gitops/statuses/')) throw new Error(`Expected gitops repo path, got: ${pathStr}`); }); -Then('the GitOps repo commit shows a source context status linking to the caller commit', function () { - if (requestCount() < 2) throw new Error(`Expected at least 2 requests, got ${requestCount()}`); - const body = getLastBody(); +Then('the GitOps repo commit shows a "no change" status', function () { + const count = requestCount(); + if (count < 1) throw new Error(`Expected at least 1 request, got ${count}`); + const body = getFirstBody(); if (!body.includes('"state":"success"')) throw new Error(`Expected success state, body: ${body.substring(0,200)}`); - if (!body.includes('"context":"source/niko/app"')) throw new Error(`Expected source context, body: ${body.substring(0,200)}`); - if (!body.includes('niko/app/commits/abc123def456')) throw new Error(`Expected link to caller, body: ${body.substring(0,200)}`); - const pathStr = getLastPath(); + if (!body.includes('"description":"Install to dev 0.2.0 \u2014 no change"')) { + throw new Error(`Expected "no change" description, body: ${body.substring(0,200)}`); + } + const pathStr = getFirstPath(); if (!pathStr.includes('/repos/niko/app-gitops/statuses/')) throw new Error(`Expected gitops repo path, got: ${pathStr}`); }); + +Then('no Git commit or push was performed', function () { + const calls = gitCalls(); + if (calls.some(l => l.includes(' commit ') || l.includes(' push '))) { + throw new Error(`Expected no commit or push, got: ${calls.join(', ')}`); + } +}); diff --git a/tests/gitops-update.bats b/tests/gitops-update.bats index 30d163f..70a117a 100644 --- a/tests/gitops-update.bats +++ b/tests/gitops-update.bats @@ -139,10 +139,9 @@ teardown() { mock_stop } -@test "two commit-status calls: code-repo and gitops-repo" { +@test "one commit-status call: gitops-repo only" { source tests/helpers/mock-api.sh mock_set_sequence '[ - {"code":201}, {"code":201} ]' mock_start @@ -159,14 +158,12 @@ teardown() { export GITEA_TOKEN=test-token run bash scripts/gitops-update.sh [ "$status" -eq 0 ] - path1=$(mock_get_first_request_path) - body1=$(mock_get_first_request_body) - [[ "$path1" == *"/repos/niko/app/statuses/"* ]] - [[ "$body1" == *'"context":"gitops/niko/app"'* ]] - path2=$(mock_get_request_path) - body2=$(mock_get_request_body) - [[ "$path2" == *"/repos/niko/app-gitops/statuses/"* ]] - [[ "$body2" == *'"context":"source/niko/app"'* ]] + path=$(mock_get_first_request_path) + body=$(mock_get_first_request_body) + [[ "$path" == *"/repos/niko/app-gitops/statuses/"* ]] + [[ "$body" == *'"context":"app unknown"'* ]] + [[ "$body" == *'"description":"Install to dev 0.2.3"'* ]] + [[ "$body" == *'"state":"success"'* ]] rm -f "$GIT_CALLS_FILE" "$YQ_CALLS_FILE" mock_stop } diff --git a/tests/helpers/git b/tests/helpers/git index 207c5a2..5173d84 100755 --- a/tests/helpers/git +++ b/tests/helpers/git @@ -8,6 +8,11 @@ if [ "${1:-}" = "push" ] && [ -n "${GIT_MOCK_FAIL_PUSH:-}" ]; then exit 1 fi +# Skip -c config arguments +while [ "${1:-}" = "-c" ]; do + shift 2 +done + case "$1" in clone) TARGET_DIR="${@: -1}" @@ -17,6 +22,14 @@ case "$1" in ;; add|commit|push|config|init) ;; + diff) + # Default: exit 1 = has changes → proceed to commit + # GIT_MOCK_DIFF_NO_CHANGES=1 → exit 0 = no changes → "no change" path + if [ -n "${GIT_MOCK_DIFF_NO_CHANGES:-}" ]; then + exit 0 + fi + exit 1 + ;; rev-parse) echo "mock-sha-9876543210fedcba9876543210fedcba98765432" ;;