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 ──────────────────────────────
#
# 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)
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
# 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
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
}
# ─── Reverse Sync: Subrepo → Monorepo ──────────────────────────────
#
# Always creates a PR on the monorepo — never pushes directly.
2026-02-12 19:32:21 +03:00
# Returns: skip | pr-created
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"
# 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
2026-02-12 19:32:21 +03:00
# 4. Clone monorepo directly (not through josh — we need a real branch from main)
local mono_auth_url api_host_path subfolder
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 "
subfolder = $( echo " $JOSH_SYNC_TARGETS " | jq -r --arg n " $JOSH_SYNC_TARGET_NAME " \
'.[] | select(.name == $n) | .subfolder' )
2026-02-12 18:00:08 +03:00
2026-02-12 19:32:21 +03:00
git clone " $mono_auth_url " \
--branch " $mono_branch " --single-branch \
" ${ work_dir } /monorepo " || die "Failed to clone monorepo"
2026-02-12 18:00:08 +03:00
2026-02-12 19:32:21 +03:00
cd " ${ work_dir } /monorepo " || exit
git config user.name " $BOT_NAME "
git config user.email " $BOT_EMAIL "
2026-02-12 18:00:08 +03:00
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
git checkout -B " $staging_branch " >& 2
# 5. Rsync subrepo content into subfolder (--delete handles removals)
mkdir -p " $subfolder "
rsync -a --delete --exclude= '.git' " ${ work_dir } /subrepo/ " " ${ subfolder } / "
git add " $subfolder "
if git diff --cached --quiet; then
log "INFO" "No tree changes after rsync — skip"
echo "skip"
return
fi
2026-02-12 09:20:55 +03:00
2026-02-12 19:32:21 +03:00
git commit -m " Sync from subrepo/ ${ subrepo_branch } $( date -u +%Y-%m-%dT%H:%M:%SZ)
2026-02-12 09:20:55 +03:00
2026-02-12 19:32:21 +03:00
${ BOT_TRAILER } : reverse/${ subrepo_branch } /$( date -u +%Y-%m-%dT%H:%M:%SZ) " >&2
2026-02-12 09:20:55 +03:00
2026-02-12 19:32:21 +03:00
# 6. Push branch and create PR on monorepo (NEVER direct push to main)
git push origin " $staging_branch " || die "Failed to push staging branch"
log "INFO" " Pushed staging branch: ${ staging_branch } "
2026-02-12 09:20:55 +03:00
2026-02-12 19:32:21 +03:00
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"
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.
# 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)
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"
# 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
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"
}