Files
josh-sync/bin/josh-sync
Slim B 0d2aea9664 Fix all shellcheck warnings for nix build gate
- SC2015: Wrap A && B || C patterns in brace groups for directive scope
- SC2064: Suppress for intentional early trap expansion (local vars)
- SC2164: Add || exit to cd commands in subshells
- SC2001: Suppress for sed URL injection (clearer than parameter expansion)
- SC1083: Handle {tree} git syntax (quote or suppress)
- SC1091: Suppress for runtime-resolved source paths
- SC2034: Remove unused exit codes (E_OK, E_CONFIG, E_AUTH)
- SC2116: Eliminate useless echo in mono_auth_url construction

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 14:33:26 +03:00

680 lines
22 KiB
Bash
Executable File

#!/usr/bin/env bash
# shellcheck disable=SC1091 # Source paths resolved at runtime
# bin/josh-sync — CLI entrypoint for josh-sync
#
# Usage: josh-sync <command> [flags]
#
# Commands:
# sync Run forward and/or reverse sync
# preflight Validate config, connectivity, auth
# import <target> Initial import: pull subrepo into monorepo
# reset <target> 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 <<EOF
josh-sync $(josh_sync_version) — bidirectional monorepo ↔ subrepo sync via josh-proxy
Usage: josh-sync <command> [flags]
Commands:
sync Run forward and/or reverse sync
preflight Validate config, connectivity, auth, workflow coverage
import <target> Initial import: pull existing subrepo into monorepo (creates PR)
reset <target> Reset subrepo to josh-filtered view (after merging import PR)
status Show target config and sync state
state show <target> [branch] Show sync state JSON
state reset <target> [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 target(s) — comma-separated for multiple (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" ] && ! echo ",$filter_target," | grep -q ",${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"
# shellcheck disable=SC2015 # A && B || C is intentional — pass/warn/fail never fail
{
[ -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"
# shellcheck disable=SC2015
{
[ -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 <target>" >&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 <target>" >&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 <target> [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 <target> [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 <show|reset> <target> [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 "$@"