#!/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" }