Files
josh-sync/lib/sync.sh
Slim B 553f006174 Fix onboard import cloning from empty new repo instead of archived repo
initial_import() now accepts an optional clone URL override parameter.
onboard_flow() passes the archived repo URL so content is cloned from
the right source.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:48:46 +03:00

317 lines
12 KiB
Bash

#!/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)
# 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
# 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\n\nMonorepo \`${mono_branch}\` has changes that conflict with \`${subrepo_branch}\`.\n\n**Conflicted files:**\n${conflicted_list}\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)
# 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} ==="
# 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. 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.
# 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)
local mono_auth_url
local api_host_path
api_host_path=$(echo "$MONOREPO_API" | sed 's|https://||; s|/api/v1/repos/|/|')
mono_auth_url="https://${BOT_USER}:${GITEA_TOKEN}@${api_host_path}.git"
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\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)
# 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"
}