2026-02-12 09:20:55 +03:00
#!/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 ──────────────────────────────
#
2026-02-14 13:30:24 +03:00
# Returns: fresh | skip | clean | lease-rejected | conflict | unrelated
2026-02-12 09:20:55 +03:00
forward_sync( ) {
local mono_branch = " $SYNC_BRANCH_MONO "
local subrepo_branch = " $SYNC_BRANCH_SUBREPO "
local work_dir
work_dir = $( mktemp -d)
2026-02-12 14:33:26 +03:00
# shellcheck disable=SC2064 # Intentional early expansion — work_dir is local
2026-02-12 09:20:55 +03:00
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"
2026-02-12 14:33:26 +03:00
cd " ${ work_dir } /filtered " || exit
2026-02-12 09:20:55 +03:00
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
2026-02-12 14:33:26 +03:00
mono_tree = $( git rev-parse 'HEAD^{tree}' )
# shellcheck disable=SC1083 # {tree} is git syntax, not shell brace expansion
2026-02-12 09:20:55 +03:00
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
2026-02-14 13:30:24 +03:00
# 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
2026-02-12 09:20:55 +03:00
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
2026-02-12 14:33:26 +03:00
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. "
2026-02-12 09:20:55 +03:00
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
}
2026-02-14 10:40:08 +03:00
# ─── 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
2026-02-14 15:11:31 +03:00
# 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.
2026-02-14 10:40:08 +03:00
local merge_commit
merge_commit = $( git commit-tree " $josh_tree " \
-p " $josh_head " \
2026-02-14 15:11:31 +03:00
-p " $subrepo_head " \
2026-02-14 10:40:08 +03:00
-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
}
2026-02-12 09:20:55 +03:00
# ─── Reverse Sync: Subrepo → Monorepo ──────────────────────────────
#
# Always creates a PR on the monorepo — never pushes directly.
2026-02-12 20:02:34 +03:00
# Returns: skip | pr-created | josh-rejected
2026-02-12 09:20:55 +03:00
reverse_sync( ) {
local mono_branch = " $SYNC_BRANCH_MONO "
local subrepo_branch = " $SYNC_BRANCH_SUBREPO "
local work_dir
work_dir = $( mktemp -d)
2026-02-12 14:33:26 +03:00
# shellcheck disable=SC2064 # Intentional early expansion — work_dir is local
2026-02-12 09:20:55 +03:00
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"
2026-02-12 14:33:26 +03:00
cd " ${ work_dir } /subrepo " || exit
2026-02-12 09:20:55 +03:00
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"
2026-02-14 15:11:31 +03:00
# 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)
2026-02-12 09:20:55 +03:00
local human_commits
2026-02-14 14:19:56 +03:00
human_commits = $( git log --ancestry-path " mono-filtered/ ${ mono_branch } ..HEAD " \
2026-02-12 09:20:55 +03:00
--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
2026-02-12 20:02:34 +03:00
# 4. Push through josh to a staging branch
2026-02-12 09:20:55 +03:00
local ts
ts = $( date +%Y%m%d-%H%M%S)
local staging_branch = " auto-sync/subrepo- ${ subrepo_branch } - ${ ts } "
2026-02-12 19:32:21 +03:00
2026-02-12 20:02:34 +03:00
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 } "
2026-02-12 19:32:21 +03:00
2026-02-12 20:02:34 +03:00
# 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 "
2026-02-12 09:20:55 +03:00
2026-02-12 20:02:34 +03:00
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)"
2026-02-12 19:32:21 +03:00
2026-02-12 20:02:34 +03:00
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
2026-02-12 09:20:55 +03:00
}
# ─── 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.
2026-02-13 19:48:46 +03:00
# 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)
2026-02-12 09:20:55 +03:00
# Returns: skip | pr-created
initial_import( ) {
2026-02-13 19:48:46 +03:00
local clone_url = " ${ 1 :- $( subrepo_auth_url) } "
2026-02-12 09:20:55 +03:00
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)
2026-02-12 14:33:26 +03:00
# shellcheck disable=SC2064 # Intentional early expansion — work_dir is local
2026-02-12 09:20:55 +03:00
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
2026-02-12 14:33:26 +03:00
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 "
2026-02-12 09:20:55 +03:00
git clone " $mono_auth_url " \
--branch " $mono_branch " --single-branch \
" ${ work_dir } /monorepo " || die "Failed to clone monorepo"
2026-02-13 19:48:46 +03:00
# 2. Clone subrepo (or archived repo when clone_url is overridden)
git clone " $clone_url " \
2026-02-12 09:20:55 +03:00
--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
2026-02-12 14:33:26 +03:00
cd " ${ work_dir } /monorepo " || exit
2026-02-12 09:20:55 +03:00
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)
2026-02-12 14:33:26 +03:00
# shellcheck disable=SC2064 # Intentional early expansion — work_dir is local
2026-02-12 09:20:55 +03:00
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"
2026-02-12 14:33:26 +03:00
cd " ${ work_dir } /filtered " || exit
2026-02-12 09:20:55 +03:00
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"
}