Compare commits

...

43 Commits

Author SHA1 Message Date
ac0b9b4ea3 fix(action): allow forward sync from trailer commits 2026-06-08 13:29:31 +01:00
427cc4c3f1 Merge remote-tracking branch 'origin/auto-sync/mono-main-20260602-120147'
# Conflicts:
#	CHANGELOG.md
2026-06-02 16:28:21 +01:00
c7871397d3 fix(import): include tracked ignored files 2026-06-02 15:02:19 +01:00
e8d4ae392e Update CHANGELOG.md 2026-05-28 05:25:22 +01:00
sync-bot
4187798fd7 Sync from monorepo 2026-05-28T04:24:11Z
Sync-Origin: forward/main/2026-05-28T04:24:11Z
2026-05-28 04:24:11 +00:00
sync-bot
fac1127539 Adopt josh-sync for josh-sync
Sync-Origin: adopt/main/2026-05-28T03:19:12Z
2026-05-28 04:19:12 +01:00
dc2cdea360 Release 2.2.0 — unified onboard strategy + safety hardening
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>
2026-05-27 03:32:04 +01:00
f2e39be5e1 "#" 2026-05-26 10:38:20 +03:00
b28662d3b4 Release 2.0.0 — explicit monorepo_url, drop first-target-host inference
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>
2026-04-29 06:31:21 +03:00
5e9b134168 Add --force flag to reverse sync
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>
2026-04-04 01:43:47 +03:00
1cb23a4411 Release 1.4.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 02:46:43 +03:00
479b592076 Add versioning contract: semver policy, breaking change sections, schema_version guard
- 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>
2026-04-01 00:56:33 +03:00
65cc8eaf8a Trim overengineered docs and ADR
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>
2026-03-17 14:55:53 +03:00
4861179c6f Fix linearize fallback for already-applied merge commits
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>
2026-03-17 14:49:10 +03:00
c9b37f2477 Fix linearize fallback applying wrong diff (full tree instead of parent)
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>
2026-03-17 14:41:04 +03:00
d3799187a1 Clean up linearize fallback output formatting
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>
2026-03-17 14:33:42 +03:00
b4eaa47ef6 Fix reverse sync missing human commits when mono-filtered advances
--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>
2026-03-17 14:07:20 +03:00
d6f334b861 Add linearize fallback for reverse sync and workflow guide (v1.3)
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>
2026-03-17 12:48:17 +03:00
8ab07b83ab Update docs, changelog, examples, and add ADRs for v1.2
- 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>
2026-02-14 21:28:40 +03:00
95b83bd538 Fix PR body newlines rendering as literal \n
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>
2026-02-14 16:13:13 +03:00
ce53d3c1d2 Fix reconciliation parent order and add reverse sync tree check
- 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>
2026-02-14 15:11:31 +03:00
16257f25d7 Fix reverse sync false positive after filter reconciliation
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>
2026-02-14 14:19:56 +03:00
c0ddb887ff Fix filter reconciliation for pre-v1.2 state and unrelated histories
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>
2026-02-14 13:30:24 +03:00
22bd59a9d7 Auto-reconcile subrepo history when josh filter changes
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>
2026-02-14 10:40:08 +03:00
d7f8618b38 Use inline :exclude in josh-proxy URL instead of stored filter files
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>
2026-02-14 10:19:41 +03:00
5929585d6c Fix josh-proxy rejecting stored filter path with slash
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>
2026-02-14 09:47:16 +03:00
187a9ead14 Add file exclusion via josh stored filters (v1.2.0)
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>
2026-02-13 22:45:13 +03:00
401d0e87a4 Show [migrated] marker and summary in migrate-pr
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>
2026-02-13 21:08:23 +03:00
fbacec7f6f Improve PR migration: fetch branches locally + 3-way merge
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>
2026-02-13 20:51:22 +03:00
553f006174 Fix onboard import cloning from empty new repo instead of archived repo
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>
2026-02-13 19:48:46 +03:00
cb14cf9bd4 Add docs for updating josh-sync version in Nix devenv
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 18:38:44 +03:00
0363b0ee77 Fix VERSION not included in Nix package and Makefile bundle
- 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>
2026-02-13 18:38:07 +03:00
72430714af Update docs for onboard and migrate-pr commands
- 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>
2026-02-13 18:33:53 +03:00
105216a27e Add onboard and migrate-pr commands (v1.1.0)
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>
2026-02-13 12:41:44 +03:00
405e5f4535 Update guide.md 2026-02-13 09:31:41 +03:00
cc08c530d1 Update sync.sh 2026-02-12 20:02:34 +03:00
5758987942 Update sync.sh 2026-02-12 19:32:21 +03:00
0edbdae558 "#" 2026-02-12 18:00:08 +03:00
a19b795f9b Create .gitignore 2026-02-12 14:37:11 +03:00
0d2aea9664 Fix all shellcheck warnings for nix build gate
- 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>
2026-02-12 14:33:26 +03:00
ad925d8228 Update flake.nix 2026-02-12 13:21:37 +03:00
f2785241bf "#" 2026-02-12 11:22:51 +03:00
7c2d731399 "#" 2026-02-12 09:20:55 +03:00
8 changed files with 127 additions and 20 deletions

View File

@@ -1,5 +1,17 @@
# Changelog
## 2.2.2
### Fixes
- 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.
## 2.2.0
### Changes

View File

@@ -1 +1 @@
2.2.0
2.2.2

View File

