From 22bd59a9d759e85e28cb839d80b5e2ebb54ae4f7 Mon Sep 17 00:00:00 2001 From: Slim B Date: Sat, 14 Feb 2026 10:40:08 +0300 Subject: [PATCH] Auto-reconcile subrepo history when josh filter changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the exclude list changes, josh-proxy recomputes filtered history with new SHAs, breaking common ancestry with the subrepo. Instead of requiring a manual reset (force-push), forward sync now detects the filter change and creates a reconciliation merge commit that connects the old and new histories — no force-push, no re-clone needed. Co-Authored-By: Claude Opus 4.6 --- bin/josh-sync | 17 +++++++++-- docs/guide.md | 4 +++ lib/sync.sh | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 3 deletions(-) diff --git a/bin/josh-sync b/bin/josh-sync index f3dbd3b..460821a 100755 --- a/bin/josh-sync +++ b/bin/josh-sync @@ -208,10 +208,20 @@ _sync_direction() { fi fi - # Run sync + # Check for filter change (forward only — reverse uses same filter) local result if [ "$direction" = "forward" ]; then - result=$(forward_sync) + local prev_filter + prev_filter=$(echo "$state" | jq -r '.last_forward.josh_filter // empty') + + if [ -n "$prev_filter" ] && [ "$prev_filter" != "$JOSH_FILTER" ]; then + log "WARN" "Josh filter changed — reconciling histories" + log "INFO" "Old: ${prev_filter}" + log "INFO" "New: ${JOSH_FILTER}" + result=$(reconcile_filter_change) + else + result=$(forward_sync) + fi else result=$(reverse_sync) fi @@ -240,8 +250,9 @@ _sync_direction() { --arg s_sha "${subrepo_sha_now:-}" \ --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ --arg status "$result" \ + --arg filter "$JOSH_FILTER" \ --argjson prev "$state" \ - '$prev + {last_forward: {mono_sha:$m_sha, subrepo_sha:$s_sha, timestamp:$ts, status:$status}}') + '$prev + {last_forward: {mono_sha:$m_sha, subrepo_sha:$s_sha, timestamp:$ts, status:$status, josh_filter:$filter}}') else local mono_sha_now mono_sha_now=$(git rev-parse "origin/${branch}" 2>/dev/null || echo "") diff --git a/docs/guide.md b/docs/guide.md index b3f0435..24e1754 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -573,6 +573,10 @@ Josh uses `::` patterns inside `:exclude[...]`: No extra files to generate or commit — the exclusion is embedded directly in the josh-proxy URL. +### Changing the exclude list + +You can safely add or remove patterns from `exclude` at any time. When josh-sync detects that the filter has changed since the last sync, it automatically creates a reconciliation merge commit on the subrepo that connects the old and new histories — no manual reset or force-push required. Developers do not need to re-clone the subrepo. + ## Adding a New Target To add a new subrepo after initial setup: diff --git a/lib/sync.sh b/lib/sync.sh index ab1b7e8..48b2136 100644 --- a/lib/sync.sh +++ b/lib/sync.sh @@ -128,6 +128,85 @@ ${BOT_TRAILER}: forward/${mono_branch}/$(date -u +%Y-%m-%dT%H:%M:%SZ)" >&2 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: subrepo HEAD + josh-proxy HEAD, with josh-proxy's tree + local merge_commit + merge_commit=$(git commit-tree "$josh_tree" \ + -p "$subrepo_head" \ + -p "$josh_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.