This commit is contained in:
2026-02-12 09:20:55 +03:00
commit 7c2d731399
24 changed files with 2123 additions and 0 deletions

7
.shellcheckrc Normal file
View File

@@ -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

25
CHANGELOG.md Normal file
View File

@@ -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`)

21
LICENSE Normal file
View File

@@ -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.

42
Makefile Normal file
View File

@@ -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/

84
README.md Normal file
View File

@@ -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 <target>
josh-sync reset <target>
josh-sync status
josh-sync state show <target> [branch]
josh-sync state reset <target> [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

1
VERSION Normal file
View File

@@ -0,0 +1 @@
1.0.0

64
action.yml Normal file
View File

@@ -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

672
bin/josh-sync Executable file
View File

@@ -0,0 +1,672 @@
#!/usr/bin/env bash
# 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 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 <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 "$@"

36
examples/devenv.nix Normal file
View File

@@ -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 <target> Initial import from subrepo
# josh-sync reset <target> Reset subrepo to josh-filtered view
# josh-sync status Show target config and sync state
# josh-sync state show <t> [b] Show state JSON
# josh-sync state reset <t> [b] Reset state
enterShell = ''
echo "Josh Sync available run 'josh-sync --help' for commands"
'';
}

74
examples/forward.yml Normal file
View File

@@ -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 }}

43
examples/josh-sync.yml Normal file
View File

@@ -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"

52
examples/reverse.yml Normal file
View File

@@ -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 }}

83
flake.nix Normal file
View File

@@ -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
];
};
});
};
}

61
lib/auth.sh Normal file
View File

@@ -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 <api_url> <token> <base> <head> <title> <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
}

122
lib/config.sh Normal file
View File

@@ -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})"
}

32
lib/core.sh Normal file
View File

@@ -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

75
lib/state.sh Normal file
View File

@@ -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"
}

303
lib/sync.sh Normal file
View File

@@ -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"
}

96
schema/config-schema.json Normal file
View File

@@ -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"
}
}
}
}
}

16
tests/fixtures/minimal.yml vendored Normal file
View File

@@ -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"

29
tests/fixtures/multi-target.yml vendored Normal file
View File

@@ -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"

39
tests/unit/auth.bats Normal file
View File

@@ -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" ]
}

106
tests/unit/config.bats Normal file
View File

@@ -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 ]
}

40
tests/unit/state.bats Normal file
View File

@@ -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" ]
}