@@ -38,21 +38,7 @@ runs:
command -v "$cmd" &>/dev/null || { echo "::error::Missing required tool: $cmd"; exit 1; }
done
- name: Loop guard (forward)
id: guard
if: inputs.direction == 'forward' || inputs.direction == 'both'
shell: bash
run: |
TRAILER=$(yq '.bot.trailer' "${{ inputs.config }}" 2>/dev/null || echo "Josh-Sync-Origin")
if git log -1 --format=%B | grep -q "^${TRAILER}:"; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "::notice::Skipping sync — HEAD commit has sync trailer (loop prevention)"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Sync
if: steps.guard.outputs.skip != 'true'
shell: bash
env:
JOSH_SYNC_DEBUG: ${{ inputs.debug == 'true' && '1' || '0' }}

View File

@@ -17,7 +17,7 @@ Bidirectional sync creates an infinite loop risk: forward sync pushes commit A t
## 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
@@ -32,7 +32,7 @@ The trailer value encodes: direction, branch, and timestamp. This aids debugging
### Filtering
- **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
@@ -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
- Configurable key avoids conflicts between multiple sync bots
- Human-readable — `git log` shows the trailer in commit messages
- CI loop guard prevents unnecessary sync runs entirely
- Repositories can act as middle hops, receiving a sync commit from one relationship and forwarding relevant changes to another
**Negative:**
- Commits with manually-added trailers matching the key would be incorrectly filtered

View File

@@ -554,7 +554,9 @@ Runs on a cron schedule (never triggered by subrepo pushes):
### 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.
### State tracking

View File

@@ -500,7 +500,7 @@ initial_import() {
mkdir -p "$subfolder"
rsync -a --exclude='.git' "${work_dir}/subrepo/" "${subfolder}/"
git add "$subfolder"
git add -f -- "$subfolder"
if git diff --cached --quiet; then
log "INFO" "No changes — subfolder already matches subrepo"

17
tests/unit/action.bats Normal file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bats
# tests/unit/action.bats — Composite action regression tests
setup() {
JOSH_SYNC_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
ACTION_FILE="${JOSH_SYNC_ROOT}/action.yml"
}
@test "forward sync is not globally skipped when HEAD has the sync trailer" {
! grep -q "Loop guard (forward)" "$ACTION_FILE"
! grep -q "HEAD commit has sync trailer" "$ACTION_FILE"
! grep -q "steps.guard.outputs.skip" "$ACTION_FILE"
}
@test "composite action still invokes the josh-sync CLI" {
grep -q "josh-sync sync" "$ACTION_FILE"
}

90
tests/unit/sync.bats Normal file
View File

@@ -0,0 +1,90 @@
#!/usr/bin/env bats
# tests/unit/sync.bats — Sync/import algorithm tests
setup() {
export JOSH_SYNC_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
source "$JOSH_SYNC_ROOT/lib/core.sh"
source "$JOSH_SYNC_ROOT/lib/sync.sh"
TEST_ROOT="$(mktemp -d)"
export BOT_NAME="test-bot"
export BOT_EMAIL="test-bot@test.local"
export BOT_TRAILER="Josh-Sync-Origin"
export GITEA_TOKEN="test-token"
export MONOREPO_API="https://gitea.test.local/api/v1/repos/org/mono"
export JOSH_SYNC_TARGET_NAME="crm"
export JOSH_SYNC_TARGETS='[{"name":"crm","subfolder":"apps/crm"}]'
export SYNC_BRANCH_MONO="main"
export SYNC_BRANCH_SUBREPO="main"
}
teardown() {
rm -rf "$TEST_ROOT"
}
make_mono_repo() {
local work="${TEST_ROOT}/mono-work"
local bare="${TEST_ROOT}/mono.git"
git init "$work" >/dev/null
git -C "$work" checkout -b main >/dev/null
git -C "$work" config user.name "Test User"
git -C "$work" config user.email "test@test.local"
printf 'mono\n' > "${work}/README.md"
git -C "$work" add README.md
git -C "$work" commit -m "mono base" >/dev/null
git init --bare "$bare" >/dev/null
git -C "$work" remote add origin "$bare"
git -C "$work" push origin main >/dev/null
printf '%s\n' "$bare"
}
make_subrepo_with_tracked_ignored_file() {
local work="${TEST_ROOT}/subrepo-work"
local bare="${TEST_ROOT}/subrepo.git"
git init "$work" >/dev/null
git -C "$work" checkout -b main >/dev/null
git -C "$work" config user.name "Test User"
git -C "$work" config user.email "test@test.local"
mkdir -p "${work}/.dependencies"
printf '.dependencies/\n' > "${work}/.gitignore"
printf 'visible\n' > "${work}/visible.txt"
printf 'tracked even though ignored\n' > "${work}/.dependencies/tracked.txt"
git -C "$work" add .gitignore visible.txt
git -C "$work" add -f .dependencies/tracked.txt
git -C "$work" commit -m "subrepo with tracked ignored file" >/dev/null
git init --bare "$bare" >/dev/null
git -C "$work" remote add origin "$bare"
git -C "$work" push origin main >/dev/null
printf '%s\n' "$bare"
}
mono_auth_url() {
printf '%s\n' "$MONO_BARE"
}
subrepo_auth_url() {
printf '%s\n' "$SUBREPO_BARE"
}
create_pr() {
printf '%s\n' "$4" > "${TEST_ROOT}/created-pr-head"
}
@test "initial_import stages files tracked in subrepo even when ignored by copied .gitignore" {
MONO_BARE="$(make_mono_repo)"
SUBREPO_BARE="$(make_subrepo_with_tracked_ignored_file)"
local result import_branch
result="$(initial_import)"
import_branch="$(cat "${TEST_ROOT}/created-pr-head")"
[ "$result" = "pr-created" ]
git --git-dir="$MONO_BARE" ls-tree -r --name-only "$import_branch" \
| grep -q '^apps/crm/.dependencies/tracked.txt$'
}