Import josh-sync from subrepo/main
Sync-Origin: import/josh-sync/20260528-041502
This commit is contained in:
109
lib/adopt.sh
Normal file
109
lib/adopt.sh
Normal file
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env bash
|
||||
# lib/adopt.sh — Non-destructive adoption merge primitive
|
||||
#
|
||||
# Provides:
|
||||
# adopt_branch() — Create one adoption merge commit and push it fast-forward
|
||||
#
|
||||
# The orchestration (state machine, import → wait → finalize) lives in
|
||||
# lib/onboard.sh under strategy="adopt". `josh-sync adopt` is now a CLI alias
|
||||
# for `josh-sync onboard --mode=adopt`; see ADR-013 for design rationale.
|
||||
#
|
||||
# Requires: lib/core.sh, lib/config.sh, lib/auth.sh sourced
|
||||
|
||||
# ─── Adopt Branch ────────────────────────────────────────────────
|
||||
# Establishes shared ancestry without rewriting the existing subrepo branch.
|
||||
#
|
||||
# Preconditions:
|
||||
# - The subrepo branch and josh-filtered monorepo branch must have identical trees.
|
||||
#
|
||||
# Resulting commit:
|
||||
# - parent 1: josh-filtered HEAD, so josh can follow first-parent history
|
||||
# - parent 2: existing subrepo HEAD, so old subrepo history stays reachable
|
||||
# - tree: josh-filtered tree
|
||||
#
|
||||
# Returns: adopted | already-adopted | tree-mismatch | push-rejected | missing-branch
|
||||
|
||||
adopt_branch() (
|
||||
# Subshell function: contains the function-level EXIT trap, the internal cd,
|
||||
# and any set -e propagation so it cannot pollute the caller's shell. Without
|
||||
# this, the trap below would clobber callers' EXIT handlers (bats's own test
|
||||
# reporting in particular silently disappears).
|
||||
local mono_branch="$SYNC_BRANCH_MONO"
|
||||
local subrepo_branch="$SYNC_BRANCH_SUBREPO"
|
||||
local work_dir
|
||||
work_dir=$(mktemp -d)
|
||||
# shellcheck disable=SC2064 # Intentional early expansion — work_dir is local
|
||||
trap "rm -rf '$work_dir'" EXIT
|
||||
|
||||
log "INFO" "=== Adopt: subrepo/${subrepo_branch} ↔ mono/${mono_branch} ==="
|
||||
|
||||
local remote_subrepo_sha
|
||||
remote_subrepo_sha=$(git ls-remote "$(subrepo_auth_url)" "refs/heads/${subrepo_branch}" | awk '{print $1}') \
|
||||
|| die "Failed to reach subrepo (check SSH key / auth)"
|
||||
if [ -z "$remote_subrepo_sha" ]; then
|
||||
log "ERROR" "Subrepo branch ${subrepo_branch} does not exist"
|
||||
echo "missing-branch"
|
||||
return
|
||||
fi
|
||||
|
||||
git clone "$(subrepo_auth_url)" \
|
||||
--branch "$subrepo_branch" --single-branch \
|
||||
"${work_dir}/subrepo" || die "Failed to clone subrepo"
|
||||
|
||||
cd "${work_dir}/subrepo" || exit
|
||||
git config user.name "$BOT_NAME"
|
||||
git config user.email "$BOT_EMAIL"
|
||||
|
||||
local subrepo_head subrepo_tree
|
||||
subrepo_head=$(git rev-parse HEAD)
|
||||
# shellcheck disable=SC1083 # {tree} is git syntax, not shell brace expansion
|
||||
subrepo_tree=$(git rev-parse "HEAD^{tree}")
|
||||
log "INFO" "Subrepo HEAD: ${subrepo_head:0:12}"
|
||||
|
||||
git remote add josh-filtered "$(josh_auth_url)"
|
||||
git fetch josh-filtered "$mono_branch" || die "Failed to fetch from josh-proxy"
|
||||
|
||||
local josh_head josh_tree
|
||||
josh_head=$(git rev-parse "josh-filtered/${mono_branch}")
|
||||
# shellcheck disable=SC1083
|
||||
josh_tree=$(git rev-parse "josh-filtered/${mono_branch}^{tree}")
|
||||
log "INFO" "Josh-filtered HEAD: ${josh_head:0:12}"
|
||||
|
||||
if [ "$subrepo_tree" != "$josh_tree" ]; then
|
||||
log "ERROR" "Tree mismatch: merge the import PR and ensure subrepo/${subrepo_branch} matches mono/${mono_branch} before adopting"
|
||||
echo "tree-mismatch"
|
||||
return
|
||||
fi
|
||||
|
||||
if git merge-base --is-ancestor "$josh_head" "$subrepo_head" \
|
||||
&& git merge-base --is-ancestor "$subrepo_head" "$josh_head"; then
|
||||
log "INFO" "Subrepo and josh-filtered histories already point to the same commit"
|
||||
echo "already-adopted"
|
||||
return
|
||||
fi
|
||||
|
||||
if git merge-base --is-ancestor "$josh_head" "$subrepo_head"; then
|
||||
log "INFO" "Josh-filtered HEAD is already an ancestor of subrepo HEAD"
|
||||
echo "already-adopted"
|
||||
return
|
||||
fi
|
||||
|
||||
local merge_commit
|
||||
merge_commit=$(git commit-tree "$josh_tree" \
|
||||
-p "$josh_head" \
|
||||
-p "$subrepo_head" \
|
||||
-m "Adopt ${JOSH_SYNC_TARGET_NAME} for josh-sync
|
||||
|
||||
${BOT_TRAILER}: adopt/${mono_branch}/$(date -u +%Y-%m-%dT%H:%M:%SZ)")
|
||||
|
||||
git reset --hard "$merge_commit" >&2
|
||||
log "INFO" "Created adoption merge: ${merge_commit:0:12}"
|
||||
|
||||
if git push "$(subrepo_auth_url)" "HEAD:refs/heads/${subrepo_branch}"; then
|
||||
log "INFO" "Adoption complete: pushed fast-forward merge to subrepo/${subrepo_branch}"
|
||||
echo "adopted"
|
||||
else
|
||||
log "WARN" "Fast-forward push rejected — subrepo changed during adoption"
|
||||
echo "push-rejected"
|
||||
fi
|
||||
)
|
||||
115
lib/auth.sh
Normal file
115
lib/auth.sh
Normal file
@@ -0,0 +1,115 @@
|
||||
#!/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, MONOREPO_URL, MONOREPO_AUTH,
|
||||
# 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"
|
||||
# shellcheck disable=SC2001 # sed is clearer than ${var//} for URL injection
|
||||
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
|
||||
# shellcheck disable=SC2001
|
||||
echo "$SUBREPO_URL" | sed "s|https://|https://${BOT_USER}:${SUBREPO_TOKEN}@|"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Monorepo Auth URL ─────────────────────────────────────────────
|
||||
# HTTPS: injects user:token into MONOREPO_URL (auto-converts git@ form to https)
|
||||
# SSH: returns bare MONOREPO_URL (auth via user's ssh-agent / ~/.ssh/config,
|
||||
# or via GIT_SSH_COMMAND if the caller wired one up for the monorepo host)
|
||||
|
||||
mono_auth_url() {
|
||||
if [ "${MONOREPO_AUTH:-https}" = "ssh" ]; then
|
||||
echo "$MONOREPO_URL"
|
||||
else
|
||||
local https_url="$MONOREPO_URL"
|
||||
# Convert git@host:org/repo.git → https://host/org/repo.git so token injection works
|
||||
if [[ "$https_url" == git@* ]]; then
|
||||
https_url=$(echo "$https_url" | sed -E 's|^git@([^:]+):(.+)$|https://\1/\2|')
|
||||
elif [[ "$https_url" == ssh://* ]]; then
|
||||
https_url=$(echo "$https_url" | sed -E 's|^ssh://[^@]*@([^/]+)/(.+)$|https://\1/\2|')
|
||||
fi
|
||||
# shellcheck disable=SC2001
|
||||
echo "$https_url" | sed "s|https://|https://${BOT_USER}:${GITEA_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 helpers for creating PRs on Gitea/GitHub API.
|
||||
# Usage: create_pr <api_url> <token> <base> <head> <title> <body>
|
||||
# number=$(create_pr_number <api_url> <token> <base> <head> <title> <body>)
|
||||
#
|
||||
# create_pr — fire-and-forget (stdout suppressed, safe inside sync functions)
|
||||
# create_pr_number — returns the new PR number via stdout
|
||||
|
||||
create_pr_number() {
|
||||
local api_url="$1" token="$2" base="$3" head="$4" title="$5" 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" | jq -r '.number'
|
||||
}
|
||||
|
||||
create_pr() {
|
||||
create_pr_number "$@" >/dev/null
|
||||
}
|
||||
|
||||
# ─── PR API Helpers ──────────────────────────────────────────────
|
||||
# Used by onboard and migrate-pr commands.
|
||||
|
||||
# List open PRs on a repo. Returns JSON array.
|
||||
# Usage: list_open_prs <api_url> <token>
|
||||
list_open_prs() {
|
||||
local api_url="$1" token="$2"
|
||||
curl -sf -H "Authorization: token ${token}" \
|
||||
"${api_url}/pulls?state=open&limit=50"
|
||||
}
|
||||
|
||||
# Get PR diff as plain text.
|
||||
# Usage: get_pr_diff <api_url> <token> <pr_number>
|
||||
get_pr_diff() {
|
||||
local api_url="$1" token="$2" pr_number="$3"
|
||||
curl -sf -H "Authorization: token ${token}" \
|
||||
"${api_url}/pulls/${pr_number}.diff"
|
||||
}
|
||||
|
||||
# Get single PR as JSON (for checking merge status, metadata, etc.).
|
||||
# Usage: get_pr <api_url> <token> <pr_number>
|
||||
get_pr() {
|
||||
local api_url="$1" token="$2" pr_number="$3"
|
||||
curl -sf -H "Authorization: token ${token}" \
|
||||
"${api_url}/pulls/${pr_number}"
|
||||
}
|
||||
161
lib/config.sh
Normal file
161
lib/config.sh
Normal file
@@ -0,0 +1,161 @@
|
||||
#!/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}"
|
||||
|
||||
# Schema version check — required, must be 2 (v1 configs are no longer supported)
|
||||
local schema_version
|
||||
schema_version=$(echo "$config_json" | jq -r '.schema_version // empty')
|
||||
[ -n "$schema_version" ] || die "schema_version missing in ${config_file} (required since v2.0.0 — set 'schema_version: 2' at the top of the file; see CHANGELOG.md for migration instructions)"
|
||||
[ "$schema_version" = "2" ] || die "Unsupported schema_version ${schema_version} in ${config_file} (this version of josh-sync requires schema_version 2 — see CHANGELOG.md for migration instructions)"
|
||||
|
||||
# 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 MONOREPO_URL
|
||||
MONOREPO_URL=$(echo "$config_json" | jq -r '.josh.monorepo_url')
|
||||
export MONOREPO_AUTH
|
||||
MONOREPO_AUTH=$(echo "$config_json" | jq -r '.josh.monorepo_auth // "https"')
|
||||
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 "$MONOREPO_URL" ] && [ "$MONOREPO_URL" != "null" ] || die "josh.monorepo_url is required (added in schema_version 2). Set it to your monorepo's git clone URL, e.g. 'git@code.example.io:org/repo.git'."
|
||||
[ "$MONOREPO_AUTH" = "ssh" ] || [ "$MONOREPO_AUTH" = "https" ] || die "josh.monorepo_auth must be 'ssh' or 'https' (got '${MONOREPO_AUTH}')"
|
||||
[ -n "$BOT_TRAILER" ] && [ "$BOT_TRAILER" != "null" ] || die "bot.trailer missing in config"
|
||||
|
||||
# Derive monorepo gitea_host and repo_path from monorepo_url (mirrors target derivation below)
|
||||
local monorepo_meta
|
||||
monorepo_meta=$(jq -nr --arg url "$MONOREPO_URL" '
|
||||
$url as $u |
|
||||
if ($u | test("^ssh://")) then
|
||||
($u | capture("ssh://[^@]*@(?<h>[^/]+)/(?<p>.+?)(\\.git)?$") // {h: "", p: ""})
|
||||
elif ($u | test("^git@")) then
|
||||
($u | capture("git@(?<h>[^:/]+)[:/](?<p>.+?)(\\.git)?$") // {h: "", p: ""})
|
||||
elif ($u | test("^https?://")) then
|
||||
($u | capture("https?://(?<h>[^/]+)/(?<p>.+?)(\\.git)?$") // {h: "", p: ""})
|
||||
else
|
||||
{h: "", p: ""}
|
||||
end | "\(.h) \(.p)"')
|
||||
export MONOREPO_GITEA_HOST="${monorepo_meta% *}"
|
||||
export MONOREPO_REPO_PATH="${monorepo_meta#* }"
|
||||
[ -n "$MONOREPO_GITEA_HOST" ] || die "Could not derive gitea host from josh.monorepo_url='${MONOREPO_URL}' (expected git@host:org/repo.git or https://host/org/repo.git)"
|
||||
|
||||
# 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
|
||||
# When exclude patterns are present, append inline :exclude[::p1,::p2,...] to the filter
|
||||
(if (.exclude // [] | length) > 0 then
|
||||
{josh_filter: (":/" + .subfolder + ":exclude[" + (.exclude | map("::" + .) | join(",")) + "]")}
|
||||
elif (.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
|
||||
)
|
||||
]')
|
||||
|
||||
# Validate targets[].history_lock against the schema enum (preserve|rewrite).
|
||||
# Caught at config parse time so typos don't sit silently until `onboard` runs.
|
||||
local bad_lock
|
||||
bad_lock=$(echo "$JOSH_SYNC_TARGETS" | jq -r '
|
||||
.[] | select(.history_lock != null)
|
||||
| select(.history_lock != "preserve" and .history_lock != "rewrite")
|
||||
| "\(.name): \(.history_lock)"' | head -1)
|
||||
[ -z "$bad_lock" ] || die "Invalid targets[].history_lock value (must be 'preserve' or 'rewrite'): ${bad_lock}"
|
||||
|
||||
# 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 monorepo_url's host, overridable via env for custom API endpoints)
|
||||
export MONOREPO_API="${MONOREPO_API:-https://${MONOREPO_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})"
|
||||
}
|
||||
29
lib/core.sh
Normal file
29
lib/core.sh
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/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_GENERAL=1
|
||||
|
||||
# ─── 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
|
||||
642
lib/onboard.sh
Normal file
642
lib/onboard.sh
Normal file
@@ -0,0 +1,642 @@
|
||||
#!/usr/bin/env bash
|
||||
# lib/onboard.sh — Unified onboard orchestration (reset + adopt strategies)
|
||||
#
|
||||
# Provides:
|
||||
# onboard_flow() — Interactive: import → wait → finalize
|
||||
# finalize = subrepo_reset (rewrite) or
|
||||
# adopt_branch (preserve)
|
||||
# resolve_onboard_strategy() — Pick reset|adopt from --mode / config /
|
||||
# ls-remote auto-detection
|
||||
# migrate_one_pr() — Migrate one PR from archived to new subrepo
|
||||
#
|
||||
# State is stored on the josh-sync-state branch at <target>/onboard.json:
|
||||
# { step, strategy, import_prs, ... }
|
||||
# read_onboard_state falls back to legacy <target>/adopt.json (with implicit
|
||||
# strategy="adopt") so in-flight v2.1 adoptions continue to resume correctly.
|
||||
#
|
||||
# Steps:
|
||||
# start → importing → waiting-for-merge → resetting | adopting → complete
|
||||
#
|
||||
# Requires: lib/core.sh, lib/config.sh, lib/auth.sh, lib/state.sh, lib/sync.sh,
|
||||
# lib/adopt.sh sourced
|
||||
# Expects: JOSH_SYNC_TARGET_NAME, BOT_NAME, BOT_EMAIL, SUBREPO_API,
|
||||
# SUBREPO_TOKEN, etc.
|
||||
|
||||
# ─── State Helpers ────────────────────────────────────────────────
|
||||
# Same pattern as read_state()/write_state() in lib/state.sh.
|
||||
|
||||
read_onboard_state() {
|
||||
local target_name="${1:-$JOSH_SYNC_TARGET_NAME}"
|
||||
git fetch origin "$STATE_BRANCH" 2>/dev/null || true
|
||||
|
||||
local json
|
||||
json=$(git show "origin/${STATE_BRANCH}:${target_name}/onboard.json" 2>/dev/null || echo "")
|
||||
if [ -n "$json" ]; then
|
||||
# Pre-unification onboard.json had no .strategy field — the only flow that
|
||||
# existed was the destructive reset path; treat missing/empty .strategy as
|
||||
# reset so resume doesn't silently flip via re-resolution.
|
||||
echo "$json" | jq '. + {strategy: ((.strategy // "") | if . == "" then "reset" else . end)}'
|
||||
return
|
||||
fi
|
||||
|
||||
# Backward-compat: fall back to v2.1 <target>/adopt.json so in-flight
|
||||
# adoptions started before the unification continue to resume. The legacy
|
||||
# schema lacks `.strategy`; inject "adopt" so resolve/dispatch downstream
|
||||
# treats it correctly. Also drop the legacy `.mode` field (v2.1 carried it
|
||||
# redundantly) to avoid future schema collisions.
|
||||
json=$(git show "origin/${STATE_BRANCH}:${target_name}/adopt.json" 2>/dev/null || echo "")
|
||||
if [ -n "$json" ]; then
|
||||
echo "$json" | jq 'del(.mode) + {strategy: ((.strategy // "") | if . == "" then "adopt" else . end)}'
|
||||
return
|
||||
fi
|
||||
|
||||
echo '{}'
|
||||
}
|
||||
|
||||
write_onboard_state() {
|
||||
local target_name="${1:-$JOSH_SYNC_TARGET_NAME}"
|
||||
local state_json="$2"
|
||||
local key="${target_name}/onboard"
|
||||
local tmp_dir
|
||||
tmp_dir=$(mktemp -d)
|
||||
|
||||
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
|
||||
|
||||
mkdir -p "$(dirname "${tmp_dir}/${key}.json")"
|
||||
echo "$state_json" | jq '.' > "${tmp_dir}/${key}.json"
|
||||
|
||||
(
|
||||
cd "$tmp_dir" || exit
|
||||
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 "onboard: update ${target_name}"
|
||||
git push origin "HEAD:${STATE_BRANCH}" || log "WARN" "Failed to push onboard state"
|
||||
fi
|
||||
)
|
||||
|
||||
git worktree remove "$tmp_dir" 2>/dev/null || rm -rf "$tmp_dir"
|
||||
}
|
||||
|
||||
# Remove the v2.1 <target>/adopt.json from the state branch if present, so
|
||||
# that a --restart durably clears legacy state on the next read. Called by
|
||||
# onboard_flow's restart path.
|
||||
_onboard_state_remove_legacy() {
|
||||
local target_name="${1:-$JOSH_SYNC_TARGET_NAME}"
|
||||
git fetch origin "$STATE_BRANCH" 2>/dev/null || true
|
||||
git show "origin/${STATE_BRANCH}:${target_name}/adopt.json" >/dev/null 2>&1 || return 0
|
||||
|
||||
local tmp_dir
|
||||
tmp_dir=$(mktemp -d)
|
||||
if ! git worktree add "$tmp_dir" "origin/${STATE_BRANCH}" 2>/dev/null; then
|
||||
rm -rf "$tmp_dir"
|
||||
return 0
|
||||
fi
|
||||
(
|
||||
cd "$tmp_dir" || exit
|
||||
git rm -q "${target_name}/adopt.json" 2>/dev/null || true
|
||||
if ! git diff --cached --quiet 2>/dev/null; then
|
||||
git -c user.name="$BOT_NAME" -c user.email="$BOT_EMAIL" \
|
||||
commit -q -m "onboard: drop legacy ${target_name}/adopt.json on --restart"
|
||||
git push -q origin "HEAD:${STATE_BRANCH}" || log "WARN" "Failed to push legacy adopt.json removal"
|
||||
fi
|
||||
)
|
||||
git worktree remove "$tmp_dir" 2>/dev/null || rm -rf "$tmp_dir"
|
||||
}
|
||||
|
||||
# ─── Strategy Resolution ──────────────────────────────────────────
|
||||
# Precedence (highest first):
|
||||
# 1. explicit --mode flag
|
||||
# 2. targets[].history_lock (preserve → adopt, rewrite → reset)
|
||||
# 3. auto-detect: subrepo has any branches → adopt, otherwise reset
|
||||
#
|
||||
# Prints the chosen strategy (reset|adopt) on stdout; logs the source on
|
||||
# stderr so the preflight line in cmd_onboard surfaces the choice.
|
||||
|
||||
resolve_onboard_strategy() {
|
||||
local target_json="$1"
|
||||
local explicit_mode="${2:-}"
|
||||
|
||||
if [ -n "$explicit_mode" ]; then
|
||||
case "$explicit_mode" in
|
||||
reset|adopt) ;;
|
||||
*) die "Invalid --mode '${explicit_mode}' (must be 'reset' or 'adopt')" ;;
|
||||
esac
|
||||
log "INFO" "Strategy: ${explicit_mode} (from --mode flag)"
|
||||
echo "$explicit_mode"
|
||||
return
|
||||
fi
|
||||
|
||||
local history_lock
|
||||
history_lock=$(echo "$target_json" | jq -r '.history_lock // empty')
|
||||
if [ -n "$history_lock" ]; then
|
||||
case "$history_lock" in
|
||||
preserve)
|
||||
log "INFO" "Strategy: adopt (from config history_lock: preserve)"
|
||||
echo "adopt"; return ;;
|
||||
rewrite)
|
||||
log "INFO" "Strategy: reset (from config history_lock: rewrite)"
|
||||
echo "reset"; return ;;
|
||||
*)
|
||||
die "Invalid history_lock '${history_lock}' in target config (must be 'preserve' or 'rewrite')" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Critical: distinguish a genuinely empty subrepo from a failed ls-remote.
|
||||
# Treating an auth/network failure as "empty" would silently pick reset and
|
||||
# force-push a live subrepo's history away.
|
||||
local heads
|
||||
if heads=$(git ls-remote --heads "$(subrepo_auth_url)" 2>/dev/null); then
|
||||
if [ -n "$heads" ]; then
|
||||
log "INFO" "Strategy: adopt (auto-detected — subrepo has branches)"
|
||||
echo "adopt"
|
||||
else
|
||||
log "INFO" "Strategy: reset (auto-detected — subrepo has no branches)"
|
||||
echo "reset"
|
||||
fi
|
||||
else
|
||||
die "Cannot auto-detect strategy: 'git ls-remote' against the subrepo failed (auth/network). Pass --mode=reset|adopt explicitly, or set targets[].history_lock for this target."
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Derive Archived API URL ─────────────────────────────────────
|
||||
# Given a URL like "git@host:org/repo-archived.git" or
|
||||
# "https://host/org/repo-archived.git", derive the Gitea API URL.
|
||||
|
||||
_archived_api_from_url() {
|
||||
local url="$1"
|
||||
url="${url%.git}"
|
||||
local host repo_path
|
||||
|
||||
if echo "$url" | grep -qE '^(ssh://|git@)'; then
|
||||
if echo "$url" | grep -q '^ssh://'; then
|
||||
host=$(echo "$url" | sed -E 's|ssh://[^@]*@([^/]+)/.*|\1|')
|
||||
repo_path=$(echo "$url" | sed -E 's|ssh://[^@]*@[^/]+/(.+)$|\1|')
|
||||
else
|
||||
host=$(echo "$url" | sed -E 's|git@([^:/]+)[:/].*|\1|')
|
||||
repo_path=$(echo "$url" | sed -E 's|git@[^:/]+[:/](.+)$|\1|')
|
||||
fi
|
||||
else
|
||||
host=$(echo "$url" | sed -E 's|https?://([^/]+)/.*|\1|')
|
||||
repo_path=$(echo "$url" | sed -E 's|https?://[^/]+/(.+)$|\1|')
|
||||
fi
|
||||
|
||||
echo "https://${host}/api/v1/repos/${repo_path}"
|
||||
}
|
||||
|
||||
# ─── Onboard Flow ────────────────────────────────────────────────
|
||||
# Interactive orchestrator with checkpoint/resume.
|
||||
# Usage: onboard_flow <target_json> <restart> [explicit_mode]
|
||||
# explicit_mode — "reset", "adopt", or "" for auto-resolve.
|
||||
|
||||
onboard_flow() {
|
||||
local target_json="$1"
|
||||
local restart="${2:-false}"
|
||||
local explicit_mode="${3:-}"
|
||||
local target_name="$JOSH_SYNC_TARGET_NAME"
|
||||
|
||||
local state current_step strategy
|
||||
state=$(read_onboard_state "$target_name")
|
||||
current_step=$(echo "$state" | jq -r '.step // "start"')
|
||||
strategy=$(echo "$state" | jq -r '.strategy // empty')
|
||||
|
||||
if [ "$restart" = true ]; then
|
||||
log "INFO" "Restarting onboard from scratch"
|
||||
current_step="start"
|
||||
state='{}'
|
||||
strategy=""
|
||||
# Durably clear any legacy v2.1 adopt.json so the next read doesn't
|
||||
# silently fall back to it.
|
||||
_onboard_state_remove_legacy "$target_name"
|
||||
fi
|
||||
|
||||
if [ -z "$strategy" ]; then
|
||||
strategy=$(resolve_onboard_strategy "$target_json" "$explicit_mode")
|
||||
elif [ -n "$explicit_mode" ] && [ "$strategy" != "$explicit_mode" ]; then
|
||||
die "Saved strategy is '${strategy}' (mid-flow); cannot switch to --mode=${explicit_mode} without --restart."
|
||||
else
|
||||
log "INFO" "Strategy: ${strategy} (resumed from state)"
|
||||
fi
|
||||
|
||||
case "$strategy" in
|
||||
reset|adopt) ;;
|
||||
*) die "Unknown strategy '${strategy}' in state (corrupt?). Re-run with --restart to start over." ;;
|
||||
esac
|
||||
|
||||
log "INFO" "Onboard step: ${current_step}"
|
||||
|
||||
# ── Step 1: Strategy-specific intro and (for reset) archived repo info ──
|
||||
if [ "$current_step" = "start" ]; then
|
||||
echo "" >&2
|
||||
echo "=== Onboarding ${target_name} (strategy: ${strategy}) ===" >&2
|
||||
echo "" >&2
|
||||
|
||||
if [ "$strategy" = "reset" ]; then
|
||||
echo "Before proceeding, you should have:" >&2
|
||||
echo " 1. Renamed the existing subrepo (e.g., storefront → storefront-archived)" >&2
|
||||
echo " 2. Created a new EMPTY repo at the original URL" >&2
|
||||
echo "" >&2
|
||||
|
||||
if git ls-remote "$(subrepo_auth_url)" >/dev/null 2>&1; then
|
||||
# shellcheck disable=SC2001 # sed is clearer for URL pattern replacement
|
||||
log "INFO" "New subrepo is reachable at $(echo "$SUBREPO_URL" | sed 's|://[^@]*@|://***@|')"
|
||||
else
|
||||
log "WARN" "New subrepo is not reachable — make sure you created the new empty repo"
|
||||
fi
|
||||
|
||||
echo "Enter the archived repo URL (e.g., git@host:org/repo-archived.git):" >&2
|
||||
local archived_url
|
||||
read -r archived_url
|
||||
[ -n "$archived_url" ] || die "Archived URL is required"
|
||||
|
||||
local archived_auth="${SUBREPO_AUTH:-https}"
|
||||
local archived_api
|
||||
archived_api=$(_archived_api_from_url "$archived_url")
|
||||
|
||||
if curl -sf -H "Authorization: token ${SUBREPO_TOKEN}" \
|
||||
"${archived_api}" >/dev/null 2>&1; then
|
||||
log "INFO" "Archived repo reachable: ${archived_api}"
|
||||
else
|
||||
log "WARN" "Cannot reach archived repo API — check URL and token"
|
||||
echo "Continue anyway? (y/N):" >&2
|
||||
local confirm
|
||||
read -r confirm
|
||||
[ "$confirm" = "y" ] || [ "$confirm" = "Y" ] || die "Aborted"
|
||||
fi
|
||||
|
||||
state=$(jq -n \
|
||||
--arg step "importing" \
|
||||
--arg strategy "reset" \
|
||||
--arg archived_api "$archived_api" \
|
||||
--arg archived_url "$archived_url" \
|
||||
--arg archived_auth "$archived_auth" \
|
||||
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
'{step:$step, strategy:$strategy, archived_api:$archived_api,
|
||||
archived_url:$archived_url, archived_auth:$archived_auth,
|
||||
import_prs:{}, reset_branches:[], migrated_prs:[], timestamp:$ts}')
|
||||
else
|
||||
# strategy = adopt
|
||||
echo "This keeps the existing subrepo history and adds one adoption merge commit per branch." >&2
|
||||
echo "No force-push is used. Existing open PR branches remain based on the old history." >&2
|
||||
echo "" >&2
|
||||
|
||||
state=$(jq -n \
|
||||
--arg step "importing" \
|
||||
--arg strategy "adopt" \
|
||||
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
'{step:$step, strategy:$strategy, import_prs:{}, adopted_branches:[], timestamp:$ts}')
|
||||
fi
|
||||
|
||||
write_onboard_state "$target_name" "$state"
|
||||
current_step="importing"
|
||||
fi
|
||||
|
||||
# ── Step 2: Import (reset uses archived clone URL; adopt uses subrepo URL) ──
|
||||
if [ "$current_step" = "importing" ]; then
|
||||
echo "" >&2
|
||||
log "INFO" "Step 2: Importing subrepo content into monorepo..."
|
||||
|
||||
local branches import_prs
|
||||
branches=$(echo "$target_json" | jq -r '.branches | keys[]')
|
||||
import_prs=$(echo "$state" | jq -r '.import_prs // {}')
|
||||
|
||||
local archived_clone_url=""
|
||||
if [ "$strategy" = "reset" ]; then
|
||||
local archived_url
|
||||
archived_url=$(echo "$state" | jq -r '.archived_url // empty')
|
||||
[ -n "$archived_url" ] || die "Reset strategy: archived_url missing from state (corrupt or interrupted before start step finished). Re-run with --restart."
|
||||
if [ "${SUBREPO_AUTH:-https}" = "ssh" ]; then
|
||||
archived_clone_url="$archived_url"
|
||||
else
|
||||
# shellcheck disable=SC2001
|
||||
archived_clone_url=$(echo "$archived_url" | sed "s|https://|https://${BOT_USER}:${SUBREPO_TOKEN}@|")
|
||||
fi
|
||||
fi
|
||||
|
||||
for branch in $branches; do
|
||||
local mapped
|
||||
mapped=$(echo "$target_json" | jq -r --arg b "$branch" '.branches[$b] // empty')
|
||||
[ -z "$mapped" ] && continue
|
||||
|
||||
if echo "$import_prs" | jq -e --arg b "$branch" 'has($b)' >/dev/null 2>&1; then
|
||||
log "INFO" "Import PR already recorded for ${branch} — skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
export SYNC_BRANCH_MONO="$branch"
|
||||
export SYNC_BRANCH_SUBREPO="$mapped"
|
||||
|
||||
log "INFO" "Importing branch: ${branch} (subrepo: ${mapped})"
|
||||
local result
|
||||
if [ -n "$archived_clone_url" ]; then
|
||||
result=$(initial_import "$archived_clone_url")
|
||||
else
|
||||
result=$(initial_import)
|
||||
fi
|
||||
log "INFO" "Import result for ${branch}: ${result}"
|
||||
|
||||
if [ "$result" = "pr-created" ]; then
|
||||
local prs pr_number
|
||||
prs=$(list_open_prs "$MONOREPO_API" "$GITEA_TOKEN")
|
||||
pr_number=$(echo "$prs" | jq -r --arg t "$target_name" --arg b "$branch" \
|
||||
'[.[] | select(.title | test("\\[Import\\] " + $t + ":")) | select(.base.ref == $b)] | .[0].number // empty')
|
||||
|
||||
if [ -n "$pr_number" ]; then
|
||||
import_prs=$(echo "$import_prs" | jq --arg b "$branch" --arg n "$pr_number" '. + {($b): ($n | tonumber)}')
|
||||
log "INFO" "Import PR for ${branch}: #${pr_number}"
|
||||
else
|
||||
# Same hazard for both strategies: continuing without recording the
|
||||
# PR number means the next resume re-runs initial_import and creates
|
||||
# a duplicate import PR. Bail out and let the user reconcile.
|
||||
die "Could not find import PR number for ${branch} on monorepo. Re-running would create a duplicate import PR — check the monorepo's open PRs first."
|
||||
fi
|
||||
fi
|
||||
|
||||
state=$(echo "$state" | jq --argjson prs "$import_prs" '.import_prs = $prs')
|
||||
write_onboard_state "$target_name" "$state"
|
||||
done
|
||||
|
||||
state=$(echo "$state" | jq \
|
||||
--arg step "waiting-for-merge" \
|
||||
--argjson prs "$import_prs" \
|
||||
'.step = $step | .import_prs = $prs')
|
||||
write_onboard_state "$target_name" "$state"
|
||||
current_step="waiting-for-merge"
|
||||
fi
|
||||
|
||||
# ── Step 3: Wait for import PR(s) to merge ──
|
||||
if [ "$current_step" = "waiting-for-merge" ]; then
|
||||
echo "" >&2
|
||||
log "INFO" "Step 3: Waiting for import PR(s) to be merged..."
|
||||
|
||||
local import_prs pr_count
|
||||
import_prs=$(echo "$state" | jq -r '.import_prs')
|
||||
pr_count=$(echo "$import_prs" | jq 'length')
|
||||
|
||||
if [ "$pr_count" -gt 0 ]; then
|
||||
echo "" >&2
|
||||
echo "Import PRs to merge:" >&2
|
||||
echo "$import_prs" | jq -r 'to_entries[] | " \(.key): PR #\(.value)"' >&2
|
||||
echo "" >&2
|
||||
echo "Merge the import PR(s) on the monorepo, then press Enter..." >&2
|
||||
read -r
|
||||
|
||||
local all_merged=true
|
||||
for branch in $(echo "$import_prs" | jq -r 'keys[]'); do
|
||||
local pr_number pr_json merged
|
||||
pr_number=$(echo "$import_prs" | jq -r --arg b "$branch" '.[$b]')
|
||||
pr_json=$(get_pr "$MONOREPO_API" "$GITEA_TOKEN" "$pr_number")
|
||||
merged=$(echo "$pr_json" | jq -r '.merged // false')
|
||||
|
||||
if [ "$merged" = "true" ]; then
|
||||
log "INFO" "PR #${pr_number} (${branch}): merged"
|
||||
else
|
||||
log "ERROR" "PR #${pr_number} (${branch}): NOT merged — merge it first"
|
||||
all_merged=false
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$all_merged" = false ]; then
|
||||
die "Not all import PRs are merged. Re-run 'josh-sync onboard ${target_name}' after merging."
|
||||
fi
|
||||
else
|
||||
log "INFO" "No import PRs recorded — subfolder already matched subrepo"
|
||||
fi
|
||||
|
||||
local next_step
|
||||
if [ "$strategy" = "reset" ]; then
|
||||
next_step="resetting"
|
||||
else
|
||||
next_step="adopting"
|
||||
fi
|
||||
state=$(echo "$state" | jq --arg s "$next_step" '.step = $s')
|
||||
write_onboard_state "$target_name" "$state"
|
||||
current_step="$next_step"
|
||||
fi
|
||||
|
||||
# ── Step 4a: Finalize via reset (force-push josh-filtered history) ──
|
||||
if [ "$current_step" = "resetting" ]; then
|
||||
echo "" >&2
|
||||
log "INFO" "Step 4: Pushing josh-filtered history to new subrepo..."
|
||||
|
||||
local branches already_reset
|
||||
branches=$(echo "$target_json" | jq -r '.branches | keys[]')
|
||||
already_reset=$(echo "$state" | jq -r '.reset_branches // []')
|
||||
|
||||
for branch in $branches; do
|
||||
if echo "$already_reset" | jq -e --arg b "$branch" 'index($b) != null' >/dev/null 2>&1; then
|
||||
log "INFO" "Branch ${branch} already reset — skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
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" "Reset result for ${branch}: ${result}"
|
||||
|
||||
state=$(echo "$state" | jq --arg b "$branch" '.reset_branches += [$b]')
|
||||
write_onboard_state "$target_name" "$state"
|
||||
done
|
||||
|
||||
state=$(echo "$state" | jq \
|
||||
--arg step "complete" \
|
||||
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
'.step = $step | .timestamp = $ts')
|
||||
write_onboard_state "$target_name" "$state"
|
||||
current_step="complete"
|
||||
fi
|
||||
|
||||
# ── Step 4b: Finalize via adoption merge ──
|
||||
if [ "$current_step" = "adopting" ]; then
|
||||
echo "" >&2
|
||||
log "INFO" "Step 4: Creating adoption merge commit(s)..."
|
||||
|
||||
local branches already_adopted
|
||||
branches=$(echo "$target_json" | jq -r '.branches | keys[]')
|
||||
already_adopted=$(echo "$state" | jq -r '.adopted_branches // []')
|
||||
|
||||
for branch in $branches; do
|
||||
if echo "$already_adopted" | jq -e --arg b "$branch" 'index($b) != null' >/dev/null 2>&1; then
|
||||
log "INFO" "Branch ${branch} already adopted — skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
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=$(adopt_branch)
|
||||
log "INFO" "Adopt result for ${branch}: ${result}"
|
||||
|
||||
case "$result" in
|
||||
adopted|already-adopted)
|
||||
state=$(echo "$state" | jq --arg b "$branch" '.adopted_branches += [$b]')
|
||||
write_onboard_state "$target_name" "$state"
|
||||
;;
|
||||
tree-mismatch)
|
||||
die "Tree mismatch for ${branch}. Merge the import PR and make sure subrepo/${mapped} matches the Josh-filtered monorepo tree."
|
||||
;;
|
||||
push-rejected)
|
||||
die "Subrepo branch ${mapped} changed during adoption. Re-run 'josh-sync onboard ${target_name} --mode=adopt' to retry."
|
||||
;;
|
||||
missing-branch)
|
||||
die "Subrepo branch ${mapped} does not exist."
|
||||
;;
|
||||
*)
|
||||
die "Unexpected adopt result: ${result}"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
state=$(echo "$state" | jq \
|
||||
--arg step "complete" \
|
||||
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
'.step = $step | .timestamp = $ts')
|
||||
write_onboard_state "$target_name" "$state"
|
||||
current_step="complete"
|
||||
fi
|
||||
|
||||
# ── Step 5: Done ──
|
||||
if [ "$current_step" = "complete" ]; then
|
||||
echo "" >&2
|
||||
echo "=== Onboarding complete! ===" >&2
|
||||
echo "" >&2
|
||||
if [ "$strategy" = "reset" ]; then
|
||||
echo "The new subrepo now has josh-filtered history." >&2
|
||||
echo "Developers should re-clone or reset their local copies:" >&2
|
||||
echo " git fetch origin && git reset --hard origin/main" >&2
|
||||
echo "" >&2
|
||||
echo "To migrate open PRs from the archived repo:" >&2
|
||||
echo " josh-sync migrate-pr ${target_name} # interactive picker" >&2
|
||||
echo " josh-sync migrate-pr ${target_name} --all # migrate all" >&2
|
||||
echo " josh-sync migrate-pr ${target_name} 5 8 12 # specific PRs" >&2
|
||||
else
|
||||
echo "The subrepo keeps its existing history and now has a josh-sync adoption merge." >&2
|
||||
echo "Developers can keep their clones; they only need to fast-forward active branches:" >&2
|
||||
echo " git fetch origin && git checkout main && git merge --ff-only origin/main" >&2
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Migrate One PR ──────────────────────────────────────────────
|
||||
# Fetches the PR's branch from the archived repo, computes a local diff,
|
||||
# and applies it to the new subrepo with --3way for resilience.
|
||||
# Usage: migrate_one_pr <pr_number>
|
||||
#
|
||||
# Expects: JOSH_SYNC_TARGET_NAME, SUBREPO_API, SUBREPO_TOKEN, BOT_NAME, BOT_EMAIL loaded
|
||||
|
||||
migrate_one_pr() {
|
||||
local pr_number="$1"
|
||||
local target_name="$JOSH_SYNC_TARGET_NAME"
|
||||
|
||||
local onboard_state strategy archived_api
|
||||
onboard_state=$(read_onboard_state "$target_name")
|
||||
strategy=$(echo "$onboard_state" | jq -r '.strategy // "reset"')
|
||||
if [ "$strategy" = "adopt" ]; then
|
||||
die "Target '${target_name}' was onboarded with the adopt strategy, which does not record an archived repo. migrate-pr only applies to the reset strategy."
|
||||
fi
|
||||
archived_api=$(echo "$onboard_state" | jq -r '.archived_api')
|
||||
if [ -z "$archived_api" ] || [ "$archived_api" = "null" ]; then
|
||||
die "No archived repo info found for '${target_name}'. Run 'josh-sync onboard ${target_name} --mode=reset' first."
|
||||
fi
|
||||
|
||||
local already_migrated
|
||||
already_migrated=$(echo "$onboard_state" | jq -r \
|
||||
--argjson num "$pr_number" '.migrated_prs // [] | map(select(.old_number == $num)) | length')
|
||||
if [ "$already_migrated" -gt 0 ]; then
|
||||
log "INFO" "PR #${pr_number} already migrated — skipping"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local archived_token="$SUBREPO_TOKEN"
|
||||
|
||||
local pr_json title base head body
|
||||
pr_json=$(get_pr "$archived_api" "$archived_token" "$pr_number") \
|
||||
|| die "Failed to fetch PR #${pr_number} from archived repo"
|
||||
title=$(echo "$pr_json" | jq -r '.title')
|
||||
base=$(echo "$pr_json" | jq -r '.base.ref')
|
||||
head=$(echo "$pr_json" | jq -r '.head.ref')
|
||||
body=$(echo "$pr_json" | jq -r '.body // ""')
|
||||
|
||||
log "INFO" "Migrating PR #${pr_number}: \"${title}\" (${base} <- ${head})"
|
||||
|
||||
local original_dir
|
||||
original_dir=$(pwd)
|
||||
|
||||
local work_dir
|
||||
work_dir=$(mktemp -d)
|
||||
# shellcheck disable=SC2064 # Intentional early expansion
|
||||
trap "cd '$original_dir' 2>/dev/null; rm -rf '$work_dir'" RETURN
|
||||
|
||||
git clone "$(subrepo_auth_url)" --branch "$base" --single-branch \
|
||||
"${work_dir}/subrepo" 2>&1 || die "Failed to clone new subrepo (branch: ${base})"
|
||||
|
||||
cd "${work_dir}/subrepo" || exit
|
||||
git config user.name "$BOT_NAME"
|
||||
git config user.email "$BOT_EMAIL"
|
||||
|
||||
local archived_url archived_clone_url
|
||||
archived_url=$(echo "$onboard_state" | jq -r '.archived_url')
|
||||
if [ "${SUBREPO_AUTH:-https}" = "ssh" ]; then
|
||||
archived_clone_url="$archived_url"
|
||||
else
|
||||
# shellcheck disable=SC2001
|
||||
archived_clone_url=$(echo "$archived_url" | sed "s|https://|https://${BOT_USER}:${SUBREPO_TOKEN}@|")
|
||||
fi
|
||||
|
||||
git remote add archived "$archived_clone_url"
|
||||
git fetch archived "$head" "$base" 2>&1 \
|
||||
|| die "Failed to fetch branches from archived repo"
|
||||
|
||||
git checkout -B "$head" >&2
|
||||
|
||||
local diff
|
||||
diff=$(git diff "archived/${base}..archived/${head}")
|
||||
if [ -z "$diff" ]; then
|
||||
log "WARN" "Empty diff for PR #${pr_number} — skipping"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if echo "$diff" | git apply --3way 2>&1; then
|
||||
git add -A
|
||||
git commit -m "${title}
|
||||
|
||||
Migrated from archived repo PR #${pr_number}" >&2
|
||||
|
||||
git push "$(subrepo_auth_url)" "$head" >&2 \
|
||||
|| die "Failed to push branch ${head}"
|
||||
|
||||
local new_number
|
||||
new_number=$(create_pr_number "$SUBREPO_API" "$SUBREPO_TOKEN" \
|
||||
"$base" "$head" "$title" "$body")
|
||||
log "INFO" "Migrated PR #${pr_number} -> #${new_number}: \"${title}\""
|
||||
|
||||
cd "$original_dir" || true
|
||||
onboard_state=$(read_onboard_state "$target_name")
|
||||
onboard_state=$(echo "$onboard_state" | jq \
|
||||
--argjson old "$pr_number" \
|
||||
--argjson new_num "${new_number}" \
|
||||
--arg title "$title" \
|
||||
'.migrated_prs += [{"old_number":$old, "new_number":$new_num, "title":$title}]')
|
||||
write_onboard_state "$target_name" "$onboard_state"
|
||||
else
|
||||
log "ERROR" "Could not apply changes for PR #${pr_number} even with 3-way merge"
|
||||
log "ERROR" "Manual migration needed: branch '${head}' from archived repo"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
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" || exit
|
||||
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"
|
||||
}
|
||||
573
lib/sync.sh
Normal file
573
lib/sync.sh
Normal file
@@ -0,0 +1,573 @@
|
||||
#!/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 | unrelated
|
||||
|
||||
forward_sync() {
|
||||
local mono_branch="$SYNC_BRANCH_MONO"
|
||||
local subrepo_branch="$SYNC_BRANCH_SUBREPO"
|
||||
local work_dir
|
||||
work_dir=$(mktemp -d)
|
||||
# shellcheck disable=SC2064 # Intentional early expansion — work_dir is local
|
||||
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" || exit
|
||||
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}')
|
||||
# shellcheck disable=SC1083 # {tree} is git syntax, not shell brace expansion
|
||||
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
|
||||
# Check: unrelated histories (filter change) vs normal merge conflict
|
||||
if ! git merge-base "subrepo/${subrepo_branch}" "$mono_head" >/dev/null 2>&1; then
|
||||
log "INFO" "No common ancestor — histories are unrelated (filter change?)"
|
||||
echo "unrelated"
|
||||
return
|
||||
fi
|
||||
|
||||
# Normal merge 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 conflicted_list
|
||||
# shellcheck disable=SC2001
|
||||
conflicted_list=$(echo "$conflicted" | sed 's/^/- /')
|
||||
pr_body="## Sync Conflict
|
||||
|
||||
Monorepo \`${mono_branch}\` has changes that conflict with \`${subrepo_branch}\`.
|
||||
|
||||
**Conflicted files:**
|
||||
${conflicted_list}
|
||||
|
||||
Please 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
|
||||
}
|
||||
|
||||
# ─── Filter Change Reconciliation ─────────────────────────────────
|
||||
# When the josh filter changes (e.g., exclude patterns added/removed),
|
||||
# josh-proxy recomputes filtered history with new SHAs. This creates a
|
||||
# merge commit on the subrepo that connects old and new histories,
|
||||
# re-establishing shared ancestry without a destructive force-push.
|
||||
# Returns: reconciled | lease-rejected
|
||||
|
||||
reconcile_filter_change() {
|
||||
local mono_branch="$SYNC_BRANCH_MONO"
|
||||
local subrepo_branch="$SYNC_BRANCH_SUBREPO"
|
||||
local work_dir
|
||||
work_dir=$(mktemp -d)
|
||||
# shellcheck disable=SC2064 # Intentional early expansion — work_dir is local
|
||||
trap "rm -rf '$work_dir'" EXIT
|
||||
|
||||
log "INFO" "=== Filter change reconciliation: ${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" || exit
|
||||
git config user.name "$BOT_NAME"
|
||||
git config user.email "$BOT_EMAIL"
|
||||
|
||||
local subrepo_head
|
||||
subrepo_head=$(git rev-parse HEAD)
|
||||
log "INFO" "Subrepo HEAD: ${subrepo_head:0:12}"
|
||||
|
||||
# 2. Fetch josh-proxy filtered view (new filter)
|
||||
git remote add josh-filtered "$(josh_auth_url)"
|
||||
git fetch josh-filtered "$mono_branch" || die "Failed to fetch from josh-proxy"
|
||||
|
||||
local josh_head josh_tree
|
||||
josh_head=$(git rev-parse "josh-filtered/${mono_branch}")
|
||||
# shellcheck disable=SC1083 # {tree} is git syntax, not shell brace expansion
|
||||
josh_tree=$(git rev-parse "josh-filtered/${mono_branch}^{tree}")
|
||||
log "INFO" "Josh-proxy HEAD (new filter): ${josh_head:0:12}"
|
||||
|
||||
# 3. Check if trees are already identical (filter change had no effect)
|
||||
local subrepo_tree
|
||||
# shellcheck disable=SC1083
|
||||
subrepo_tree=$(git rev-parse "HEAD^{tree}")
|
||||
if [ "$josh_tree" = "$subrepo_tree" ]; then
|
||||
log "INFO" "Trees identical after filter change — no reconciliation needed"
|
||||
echo "skip"
|
||||
return
|
||||
fi
|
||||
|
||||
# 4. Create merge commit: josh-proxy HEAD (first parent) + subrepo HEAD, with josh-proxy's tree
|
||||
# Josh follows first-parent traversal — josh-filtered MUST be first so josh can map
|
||||
# the history back to the monorepo. Old subrepo history hangs off parent 2.
|
||||
local merge_commit
|
||||
merge_commit=$(git commit-tree "$josh_tree" \
|
||||
-p "$josh_head" \
|
||||
-p "$subrepo_head" \
|
||||
-m "Sync: filter configuration updated
|
||||
|
||||
${BOT_TRAILER}: filter-change/${mono_branch}/$(date -u +%Y-%m-%dT%H:%M:%SZ)")
|
||||
|
||||
git reset --hard "$merge_commit" >&2
|
||||
log "INFO" "Created reconciliation merge: ${merge_commit:0:12}"
|
||||
|
||||
# 5. Record lease and push
|
||||
local subrepo_sha
|
||||
subrepo_sha=$(subrepo_ls_remote "$subrepo_branch")
|
||||
|
||||
if git push \
|
||||
--force-with-lease="refs/heads/${subrepo_branch}:${subrepo_sha}" \
|
||||
"$(subrepo_auth_url)" \
|
||||
"HEAD:refs/heads/${subrepo_branch}"; then
|
||||
|
||||
log "INFO" "Filter change reconciled — shared ancestry re-established"
|
||||
echo "reconciled"
|
||||
else
|
||||
log "WARN" "Force-with-lease rejected — subrepo changed during reconciliation"
|
||||
echo "lease-rejected"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Reverse Sync: Subrepo → Monorepo ──────────────────────────────
|
||||
#
|
||||
# Always creates a PR on the monorepo — never pushes directly.
|
||||
# Returns: skip | skip-dirty | pr-created | josh-rejected
|
||||
# skip — trees identical, nothing to do
|
||||
# skip-dirty — trees differ but no human commits found (don't update state)
|
||||
|
||||
reverse_sync() {
|
||||
local mono_branch="$SYNC_BRANCH_MONO"
|
||||
local subrepo_branch="$SYNC_BRANCH_SUBREPO"
|
||||
local work_dir
|
||||
work_dir=$(mktemp -d)
|
||||
# shellcheck disable=SC2064 # Intentional early expansion — work_dir is local
|
||||
trap "rm -rf '$work_dir'" EXIT
|
||||
|
||||
log "INFO" "=== Reverse sync: subrepo/${subrepo_branch} → mono/${mono_branch} ==="
|
||||
|
||||
local force_mode="${SYNC_REVERSE_FORCE:-0}"
|
||||
|
||||
# 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" || exit
|
||||
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. Compare trees — skip if subrepo matches josh-filtered view
|
||||
local subrepo_tree josh_tree
|
||||
# shellcheck disable=SC1083 # {tree} is git syntax, not shell brace expansion
|
||||
subrepo_tree=$(git rev-parse "HEAD^{tree}")
|
||||
# shellcheck disable=SC1083
|
||||
josh_tree=$(git rev-parse "mono-filtered/${mono_branch}^{tree}")
|
||||
|
||||
if [ "$subrepo_tree" = "$josh_tree" ]; then
|
||||
log "INFO" "Subrepo tree matches josh-filtered view — nothing to sync"
|
||||
echo "skip"
|
||||
return
|
||||
fi
|
||||
|
||||
# 4. Find new human commits (excludes bot commits from forward sync)
|
||||
# Uses merge-base + --ancestry-path to restrict to the direct lineage.
|
||||
# merge-base is needed because mono-filtered may have advanced past the
|
||||
# last forward sync (any monorepo commit, even outside the subfolder,
|
||||
# causes josh to create a new filtered commit). Using mono-filtered
|
||||
# directly with --ancestry-path returns empty when it's not an ancestor
|
||||
# of HEAD. merge-base always finds the last connected point.
|
||||
local base human_commits
|
||||
base=$(git merge-base "mono-filtered/${mono_branch}" HEAD 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$base" ]; then
|
||||
human_commits=$(git log --ancestry-path "${base}..HEAD" \
|
||||
--oneline --invert-grep --grep="^${BOT_TRAILER}:" 2>/dev/null || echo "")
|
||||
else
|
||||
# No common ancestor (first sync or fully diverged) — skip ancestry-path
|
||||
human_commits=$(git log "mono-filtered/${mono_branch}..HEAD" \
|
||||
--oneline --invert-grep --grep="^${BOT_TRAILER}:" 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
if [ -z "$human_commits" ]; then
|
||||
if [ "$force_mode" = "1" ]; then
|
||||
log "WARN" "No human commits found — force mode: continuing anyway"
|
||||
else
|
||||
log "INFO" "No new human commits in subrepo — nothing to sync"
|
||||
echo "skip-dirty"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
log "INFO" "New human commits to sync:"
|
||||
echo "$human_commits" >&2
|
||||
|
||||
# 5. 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}"
|
||||
local push_ref="HEAD"
|
||||
local linearized=false
|
||||
|
||||
if ! git push -o "base=${mono_branch}" "$(josh_auth_url)" "${push_ref}:refs/heads/${staging_branch}" 2>/dev/null; then
|
||||
# Josh rejects pushes when the history contains merge commits whose
|
||||
# parents it cannot map (e.g., merges of staging/feature branches that
|
||||
# include auto-sync history). Fall back to linearizing: cherry-pick
|
||||
# regular commits as-is, squash only the problematic merge commits.
|
||||
# See ADR-011.
|
||||
log "WARN" "Direct push failed — linearizing history (cherry-pick + squash merges)"
|
||||
log "INFO" "─── linearize start ───"
|
||||
|
||||
local original_head
|
||||
original_head=$(git rev-parse HEAD)
|
||||
|
||||
git checkout -b "tmp-linearize-$$" "mono-filtered/${mono_branch}" >/dev/null 2>&1 \
|
||||
|| die "Failed to create linearize branch"
|
||||
|
||||
# Reuse merge-base from step 4 (same reasoning: mono-filtered may have
|
||||
# advanced past the last forward sync, breaking --ancestry-path).
|
||||
local commit_shas
|
||||
if [ -n "$base" ]; then
|
||||
commit_shas=$(git log --ancestry-path "${base}..${original_head}" \
|
||||
--reverse --format="%H" --invert-grep --grep="^${BOT_TRAILER}:" 2>/dev/null || echo "")
|
||||
else
|
||||
commit_shas=$(git log "mono-filtered/${mono_branch}..${original_head}" \
|
||||
--reverse --format="%H" --invert-grep --grep="^${BOT_TRAILER}:" 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
local sha
|
||||
for sha in $commit_shas; do
|
||||
local parent_count
|
||||
parent_count=$(git cat-file -p "$sha" | grep -c "^parent ")
|
||||
local short_msg
|
||||
short_msg=$(git log -1 --format="%s" "$sha")
|
||||
|
||||
if [ "$parent_count" -le 1 ]; then
|
||||
# Regular commit — cherry-pick to preserve message and authorship
|
||||
if git cherry-pick "$sha" >/dev/null 2>&1; then
|
||||
log "INFO" " cherry-pick ${sha:0:7}: ${short_msg}"
|
||||
else
|
||||
log "WARN" " conflict ${sha:0:7} — applying patch from parent"
|
||||
git cherry-pick --abort 2>/dev/null
|
||||
local msg author_name author_email author_date
|
||||
msg=$(git log -1 --format="%B" "$sha")
|
||||
author_name=$(git log -1 --format="%an" "$sha")
|
||||
author_email=$(git log -1 --format="%ae" "$sha")
|
||||
author_date=$(git log -1 --format="%aI" "$sha")
|
||||
# Diff from the commit's own parent — NOT from HEAD (which is
|
||||
# the linearize branch and may have a different base).
|
||||
git diff "${sha}^" "$sha" | git apply --index >/dev/null 2>&1 \
|
||||
|| die "Failed to apply diff for ${sha:0:7}"
|
||||
GIT_AUTHOR_NAME="$author_name" GIT_AUTHOR_EMAIL="$author_email" \
|
||||
GIT_AUTHOR_DATE="$author_date" \
|
||||
git commit -m "$msg" >/dev/null 2>&1 || die "Failed to commit ${sha:0:7}"
|
||||
log "INFO" " applied ${sha:0:7}: ${short_msg}"
|
||||
fi
|
||||
else
|
||||
# Merge commit — cherry-pick relative to first parent (squashes the merge)
|
||||
local msg author_name author_email author_date
|
||||
msg=$(git log -1 --format="%B" "$sha")
|
||||
author_name=$(git log -1 --format="%an" "$sha")
|
||||
author_email=$(git log -1 --format="%ae" "$sha")
|
||||
author_date=$(git log -1 --format="%aI" "$sha")
|
||||
|
||||
if git cherry-pick -m 1 "$sha" >/dev/null 2>&1; then
|
||||
log "INFO" " squash ${sha:0:7}: ${short_msg}"
|
||||
else
|
||||
# Cherry-pick failed — distinguish empty result (changes already
|
||||
# applied by a prior cherry-pick) from a real conflict.
|
||||
if git diff --cached --quiet 2>/dev/null && git diff --quiet 2>/dev/null; then
|
||||
# Working tree clean — the merge changes are already present
|
||||
git cherry-pick --abort 2>/dev/null || git reset --hard HEAD >/dev/null 2>&1
|
||||
log "INFO" " skip ${sha:0:7}: ${short_msg} (already applied)"
|
||||
else
|
||||
# Real conflict — apply diff from first parent (what the merge
|
||||
# actually introduced, NOT from HEAD which would capture all
|
||||
# tree differences).
|
||||
git cherry-pick --abort 2>/dev/null
|
||||
git diff "${sha}^1" "$sha" | git apply --index >/dev/null 2>&1 \
|
||||
|| die "Failed to apply merge diff for ${sha:0:7}"
|
||||
GIT_AUTHOR_NAME="$author_name" GIT_AUTHOR_EMAIL="$author_email" \
|
||||
GIT_AUTHOR_DATE="$author_date" \
|
||||
git commit -m "$msg" >/dev/null 2>&1 || die "Failed to commit merge ${sha:0:7}"
|
||||
log "INFO" " squash ${sha:0:7}: ${short_msg}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
log "INFO" "─── linearize done ────"
|
||||
|
||||
push_ref="HEAD"
|
||||
linearized=true
|
||||
|
||||
git push -o "base=${mono_branch}" "$(josh_auth_url)" \
|
||||
"${push_ref}:refs/heads/${staging_branch}" \
|
||||
|| { log "ERROR" "Josh rejected linearized push — check josh-proxy logs"; echo "josh-rejected"; return; }
|
||||
fi
|
||||
|
||||
log "INFO" "Pushed to staging branch via josh: ${staging_branch}${linearized:+ (linearized)}"
|
||||
|
||||
# 6. Publish: force-push directly (--force) or create PR (default)
|
||||
if [ "$force_mode" = "1" ]; then
|
||||
log "WARN" "Force mode: pushing ${staging_branch} directly to mono/${mono_branch} — bypassing PR"
|
||||
local mono_dir="${work_dir}/monorepo"
|
||||
git clone "$(mono_auth_url)" \
|
||||
--branch "$staging_branch" --single-branch \
|
||||
"$mono_dir" || die "Failed to clone monorepo staging branch for force-push"
|
||||
git -C "$mono_dir" push --force "$(mono_auth_url)" \
|
||||
"HEAD:refs/heads/${mono_branch}" \
|
||||
|| die "Failed to force-push to mono/${mono_branch}"
|
||||
log "INFO" "Force-pushed mono/${staging_branch} → mono/${mono_branch}"
|
||||
echo "forced"
|
||||
else
|
||||
local pr_body
|
||||
local linearize_note=""
|
||||
if [ "$linearized" = true ]; then
|
||||
linearize_note="
|
||||
> **Note:** Merge commits were squashed during sync because the subrepo
|
||||
> history contained merges that josh-proxy cannot map (see ADR-011).
|
||||
> Regular commits are preserved individually."
|
||||
fi
|
||||
pr_body="## Subrepo changes
|
||||
|
||||
New commits from subrepo \`${subrepo_branch}\`:
|
||||
|
||||
\`\`\`
|
||||
${human_commits}
|
||||
\`\`\`
|
||||
${linearize_note}
|
||||
**Review checklist:**
|
||||
- [ ] Changes scoped to synced subfolder
|
||||
- [ ] No leaked credentials or environment-specific config
|
||||
- [ ] 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"
|
||||
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.
|
||||
# Usage: initial_import [clone_url_override]
|
||||
# clone_url_override — if set, clone from this URL instead of subrepo_auth_url()
|
||||
# (used by onboard to clone from the archived repo)
|
||||
# Returns: skip | pr-created
|
||||
|
||||
initial_import() {
|
||||
local clone_url="${1:-$(subrepo_auth_url)}"
|
||||
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)
|
||||
# shellcheck disable=SC2064 # Intentional early expansion — work_dir is local
|
||||
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)
|
||||
git clone "$(mono_auth_url)" \
|
||||
--branch "$mono_branch" --single-branch \
|
||||
"${work_dir}/monorepo" || die "Failed to clone monorepo"
|
||||
|
||||
# 2. Clone subrepo (or archived repo when clone_url is overridden)
|
||||
git clone "$clone_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" || exit
|
||||
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
|
||||
|
||||
Importing existing subrepo \`${subrepo_branch}\` (${file_count} files) into \`${subfolder}/\`.
|
||||
|
||||
**Review checklist:**
|
||||
- [ ] Content looks correct
|
||||
- [ ] No leaked credentials or environment-specific config
|
||||
- [ ] 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)
|
||||
# shellcheck disable=SC2064 # Intentional early expansion — work_dir is local
|
||||
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" || exit
|
||||
|
||||
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