Folds `josh-sync adopt` into `josh-sync onboard <target>` as the adopt
strategy alongside the original reset flow. Strategy resolves by
precedence: --mode flag > targets[].history_lock config (preserve|rewrite)
> auto-detect via `git ls-remote --heads`. `josh-sync adopt` is kept as
a thin alias. Adds the new `targets[].history_lock` config field
(validated at parse time) and folds `<target>/adopt.json` state into
`<target>/onboard.json` with a `.strategy` field; legacy state files
are read with a backward-compat fallback.
Safety hardening (from a multi-angle review of the unification):
- auto-detect distinguishes auth/network failure from empty repo
- resume validates --mode against the strategy saved in state
- `josh-sync adopt` rejects a conflicting --mode in the forwarded args
- missing import-PR lookup dies in both strategies (was WARN+continue
for reset, which could create duplicate import PRs on resume)
- --restart durably removes the legacy adopt.json from the state branch
- adopt_branch is now a subshell function (EXIT trap can't clobber callers)
- strategy value validated after resolve/load (reset|adopt)
- --mode with missing/empty value dies with a usage hint
- migrate-pr against an adopt-strategy target dies with a specific hint
- reset importing asserts archived_url is present (no "null" → git clone)
End-to-end + CLI bats coverage added (tests/unit/adopt_e2e.bats,
tests/unit/cli.bats). 72 tests, shellcheck clean.
Makefile dist bundle header now correctly interpolates VERSION and
line count.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Breaking change. The monorepo's gitea host was previously inferred from the
first target's subrepo_url, silently coupling the two and breaking on any
multi-host setup (monorepo on host A, target on host B). v2.0.0 replaces the
inference with a required josh.monorepo_url field that mirrors subrepo_url —
same parsing, same SSH/HTTPS auth options, same shape.
- schema_version: 2 is now required; v1 configs are rejected with a clear migration error
- josh.monorepo_url is required; josh.monorepo_auth is optional (default https)
- mono_auth_url() in lib/auth.sh mirrors subrepo_auth_url()
- MONOREPO_API derives from monorepo_url's host instead of .[0].gitea_host;
env-var override preserved as escape hatch
- initial_import() and force-push call mono_auth_url() instead of building URLs inline
- ADR-012 documents the rationale; CHANGELOG includes migration snippet
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bypasses the PR step and force-pushes the subrepo's state directly onto
the monorepo branch. Also skips the skip-dirty guard. Only valid with
--reverse. Returns status "forced".
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- CHANGELOG: add ### Breaking Changes section to all versions (None for 1.1–1.3)
- README: add Versioning section with semver table and floating tag explanation
- schema/config-schema.json: add schema_version field (const: 1, optional)
- lib/config.sh: fail fast if schema_version is present and unsupported
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove redundant step-by-step subsections from workflow guide (the
table already covers it), tighten ADR-011 alternatives, and remove
version pins that go stale.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When cherry-pick -m 1 fails because the merge's changes were already
applied by a prior cherry-pick (empty result, clean working tree),
skip the commit instead of trying git-apply which also fails. Only
use the diff-from-first-parent fallback for real conflicts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The fallback for failed cherry-picks used `git diff HEAD $sha` which
computes the diff between the linearize branch and the commit's full
tree. This captures ALL tree differences, not just the changes the
commit introduced — causing unrelated file deletions.
Fix: diff from the commit's own parent (`${sha}^` for regular commits,
`${sha}^1` for merge commits). Also detect and skip empty merges
(where cherry-pick -m 1 produces nothing because the changes were
already applied by a prior cherry-pick).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Suppress git stdout/stderr noise from cherry-pick, checkout, and
commit during linearization. Add delimiters and consistent indented
log lines so the output is easy to scan.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
--ancestry-path returns empty when mono-filtered/main has advanced
past the last forward sync (any monorepo commit causes josh to create
a new filtered SHA not in the subrepo's ancestry). Use git merge-base
to find the last connected point instead.
Also return skip-dirty (not skip) when trees differ but no human
commits found, preventing state from being updated on false skips
which would permanently lose unsynced commits.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When josh-proxy rejects a reverse sync push due to unmappable merge
commits, fall back to linearizing: cherry-pick regular commits
individually, squash only the merge commits via cherry-pick -m 1.
Also adds a recommended Git workflow section to the guide explaining
where cross-branch merges should happen (monorepo) vs feature work
(subrepo), and expands troubleshooting for the "josh rejected push"
error with root cause analysis and prevention advice.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add v1.1.0 and v1.2.0 changelog entries
- Add exclude field to config reference and example config
- Add ADRs documenting all major design decisions
- Fix step numbering in reverse_sync()
- Fix action.yml to copy VERSION file
- Add dist/ and .env to .gitignore
- Use refs/tags/ format for Nix flake tag refs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Bash double-quoted strings don't interpret \n as newlines.
Use actual newlines in the pr_body strings instead.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Swap parent order in reconcile_filter_change(): josh-filtered must
be first parent so josh can follow first-parent traversal to map
history back to the monorepo. Old subrepo history on parent 2.
- Add tree comparison in reverse_sync() before commit detection:
if subrepo tree matches josh-filtered tree, skip immediately.
Prevents false positive PRs after reconciliation.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add --ancestry-path to git log in reverse_sync() to prevent old
subrepo history from leaking through reconciliation merge parents.
Without this, every old subrepo commit appears as a "human commit"
triggering a spurious 0-commit PR on the monorepo.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Three bugs found during first CI run after enabling :exclude:
- Derive old filter (:/subfolder) when state has no josh_filter stored
(pre-v1.2 upgrade path)
- Detect unrelated histories in forward_sync() and fall back to
reconcile_filter_change() instead of creating a useless conflict PR
- Skip state update on conflict result (prevents storing wrong filter
and mono SHA that blocks retries)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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 <noreply@anthropic.com>
The :+ stored filter syntax doesn't work in josh-proxy URLs.
Inline :exclude[::p1,::p2] works directly — no files to generate
or commit, no extra dependencies.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Josh-proxy's parser treats "/" in :+ paths as a filter separator,
so :+.josh-filters/backend fails. Use flat naming at repo root:
.josh-filter-<target>.josh referenced as :+.josh-filter-<target>.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New `exclude` config field per target generates .josh-filters/<name>.josh
files with josh :exclude clauses. Josh-proxy applies exclusions at the
transport layer — excluded files never appear in the subrepo.
Preflight checks that generated filter files are committed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Interactive picker now marks already-migrated PRs. All modes (--all,
explicit numbers, interactive) track and display success/fail/skip
counts at the end.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Instead of fetching the API diff (which has context-sensitive patches
that break after josh-filtered reset), fetch the archived repo's
branches directly as a second remote and compute the diff locally.
Apply with git apply --3way for resilience against context mismatches.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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>
- flake.nix: copy VERSION file to $out/ so josh_sync_version() finds it
- Makefile: add lib/onboard.sh to the bundle loop
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- README: add onboard and migrate-pr to CLI reference
- Guide Step 5: add onboard as recommended Option A, move manual
import/reset to Option B, document migrate-pr usage
- Guide "Adding a New Target": mention onboard as preferred path
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New commands for safely onboarding existing subrepos into the monorepo
without losing open PRs:
- josh-sync onboard <target>: interactive, resumable 5-step flow
(import → wait for merge → reset to new repo)
- josh-sync migrate-pr <target> [PR#...] [--all]: migrate PRs from
archived repo to new repo via patch application
Also refactors create_pr() to wrap create_pr_number(), eliminating
duplicated curl/jq logic.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- SC2015: Wrap A && B || C patterns in brace groups for directive scope
- SC2064: Suppress for intentional early trap expansion (local vars)
- SC2164: Add || exit to cd commands in subshells
- SC2001: Suppress for sed URL injection (clearer than parameter expansion)
- SC1083: Handle {tree} git syntax (quote or suppress)
- SC1091: Suppress for runtime-resolved source paths
- SC2034: Remove unused exit codes (E_OK, E_CONFIG, E_AUTH)
- SC2116: Eliminate useless echo in mono_auth_url construction
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Forward sync no longer skips the entire action when the checked-out HEAD commit has the configured sync trailer. This allows valid multi-hop setups where one repo receives sync commits from an upstream repository and forwards its filtered subtree to another repository. Reverse sync still filters bot commits by trailer for loop prevention.
## 2.2.1
### Fixes
- Initial import now force-adds copied subrepo files so tracked files that match the copied `.gitignore` are included in the import PR.
@@ -17,7 +17,7 @@ Bidirectional sync creates an infinite loop risk: forward sync pushes commit A t
## Decision
## Decision
All bot commits include a git trailer with a configurable key (default: `Josh-Sync-Origin`). Both sync directions filter out commits containing this trailer.
All bot commits include a git trailer with a configurable key (default: `Josh-Sync-Origin`). Reverse sync filters out commits containing this trailer so bot-generated forward-sync commits are not proposed back to the monorepo.
### Format
### Format
@@ -32,7 +32,7 @@ The trailer value encodes: direction, branch, and timestamp. This aids debugging
### Filtering
### Filtering
- **Reverse sync**: `git log --invert-grep --grep="^${BOT_TRAILER}:"` excludes all commits with the trailer
- **Reverse sync**: `git log --invert-grep --grep="^${BOT_TRAILER}:"` excludes all commits with the trailer
- **CI loop guard**: The composite action checks if HEAD commit has the trailer before running sync at all
- **Forward sync**: the composite action still runs even when HEAD has the trailer; forward sync uses tree comparison, sync state, and merge checks to decide whether to push
### Configuration
### Configuration
@@ -44,7 +44,7 @@ The trailer key is set in `.josh-sync.yml` under `bot.trailer`. This allows mult
- Reliable loop prevention — trailer is part of the immutable commit object
- Reliable loop prevention — trailer is part of the immutable commit object
- Configurable key avoids conflicts between multiple sync bots
- Configurable key avoids conflicts between multiple sync bots
- Human-readable — `git log` shows the trailer in commit messages
- Human-readable — `git log` shows the trailer in commit messages
@@ -554,7 +554,9 @@ Runs on a cron schedule (never triggered by subrepo pushes):
### Loop prevention
### Loop prevention
Bot commits include a git trailer like `Josh-Sync-Origin: forward/main/2024-02-12T10:30:00Z`. Each sync direction filters out commits with this trailer, preventing changes from bouncing back and forth. The CI action also has a loop guard that skips entirely if the HEAD commit has the trailer.
Bot commits include a git trailer like `Josh-Sync-Origin: forward/main/2024-02-12T10:30:00Z`. Reverse sync filters out commits with this trailer, preventing changes from bouncing back from the target repo into the source repo.
The CI action does not skip forward sync solely because the checked-out HEAD commit has a sync trailer. A repo can be a valid middle hop: it may receive a sync commit from one repository and still need to forward its filtered subtree to another repository. Forward sync relies on tree comparison, sync state, and merge checks to decide whether anything should be pushed.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.