"#"
This commit is contained in:
7
.shellcheckrc
Normal file
7
.shellcheckrc
Normal 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
25
CHANGELOG.md
Normal 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
21
LICENSE
Normal 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
42
Makefile
Normal 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
84
README.md
Normal 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
|
||||||
64
action.yml
Normal file
64
action.yml
Normal 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
672
bin/josh-sync
Executable 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
36
examples/devenv.nix
Normal 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
74
examples/forward.yml
Normal 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
43
examples/josh-sync.yml
Normal 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
52
examples/reverse.yml
Normal 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
83
flake.nix
Normal 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
61
lib/auth.sh
Normal 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
122
lib/config.sh
Normal 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
32
lib/core.sh
Normal 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
75
lib/state.sh
Normal 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
303
lib/sync.sh
Normal 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
96
schema/config-schema.json
Normal 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
16
tests/fixtures/minimal.yml
vendored
Normal 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
29
tests/fixtures/multi-target.yml
vendored
Normal 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
39
tests/unit/auth.bats
Normal 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
106
tests/unit/config.bats
Normal 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
40
tests/unit/state.bats
Normal 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" ]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user