From 7c2d7313997557edb40ed735d088d7f8c574de38 Mon Sep 17 00:00:00 2001 From: Slim B Date: Thu, 12 Feb 2026 09:20:55 +0300 Subject: [PATCH] "#" --- .shellcheckrc | 7 + CHANGELOG.md | 25 ++ LICENSE | 21 + Makefile | 42 ++ README.md | 84 ++++ VERSION | 1 + action.yml | 64 +++ bin/josh-sync | 672 ++++++++++++++++++++++++++++++++ examples/devenv.nix | 36 ++ examples/forward.yml | 74 ++++ examples/josh-sync.yml | 43 ++ examples/reverse.yml | 52 +++ flake.nix | 83 ++++ lib/auth.sh | 61 +++ lib/config.sh | 122 ++++++ lib/core.sh | 32 ++ lib/state.sh | 75 ++++ lib/sync.sh | 303 ++++++++++++++ schema/config-schema.json | 96 +++++ tests/fixtures/minimal.yml | 16 + tests/fixtures/multi-target.yml | 29 ++ tests/unit/auth.bats | 39 ++ tests/unit/config.bats | 106 +++++ tests/unit/state.bats | 40 ++ 24 files changed, 2123 insertions(+) create mode 100644 .shellcheckrc create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 VERSION create mode 100644 action.yml create mode 100755 bin/josh-sync create mode 100644 examples/devenv.nix create mode 100644 examples/forward.yml create mode 100644 examples/josh-sync.yml create mode 100644 examples/reverse.yml create mode 100644 flake.nix create mode 100644 lib/auth.sh create mode 100644 lib/config.sh create mode 100644 lib/core.sh create mode 100644 lib/state.sh create mode 100644 lib/sync.sh create mode 100644 schema/config-schema.json create mode 100644 tests/fixtures/minimal.yml create mode 100644 tests/fixtures/multi-target.yml create mode 100644 tests/unit/auth.bats create mode 100644 tests/unit/config.bats create mode 100644 tests/unit/state.bats diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 0000000..94d00ee --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1,7 @@ +# josh-sync shellcheck configuration +# SC2155: Declare and assign separately to avoid masking return values +# We accept this pattern for jq/yq pipeline assignments where failure is handled by set -e +disable=SC2155 + +# Bash 4+ features +shell=bash diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f53e45c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +## 1.0.0 + +Initial release. Extracted from [private-monorepo-example](https://code.itkan.io/pe/private-monorepo-example) into a standalone reusable library. + +### Features + +- Bidirectional sync: forward (mono → subrepo) and reverse (subrepo → mono) +- Multi-target support via `.josh-sync.yml` config +- Per-target credential overrides (SSH keys, HTTPS tokens) +- Force-with-lease safety for forward sync +- Loop prevention via git trailers +- State tracking on orphan branch (`josh-sync-state`) +- Initial import and subrepo reset commands +- Composite action for Gitea/GitHub CI +- Nix flake with devenv module +- Preflight validation checks +- Config schema (JSON Schema) + +### Breaking Changes (vs. inline scripts) + +- Python + pyyaml replaced by yq-go (single static binary) +- 7 separate scripts replaced by single `josh-sync` CLI +- Consumer workflows use composite action (`uses: org/josh-sync@v1`) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5019ec7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 itkan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..073f855 --- /dev/null +++ b/Makefile @@ -0,0 +1,42 @@ +.PHONY: lint test build clean + +# Lint all shell scripts with shellcheck +lint: + @echo "Running shellcheck..." + shellcheck -x lib/*.sh bin/josh-sync + @echo "All checks passed." + +# Run bats-core unit tests +test: + @echo "Running tests..." + bats tests/unit/ + @echo "All tests passed." + +# Bundle into a single self-contained script +build: dist/josh-sync + +dist/josh-sync: bin/josh-sync lib/*.sh VERSION + @mkdir -p dist + @echo "Bundling josh-sync..." + @echo '#!/usr/bin/env bash' > dist/josh-sync + @echo "# josh-sync $(cat VERSION) — bundled distribution" >> dist/josh-sync + @echo '# Generated by: make build' >> dist/josh-sync + @echo '' >> dist/josh-sync + @# Inline all library modules (strip shebangs and source directives) + @for f in lib/core.sh lib/config.sh lib/auth.sh lib/state.sh lib/sync.sh; do \ + echo "# --- $$f ---" >> dist/josh-sync; \ + grep -v '^#!/' "$$f" | grep -v '^# shellcheck source=' >> dist/josh-sync; \ + echo '' >> dist/josh-sync; \ + done + @# Append CLI (strip shebangs, source directives, and source commands) + @echo '# --- bin/josh-sync ---' >> dist/josh-sync + @grep -v '^#!/' bin/josh-sync \ + | grep -v '^# shellcheck source=' \ + | grep -v '^source "\$${JOSH_LIB_DIR}/' \ + | sed '/^JOSH_LIB_DIR=/,/^source/d' \ + >> dist/josh-sync + @chmod +x dist/josh-sync + @echo "Built: dist/josh-sync ($(wc -l < dist/josh-sync) lines)" + +clean: + rm -rf dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..94e16f9 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# josh-sync + +Bidirectional monorepo ↔ subrepo sync via [josh-proxy](https://josh-project.github.io/josh/). Supports multiple sync targets from a single config. + +## Quick Start + +### 1. Add config + +Create `.josh-sync.yml` in your monorepo root: + +```yaml +josh: + proxy_url: "https://josh.example.com" + monorepo_path: "org/monorepo" + +targets: + - name: "billing" + subfolder: "services/billing" + josh_filter: ":/services/billing" + subrepo_url: "git@gitea.example.com:ext/billing.git" + subrepo_auth: "ssh" + branches: + main: main + forward_only: [] + +bot: + name: "josh-sync-bot" + email: "josh-sync-bot@example.com" + trailer: "Josh-Sync-Origin" +``` + +### 2. Add CI workflows + +Copy from [examples/](examples/) and customize paths/branches: + +```yaml +# .gitea/workflows/josh-sync-forward.yml +- uses: org/josh-sync@v1 + with: + direction: forward + env: + SYNC_BOT_USER: ${{ secrets.SYNC_BOT_USER }} + SYNC_BOT_TOKEN: ${{ secrets.SYNC_BOT_TOKEN }} + SUBREPO_SSH_KEY: ${{ secrets.SUBREPO_SSH_KEY }} +``` + +### 3. Local dev (Nix) + +Add josh-sync as a flake input, then: + +```nix +{ inputs, ... }: { + imports = [ inputs.josh-sync.devenvModules.default ]; +} +``` + +Run `josh-sync preflight` to validate your setup. + +## CLI + +``` +josh-sync sync [--forward|--reverse] [--target NAME] [--branch BRANCH] +josh-sync preflight +josh-sync import +josh-sync reset +josh-sync status +josh-sync state show [branch] +josh-sync state reset [branch] +``` + +## How It Works + +- **Forward sync** (mono → subrepo): pushes directly if clean, creates conflict PR if not. Uses `--force-with-lease` for safety. +- **Reverse sync** (subrepo → mono): always creates a PR, never pushes directly. +- **Loop prevention**: `Josh-Sync-Origin:` git trailer filters out bot commits. +- **State tracking**: orphan branch `josh-sync-state` stores JSON per target/branch. + +## Dependencies + +`bash >=4`, `git`, `curl`, `jq`, `yq` ([mikefarah/yq](https://github.com/mikefarah/yq) v4+), `openssh` + +## License + +MIT diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..5faf00f --- /dev/null +++ b/action.yml @@ -0,0 +1,64 @@ +name: josh-sync +description: Bidirectional monorepo ↔ subrepo sync via josh-proxy + +inputs: + config: + description: "Path to .josh-sync.yml" + default: ".josh-sync.yml" + direction: + description: "forward, reverse, or both" + default: "both" + target: + description: "Sync only this target (default: all)" + required: false + branch: + description: "Sync only this branch (default: all)" + required: false + debug: + description: "Enable debug output" + default: "false" + +runs: + using: composite + steps: + - name: Setup josh-sync + shell: bash + run: | + JOSH_DIR="$(mktemp -d)" + cp -r "${{ github.action_path }}/bin" "${{ github.action_path }}/lib" "${JOSH_DIR}/" + chmod +x "${JOSH_DIR}/bin/josh-sync" + echo "${JOSH_DIR}/bin" >> "$GITHUB_PATH" + echo "JOSH_SYNC_ROOT=${JOSH_DIR}" >> "$GITHUB_ENV" + + - name: Check dependencies + shell: bash + run: | + for cmd in git curl jq yq; do + command -v "$cmd" &>/dev/null || { echo "::error::Missing required tool: $cmd"; exit 1; } + done + + - name: Loop guard (forward) + id: guard + if: inputs.direction == 'forward' || inputs.direction == 'both' + shell: bash + run: | + TRAILER=$(yq '.bot.trailer' "${{ inputs.config }}" 2>/dev/null || echo "Josh-Sync-Origin") + if git log -1 --format=%B | grep -q "^${TRAILER}:"; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "::notice::Skipping sync — HEAD commit has sync trailer (loop prevention)" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Sync + if: steps.guard.outputs.skip != 'true' + shell: bash + env: + JOSH_SYNC_DEBUG: ${{ inputs.debug == 'true' && '1' || '0' }} + run: | + ARGS="--config ${{ inputs.config }}" + [[ "${{ inputs.direction }}" == "forward" ]] && ARGS+=" --forward" + [[ "${{ inputs.direction }}" == "reverse" ]] && ARGS+=" --reverse" + [[ -n "${{ inputs.target }}" ]] && ARGS+=" --target ${{ inputs.target }}" + [[ -n "${{ inputs.branch }}" ]] && ARGS+=" --branch ${{ inputs.branch }}" + josh-sync sync $ARGS diff --git a/bin/josh-sync b/bin/josh-sync new file mode 100755 index 0000000..38f8a63 --- /dev/null +++ b/bin/josh-sync @@ -0,0 +1,672 @@ +#!/usr/bin/env bash +# bin/josh-sync — CLI entrypoint for josh-sync +# +# Usage: josh-sync [flags] +# +# Commands: +# sync Run forward and/or reverse sync +# preflight Validate config, connectivity, auth +# import Initial import: pull subrepo into monorepo +# reset Reset subrepo to josh-filtered view +# status Show target config and sync state +# state show|reset Manage sync state directly +# +# Global flags: +# --config FILE Config path (default: .josh-sync.yml) +# --debug Verbose logging +# --version Show version +# --help Show usage + +set -euo pipefail + +# ─── Resolve library root ────────────────────────────────────────── + +if [ -n "${JOSH_SYNC_ROOT:-}" ]; then + JOSH_LIB_DIR="${JOSH_SYNC_ROOT}/lib" +else + JOSH_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" && pwd)" +fi + +# Source library modules +# shellcheck source=../lib/core.sh +source "${JOSH_LIB_DIR}/core.sh" +# shellcheck source=../lib/config.sh +source "${JOSH_LIB_DIR}/config.sh" +# shellcheck source=../lib/auth.sh +source "${JOSH_LIB_DIR}/auth.sh" +# shellcheck source=../lib/state.sh +source "${JOSH_LIB_DIR}/state.sh" +# shellcheck source=../lib/sync.sh +source "${JOSH_LIB_DIR}/sync.sh" + +# ─── Version ──────────────────────────────────────────────────────── + +josh_sync_version() { + local version_file + if [ -n "${JOSH_SYNC_ROOT:-}" ]; then + version_file="${JOSH_SYNC_ROOT}/VERSION" + else + version_file="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/VERSION" + fi + if [ -f "$version_file" ]; then + cat "$version_file" + else + echo "dev" + fi +} + +# ─── Usage ────────────────────────────────────────────────────────── + +usage() { + cat >&2 < [flags] + +Commands: + sync Run forward and/or reverse sync + preflight Validate config, connectivity, auth, workflow coverage + import Initial import: pull existing subrepo into monorepo (creates PR) + reset Reset subrepo to josh-filtered view (after merging import PR) + status Show target config and sync state + state show [branch] Show sync state JSON + state reset [branch] Reset sync state to {} + +Global flags: + --config FILE Config path (default: .josh-sync.yml) + --debug Verbose logging + --version Show version and exit + --help Show this help + +Sync flags: + --forward Forward only (mono → subrepo) + --reverse Reverse only (subrepo → mono) + --target NAME Filter to one target (env: JOSH_SYNC_TARGET) + --branch BRANCH Filter to one branch + +Environment: + JOSH_SYNC_TARGET Restrict to a single target name + JOSH_SYNC_STATE_BRANCH State branch name (default: josh-sync-state) + SYNC_BOT_USER Git username for auth + SYNC_BOT_TOKEN Token with repo scope + SUBREPO_TOKEN Subrepo-specific token (optional) + SUBREPO_SSH_KEY SSH key for SSH targets (optional) +EOF +} + +# ─── Sync Command ─────────────────────────────────────────────────── + +cmd_sync() { + local direction="both" + local filter_target="${JOSH_SYNC_TARGET:-}" + local filter_branch="" + local config_file=".josh-sync.yml" + + while [ $# -gt 0 ]; do + case "$1" in + --forward) direction="forward"; shift ;; + --reverse) direction="reverse"; shift ;; + --target) filter_target="$2"; shift 2 ;; + --branch) filter_branch="$2"; shift 2 ;; + --config) config_file="$2"; shift 2 ;; + --debug) export JOSH_SYNC_DEBUG=1; shift ;; + *) die "Unknown flag: $1" ;; + esac + done + + parse_config "$config_file" + + # Forward sync + if [ "$direction" = "forward" ] || [ "$direction" = "both" ]; then + _sync_direction "forward" "$filter_target" "$filter_branch" + fi + + # Reverse sync + if [ "$direction" = "reverse" ] || [ "$direction" = "both" ]; then + _sync_direction "reverse" "$filter_target" "$filter_branch" + fi +} + +_sync_direction() { + local direction="$1" + local filter_target="$2" + local filter_branch="$3" + + while read -r TARGET_JSON; do + local target_name + target_name=$(echo "$TARGET_JSON" | jq -r '.name') + + # Filter to specific target if requested + if [ -n "$filter_target" ] && [ "$filter_target" != "$target_name" ]; then + continue + fi + + log "INFO" "══════ Target: ${target_name} (${direction}) ══════" + load_target "$TARGET_JSON" + + # Resolve branches + local branches + if [ -n "$filter_branch" ]; then + branches="$filter_branch" + elif [ "$direction" = "reverse" ]; then + # Exclude forward_only branches for reverse sync + branches=$(echo "$TARGET_JSON" | jq -r ' + (.forward_only // []) as $fwd | + .branches | keys[] | select(. as $b | $fwd | index($b) | not) + ') + else + branches=$(echo "$TARGET_JSON" | jq -r '.branches | keys[]') + fi + + # Sync each branch + for branch in $branches; do + echo "" >&2 + log "INFO" "━━━ Processing branch: ${branch} (${direction}) ━━━" + + # Resolve branch mapping + local mapped + mapped=$(echo "$TARGET_JSON" | jq -r --arg b "$branch" '.branches[$b] // empty') + if [ -z "$mapped" ]; then + log "WARN" "No mapping for branch ${branch} — skipping" + continue + fi + + export SYNC_BRANCH_MONO="$branch" + export SYNC_BRANCH_SUBREPO="$mapped" + + # Check state BEFORE cloning (skip if unchanged) + local state prev_sha current_sha + state=$(read_state "$branch") + + if [ "$direction" = "forward" ]; then + prev_sha=$(echo "$state" | jq -r '.last_forward.mono_sha // empty') + current_sha=$(git rev-parse "origin/${branch}" 2>/dev/null || echo "unknown") + + if [ "$prev_sha" = "$current_sha" ] && [ -n "$prev_sha" ]; then + log "INFO" "Branch ${branch} unchanged since last sync — skipping" + continue + fi + else + prev_sha=$(echo "$state" | jq -r '.last_reverse.subrepo_sha // empty') + current_sha=$(subrepo_ls_remote "${mapped}") + + if [ -z "$current_sha" ]; then + log "WARN" "Subrepo branch ${mapped} doesn't exist — skipping" + continue + fi + + if [ "$prev_sha" = "$current_sha" ] && [ -n "$prev_sha" ]; then + log "INFO" "Subrepo branch ${mapped} unchanged — skipping" + continue + fi + fi + + # Run sync + local result + if [ "$direction" = "forward" ]; then + result=$(forward_sync) + else + result=$(reverse_sync) + fi + log "INFO" "Result: ${result}" + + # Handle warnings + if [ "$result" = "lease-rejected" ]; then + echo "::warning::Target ${target_name}, branch ${branch}: subrepo changed during sync — will retry" + continue + fi + if [ "$result" = "conflict" ]; then + echo "::warning::Target ${target_name}, branch ${branch}: merge conflict — PR created on subrepo" + fi + if [ "$result" = "josh-rejected" ]; then + echo "::error::Target ${target_name}, branch ${branch}: josh rejected push — check proxy logs" + continue + fi + + # Update state (only on success) + local new_state + if [ "$direction" = "forward" ]; then + local subrepo_sha_now + subrepo_sha_now=$(subrepo_ls_remote "${mapped}") + new_state=$(jq -n \ + --arg m_sha "$current_sha" \ + --arg s_sha "${subrepo_sha_now:-}" \ + --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg status "$result" \ + --argjson prev "$state" \ + '$prev + {last_forward: {mono_sha:$m_sha, subrepo_sha:$s_sha, timestamp:$ts, status:$status}}') + else + local mono_sha_now + mono_sha_now=$(git rev-parse "origin/${branch}" 2>/dev/null || echo "") + new_state=$(jq -n \ + --arg s_sha "$current_sha" \ + --arg m_sha "${mono_sha_now:-}" \ + --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg status "$result" \ + --argjson prev "$state" \ + '$prev + {last_reverse: {subrepo_sha:$s_sha, mono_sha:$m_sha, timestamp:$ts, status:$status}}') + fi + + write_state "$branch" "$new_state" + done + done < <(echo "$JOSH_SYNC_TARGETS" | jq -c '.[]') +} + +# ─── Preflight Command ───────────────────────────────────────────── + +cmd_preflight() { + local config_file=".josh-sync.yml" + + while [ $# -gt 0 ]; do + case "$1" in + --config) config_file="$2"; shift 2 ;; + --debug) export JOSH_SYNC_DEBUG=1; shift ;; + *) die "Unknown flag: $1" ;; + esac + done + + local RED='\033[0;31m' + local GREEN='\033[0;32m' + local YELLOW='\033[1;33m' + local NC='\033[0m' + + pass() { echo -e " ${GREEN}✓${NC} $1"; } + fail() { echo -e " ${RED}✗${NC} $1"; ERRORS=$((ERRORS + 1)); } + warn() { echo -e " ${YELLOW}!${NC} $1"; } + + local ERRORS=0 + + echo "Josh Sync — Pre-flight Check" + echo "=============================" + echo "" + + # 1. Config + echo "1. Configuration" + + if [ ! -f "$config_file" ]; then + fail "${config_file} not found (run from monorepo root)" + exit 1 + fi + pass "${config_file} exists" + + parse_config "$config_file" + + [ -n "$JOSH_PROXY_URL" ] && pass "proxy_url: ${JOSH_PROXY_URL}" || fail "proxy_url missing" + [ -n "$MONOREPO_PATH" ] && pass "monorepo_path: ${MONOREPO_PATH}" || fail "monorepo_path missing" + [ -n "$BOT_TRAILER" ] && pass "trailer: ${BOT_TRAILER}" || fail "trailer missing" + [ -n "${GITEA_TOKEN:-}" ] && pass "SYNC_BOT_TOKEN is set" || warn "SYNC_BOT_TOKEN missing in .env" + [ -n "${BOT_USER:-}" ] && pass "BOT_USER: ${BOT_USER}" || warn "BOT_USER missing in .env" + + local target_count + target_count=$(echo "$JOSH_SYNC_TARGETS" | jq 'length') + pass "Targets configured: ${target_count}" + + # 2. Per-target checks + echo "" + echo "2. Targets" + + while read -r TARGET_JSON; do + local target_name subfolder auth + target_name=$(echo "$TARGET_JSON" | jq -r '.name') + subfolder=$(echo "$TARGET_JSON" | jq -r '.subfolder') + auth=$(echo "$TARGET_JSON" | jq -r '.subrepo_auth // "https"') + + echo "" + echo " ── Target: ${target_name} ──" + + load_target "$TARGET_JSON" + + [ -n "$JOSH_FILTER" ] && pass "josh_filter: ${JOSH_FILTER}" || fail "josh_filter missing" + [ -n "$SUBREPO_URL" ] && pass "subrepo_url: ${SUBREPO_URL}" || fail "subrepo_url missing" + + # Subfolder exists + if [ -d "$subfolder" ]; then + local file_count + file_count=$(find "$subfolder" -type f | wc -l) + pass "Subfolder ${subfolder}/ exists (${file_count} files)" + else + fail "Subfolder ${subfolder}/ not found" + if [ -z "${CI:-}" ]; then + echo " If the subrepo already has content, run: josh-sync import ${target_name}" + fi + fi + + # SSH key check + if [ "$auth" = "ssh" ]; then + local local_ssh_key_var + local_ssh_key_var=$(echo "$TARGET_JSON" | jq -r '.subrepo_ssh_key_var // "SUBREPO_SSH_KEY"') + if [ -n "${!local_ssh_key_var:-}" ]; then + pass "SSH key set (${local_ssh_key_var})" + else + warn "SSH key missing (${local_ssh_key_var})" + fi + fi + + # Josh proxy connectivity + if timeout 60 git ls-remote "$(josh_auth_url)" HEAD >/dev/null 2>&1; then + pass "Josh filter works for ${target_name}" + else + fail "Cannot ls-remote through josh for ${target_name}" + echo " Try: git ls-remote ${JOSH_PROXY_URL}/${MONOREPO_PATH}.git${JOSH_FILTER}.git" + fi + + # Subrepo connectivity + if [ "$auth" = "ssh" ]; then + local local_ssh_key_var2 + local_ssh_key_var2=$(echo "$TARGET_JSON" | jq -r '.subrepo_ssh_key_var // "SUBREPO_SSH_KEY"') + if [ -n "${!local_ssh_key_var2:-}" ]; then + if timeout 10 git ls-remote "$(subrepo_auth_url)" HEAD >/dev/null 2>&1; then + pass "Subrepo reachable via SSH" + else + fail "Cannot ls-remote subrepo via SSH" + fi + else + warn "Skipping subrepo check — no SSH key" + fi + else + if timeout 10 git ls-remote "$(subrepo_auth_url)" HEAD >/dev/null 2>&1; then + pass "Subrepo reachable via HTTPS" + else + fail "Cannot ls-remote subrepo via HTTPS" + fi + fi + + # Branch mapping + echo "$TARGET_JSON" | jq -r ' + (.forward_only // []) as $fwd | + .branches | to_entries[] | + .key as $k | .value as $v | + " mono/\($k) \(if ($fwd | index($k)) then "→ only" else "↔" end) subrepo/\($v)" + ' + done < <(echo "$JOSH_SYNC_TARGETS" | jq -c '.[]') + + # 3. Workflow path coverage + echo "" + echo "3. Workflow path coverage" + + local forward_workflow=".gitea/workflows/josh-sync-forward.yml" + if [ -f "$forward_workflow" ]; then + pass "${forward_workflow} exists" + + local workflow_paths + workflow_paths=$(yq -r '.on.push.paths[]?' "$forward_workflow" 2>/dev/null \ + | sed 's|/\*\*$||; s|/\*$||' || echo "") + + while read -r subfolder; do + if echo "$workflow_paths" | grep -q "^${subfolder}$"; then + pass "Workflow path covers ${subfolder}" + else + warn "Workflow paths missing ${subfolder}/** — add it to ${forward_workflow}" + fi + done < <(echo "$JOSH_SYNC_TARGETS" | jq -r '.[].subfolder') + else + warn "${forward_workflow} not found (optional for local-only use)" + fi + + if [ -f ".gitea/workflows/josh-sync-reverse.yml" ]; then + pass ".gitea/workflows/josh-sync-reverse.yml exists" + else + warn ".gitea/workflows/josh-sync-reverse.yml not found (optional)" + fi + + # Summary + echo "" + echo "=============================" + if [ "$ERRORS" -eq 0 ]; then + echo -e "${GREEN}All checks passed.${NC} Ready to deploy." + else + echo -e "${RED}${ERRORS} check(s) failed.${NC} Fix issues above before deploying." + exit 1 + fi +} + +# ─── Import Command ──────────────────────────────────────────────── + +cmd_import() { + local config_file=".josh-sync.yml" + local target_name="" + + while [ $# -gt 0 ]; do + case "$1" in + --config) config_file="$2"; shift 2 ;; + --debug) export JOSH_SYNC_DEBUG=1; shift ;; + -*) die "Unknown flag: $1" ;; + *) target_name="$1"; shift ;; + esac + done + + if [ -z "$target_name" ]; then + echo "Usage: josh-sync import " >&2 + parse_config "$config_file" + echo "Available targets:" >&2 + echo "$JOSH_SYNC_TARGETS" | jq -r '.[].name' | sed 's/^/ /' >&2 + exit 1 + fi + + parse_config "$config_file" + + local target_json + target_json=$(echo "$JOSH_SYNC_TARGETS" | jq -c --arg n "$target_name" '.[] | select(.name == $n)') + [ -n "$target_json" ] || die "Target '${target_name}' not found in config" + + log "INFO" "══════ Import target: ${target_name} ══════" + load_target "$target_json" + + local branches + branches=$(echo "$target_json" | jq -r '.branches | keys[]') + + for branch in $branches; do + local mapped + mapped=$(echo "$target_json" | jq -r --arg b "$branch" '.branches[$b] // empty') + [ -z "$mapped" ] && continue + + export SYNC_BRANCH_MONO="$branch" + export SYNC_BRANCH_SUBREPO="$mapped" + + local result + result=$(initial_import) + log "INFO" "Result: ${result}" + done +} + +# ─── Reset Command ───────────────────────────────────────────────── + +cmd_reset() { + local config_file=".josh-sync.yml" + local target_name="" + + while [ $# -gt 0 ]; do + case "$1" in + --config) config_file="$2"; shift 2 ;; + --debug) export JOSH_SYNC_DEBUG=1; shift ;; + -*) die "Unknown flag: $1" ;; + *) target_name="$1"; shift ;; + esac + done + + if [ -z "$target_name" ]; then + echo "Usage: josh-sync reset " >&2 + parse_config "$config_file" + echo "Available targets:" >&2 + echo "$JOSH_SYNC_TARGETS" | jq -r '.[].name' | sed 's/^/ /' >&2 + exit 1 + fi + + parse_config "$config_file" + + local target_json + target_json=$(echo "$JOSH_SYNC_TARGETS" | jq -c --arg n "$target_name" '.[] | select(.name == $n)') + [ -n "$target_json" ] || die "Target '${target_name}' not found in config" + + log "INFO" "══════ Reset target: ${target_name} ══════" + load_target "$target_json" + + local branches + branches=$(echo "$target_json" | jq -r '.branches | keys[]') + + for branch in $branches; do + local mapped + mapped=$(echo "$target_json" | jq -r --arg b "$branch" '.branches[$b] // empty') + [ -z "$mapped" ] && continue + + export SYNC_BRANCH_MONO="$branch" + export SYNC_BRANCH_SUBREPO="$mapped" + + local result + result=$(subrepo_reset) + log "INFO" "Result: ${result}" + done +} + +# ─── Status Command ──────────────────────────────────────────────── + +cmd_status() { + local config_file=".josh-sync.yml" + + while [ $# -gt 0 ]; do + case "$1" in + --config) config_file="$2"; shift 2 ;; + --debug) export JOSH_SYNC_DEBUG=1; shift ;; + *) die "Unknown flag: $1" ;; + esac + done + + parse_config "$config_file" + + echo "Josh Sync Status" + echo "================" + echo "" + echo "Josh proxy: ${JOSH_PROXY_URL}" + echo "Monorepo: ${MONOREPO_PATH}" + echo "Bot: ${BOT_NAME} <${BOT_EMAIL}>" + echo "" + + while read -r TARGET_JSON; do + local target_name subfolder auth + target_name=$(echo "$TARGET_JSON" | jq -r '.name') + subfolder=$(echo "$TARGET_JSON" | jq -r '.subfolder') + auth=$(echo "$TARGET_JSON" | jq -r '.subrepo_auth // "https"') + + echo "Target: ${target_name}" + echo " Subfolder: ${subfolder}" + echo " Subrepo: $(echo "$TARGET_JSON" | jq -r '.subrepo_url')" + echo " Auth: ${auth}" + echo " Filter: $(echo "$TARGET_JSON" | jq -r '.josh_filter')" + + load_target "$TARGET_JSON" + + echo " Branches:" + echo "$TARGET_JSON" | jq -r '.branches | to_entries[] | " \(.key) → \(.value)"' + + local fwd_only + fwd_only=$(echo "$TARGET_JSON" | jq -r '(.forward_only // []) | join(", ")') + [ -n "$fwd_only" ] && echo " Forward only: ${fwd_only}" + + # Show state for each branch + echo " State:" + for branch in $(echo "$TARGET_JSON" | jq -r '.branches | keys[]'); do + local state + state=$(read_state "$branch") + if [ "$state" = "{}" ]; then + echo " ${branch}: (no state)" + else + local fwd_status fwd_ts rev_status rev_ts + fwd_status=$(echo "$state" | jq -r '.last_forward.status // "-"') + fwd_ts=$(echo "$state" | jq -r '.last_forward.timestamp // "-"') + rev_status=$(echo "$state" | jq -r '.last_reverse.status // "-"') + rev_ts=$(echo "$state" | jq -r '.last_reverse.timestamp // "-"') + echo " ${branch}: fwd=${fwd_status} (${fwd_ts}), rev=${rev_status} (${rev_ts})" + fi + done + echo "" + done < <(echo "$JOSH_SYNC_TARGETS" | jq -c '.[]') +} + +# ─── State Command ───────────────────────────────────────────────── + +cmd_state() { + local subcmd="${1:-}" + shift || true + + local config_file=".josh-sync.yml" + local target_name="" + local branch="main" + + # Parse remaining args + local args=() + while [ $# -gt 0 ]; do + case "$1" in + --config) config_file="$2"; shift 2 ;; + -*) die "Unknown flag: $1" ;; + *) args+=("$1"); shift ;; + esac + done + + [ "${#args[@]}" -ge 1 ] && target_name="${args[0]}" + [ "${#args[@]}" -ge 2 ] && branch="${args[1]}" + + case "$subcmd" in + show) + [ -n "$target_name" ] || { echo "Usage: josh-sync state show [branch]" >&2; exit 1; } + parse_config "$config_file" + + local target_json + target_json=$(echo "$JOSH_SYNC_TARGETS" | jq -c --arg n "$target_name" '.[] | select(.name == $n)') + [ -n "$target_json" ] || die "Target '${target_name}' not found" + + load_target "$target_json" + read_state "$branch" | jq '.' + ;; + reset) + [ -n "$target_name" ] || { echo "Usage: josh-sync state reset [branch]" >&2; exit 1; } + parse_config "$config_file" + + local target_json + target_json=$(echo "$JOSH_SYNC_TARGETS" | jq -c --arg n "$target_name" '.[] | select(.name == $n)') + [ -n "$target_json" ] || die "Target '${target_name}' not found" + + load_target "$target_json" + write_state "$branch" "{}" + log "INFO" "State reset for target '${target_name}', branch '${branch}'" + ;; + *) + echo "Usage: josh-sync state [branch]" >&2 + exit 1 + ;; + esac +} + +# ─── Main ─────────────────────────────────────────────────────────── + +main() { + local command="${1:-}" + shift || true + + # Handle global flags that can appear before command + case "$command" in + --version|-v) + echo "josh-sync $(josh_sync_version)" + exit 0 + ;; + --help|-h|"") + usage + exit 0 + ;; + esac + + case "$command" in + sync) cmd_sync "$@" ;; + preflight) cmd_preflight "$@" ;; + import) cmd_import "$@" ;; + reset) cmd_reset "$@" ;; + status) cmd_status "$@" ;; + state) cmd_state "$@" ;; + *) + echo "Unknown command: ${command}" >&2 + usage + exit 1 + ;; + esac +} + +main "$@" diff --git a/examples/devenv.nix b/examples/devenv.nix new file mode 100644 index 0000000..e7b0c17 --- /dev/null +++ b/examples/devenv.nix @@ -0,0 +1,36 @@ +# Consumer devenv.nix example +# Add josh-sync as a flake input in your devenv.yaml or flake.nix, +# then import the module here. +# +# In devenv.yaml: +# inputs: +# josh-sync: +# url: github:org/josh-sync/v1.0.0 +# flake: true +# +# Or in flake.nix: +# inputs.josh-sync = { +# url = "github:org/josh-sync/v1.0.0"; +# inputs.nixpkgs.follows = "nixpkgs"; +# }; + +{ inputs, pkgs, ... }: + +{ + imports = [ inputs.josh-sync.devenvModules.default ]; + + # josh-sync CLI is now available in the shell. + # Commands: + # josh-sync sync --forward Forward sync (mono → subrepo) + # josh-sync sync --reverse Reverse sync (subrepo → mono) + # josh-sync preflight Validate config and connectivity + # josh-sync import Initial import from subrepo + # josh-sync reset Reset subrepo to josh-filtered view + # josh-sync status Show target config and sync state + # josh-sync state show [b] Show state JSON + # josh-sync state reset [b] Reset state + + enterShell = '' + echo "Josh Sync available — run 'josh-sync --help' for commands" + ''; +} diff --git a/examples/forward.yml b/examples/forward.yml new file mode 100644 index 0000000..5c2fad7 --- /dev/null +++ b/examples/forward.yml @@ -0,0 +1,74 @@ +# .gitea/workflows/josh-sync-forward.yml — Consumer workflow template +# Syncs monorepo subfolder(s) → external subrepo(s) +# +# Customize: +# - paths: list all target subfolders +# - branches: list all monorepo branches to trigger on +# - org/josh-sync@v1: pin to your library repo and version + +name: "Josh Sync → Subrepo" + +on: + push: + branches: [main] + paths: + # List all target subfolders here (must match .josh-sync.yml targets[].subfolder) + - "services/billing/**" + - "services/auth/**" + - "libs/shared/**" + schedule: + - cron: "0 */6 * * *" + workflow_dispatch: + inputs: + target: + description: "Target to sync (empty = detect from push or all on schedule)" + required: false + default: "" + branch: + description: "Branch to sync (empty = triggered branch or all)" + required: false + default: "" + +concurrency: + group: josh-sync-fwd-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + sync: + runs-on: docker + container: node:20-bookworm + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Install tools + run: | + apt-get update -qq && apt-get install -y -qq jq curl git openssh-client >/dev/null 2>&1 + YQ_VERSION=v4.44.6 + curl -sL "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" \ + -o /usr/local/bin/yq && chmod +x /usr/local/bin/yq + + - name: Detect changed target + if: github.event_name == 'push' + id: detect + run: | + CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || echo "") + TARGETS=$(yq -o json '.targets' .josh-sync.yml \ + | jq -r '.[] | "\(.name):\(.subfolder)"' \ + | while IFS=: read -r name prefix; do + echo "$CHANGED" | grep -q "^${prefix}/" && echo "$name" + done | sort -u | tr '\n' ' ') + echo "targets=${TARGETS}" >> "$GITHUB_OUTPUT" + + - uses: org/josh-sync@v1 + with: + direction: forward + target: ${{ github.event.inputs.target || steps.detect.outputs.targets }} + branch: ${{ github.event.inputs.branch || github.ref_name }} + env: + SYNC_BOT_USER: ${{ secrets.SYNC_BOT_USER }} + SYNC_BOT_TOKEN: ${{ secrets.SYNC_BOT_TOKEN }} + SUBREPO_TOKEN: ${{ secrets.SUBREPO_TOKEN || secrets.SYNC_BOT_TOKEN }} + SUBREPO_SSH_KEY: ${{ secrets.SUBREPO_SSH_KEY }} diff --git a/examples/josh-sync.yml b/examples/josh-sync.yml new file mode 100644 index 0000000..5db4223 --- /dev/null +++ b/examples/josh-sync.yml @@ -0,0 +1,43 @@ +# .josh-sync.yml — Multi-target configuration example +# Place this at the root of your monorepo. + +josh: + # Your josh-proxy instance URL (no trailing slash) + proxy_url: "https://josh.example.com" + # Repo path as josh sees it (org/repo on your Gitea/GitHub) + monorepo_path: "org/monorepo" + +targets: + - name: "billing" + subfolder: "services/billing" + josh_filter: ":/services/billing" + subrepo_url: "https://gitea.example.com/ext/billing.git" + subrepo_auth: "https" + branches: + main: main + develop: develop + forward_only: [] + + - name: "auth" + subfolder: "services/auth" + josh_filter: ":/services/auth" + subrepo_url: "git@gitea.example.com:ext/auth.git" + subrepo_auth: "ssh" + # Per-target credential override (reads from $AUTH_SSH_KEY instead of $SUBREPO_SSH_KEY) + subrepo_ssh_key_var: "AUTH_SSH_KEY" + branches: + main: main + forward_only: [] + + - name: "shared-lib" + subfolder: "libs/shared" + josh_filter: ":/libs/shared" + subrepo_url: "https://gitea.example.com/ext/shared-lib.git" + branches: + main: main + forward_only: [main] # one-way: mono → subrepo only + +bot: + name: "josh-sync-bot" + email: "josh-sync-bot@example.com" + trailer: "Josh-Sync-Origin" diff --git a/examples/reverse.yml b/examples/reverse.yml new file mode 100644 index 0000000..e532b01 --- /dev/null +++ b/examples/reverse.yml @@ -0,0 +1,52 @@ +# .gitea/workflows/josh-sync-reverse.yml — Consumer workflow template +# Checks external subrepo(s) for new commits and creates PRs on monorepo. +# Always creates PRs, never pushes directly. +# +# Customize: +# - cron schedule +# - org/josh-sync@v1: pin to your library repo and version + +name: "Josh Sync ← Subrepo" + +on: + schedule: + - cron: "0 1,7,13,19 * * *" # Every 6h, offset from forward + workflow_dispatch: + inputs: + target: + description: "Target to reverse-sync (empty = all)" + required: false + default: "" + branch: + description: "Branch to reverse-sync (empty = all eligible)" + required: false + default: "" + +concurrency: + group: josh-sync-rev-${{ github.event.inputs.target || 'all' }} + cancel-in-progress: false + +jobs: + sync: + runs-on: docker + container: node:20-bookworm + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Install tools + run: | + apt-get update -qq && apt-get install -y -qq jq curl git openssh-client >/dev/null 2>&1 + curl -sL "https://github.com/mikefarah/yq/releases/download/v4.44.6/yq_linux_amd64" \ + -o /usr/local/bin/yq && chmod +x /usr/local/bin/yq + + - uses: org/josh-sync@v1 + with: + direction: reverse + target: ${{ github.event.inputs.target || '' }} + branch: ${{ github.event.inputs.branch || '' }} + env: + SYNC_BOT_USER: ${{ secrets.SYNC_BOT_USER }} + SYNC_BOT_TOKEN: ${{ secrets.SYNC_BOT_TOKEN }} + SUBREPO_TOKEN: ${{ secrets.SUBREPO_TOKEN || secrets.SYNC_BOT_TOKEN }} + SUBREPO_SSH_KEY: ${{ secrets.SUBREPO_SSH_KEY }} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..cfee63e --- /dev/null +++ b/flake.nix @@ -0,0 +1,83 @@ +{ + description = "josh-sync: bidirectional monorepo ↔ subrepo sync via josh-proxy"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = { self, nixpkgs }: + let + forAllSystems = nixpkgs.lib.genAttrs [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + in + { + packages = forAllSystems (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + version = builtins.replaceStrings [ "\n" ] [ "" ] (builtins.readFile ./VERSION); + in + { + default = pkgs.stdenv.mkDerivation { + pname = "josh-sync"; + inherit version; + src = ./.; + + nativeBuildInputs = [ pkgs.makeWrapper ]; + + installPhase = '' + mkdir -p $out/{bin,lib} + cp lib/*.sh $out/lib/ + cp bin/josh-sync $out/bin/ + chmod +x $out/bin/josh-sync + + wrapProgram $out/bin/josh-sync \ + --set JOSH_SYNC_ROOT "$out" \ + --prefix PATH : ${pkgs.lib.makeBinPath [ + pkgs.bash + pkgs.git + pkgs.curl + pkgs.jq + pkgs.yq-go + pkgs.openssh + pkgs.coreutils + pkgs.findutils + pkgs.rsync + ]} + ''; + + meta = { + description = "Bidirectional monorepo ↔ subrepo sync via josh-proxy"; + license = pkgs.lib.licenses.mit; + mainProgram = "josh-sync"; + }; + }; + }); + + # devenv module for consumers + devenvModules.default = { pkgs, ... }: { + packages = [ self.packages.${pkgs.system}.default ]; + dotenv.disableHint = true; + }; + + # Dev shell for library contributors + devShells = forAllSystems (system: + let pkgs = nixpkgs.legacyPackages.${system}; + in { + default = pkgs.mkShell { + packages = [ + pkgs.bash + pkgs.git + pkgs.curl + pkgs.jq + pkgs.yq-go + pkgs.openssh + pkgs.shellcheck + pkgs.bats + pkgs.rsync + ]; + }; + }); + }; +} diff --git a/lib/auth.sh b/lib/auth.sh new file mode 100644 index 0000000..58e404d --- /dev/null +++ b/lib/auth.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# lib/auth.sh — Authenticated URLs, remote queries, and PR creation +# +# Requires: lib/core.sh and lib/config.sh sourced first +# Expects: JOSH_PROXY_URL, MONOREPO_PATH, BOT_USER, GITEA_TOKEN, JOSH_FILTER, +# SUBREPO_URL, SUBREPO_AUTH, SUBREPO_TOKEN (set by parse_config + load_target) + +# ─── Josh-Proxy Auth URL ─────────────────────────────────────────── +# Josh always uses HTTPS. Filter is embedded in the URL path. +# Result: https://user:token@proxy/org/repo.git:/services/app.git + +josh_auth_url() { + local base="${JOSH_PROXY_URL}/${MONOREPO_PATH}.git${JOSH_FILTER}.git" + echo "$base" | sed "s|https://|https://${BOT_USER}:${GITEA_TOKEN}@|" +} + +# ─── Subrepo Auth URL ────────────────────────────────────────────── +# HTTPS: injects user:token into URL +# SSH: returns bare URL (auth via GIT_SSH_COMMAND set by load_target) + +subrepo_auth_url() { + if [ "${SUBREPO_AUTH:-https}" = "ssh" ]; then + echo "$SUBREPO_URL" + else + echo "$SUBREPO_URL" | sed "s|https://|https://${BOT_USER}:${SUBREPO_TOKEN}@|" + fi +} + +# ─── Remote Queries ───────────────────────────────────────────────── + +subrepo_ls_remote() { + local ref="${1:-HEAD}" + local output + output=$(git ls-remote "$(subrepo_auth_url)" "refs/heads/${ref}") \ + || die "Failed to reach subrepo (check SSH key / auth)" + echo "$output" | awk '{print $1}' +} + +# ─── PR Creation ──────────────────────────────────────────────────── +# Shared helper for creating PRs on Gitea/GitHub API. +# Usage: create_pr <body> + +create_pr() { + local api_url="$1" + local token="$2" + local base="$3" + local head="$4" + local title="$5" + local body="$6" + + curl -sf -X POST \ + -H "Authorization: token ${token}" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg base "$base" \ + --arg head "$head" \ + --arg title "$title" \ + --arg body "$body" \ + '{base:$base, head:$head, title:$title, body:$body}')" \ + "${api_url}/pulls" >/dev/null +} diff --git a/lib/config.sh b/lib/config.sh new file mode 100644 index 0000000..e11214c --- /dev/null +++ b/lib/config.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# lib/config.sh — Config loading and target resolution +# +# Two-phase loading: +# 1. parse_config() — reads .josh-sync.yml via yq+jq, exports globals + JOSH_SYNC_TARGETS +# 2. load_target() — called per-target during iteration, sets target-specific env vars +# +# Requires: lib/core.sh sourced first, yq and jq on PATH + +# ─── Phase 1: Parse Config ───────────────────────────────────────── + +parse_config() { + local config_file="${1:-.josh-sync.yml}" + [ -f "$config_file" ] || die "Config not found: ${config_file} (run from monorepo root)" + + local config_json + config_json=$(yq -o json "$config_file") || die "Failed to parse ${config_file}" + + # Export global values + export JOSH_PROXY_URL + JOSH_PROXY_URL=$(echo "$config_json" | jq -r '.josh.proxy_url') + export MONOREPO_PATH + MONOREPO_PATH=$(echo "$config_json" | jq -r '.josh.monorepo_path') + export BOT_NAME + BOT_NAME=$(echo "$config_json" | jq -r '.bot.name') + export BOT_EMAIL + BOT_EMAIL=$(echo "$config_json" | jq -r '.bot.email') + export BOT_TRAILER + BOT_TRAILER=$(echo "$config_json" | jq -r '.bot.trailer') + + [ -n "$JOSH_PROXY_URL" ] && [ "$JOSH_PROXY_URL" != "null" ] || die "josh.proxy_url missing in config" + [ -n "$MONOREPO_PATH" ] && [ "$MONOREPO_PATH" != "null" ] || die "josh.monorepo_path missing in config" + [ -n "$BOT_TRAILER" ] && [ "$BOT_TRAILER" != "null" ] || die "bot.trailer missing in config" + + # Enrich targets with derived fields (gitea_host, subrepo_repo_path, auto josh_filter) + export JOSH_SYNC_TARGETS + JOSH_SYNC_TARGETS=$(echo "$config_json" | jq '[.targets[] | . + + # Auto-derive josh_filter from subfolder if not set + (if (.josh_filter // "") == "" then + {josh_filter: (":/" + .subfolder)} + else {} end) + + # Derive gitea_host and subrepo_repo_path from subrepo_url + (.subrepo_url as $url | + if ($url | test("^ssh://")) then + ($url | capture("ssh://[^@]*@(?<h>[^/]+)/(?<p>.+?)(\\.git)?$") // + {h: "", p: ""} | {gitea_host: .h, subrepo_repo_path: .p}) + elif ($url | test("^git@")) then + ($url | capture("git@(?<h>[^:/]+)[:/](?<p>.+?)(\\.git)?$") // + {h: "", p: ""} | {gitea_host: .h, subrepo_repo_path: .p}) + elif ($url | test("^https?://")) then + ($url | capture("https?://(?<h>[^/]+)/(?<p>.+?)(\\.git)?$") // + {h: "", p: ""} | {gitea_host: .h, subrepo_repo_path: .p}) + else + {gitea_host: "", subrepo_repo_path: ""} + end + ) + ]') + + # Load .env credentials (if present, not required — CI sets these via secrets) + if [ -f .env ]; then + # shellcheck source=/dev/null + source .env + fi + + export GITEA_TOKEN="${GITEA_TOKEN:-${SYNC_BOT_TOKEN:-}}" + export BOT_USER="${BOT_USER:-${SYNC_BOT_USER:-}}" + + # Monorepo API URL (derived from first target's host, overridable via env) + local gitea_host + gitea_host=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].gitea_host') + export MONOREPO_API="${MONOREPO_API:-https://${gitea_host}/api/v1/repos/${MONOREPO_PATH}}" + + log "INFO" "Config loaded: $(echo "$JOSH_SYNC_TARGETS" | jq 'length') target(s)" +} + +# ─── Phase 2: Load Target ────────────────────────────────────────── +# Called per-target during iteration. Takes a JSON object (one element +# of JOSH_SYNC_TARGETS) and sets all target-specific env vars. + +load_target() { + local tj="$1" + + export JOSH_SYNC_TARGET_NAME + JOSH_SYNC_TARGET_NAME=$(echo "$tj" | jq -r '.name') + export JOSH_FILTER + JOSH_FILTER=$(echo "$tj" | jq -r '.josh_filter') + export SUBREPO_URL + SUBREPO_URL=$(echo "$tj" | jq -r '.subrepo_url') + export SUBREPO_AUTH + SUBREPO_AUTH=$(echo "$tj" | jq -r '.subrepo_auth // "https"') + + # API URL from pre-derived fields + local gitea_host subrepo_repo_path + gitea_host=$(echo "$tj" | jq -r '.gitea_host') + subrepo_repo_path=$(echo "$tj" | jq -r '.subrepo_repo_path') + export SUBREPO_API="https://${gitea_host}/api/v1/repos/${subrepo_repo_path}" + + # Per-target credential resolution (indirect variable reference) + local token_var ssh_key_var + token_var=$(echo "$tj" | jq -r '.subrepo_token_var // "SUBREPO_TOKEN"') + ssh_key_var=$(echo "$tj" | jq -r '.subrepo_ssh_key_var // "SUBREPO_SSH_KEY"') + + # Resolve: per-target var → default var → SYNC_BOT_TOKEN fallback + export SUBREPO_TOKEN="${!token_var:-${SUBREPO_TOKEN:-${SYNC_BOT_TOKEN:-}}}" + local ssh_key_value="${!ssh_key_var:-${SUBREPO_SSH_KEY:-}}" + + # Clean up previous SSH state and set up new if needed + if [ -n "${JOSH_SSH_DIR:-}" ]; then + rm -rf "$JOSH_SSH_DIR" + unset JOSH_SSH_DIR GIT_SSH_COMMAND + fi + + if [ "$SUBREPO_AUTH" = "ssh" ] && [ -n "$ssh_key_value" ]; then + JOSH_SSH_DIR=$(mktemp -d) + echo "$ssh_key_value" > "${JOSH_SSH_DIR}/subrepo_key" + chmod 600 "${JOSH_SSH_DIR}/subrepo_key" + export GIT_SSH_COMMAND="ssh -i ${JOSH_SSH_DIR}/subrepo_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" + log "INFO" "SSH auth configured for target ${JOSH_SYNC_TARGET_NAME}" + fi + + log "INFO" "Loaded target: ${JOSH_SYNC_TARGET_NAME} (${SUBREPO_AUTH})" +} diff --git a/lib/core.sh b/lib/core.sh new file mode 100644 index 0000000..64ab9b7 --- /dev/null +++ b/lib/core.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# lib/core.sh — Foundation: logging, exit codes, git environment isolation +# +# Source this first. All other modules depend on it. + +set -euo pipefail + +# ─── Exit Codes ───────────────────────────────────────────────────── + +readonly E_OK=0 +readonly E_GENERAL=1 +readonly E_CONFIG=10 +readonly E_AUTH=11 + +# ─── Logging ──────────────────────────────────────────────────────── +# All log output goes to stderr. Sync functions use stdout for return values. + +log() { + local level="$1"; shift + echo "$(date -u +%H:%M:%S) [${level}] $*" >&2 +} + +die() { log "FATAL" "$@"; exit "$E_GENERAL"; } + +# ─── Git Environment Isolation ────────────────────────────────────── +# Prevent user/system config from interfering with sync operations. +# Safe because josh-sync always runs as a subprocess, never sourced into +# an interactive shell. + +export GIT_CONFIG_GLOBAL=/dev/null +export GIT_CONFIG_SYSTEM=/dev/null +export GIT_TERMINAL_PROMPT=0 diff --git a/lib/state.sh b/lib/state.sh new file mode 100644 index 0000000..90a4c41 --- /dev/null +++ b/lib/state.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# lib/state.sh — Sync state management on orphan branch +# +# State persists on an orphan git branch (default: josh-sync-state), +# committed and pushed to origin. Survives CI runner teardown. +# +# Storage layout: +# origin/josh-sync-state/ +# <target>/<branch>.json (e.g., billing/main.json) +# +# JSON per state file: +# { +# "last_forward": { "mono_sha": "...", "subrepo_sha": "...", "timestamp": "...", "status": "..." }, +# "last_reverse": { "subrepo_sha": "...", "mono_sha": "...", "timestamp": "...", "status": "..." } +# } +# +# Forward and reverse state are independent — updated with jq merge. +# +# Requires: lib/core.sh sourced first +# Expects: JOSH_SYNC_TARGET_NAME, BOT_NAME, BOT_EMAIL (set by load_target) + +STATE_BRANCH="${JOSH_SYNC_STATE_BRANCH:-josh-sync-state}" + +# ─── State Key ────────────────────────────────────────────────────── +# Namespace with target: "billing" + "main" → "billing/main" +# Slashes in branch names converted to hyphens. + +state_key() { + local branch_key="${1//\//-}" + echo "${JOSH_SYNC_TARGET_NAME}/${branch_key}" +} + +# ─── Read State ───────────────────────────────────────────────────── + +read_state() { + local key + key=$(state_key "$1") + git fetch origin "$STATE_BRANCH" 2>/dev/null || true + git show "origin/${STATE_BRANCH}:${key}.json" 2>/dev/null || echo '{}' +} + +# ─── Write State ──────────────────────────────────────────────────── +# Uses git worktree to avoid touching the working tree. + +write_state() { + local key + key=$(state_key "$1") + local state_json="$2" + local tmp_dir + tmp_dir=$(mktemp -d) + + # Try to check out existing state branch, or create orphan + if git rev-parse "origin/${STATE_BRANCH}" >/dev/null 2>&1; then + git worktree add "$tmp_dir" "origin/${STATE_BRANCH}" 2>/dev/null + else + git worktree add --detach "$tmp_dir" 2>/dev/null + (cd "$tmp_dir" && git checkout --orphan "$STATE_BRANCH" && git rm -rf . 2>/dev/null || true) + fi + + # Create target subdirectory and write state + mkdir -p "$(dirname "${tmp_dir}/${key}.json")" + echo "$state_json" | jq '.' > "${tmp_dir}/${key}.json" + + ( + cd "$tmp_dir" + git add -A + if ! git diff --cached --quiet 2>/dev/null; then + git -c user.name="$BOT_NAME" -c user.email="$BOT_EMAIL" \ + commit -m "state: update ${key}" + git push origin "HEAD:${STATE_BRANCH}" || log "WARN" "Failed to push state" + fi + ) + + git worktree remove "$tmp_dir" 2>/dev/null || rm -rf "$tmp_dir" +} diff --git a/lib/sync.sh b/lib/sync.sh new file mode 100644 index 0000000..f18a472 --- /dev/null +++ b/lib/sync.sh @@ -0,0 +1,303 @@ +#!/usr/bin/env bash +# lib/sync.sh — Sync algorithms: forward, reverse, import, reset +# +# All four operations: +# 1. Create a temp work dir with cleanup trap +# 2. Perform git operations +# 3. Return a status string via stdout +# +# Requires: lib/core.sh, lib/config.sh, lib/auth.sh, lib/state.sh sourced +# Expects: SYNC_BRANCH_MONO, SYNC_BRANCH_SUBREPO, JOSH_FILTER, etc. + +# ─── Forward Sync: Monorepo → Subrepo ────────────────────────────── +# +# Returns: fresh | skip | clean | lease-rejected | conflict + +forward_sync() { + local mono_branch="$SYNC_BRANCH_MONO" + local subrepo_branch="$SYNC_BRANCH_SUBREPO" + local work_dir + work_dir=$(mktemp -d) + trap "rm -rf '$work_dir'" EXIT + + log "INFO" "=== Forward sync: mono/${mono_branch} → subrepo/${subrepo_branch} ===" + + # 1. Clone the monorepo subfolder through josh (filtered view) + log "INFO" "Cloning filtered monorepo via josh-proxy..." + git clone "$(josh_auth_url)" \ + --branch "$mono_branch" --single-branch \ + "${work_dir}/filtered" || die "Failed to clone through josh-proxy" + + cd "${work_dir}/filtered" + git config user.name "$BOT_NAME" + git config user.email "$BOT_EMAIL" + + local mono_head + mono_head=$(git rev-parse HEAD) + log "INFO" "Mono filtered HEAD: ${mono_head:0:12}" + + # 2. Record subrepo HEAD before any operations (the "lease") + local subrepo_sha + subrepo_sha=$(subrepo_ls_remote "$subrepo_branch") + log "INFO" "Subrepo HEAD (lease): ${subrepo_sha:-(empty)}" + + # 3. Handle fresh push (subrepo branch doesn't exist) + if [ -z "$subrepo_sha" ]; then + log "INFO" "Subrepo branch doesn't exist — doing fresh push" + git push "$(subrepo_auth_url)" "HEAD:refs/heads/${subrepo_branch}" \ + || die "Failed to push to subrepo" + echo "fresh" + return + fi + + # 4. Fetch subrepo for comparison + git remote add subrepo "$(subrepo_auth_url)" + git fetch subrepo "$subrepo_branch" + + # 5. Compare trees — skip if identical + local mono_tree subrepo_tree + mono_tree=$(git rev-parse HEAD^{tree}) + subrepo_tree=$(git rev-parse "subrepo/${subrepo_branch}^{tree}" 2>/dev/null || echo "none") + + if [ "$mono_tree" = "$subrepo_tree" ]; then + log "INFO" "Trees identical — nothing to sync" + echo "skip" + return + fi + + # 6. Attempt merge: start from subrepo state, merge mono changes + git checkout -B sync-attempt "subrepo/${subrepo_branch}" >&2 + + if git merge --no-commit --no-ff "$mono_head" >&2 2>&1; then + # Clean merge + if git diff --cached --quiet; then + log "INFO" "Merge empty — skip" + git merge --abort 2>/dev/null || true + echo "skip" + return + fi + + git commit -m "Sync from monorepo $(date -u +%Y-%m-%dT%H:%M:%SZ) + +${BOT_TRAILER}: forward/${mono_branch}/$(date -u +%Y-%m-%dT%H:%M:%SZ)" >&2 + + # 7. Push with force-with-lease (explicit SHA) + if git push \ + --force-with-lease="refs/heads/${subrepo_branch}:${subrepo_sha}" \ + "$(subrepo_auth_url)" \ + "HEAD:refs/heads/${subrepo_branch}"; then + + log "INFO" "Forward sync complete" + echo "clean" + else + log "WARN" "Force-with-lease rejected — subrepo changed during sync" + echo "lease-rejected" + fi + + else + # Conflict! + local conflicted + conflicted=$(git diff --name-only --diff-filter=U 2>/dev/null || echo "(unknown)") + git merge --abort + + log "WARN" "Merge conflict on: ${conflicted}" + + # Push mono state as a conflict branch for PR + local ts + ts=$(date +%Y%m%d-%H%M%S) + local conflict_branch="auto-sync/mono-${mono_branch}-${ts}" + git checkout -B "$conflict_branch" "$mono_head" >&2 + git push "$(subrepo_auth_url)" "${conflict_branch}" + + # Create PR on subrepo + local pr_body + pr_body="## Sync Conflict\n\nMonorepo \`${mono_branch}\` has changes that conflict with \`${subrepo_branch}\`.\n\n**Conflicted files:**\n$(echo "$conflicted" | sed 's/^/- /')\n\nPlease resolve and merge this PR to complete the sync." + + create_pr "${SUBREPO_API}" "${SUBREPO_TOKEN}" \ + "$subrepo_branch" "$conflict_branch" \ + "[Sync Conflict] mono/${mono_branch} → ${subrepo_branch}" \ + "$pr_body" \ + || die "Failed to create conflict PR on subrepo (check SUBREPO_TOKEN)" + + log "INFO" "Conflict PR created on subrepo" + echo "conflict" + fi +} + +# ─── Reverse Sync: Subrepo → Monorepo ────────────────────────────── +# +# Always creates a PR on the monorepo — never pushes directly. +# Returns: skip | pr-created | josh-rejected + +reverse_sync() { + local mono_branch="$SYNC_BRANCH_MONO" + local subrepo_branch="$SYNC_BRANCH_SUBREPO" + local work_dir + work_dir=$(mktemp -d) + trap "rm -rf '$work_dir'" EXIT + + log "INFO" "=== Reverse sync: subrepo/${subrepo_branch} → mono/${mono_branch} ===" + + # 1. Clone subrepo + git clone "$(subrepo_auth_url)" \ + --branch "$subrepo_branch" --single-branch \ + "${work_dir}/subrepo" || die "Failed to clone subrepo" + + cd "${work_dir}/subrepo" + git config user.name "$BOT_NAME" + git config user.email "$BOT_EMAIL" + + # 2. Fetch monorepo's filtered view for comparison + git remote add mono-filtered "$(josh_auth_url)" + git fetch mono-filtered "$mono_branch" || die "Failed to fetch from josh-proxy" + + # 3. Find new human commits (excludes bot commits from forward sync) + local human_commits + human_commits=$(git log "mono-filtered/${mono_branch}..HEAD" \ + --oneline --invert-grep --grep="^${BOT_TRAILER}:" 2>/dev/null || echo "") + + if [ -z "$human_commits" ]; then + log "INFO" "No new human commits in subrepo — nothing to sync" + echo "skip" + return + fi + + log "INFO" "New human commits to sync:" + echo "$human_commits" >&2 + + # 4. Push through josh to a staging branch + local ts + ts=$(date +%Y%m%d-%H%M%S) + local staging_branch="auto-sync/subrepo-${subrepo_branch}-${ts}" + + if git push -o "base=${mono_branch}" "$(josh_auth_url)" "HEAD:refs/heads/${staging_branch}"; then + log "INFO" "Pushed to staging branch via josh: ${staging_branch}" + + # 5. Create PR on monorepo (NEVER direct push) + local pr_body + pr_body="## Subrepo changes\n\nNew commits from subrepo \`${subrepo_branch}\`:\n\n\`\`\`\n${human_commits}\n\`\`\`\n\n**Review checklist:**\n- [ ] Changes scoped to synced subfolder\n- [ ] No leaked credentials or environment-specific config\n- [ ] CI passes" + + create_pr "${MONOREPO_API}" "${GITEA_TOKEN}" \ + "$mono_branch" "$staging_branch" \ + "[Subrepo Sync] ${subrepo_branch} → ${mono_branch}" \ + "$pr_body" \ + || die "Failed to create PR on monorepo (check GITEA_TOKEN)" + + log "INFO" "Reverse sync PR created on monorepo" + echo "pr-created" + else + log "ERROR" "Josh rejected push — check josh-proxy logs" + echo "josh-rejected" + fi +} + +# ─── Initial Import: Subrepo → Monorepo (first time) ─────────────── +# +# Used when a subrepo already has content and you're adding it to the +# monorepo for the first time. Creates a PR. +# Returns: skip | pr-created + +initial_import() { + local mono_branch="$SYNC_BRANCH_MONO" + local subrepo_branch="$SYNC_BRANCH_SUBREPO" + local subfolder + subfolder=$(echo "$JOSH_SYNC_TARGETS" | jq -r --arg n "$JOSH_SYNC_TARGET_NAME" \ + '.[] | select(.name == $n) | .subfolder') + local work_dir + work_dir=$(mktemp -d) + trap "rm -rf '$work_dir'" EXIT + + log "INFO" "=== Initial import: subrepo/${subrepo_branch} → mono/${mono_branch}:${subfolder}/ ===" + + # 1. Clone monorepo (directly, not through josh — we need the real repo) + local mono_auth_url + mono_auth_url=$(echo "https://${BOT_USER}:${GITEA_TOKEN}@$(echo "$MONOREPO_API" | sed 's|https://||; s|/api/v1/repos/|/|').git") + + git clone "$mono_auth_url" \ + --branch "$mono_branch" --single-branch \ + "${work_dir}/monorepo" || die "Failed to clone monorepo" + + # 2. Clone subrepo + git clone "$(subrepo_auth_url)" \ + --branch "$subrepo_branch" --single-branch \ + "${work_dir}/subrepo" || die "Failed to clone subrepo" + + local file_count + file_count=$(find "${work_dir}/subrepo" -not -path '*/.git/*' -not -path '*/.git' -type f | wc -l | tr -d ' ') + log "INFO" "Subrepo has ${file_count} files" + + # 3. Copy subrepo content into monorepo subfolder + cd "${work_dir}/monorepo" + git config user.name "$BOT_NAME" + git config user.email "$BOT_EMAIL" + + local ts + ts=$(date +%Y%m%d-%H%M%S) + local staging_branch="auto-sync/import-${JOSH_SYNC_TARGET_NAME}-${ts}" + git checkout -B "$staging_branch" >&2 + + mkdir -p "$subfolder" + rsync -a --exclude='.git' "${work_dir}/subrepo/" "${subfolder}/" + git add "$subfolder" + + if git diff --cached --quiet; then + log "INFO" "No changes — subfolder already matches subrepo" + echo "skip" + return + fi + + git commit -m "Import ${JOSH_SYNC_TARGET_NAME} from subrepo/${subrepo_branch} + +${BOT_TRAILER}: import/${JOSH_SYNC_TARGET_NAME}/${ts}" >&2 + + # 4. Push branch + git push origin "$staging_branch" || die "Failed to push import branch" + log "INFO" "Pushed import branch: ${staging_branch}" + + # 5. Create PR on monorepo + local pr_body + pr_body="## Initial import\n\nImporting existing subrepo \`${subrepo_branch}\` (${file_count} files) into \`${subfolder}/\`.\n\n**Review checklist:**\n- [ ] Content looks correct\n- [ ] No leaked credentials or environment-specific config\n- [ ] CI passes" + + create_pr "${MONOREPO_API}" "${GITEA_TOKEN}" \ + "$mono_branch" "$staging_branch" \ + "[Import] ${JOSH_SYNC_TARGET_NAME}: ${subrepo_branch}" \ + "$pr_body" \ + || die "Failed to create PR on monorepo (check GITEA_TOKEN)" + + log "INFO" "Import PR created on monorepo" + echo "pr-created" +} + +# ─── Subrepo Reset: Force-push josh-filtered view to subrepo ─────── +# Run this AFTER merging an import PR to establish shared josh history. +# Returns: reset + +subrepo_reset() { + local mono_branch="$SYNC_BRANCH_MONO" + local subrepo_branch="$SYNC_BRANCH_SUBREPO" + local work_dir + work_dir=$(mktemp -d) + trap "rm -rf '$work_dir'" EXIT + + log "INFO" "=== Subrepo reset: josh-filtered mono/${mono_branch} → subrepo/${subrepo_branch} ===" + + # 1. Clone monorepo through josh (filtered view — this IS the shared history) + log "INFO" "Cloning filtered monorepo via josh-proxy..." + git clone "$(josh_auth_url)" \ + --branch "$mono_branch" --single-branch \ + "${work_dir}/filtered" || die "Failed to clone through josh-proxy" + + cd "${work_dir}/filtered" + + local head_sha + head_sha=$(git rev-parse HEAD) + log "INFO" "Josh-filtered HEAD: ${head_sha:0:12}" + + # 2. Force-push to subrepo (replaces subrepo history with josh-filtered history) + log "WARN" "Force-pushing to subrepo/${subrepo_branch} — this replaces subrepo history" + git push --force "$(subrepo_auth_url)" "HEAD:refs/heads/${subrepo_branch}" \ + || die "Failed to force-push to subrepo" + + log "INFO" "Subrepo reset complete — histories are now linked through josh" + echo "reset" +} diff --git a/schema/config-schema.json b/schema/config-schema.json new file mode 100644 index 0000000..bb222e9 --- /dev/null +++ b/schema/config-schema.json @@ -0,0 +1,96 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "josh-sync configuration", + "description": "Configuration for bidirectional monorepo ↔ subrepo sync via josh-proxy", + "type": "object", + "required": ["josh", "targets", "bot"], + "properties": { + "josh": { + "type": "object", + "required": ["proxy_url", "monorepo_path"], + "properties": { + "proxy_url": { + "type": "string", + "description": "Josh-proxy URL (no trailing slash)", + "pattern": "^https?://" + }, + "monorepo_path": { + "type": "string", + "description": "Repo path as josh sees it (org/repo)" + } + } + }, + "targets": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["name", "subfolder", "subrepo_url", "branches"], + "properties": { + "name": { + "type": "string", + "description": "Unique target identifier" + }, + "subfolder": { + "type": "string", + "description": "Monorepo subfolder path" + }, + "josh_filter": { + "type": "string", + "description": "Josh filter expression (auto-derived from subfolder if omitted)", + "pattern": "^:" + }, + "subrepo_url": { + "type": "string", + "description": "External subrepo git URL (HTTPS or SSH)" + }, + "subrepo_auth": { + "type": "string", + "enum": ["https", "ssh"], + "default": "https", + "description": "Auth method for subrepo" + }, + "subrepo_token_var": { + "type": "string", + "description": "Env var name for per-target HTTPS token (default: SUBREPO_TOKEN)" + }, + "subrepo_ssh_key_var": { + "type": "string", + "description": "Env var name for per-target SSH key (default: SUBREPO_SSH_KEY)" + }, + "branches": { + "type": "object", + "description": "Branch mapping: mono_branch → subrepo_branch", + "additionalProperties": { + "type": "string" + } + }, + "forward_only": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "Branches that only sync mono → subrepo (never reverse)" + } + } + } + }, + "bot": { + "type": "object", + "required": ["name", "email", "trailer"], + "properties": { + "name": { + "type": "string", + "description": "Git commit author name for sync commits" + }, + "email": { + "type": "string", + "description": "Git commit author email" + }, + "trailer": { + "type": "string", + "description": "Git trailer key for loop prevention" + } + } + } + } +} diff --git a/tests/fixtures/minimal.yml b/tests/fixtures/minimal.yml new file mode 100644 index 0000000..d767d4e --- /dev/null +++ b/tests/fixtures/minimal.yml @@ -0,0 +1,16 @@ +# Test fixture: minimal single-target config +josh: + proxy_url: "https://josh.test.local" + monorepo_path: "org/repo" + +targets: + - name: "example" + subfolder: "services/example" + subrepo_url: "https://gitea.test.local/ext/example.git" + branches: + main: main + +bot: + name: "test-bot" + email: "test@test.local" + trailer: "Josh-Sync-Origin" diff --git a/tests/fixtures/multi-target.yml b/tests/fixtures/multi-target.yml new file mode 100644 index 0000000..d0ced19 --- /dev/null +++ b/tests/fixtures/multi-target.yml @@ -0,0 +1,29 @@ +# Test fixture: multi-target config +josh: + proxy_url: "https://josh.test.local" + monorepo_path: "org/monorepo" + +targets: + - name: "app-a" + subfolder: "services/app-a" + josh_filter: ":/services/app-a" + subrepo_url: "https://gitea.test.local/ext/app-a.git" + subrepo_auth: "https" + branches: + main: main + forward_only: [] + + - name: "app-b" + subfolder: "services/app-b" + subrepo_url: "git@gitea.test.local:ext/app-b.git" + subrepo_auth: "ssh" + subrepo_ssh_key_var: "APP_B_SSH_KEY" + branches: + main: main + develop: dev + forward_only: [develop] + +bot: + name: "test-bot" + email: "test-bot@test.local" + trailer: "Josh-Sync-Origin" diff --git a/tests/unit/auth.bats b/tests/unit/auth.bats new file mode 100644 index 0000000..e94b5cc --- /dev/null +++ b/tests/unit/auth.bats @@ -0,0 +1,39 @@ +#!/usr/bin/env bats +# tests/unit/auth.bats — Auth URL construction tests + +setup() { + export JOSH_SYNC_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + source "$JOSH_SYNC_ROOT/lib/core.sh" + source "$JOSH_SYNC_ROOT/lib/auth.sh" + + # Set up env vars as parse_config + load_target would + export JOSH_PROXY_URL="https://josh.test.local" + export MONOREPO_PATH="org/monorepo" + export BOT_USER="sync-bot" + export GITEA_TOKEN="test-token-123" + export JOSH_FILTER=":/services/app-a" + export SUBREPO_URL="https://gitea.test.local/ext/app-a.git" + export SUBREPO_AUTH="https" + export SUBREPO_TOKEN="subrepo-token-456" +} + +@test "josh_auth_url builds correct HTTPS URL with credentials and filter" { + local url + url=$(josh_auth_url) + [ "$url" = "https://sync-bot:test-token-123@josh.test.local/org/monorepo.git:/services/app-a.git" ] +} + +@test "subrepo_auth_url injects credentials for HTTPS" { + local url + url=$(subrepo_auth_url) + [ "$url" = "https://sync-bot:subrepo-token-456@gitea.test.local/ext/app-a.git" ] +} + +@test "subrepo_auth_url returns bare URL for SSH" { + export SUBREPO_AUTH="ssh" + export SUBREPO_URL="git@gitea.test.local:ext/app-a.git" + + local url + url=$(subrepo_auth_url) + [ "$url" = "git@gitea.test.local:ext/app-a.git" ] +} diff --git a/tests/unit/config.bats b/tests/unit/config.bats new file mode 100644 index 0000000..7c306d0 --- /dev/null +++ b/tests/unit/config.bats @@ -0,0 +1,106 @@ +#!/usr/bin/env bats +# tests/unit/config.bats — Config parsing tests + +setup() { + export JOSH_SYNC_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + source "$JOSH_SYNC_ROOT/lib/core.sh" + source "$JOSH_SYNC_ROOT/lib/config.sh" + + FIXTURES="$JOSH_SYNC_ROOT/tests/fixtures" +} + +@test "parse_config loads multi-target config" { + cd "$(mktemp -d)" + cp "$FIXTURES/multi-target.yml" .josh-sync.yml + + parse_config ".josh-sync.yml" + + [ "$JOSH_PROXY_URL" = "https://josh.test.local" ] + [ "$MONOREPO_PATH" = "org/monorepo" ] + [ "$BOT_NAME" = "test-bot" ] + [ "$BOT_EMAIL" = "test-bot@test.local" ] + [ "$BOT_TRAILER" = "Josh-Sync-Origin" ] +} + +@test "parse_config derives gitea_host from HTTPS URL" { + cd "$(mktemp -d)" + cp "$FIXTURES/multi-target.yml" .josh-sync.yml + + parse_config ".josh-sync.yml" + + local host + host=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].gitea_host') + [ "$host" = "gitea.test.local" ] +} + +@test "parse_config derives gitea_host from SSH URL" { + cd "$(mktemp -d)" + cp "$FIXTURES/multi-target.yml" .josh-sync.yml + + parse_config ".josh-sync.yml" + + local host + host=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[1].gitea_host') + [ "$host" = "gitea.test.local" ] +} + +@test "parse_config derives subrepo_repo_path from HTTPS URL" { + cd "$(mktemp -d)" + cp "$FIXTURES/multi-target.yml" .josh-sync.yml + + parse_config ".josh-sync.yml" + + local path + path=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].subrepo_repo_path') + [ "$path" = "ext/app-a" ] +} + +@test "parse_config derives subrepo_repo_path from SSH URL" { + cd "$(mktemp -d)" + cp "$FIXTURES/multi-target.yml" .josh-sync.yml + + parse_config ".josh-sync.yml" + + local path + path=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[1].subrepo_repo_path') + [ "$path" = "ext/app-b" ] +} + +@test "parse_config auto-derives josh_filter from subfolder when not set" { + cd "$(mktemp -d)" + cp "$FIXTURES/minimal.yml" .josh-sync.yml + + parse_config ".josh-sync.yml" + + local filter + filter=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].josh_filter') + [ "$filter" = ":/services/example" ] +} + +@test "parse_config preserves explicit josh_filter" { + cd "$(mktemp -d)" + cp "$FIXTURES/multi-target.yml" .josh-sync.yml + + parse_config ".josh-sync.yml" + + local filter + filter=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].josh_filter') + [ "$filter" = ":/services/app-a" ] +} + +@test "parse_config reports correct target count" { + cd "$(mktemp -d)" + cp "$FIXTURES/multi-target.yml" .josh-sync.yml + + parse_config ".josh-sync.yml" + + local count + count=$(echo "$JOSH_SYNC_TARGETS" | jq 'length') + [ "$count" -eq 2 ] +} + +@test "parse_config fails on missing config file" { + cd "$(mktemp -d)" + run bash -c 'source "$JOSH_SYNC_ROOT/lib/core.sh"; source "$JOSH_SYNC_ROOT/lib/config.sh"; parse_config "nonexistent.yml"' + [ "$status" -ne 0 ] +} diff --git a/tests/unit/state.bats b/tests/unit/state.bats new file mode 100644 index 0000000..9f41e43 --- /dev/null +++ b/tests/unit/state.bats @@ -0,0 +1,40 @@ +#!/usr/bin/env bats +# tests/unit/state.bats — State key generation tests + +setup() { + export JOSH_SYNC_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + source "$JOSH_SYNC_ROOT/lib/core.sh" + source "$JOSH_SYNC_ROOT/lib/state.sh" + + export JOSH_SYNC_TARGET_NAME="billing" +} + +@test "state_key generates target/branch format" { + local key + key=$(state_key "main") + [ "$key" = "billing/main" ] +} + +@test "state_key converts slashes to hyphens in branch name" { + local key + key=$(state_key "feature/my-branch") + [ "$key" = "billing/feature-my-branch" ] +} + +@test "state_key works with different target names" { + export JOSH_SYNC_TARGET_NAME="auth-service" + local key + key=$(state_key "develop") + [ "$key" = "auth-service/develop" ] +} + +@test "STATE_BRANCH defaults to josh-sync-state" { + [ "$STATE_BRANCH" = "josh-sync-state" ] +} + +@test "STATE_BRANCH can be overridden via env" { + export JOSH_SYNC_STATE_BRANCH="custom-state" + # Re-source to pick up the new value + source "$JOSH_SYNC_ROOT/lib/state.sh" + [ "$STATE_BRANCH" = "custom-state" ] +}