"#"
This commit is contained in:
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"
|
||||
}
|
||||
Reference in New Issue
Block a user