commit 7c2d7313997557edb40ed735d088d7f8c574de38 Author: Slim B Date: Thu Feb 12 09:20:55 2026 +0300 "#" 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" ] +}