Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac0b9b4ea3 | |||
| 427cc4c3f1 | |||
| c7871397d3 | |||
| 8776ccf2d2 | |||
| b82311829c | |||
| e8d4ae392e | |||
| 9071e17d31 | |||
|
|
4187798fd7 | ||
|
|
5f942bced4 | ||
| 5d8af5f76a | |||
| 2fce9b19af | |||
|
|
fac1127539 | ||
|
|
4df4044f20 | ||
|
|
b35703d271 | ||
| dc2cdea360 | |||
| f2e39be5e1 | |||
| b28662d3b4 | |||
| 5e9b134168 | |||
| 1cb23a4411 |
126
CHANGELOG.md
126
CHANGELOG.md
@@ -1,5 +1,131 @@
|
|||||||
# Changelog
|
# 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
|
||||||
|
|
||||||
|
- **Unified onboarding via `josh-sync onboard <target>`.** The previously separate `adopt` workflow is now an onboarding strategy alongside the original `reset` flow. Strategy is resolved by precedence: `--mode={reset,adopt}` flag → `targets[].history_lock` config (`preserve` → adopt, `rewrite` → reset) → auto-detect via `git ls-remote --heads` on the subrepo (heads → adopt, empty → reset). The chosen strategy is logged at preflight (and re-announced on every resume).
|
||||||
|
- **`josh-sync adopt` kept as a back-compat CLI alias** for `josh-sync onboard --mode=adopt`. Existing in-flight adoptions (state at `<target>/adopt.json` on the `josh-sync-state` branch) continue to resume — `read_onboard_state` falls back to that legacy file with an implicit `strategy: adopt` and strips the legacy `.mode` field. Pre-unification `onboard.json` files (no `.strategy`) are read as `strategy: reset`.
|
||||||
|
- **New `targets[].history_lock` config field** (`preserve` | `rewrite`) — pins the onboarding strategy independently of the subrepo's current emptiness. Validated at config parse time. See [Config Reference](docs/config-reference.md#targets-section).
|
||||||
|
- **End-to-end bats coverage** — tree-mismatch surfacing, idempotency, merge shape (ADR-005 trailer, ADR-008 first-parent ordering), fast-forward push semantics, reverse-sync loop guard, linearize-fallback selection, resume from every checkpoint, strategy resolution precedence, legacy-state migration, and the CLI surface (flag parsing, alias forwarding, error messages).
|
||||||
|
|
||||||
|
### Safety
|
||||||
|
|
||||||
|
- **Auto-detect now distinguishes auth/network failures from genuinely empty repos.** A failing `git ls-remote` previously fell through to `reset` (destructive force-push); it now dies with a clear "pass --mode explicitly" message.
|
||||||
|
- **Resume validates `--mode` against the strategy already saved in state.** Conflicting flags die with a `--restart` hint instead of being silently ignored.
|
||||||
|
- **`josh-sync adopt` rejects a conflicting `--mode`.** Previously the user-supplied flag silently won.
|
||||||
|
- **Missing import-PR lookup now `die`s in both strategies.** The reset path used to `WARN` and continue without recording the PR number, leading to duplicate import PRs on resume.
|
||||||
|
- **`--restart` durably removes the legacy `<target>/adopt.json`** from the state branch so it can't silently resurrect via the read fallback.
|
||||||
|
- **`adopt_branch` is now a subshell function** — its EXIT trap can no longer clobber callers' traps (test reporting, etc.).
|
||||||
|
- **Strategy value is validated** after resolve/load (`reset|adopt`); unknown/corrupt values die with a `--restart` hint.
|
||||||
|
- **`--mode` with a missing/empty value** dies with a usage hint instead of crashing under `set -u`.
|
||||||
|
- **`migrate-pr` against an adopt-strategy target** dies with a specific message rather than the misleading "Run onboard first" loop.
|
||||||
|
- **Reset import step asserts `archived_url` is present** instead of silently flowing the literal string `"null"` into `git clone`.
|
||||||
|
|
||||||
|
ADR-013 updated with a status note pointing at the unified command.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- Makefile `dist/josh-sync` bundle header now correctly interpolates VERSION and line count (was emitting empty values due to make's `$(...)` swallowing the shell command substitution).
|
||||||
|
|
||||||
|
## 2.1.0
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
_None._
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **`adopt` command for non-destructive existing subrepos**: `josh-sync adopt <target>` imports existing subrepo content into the monorepo, waits for the import PR to merge, then creates an adoption merge commit on the subrepo. The adoption merge uses the Josh-filtered monorepo commit as first parent and the existing subrepo commit as second parent, preserving old subrepo history while establishing Josh-compatible ancestry.
|
||||||
|
- **Resumable adoption state**: adoption progress is stored on `josh-sync-state` at `<target>/adopt.json`, separate from sync and onboard state.
|
||||||
|
|
||||||
|
### Safety
|
||||||
|
|
||||||
|
- Adoption requires the subrepo tree to match the Josh-filtered monorepo tree before creating the merge commit.
|
||||||
|
- Adoption pushes with a normal fast-forward push only. It never force-pushes.
|
||||||
|
|
||||||
|
## 2.0.0
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- **`schema_version: 2` is now required.** v1 configs are rejected with a migration pointer. Add `schema_version: 2` at the top of `.josh-sync.yml`.
|
||||||
|
- **`josh.monorepo_url` is now required.** Set it to your monorepo's git clone URL — `git@host:org/repo.git` (SSH), `ssh://...`, or `https://host/org/repo.git`. Removes the silent inference of the gitea host from the first target's `subrepo_url`, which silently misbehaved when the monorepo and a target lived on different gitea instances. See [ADR-012](docs/adr/012-explicit-monorepo-url.md).
|
||||||
|
- **`MONOREPO_API` derivation changed.** The default is now `https://{monorepo_url's host}/api/v1/repos/{monorepo_path}`. The env-var override is preserved as an escape hatch for custom API endpoints.
|
||||||
|
|
||||||
|
#### Migration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Before (v1.x)
|
||||||
|
josh:
|
||||||
|
proxy_url: "https://josh.example.com"
|
||||||
|
monorepo_path: "org/monorepo"
|
||||||
|
|
||||||
|
# After (v2.0.0)
|
||||||
|
schema_version: 2
|
||||||
|
josh:
|
||||||
|
proxy_url: "https://josh.example.com"
|
||||||
|
monorepo_path: "org/monorepo"
|
||||||
|
monorepo_url: "git@gitea.example.com:org/monorepo.git" # required
|
||||||
|
monorepo_auth: "ssh" # optional, default "https"
|
||||||
|
```
|
||||||
|
|
||||||
|
If you previously set the `MONOREPO_API` env var to work around the first-target-host bug, you can remove it — the new derivation gets it right from `monorepo_url`.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Multi-host configs supported.** The monorepo and any subrepo may live on different gitea instances. Target ordering no longer affects how the monorepo's host is resolved.
|
||||||
|
- **SSH clone of the monorepo via `monorepo_auth: ssh`.** Uses the user's ssh-agent / `~/.ssh/config`; no token required for the clone itself. PR creation still uses `GITEA_TOKEN`.
|
||||||
|
- **Symmetric config shape.** Monorepo and subrepos now both declare a URL plus an auth method.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- First-target ordering no longer load-bearing on `MONOREPO_API` derivation (was `gitea_host=.[0].gitea_host` in v1.x).
|
||||||
|
|
||||||
|
## 1.5.0
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
_None._
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **`--force` flag for reverse sync**: `josh-sync sync --reverse --force` 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`.
|
||||||
|
|
||||||
|
## 1.4.0
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
_None._
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **`schema_version` config field**: New optional integer field in `.josh-sync.yml`. Defaults to `1` when absent. josh-sync fails fast with a clear error if an unsupported version is present — safe path for future config migrations without silent misinterpretation.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Linearize fallback applying wrong diff**: Fallback for failed `cherry-pick` was diffing from `HEAD` to the commit's full tree instead of from the commit's own parent, causing unrelated file deletions. Now diffs from the commit's own parent (`${sha}^` / `${sha}^1`).
|
||||||
|
- **Linearize fallback for already-applied merge commits**: When `cherry-pick -m 1` produces an empty result because a merge's changes were already applied by a prior cherry-pick, the commit is now skipped instead of attempting a failing `git apply`.
|
||||||
|
- **Linearize fallback output**: Suppressed git noise from cherry-pick, checkout, and commit during linearization; added delimiters and consistent indented log lines.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
- Workflow guide: removed redundant step-by-step subsections (table already covers them); tightened ADR-011 alternatives; removed stale version pins.
|
||||||
|
- README: added Versioning section with semver policy table and floating tag explanation.
|
||||||
|
|
||||||
## 1.3.0
|
## 1.3.0
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|||||||
6
Makefile
6
Makefile
@@ -19,11 +19,11 @@ dist/josh-sync: bin/josh-sync lib/*.sh VERSION
|
|||||||
@mkdir -p dist
|
@mkdir -p dist
|
||||||
@echo "Bundling josh-sync..."
|
@echo "Bundling josh-sync..."
|
||||||
@echo '#!/usr/bin/env bash' > dist/josh-sync
|
@echo '#!/usr/bin/env bash' > dist/josh-sync
|
||||||
@echo "# josh-sync $(cat VERSION) — bundled distribution" >> dist/josh-sync
|
@echo "# josh-sync $$(cat VERSION) — bundled distribution" >> dist/josh-sync
|
||||||
@echo '# Generated by: make build' >> dist/josh-sync
|
@echo '# Generated by: make build' >> dist/josh-sync
|
||||||
@echo '' >> dist/josh-sync
|
@echo '' >> dist/josh-sync
|
||||||
@# Inline all library modules (strip shebangs and source directives)
|
@# Inline all library modules (strip shebangs and source directives)
|
||||||
@for f in lib/core.sh lib/config.sh lib/auth.sh lib/state.sh lib/sync.sh lib/onboard.sh; do \
|
@for f in lib/core.sh lib/config.sh lib/auth.sh lib/state.sh lib/sync.sh lib/adopt.sh lib/onboard.sh; do \
|
||||||
echo "# --- $$f ---" >> dist/josh-sync; \
|
echo "# --- $$f ---" >> dist/josh-sync; \
|
||||||
grep -v '^#!/' "$$f" | grep -v '^# shellcheck source=' >> dist/josh-sync; \
|
grep -v '^#!/' "$$f" | grep -v '^# shellcheck source=' >> dist/josh-sync; \
|
||||||
echo '' >> dist/josh-sync; \
|
echo '' >> dist/josh-sync; \
|
||||||
@@ -36,7 +36,7 @@ dist/josh-sync: bin/josh-sync lib/*.sh VERSION
|
|||||||
| sed '/^JOSH_LIB_DIR=/,/^source/d' \
|
| sed '/^JOSH_LIB_DIR=/,/^source/d' \
|
||||||
>> dist/josh-sync
|
>> dist/josh-sync
|
||||||
@chmod +x dist/josh-sync
|
@chmod +x dist/josh-sync
|
||||||
@echo "Built: dist/josh-sync ($(wc -l < dist/josh-sync) lines)"
|
@echo "Built: dist/josh-sync ($$(wc -l < dist/josh-sync) lines)"
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf dist/
|
rm -rf dist/
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -9,9 +9,13 @@ Bidirectional monorepo ↔ subrepo sync via [josh-proxy](https://josh-project.gi
|
|||||||
Create `.josh-sync.yml` in your monorepo root:
|
Create `.josh-sync.yml` in your monorepo root:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
schema_version: 2
|
||||||
|
|
||||||
josh:
|
josh:
|
||||||
proxy_url: "https://josh.example.com"
|
proxy_url: "https://josh.example.com"
|
||||||
monorepo_path: "org/monorepo"
|
monorepo_path: "org/monorepo"
|
||||||
|
monorepo_url: "git@gitea.example.com:org/monorepo.git"
|
||||||
|
monorepo_auth: "ssh" # optional, default "https"
|
||||||
|
|
||||||
targets:
|
targets:
|
||||||
- name: "billing"
|
- name: "billing"
|
||||||
@@ -78,9 +82,10 @@ CI workflows pin to a floating major tag (e.g. `@v1`). A breaking change bumps t
|
|||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
```
|
```
|
||||||
josh-sync sync [--forward|--reverse] [--target NAME[,NAME]] [--branch BRANCH]
|
josh-sync sync [--forward|--reverse] [--force] [--target NAME[,NAME]] [--branch BRANCH]
|
||||||
josh-sync preflight
|
josh-sync preflight
|
||||||
josh-sync import <target>
|
josh-sync import <target>
|
||||||
|
josh-sync adopt <target> [--restart]
|
||||||
josh-sync reset <target>
|
josh-sync reset <target>
|
||||||
josh-sync onboard <target> [--restart]
|
josh-sync onboard <target> [--restart]
|
||||||
josh-sync migrate-pr <target> [PR#...] [--all]
|
josh-sync migrate-pr <target> [PR#...] [--all]
|
||||||
@@ -92,7 +97,8 @@ josh-sync state reset <target> [branch]
|
|||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
- **Forward sync** (mono → subrepo): pushes directly if clean, creates conflict PR if not. Uses `--force-with-lease` for safety.
|
- **Forward sync** (mono → subrepo): pushes directly if clean, creates conflict PR if not. Uses `--force-with-lease` for safety.
|
||||||
- **Reverse sync** (subrepo → mono): always creates a PR, never pushes directly.
|
- **Reverse sync** (subrepo → mono): creates a PR by default. Add `--force` to bypass the PR and push directly to the mono branch (destructive).
|
||||||
|
- **Adoption**: existing active subrepos can be connected to josh-sync with a normal fast-forward merge commit, preserving old subrepo history and avoiding force-push.
|
||||||
- **File exclusion**: `exclude` patterns are embedded inline in the josh-proxy URL. Excluded files exist only in the monorepo.
|
- **File exclusion**: `exclude` patterns are embedded inline in the josh-proxy URL. Excluded files exist only in the monorepo.
|
||||||
- **Filter reconciliation**: Changing the exclude list auto-creates a merge commit that connects old and new histories — no force-push needed.
|
- **Filter reconciliation**: Changing the exclude list auto-creates a merge commit that connects old and new histories — no force-push needed.
|
||||||
- **Loop prevention**: `Josh-Sync-Origin:` git trailer filters out bot commits.
|
- **Loop prevention**: `Josh-Sync-Origin:` git trailer filters out bot commits.
|
||||||
|
|||||||
14
action.yml
14
action.yml
@@ -38,21 +38,7 @@ runs:
|
|||||||
command -v "$cmd" &>/dev/null || { echo "::error::Missing required tool: $cmd"; exit 1; }
|
command -v "$cmd" &>/dev/null || { echo "::error::Missing required tool: $cmd"; exit 1; }
|
||||||
done
|
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
|
- name: Sync
|
||||||
if: steps.guard.outputs.skip != 'true'
|
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
JOSH_SYNC_DEBUG: ${{ inputs.debug == 'true' && '1' || '0' }}
|
JOSH_SYNC_DEBUG: ${{ inputs.debug == 'true' && '1' || '0' }}
|
||||||
|
|||||||
@@ -8,8 +8,9 @@
|
|||||||
# sync Run forward and/or reverse sync
|
# sync Run forward and/or reverse sync
|
||||||
# preflight Validate config, connectivity, auth
|
# preflight Validate config, connectivity, auth
|
||||||
# import <target> Initial import: pull subrepo into monorepo
|
# import <target> Initial import: pull subrepo into monorepo
|
||||||
|
# onboard <target> Interactive onboarding (auto-picks reset|adopt strategy)
|
||||||
|
# adopt <target> Alias for `onboard --mode=adopt`
|
||||||
# reset <target> Reset subrepo to josh-filtered view
|
# reset <target> Reset subrepo to josh-filtered view
|
||||||
# onboard <target> Import existing subrepo into monorepo (interactive)
|
|
||||||
# migrate-pr <target> [PR#...] [--all] Move PRs from archived to new subrepo
|
# migrate-pr <target> [PR#...] [--all] Move PRs from archived to new subrepo
|
||||||
# status Show target config and sync state
|
# status Show target config and sync state
|
||||||
# state show|reset Manage sync state directly
|
# state show|reset Manage sync state directly
|
||||||
@@ -41,6 +42,8 @@ source "${JOSH_LIB_DIR}/auth.sh"
|
|||||||
source "${JOSH_LIB_DIR}/state.sh"
|
source "${JOSH_LIB_DIR}/state.sh"
|
||||||
# shellcheck source=../lib/sync.sh
|
# shellcheck source=../lib/sync.sh
|
||||||
source "${JOSH_LIB_DIR}/sync.sh"
|
source "${JOSH_LIB_DIR}/sync.sh"
|
||||||
|
# shellcheck source=../lib/adopt.sh
|
||||||
|
source "${JOSH_LIB_DIR}/adopt.sh"
|
||||||
# shellcheck source=../lib/onboard.sh
|
# shellcheck source=../lib/onboard.sh
|
||||||
source "${JOSH_LIB_DIR}/onboard.sh"
|
source "${JOSH_LIB_DIR}/onboard.sh"
|
||||||
|
|
||||||
@@ -72,8 +75,13 @@ Commands:
|
|||||||
sync Run forward and/or reverse sync
|
sync Run forward and/or reverse sync
|
||||||
preflight Validate config, connectivity, auth, workflow coverage
|
preflight Validate config, connectivity, auth, workflow coverage
|
||||||
import <target> Initial import: pull existing subrepo into monorepo (creates PR)
|
import <target> Initial import: pull existing subrepo into monorepo (creates PR)
|
||||||
|
onboard <target> Onboard a subrepo (interactive, resumable). Picks a strategy:
|
||||||
|
reset — force-push josh-filtered history (replaces subrepo)
|
||||||
|
adopt — non-destructive merge that keeps subrepo history
|
||||||
|
Precedence: --mode flag > targets[].history_lock > auto-detect
|
||||||
|
via ls-remote (heads → adopt, empty → reset).
|
||||||
|
adopt <target> Alias for 'onboard --mode=adopt' (kept for back-compat)
|
||||||
reset <target> Reset subrepo to josh-filtered view (after merging import PR)
|
reset <target> Reset subrepo to josh-filtered view (after merging import PR)
|
||||||
onboard <target> Import existing subrepo into monorepo (interactive, resumable)
|
|
||||||
migrate-pr <target> [PR#...] [--all] Move PRs from archived to new subrepo
|
migrate-pr <target> [PR#...] [--all] Move PRs from archived to new subrepo
|
||||||
status Show target config and sync state
|
status Show target config and sync state
|
||||||
state show <target> [branch] Show sync state JSON
|
state show <target> [branch] Show sync state JSON
|
||||||
@@ -88,9 +96,13 @@ Global flags:
|
|||||||
Sync flags:
|
Sync flags:
|
||||||
--forward Forward only (mono → subrepo)
|
--forward Forward only (mono → subrepo)
|
||||||
--reverse Reverse only (subrepo → mono)
|
--reverse Reverse only (subrepo → mono)
|
||||||
|
--force Force-push subrepo state to mono branch (--reverse only, skips PR)
|
||||||
--target NAME Filter to target(s) — comma-separated for multiple (env: JOSH_SYNC_TARGET)
|
--target NAME Filter to target(s) — comma-separated for multiple (env: JOSH_SYNC_TARGET)
|
||||||
--branch BRANCH Filter to one branch
|
--branch BRANCH Filter to one branch
|
||||||
|
|
||||||
|
Onboard flags:
|
||||||
|
--mode={reset,adopt} Override the onboarding strategy
|
||||||
|
|
||||||
Environment:
|
Environment:
|
||||||
JOSH_SYNC_TARGET Restrict to a single target name
|
JOSH_SYNC_TARGET Restrict to a single target name
|
||||||
JOSH_SYNC_STATE_BRANCH State branch name (default: josh-sync-state)
|
JOSH_SYNC_STATE_BRANCH State branch name (default: josh-sync-state)
|
||||||
@@ -108,6 +120,7 @@ cmd_sync() {
|
|||||||
local filter_target="${JOSH_SYNC_TARGET:-}"
|
local filter_target="${JOSH_SYNC_TARGET:-}"
|
||||||
local filter_branch=""
|
local filter_branch=""
|
||||||
local config_file=".josh-sync.yml"
|
local config_file=".josh-sync.yml"
|
||||||
|
local force_flag=false
|
||||||
|
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -117,10 +130,18 @@ cmd_sync() {
|
|||||||
--branch) filter_branch="$2"; shift 2 ;;
|
--branch) filter_branch="$2"; shift 2 ;;
|
||||||
--config) config_file="$2"; shift 2 ;;
|
--config) config_file="$2"; shift 2 ;;
|
||||||
--debug) export JOSH_SYNC_DEBUG=1; shift ;;
|
--debug) export JOSH_SYNC_DEBUG=1; shift ;;
|
||||||
|
--force) force_flag=true; shift ;;
|
||||||
*) die "Unknown flag: $1" ;;
|
*) die "Unknown flag: $1" ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
if [ "$force_flag" = true ] && [ "$direction" != "reverse" ]; then
|
||||||
|
die "--force requires --reverse"
|
||||||
|
fi
|
||||||
|
if [ "$force_flag" = true ]; then
|
||||||
|
export SYNC_REVERSE_FORCE=1
|
||||||
|
fi
|
||||||
|
|
||||||
parse_config "$config_file"
|
parse_config "$config_file"
|
||||||
|
|
||||||
# Forward sync
|
# Forward sync
|
||||||
@@ -263,6 +284,9 @@ _sync_direction() {
|
|||||||
log "WARN" "Trees differ but no human commits — skipping state update (will retry)"
|
log "WARN" "Trees differ but no human commits — skipping state update (will retry)"
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
if [ "$result" = "forced" ]; then
|
||||||
|
log "WARN" "Target ${target_name}, branch ${branch}: force-pushed subrepo directly to mono — DESTRUCTIVE"
|
||||||
|
fi
|
||||||
|
|
||||||
# Update state (only on success)
|
# Update state (only on success)
|
||||||
local new_state
|
local new_state
|
||||||
@@ -517,6 +541,35 @@ cmd_import() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ─── Adopt Command (alias) ─────────────────────────────────────────
|
||||||
|
# `josh-sync adopt` is preserved as a thin alias for the unified onboard
|
||||||
|
# command. Effectively: `josh-sync onboard --mode=adopt <args...>`.
|
||||||
|
|
||||||
|
cmd_adopt() {
|
||||||
|
# Reject a conflicting --mode in the forwarded args: the alias means adopt,
|
||||||
|
# so `josh-sync adopt … --mode=reset` is contradictory. Without this guard
|
||||||
|
# the user-supplied --mode would silently win (last --mode in cmd_onboard's
|
||||||
|
# parser is the one that takes effect).
|
||||||
|
local i=1 a
|
||||||
|
while [ $i -le $# ]; do
|
||||||
|
a="${!i}"
|
||||||
|
case "$a" in
|
||||||
|
--mode=adopt) ;;
|
||||||
|
--mode=*) die "'josh-sync adopt' is an alias for --mode=adopt; conflicting '${a}' given. Use 'josh-sync onboard <target>' instead." ;;
|
||||||
|
--mode)
|
||||||
|
i=$((i + 1))
|
||||||
|
if [ $i -gt $# ] || [ "${!i}" != "adopt" ]; then
|
||||||
|
die "'josh-sync adopt' is an alias for --mode=adopt; conflicting '--mode ${!i:-<missing>}' given. Use 'josh-sync onboard <target>' instead."
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
log "INFO" "(josh-sync adopt is now an alias for: josh-sync onboard --mode=adopt)"
|
||||||
|
cmd_onboard --mode=adopt "$@"
|
||||||
|
}
|
||||||
|
|
||||||
# ─── Reset Command ─────────────────────────────────────────────────
|
# ─── Reset Command ─────────────────────────────────────────────────
|
||||||
|
|
||||||
cmd_reset() {
|
cmd_reset() {
|
||||||
@@ -690,19 +743,25 @@ cmd_onboard() {
|
|||||||
local config_file=".josh-sync.yml"
|
local config_file=".josh-sync.yml"
|
||||||
local target_name=""
|
local target_name=""
|
||||||
local restart=false
|
local restart=false
|
||||||
|
local mode=""
|
||||||
|
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--config) config_file="$2"; shift 2 ;;
|
--config) config_file="$2"; shift 2 ;;
|
||||||
--debug) export JOSH_SYNC_DEBUG=1; shift ;;
|
--debug) export JOSH_SYNC_DEBUG=1; shift ;;
|
||||||
--restart) restart=true; shift ;;
|
--restart) restart=true; shift ;;
|
||||||
|
--mode=*) mode="${1#--mode=}"
|
||||||
|
[ -n "$mode" ] || die "Empty --mode= (use --mode=reset|adopt)"
|
||||||
|
shift ;;
|
||||||
|
--mode) [ $# -ge 2 ] || die "--mode requires a value (reset|adopt)"
|
||||||
|
mode="$2"; shift 2 ;;
|
||||||
-*) die "Unknown flag: $1" ;;
|
-*) die "Unknown flag: $1" ;;
|
||||||
*) target_name="$1"; shift ;;
|
*) target_name="$1"; shift ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ -z "$target_name" ]; then
|
if [ -z "$target_name" ]; then
|
||||||
echo "Usage: josh-sync onboard <target> [--restart]" >&2
|
echo "Usage: josh-sync onboard <target> [--mode=reset|adopt] [--restart]" >&2
|
||||||
parse_config "$config_file"
|
parse_config "$config_file"
|
||||||
echo "Available targets:" >&2
|
echo "Available targets:" >&2
|
||||||
echo "$JOSH_SYNC_TARGETS" | jq -r '.[].name' | sed 's/^/ /' >&2
|
echo "$JOSH_SYNC_TARGETS" | jq -r '.[].name' | sed 's/^/ /' >&2
|
||||||
@@ -717,7 +776,7 @@ cmd_onboard() {
|
|||||||
|
|
||||||
log "INFO" "══════ Onboard target: ${target_name} ══════"
|
log "INFO" "══════ Onboard target: ${target_name} ══════"
|
||||||
load_target "$target_json"
|
load_target "$target_json"
|
||||||
onboard_flow "$target_json" "$restart"
|
onboard_flow "$target_json" "$restart" "$mode"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ─── Migrate PR Command ──────────────────────────────────────────
|
# ─── Migrate PR Command ──────────────────────────────────────────
|
||||||
@@ -873,6 +932,7 @@ main() {
|
|||||||
sync) cmd_sync "$@" ;;
|
sync) cmd_sync "$@" ;;
|
||||||
preflight) cmd_preflight "$@" ;;
|
preflight) cmd_preflight "$@" ;;
|
||||||
import) cmd_import "$@" ;;
|
import) cmd_import "$@" ;;
|
||||||
|
adopt) cmd_adopt "$@" ;;
|
||||||
reset) cmd_reset "$@" ;;
|
reset) cmd_reset "$@" ;;
|
||||||
onboard) cmd_onboard "$@" ;;
|
onboard) cmd_onboard "$@" ;;
|
||||||
migrate-pr) cmd_migrate_pr "$@" ;;
|
migrate-pr) cmd_migrate_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
|
||||||
- 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:**
|
**Negative:**
|
||||||
- Commits with manually-added trailers matching the key would be incorrectly filtered
|
- Commits with manually-added trailers matching the key would be incorrectly filtered
|
||||||
|
|||||||
53
docs/adr/012-explicit-monorepo-url.md
Normal file
53
docs/adr/012-explicit-monorepo-url.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# ADR-012: Explicit `monorepo_url` (drop first-target-host inference)
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Date:** 2026-04
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
josh-sync v1.x had no first-class field for the monorepo's git location. To call the gitea PR API and to clone the monorepo for `initial_import` / force-push, the script derived the gitea host from the **first target's `subrepo_url`**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# lib/config.sh (v1.x)
|
||||||
|
gitea_host=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].gitea_host')
|
||||||
|
export MONOREPO_API="${MONOREPO_API:-https://${gitea_host}/api/v1/repos/${MONOREPO_PATH}}"
|
||||||
|
```
|
||||||
|
|
||||||
|
This worked silently for every existing config — studio, kinanah/stores, itkan/sdlc — because in each, target 0's host happens to match the monorepo's host. The portal monorepo broke the assumption: monorepo on `code.atome.ac:atome-portal/portal`, single target `bff` on `code.itkan.io:sdlc/odoo_bff_core`. The derivation produced `https://code.itkan.io/atome-portal/portal.git` (host borrowed from the wrong target), giving a 404 on `josh-sync import`.
|
||||||
|
|
||||||
|
Two issues stack:
|
||||||
|
|
||||||
|
1. **Implicit coupling**: target ordering becomes load-bearing in a non-obvious way. The `MONOREPO_API` env var was the only escape hatch, hidden in shell init.
|
||||||
|
2. **Asymmetry**: `subrepo_url` is declared explicitly per target with full SSH/HTTPS support; the monorepo had only a `monorepo_path` and was always cloned via HTTPS+token. Local users with SSH keys for the monorepo still had to provide a token.
|
||||||
|
|
||||||
|
### Alternatives considered
|
||||||
|
|
||||||
|
1. **Keep deriving, document the env override loudly.** Cheapest. Leaves the booby trap in place — still fails closed for any future cross-host config.
|
||||||
|
2. **Add `josh.gitea_host` only.** Fixes the host issue without enabling SSH cloning of the monorepo. Half a fix, and asymmetric with how subrepos are declared.
|
||||||
|
3. **Add `josh.monorepo_url` (full URL), mirror `subrepo_url` exactly.** Removes the inference; enables SSH clone; symmetric with subrepos. Breaking — every existing config needs to add the field.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Add a required `josh.monorepo_url` field plus an optional `josh.monorepo_auth` (default `"https"`). Mirror the `subrepo_url` parsing and `subrepo_auth_url()` helper exactly:
|
||||||
|
|
||||||
|
- `parse_config` extracts `MONOREPO_GITEA_HOST` and `MONOREPO_REPO_PATH` from `monorepo_url` using the same jq regex as `subrepo_url`.
|
||||||
|
- `MONOREPO_API` derives from `MONOREPO_GITEA_HOST` instead of `.[0].gitea_host`. The env-var override stays as a real escape hatch (custom API endpoints).
|
||||||
|
- `mono_auth_url()` in `lib/auth.sh` mirrors `subrepo_auth_url()`: SSH returns the bare URL, HTTPS injects `${BOT_USER}:${GITEA_TOKEN}@` (auto-converting `git@host:org/repo.git` to `https://host/org/repo.git` first).
|
||||||
|
- `initial_import()` and the force-push path in `lib/sync.sh` call `mono_auth_url()` instead of constructing the URL inline from `MONOREPO_API`.
|
||||||
|
|
||||||
|
The change is shipped as **v2.0.0** with `schema_version: 2` required. v1 configs are rejected with a migration pointer; configs missing `monorepo_url` are rejected with the field name and an example.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:**
|
||||||
|
- No more first-target-ordering coupling. Add or reorder targets freely.
|
||||||
|
- Multi-host setups (monorepo on host A, subrepo on host B) are first-class.
|
||||||
|
- SSH clone of the monorepo works without a token (the user's ssh-agent / `~/.ssh/config` resolves the key). PR creation still needs `GITEA_TOKEN`.
|
||||||
|
- Symmetric config: monorepo and subrepos both declare a URL + auth method.
|
||||||
|
|
||||||
|
**Negative:**
|
||||||
|
- Breaking change. Every existing config must add `schema_version: 2` and `josh.monorepo_url`. Mitigated by the validation `die`s pointing at the field name and changelog.
|
||||||
|
- Two related-but-distinct fields (`monorepo_path` for the josh-proxy filter; `monorepo_url` for the gitea clone) — kept separate because josh-proxy's path may not always equal the gitea path in custom setups.
|
||||||
|
|
||||||
|
**Risks:**
|
||||||
|
- Users may set `monorepo_url` to a host that doesn't match their `proxy_url`'s upstream. Detection is out of scope for this ADR; a follow-up `josh-sync preflight` check could compare hosts and warn.
|
||||||
56
docs/adr/013-non-destructive-adoption.md
Normal file
56
docs/adr/013-non-destructive-adoption.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# ADR-013: Non-destructive adoption merge for existing subrepos
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Date:** 2026-04
|
||||||
|
**Update (2026-05):** The standalone `josh-sync adopt <target>` command described below has been folded into `josh-sync onboard <target>` as the `adopt` strategy (selectable via `--mode=adopt`, `targets[].history_lock: preserve`, or auto-detection when the subrepo has any branches). `josh-sync adopt` is retained as a thin CLI alias. The state file moved from `<target>/adopt.json` to `<target>/onboard.json` with a `.strategy` field; the legacy file is still read for backward compatibility. See the guide's Onboarding section.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Existing subrepos often already have real history, open PRs, and developer clones.
|
||||||
|
The original `import` then `reset` onboarding path establishes Josh-compatible
|
||||||
|
history by force-pushing the Josh-filtered monorepo history onto the subrepo.
|
||||||
|
That is correct for empty replacement repos, but it rewrites the active subrepo
|
||||||
|
branch and invalidates local clones.
|
||||||
|
|
||||||
|
We need a path for active subrepos where history must remain available on the
|
||||||
|
same branch and where open PR branches should stay based on the existing history.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Add `josh-sync adopt <target>` as a separate workflow from `onboard` and `reset`.
|
||||||
|
Adoption imports the subrepo content into the monorepo, waits for the import PR
|
||||||
|
to merge, then creates one merge commit on each configured subrepo branch:
|
||||||
|
|
||||||
|
1. First parent: Josh-filtered monorepo HEAD
|
||||||
|
2. Second parent: existing subrepo HEAD
|
||||||
|
3. Tree: Josh-filtered monorepo tree
|
||||||
|
|
||||||
|
Josh follows first-parent history back to the monorepo, while Git still keeps
|
||||||
|
the old subrepo history reachable through parent 2. The push is a normal
|
||||||
|
fast-forward push from the existing subrepo HEAD to the adoption merge commit.
|
||||||
|
No force-push is used.
|
||||||
|
|
||||||
|
Before creating the adoption merge, josh-sync requires the existing subrepo tree
|
||||||
|
to match the Josh-filtered monorepo tree. If the trees differ, adoption aborts
|
||||||
|
and the user must merge the import PR or reconcile the branch contents first.
|
||||||
|
|
||||||
|
Adoption state is stored separately at `<target>/adopt.json` on the
|
||||||
|
`josh-sync-state` branch.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:**
|
||||||
|
|
||||||
|
- Existing subrepo history remains on the active branch.
|
||||||
|
- Existing clones can fast-forward instead of hard-resetting or re-cloning.
|
||||||
|
- Open PR branches remain based on reachable history.
|
||||||
|
- Josh-compatible ancestry is established without a destructive rewrite.
|
||||||
|
|
||||||
|
**Negative:**
|
||||||
|
|
||||||
|
- Adoption adds one synthetic merge commit to each adopted subrepo branch.
|
||||||
|
- Strict "no subrepo commit at all" adoption is impossible if the existing
|
||||||
|
subrepo branch must become connected to the Josh-filtered history without a
|
||||||
|
rewrite. A commit is needed to join the two histories.
|
||||||
|
- Tree equality is strict. If the monorepo import differs from the subrepo
|
||||||
|
content, adoption stops until the user resolves the mismatch.
|
||||||
@@ -17,3 +17,5 @@ This directory contains Architecture Decision Records (ADRs) for josh-sync. Each
|
|||||||
| [009](009-tree-comparison-guard.md) | Tree comparison as sync skip guard | Accepted |
|
| [009](009-tree-comparison-guard.md) | Tree comparison as sync skip guard | Accepted |
|
||||||
| [010](010-onboard-checkpoint-resume.md) | Onboard workflow with checkpoint/resume | Accepted |
|
| [010](010-onboard-checkpoint-resume.md) | Onboard workflow with checkpoint/resume | Accepted |
|
||||||
| [011](011-linearize-fallback-reverse.md) | Linearize fallback for reverse sync | Accepted |
|
| [011](011-linearize-fallback-reverse.md) | Linearize fallback for reverse sync | Accepted |
|
||||||
|
| [012](012-explicit-monorepo-url.md) | Explicit `monorepo_url` (drop first-target-host inference) | Accepted |
|
||||||
|
| [013](013-non-destructive-adoption.md) | Non-destructive adoption merge for existing subrepos | Accepted |
|
||||||
|
|||||||
@@ -5,17 +5,26 @@ Full reference for `.josh-sync.yml` fields and environment variables.
|
|||||||
## `.josh-sync.yml` Structure
|
## `.josh-sync.yml` Structure
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
josh: # josh-proxy settings (required)
|
schema_version: 2 # required since v2.0.0
|
||||||
targets: # sync targets (required, at least 1)
|
josh: # josh-proxy + monorepo settings (required)
|
||||||
bot: # bot identity for sync commits (required)
|
targets: # sync targets (required, at least 1)
|
||||||
|
bot: # bot identity for sync commits (required)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## `schema_version`
|
||||||
|
|
||||||
|
| Type | Required | Description |
|
||||||
|
|------|----------|-------------|
|
||||||
|
| integer | Yes | Must be `2` for josh-sync v2.x configs. v1 configs are rejected; see [Changelog v2.0.0](../CHANGELOG.md#200) for migration. |
|
||||||
|
|
||||||
## `josh` Section
|
## `josh` Section
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
|-------|------|----------|-------------|
|
|-------|------|----------|-------------|
|
||||||
| `proxy_url` | string | Yes | Josh-proxy URL, no trailing slash. Must start with `http://` or `https://`. |
|
| `proxy_url` | string | Yes | Josh-proxy URL, no trailing slash. Must start with `http://` or `https://`. |
|
||||||
| `monorepo_path` | string | Yes | Repository path as josh-proxy sees it (e.g., `org/monorepo`). |
|
| `monorepo_path` | string | Yes | Repository path as josh-proxy sees it (e.g., `org/monorepo`). |
|
||||||
|
| `monorepo_url` | string | Yes | Monorepo git clone URL — `git@host:org/repo.git` (SSH), `ssh://...`, or `https://host/org/repo.git`. The gitea host is derived from this URL and used for the PR API and direct clones, decoupled from any target's host. |
|
||||||
|
| `monorepo_auth` | string | No (default `"https"`) | Clone auth method: `"https"` (injects `${SYNC_BOT_USER}:${SYNC_BOT_TOKEN}` into the URL) or `"ssh"` (uses the user's ssh-agent / `~/.ssh/config`). PR creation always uses `GITEA_TOKEN` regardless. |
|
||||||
|
|
||||||
## `targets[]` Section
|
## `targets[]` Section
|
||||||
|
|
||||||
@@ -33,6 +42,7 @@ Each target maps a monorepo subfolder to an external subrepo.
|
|||||||
| `branches` | object | Yes | — | Branch mapping: `mono_branch: subrepo_branch`. Each key-value pair syncs those branches bidirectionally. |
|
| `branches` | object | Yes | — | Branch mapping: `mono_branch: subrepo_branch`. Each key-value pair syncs those branches bidirectionally. |
|
||||||
| `forward_only` | string[] | No | `[]` | Branches that only sync mono → subrepo, never reverse. |
|
| `forward_only` | string[] | No | `[]` | Branches that only sync mono → subrepo, never reverse. |
|
||||||
| `exclude` | string[] | No | `[]` | File/directory patterns to exclude from sync via josh `:exclude` filter. Excluded files exist only in the monorepo, never in the subrepo. See [Excluding Files](guide.md#excluding-files-from-sync). |
|
| `exclude` | string[] | No | `[]` | File/directory patterns to exclude from sync via josh `:exclude` filter. Excluded files exist only in the monorepo, never in the subrepo. See [Excluding Files](guide.md#excluding-files-from-sync). |
|
||||||
|
| `history_lock` | string | No | (auto-detect) | Onboarding strategy hint. `"preserve"` selects the non-destructive [adopt](guide.md#onboarding) strategy (keeps subrepo history via an adoption merge — ADR-013); `"rewrite"` selects the destructive reset strategy (force-pushes josh-filtered history). When omitted, `josh-sync onboard` auto-detects: subrepos with branches → adopt, empty subrepos → reset. The CLI flag `--mode={reset,adopt}` overrides this. |
|
||||||
|
|
||||||
## `bot` Section
|
## `bot` Section
|
||||||
|
|
||||||
@@ -73,7 +83,7 @@ targets:
|
|||||||
| `JOSH_SYNC_TARGET` | Restrict sync to a single target | All targets |
|
| `JOSH_SYNC_TARGET` | Restrict sync to a single target | All targets |
|
||||||
| `JOSH_SYNC_STATE_BRANCH` | Name of the orphan branch for state storage | `josh-sync-state` |
|
| `JOSH_SYNC_STATE_BRANCH` | Name of the orphan branch for state storage | `josh-sync-state` |
|
||||||
| `JOSH_SYNC_DEBUG` | Enable verbose logging (`1` to enable) | `0` |
|
| `JOSH_SYNC_DEBUG` | Enable verbose logging (`1` to enable) | `0` |
|
||||||
| `MONOREPO_API` | Override monorepo API URL | Auto-derived from first target's host |
|
| `MONOREPO_API` | Override the monorepo gitea PR API URL. Escape hatch for custom API endpoints; rarely needed. | `https://{monorepo_url's host}/api/v1/repos/{monorepo_path}` |
|
||||||
|
|
||||||
## JSON Schema
|
## JSON Schema
|
||||||
|
|
||||||
|
|||||||
@@ -78,9 +78,13 @@ git ls-remote https://josh.example.com/org/monorepo.git HEAD
|
|||||||
Create `.josh-sync.yml` at the monorepo root. Each target maps a monorepo subfolder to an external subrepo:
|
Create `.josh-sync.yml` at the monorepo root. Each target maps a monorepo subfolder to an external subrepo:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
schema_version: 2
|
||||||
|
|
||||||
josh:
|
josh:
|
||||||
proxy_url: "https://josh.example.com" # josh-proxy URL (no trailing slash)
|
proxy_url: "https://josh.example.com" # josh-proxy URL (no trailing slash)
|
||||||
monorepo_path: "org/monorepo" # repo path as josh sees it
|
monorepo_path: "org/monorepo" # repo path as josh-proxy sees it
|
||||||
|
monorepo_url: "git@gitea.example.com:org/monorepo.git" # monorepo git clone URL
|
||||||
|
monorepo_auth: "ssh" # optional, default "https"
|
||||||
|
|
||||||
targets:
|
targets:
|
||||||
- name: "billing" # unique identifier
|
- name: "billing" # unique identifier
|
||||||
@@ -218,16 +222,53 @@ This validates:
|
|||||||
|
|
||||||
For a new monorepo before import, preflight may warn that subfolders don't exist yet — that's expected.
|
For a new monorepo before import, preflight may warn that subfolders don't exist yet — that's expected.
|
||||||
|
|
||||||
## Step 5: Import Existing Subrepos
|
## Step 5: Onboard Existing Subrepos
|
||||||
|
|
||||||
This is the critical onboarding step. There are two approaches:
|
`josh-sync onboard <target>` is the single entry point for connecting an existing subrepo to the monorepo. It runs interactively with checkpoint/resume and picks one of two strategies:
|
||||||
|
|
||||||
- **`josh-sync onboard`** (recommended) — interactive, resumable, preserves open PRs
|
- **adopt** (non-destructive) — keeps the subrepo's existing history and joins it to josh-filtered history via a single adoption merge commit. No force-push. Developers fast-forward; open PR branches stay valid.
|
||||||
- **Manual `import` → merge → `reset`** — lower-level, for automation or when there are no open PRs to preserve
|
- **reset** (destructive) — force-pushes josh-filtered history onto the subrepo, replacing its prior history. Use this for empty replacement repos (typically paired with renaming the old repo to `*-archived`).
|
||||||
|
|
||||||
### Option A: Onboard (recommended)
|
### Strategy selection
|
||||||
|
|
||||||
The `onboard` command walks you through the entire process interactively, with checkpoint/resume at every step.
|
`josh-sync onboard` resolves the strategy in this order (highest precedence first):
|
||||||
|
|
||||||
|
1. **CLI flag** — `--mode=adopt` or `--mode=reset` overrides everything.
|
||||||
|
2. **Config** — `targets[].history_lock: preserve` selects adopt; `rewrite` selects reset.
|
||||||
|
3. **Auto-detect** — `git ls-remote --heads` on the subrepo. Branches present → adopt. Empty repo → reset.
|
||||||
|
|
||||||
|
The chosen strategy is logged at preflight so you can see what will happen before any side effects.
|
||||||
|
|
||||||
|
`josh-sync adopt <target>` is kept as a back-compat alias for `josh-sync onboard --mode=adopt`.
|
||||||
|
|
||||||
|
### Adopt strategy (auto-selected for active subrepos)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
josh-sync onboard billing # auto-detect — picks adopt for non-empty subrepo
|
||||||
|
josh-sync onboard billing --mode=adopt # force adopt explicitly
|
||||||
|
josh-sync adopt billing # equivalent back-compat alias
|
||||||
|
```
|
||||||
|
|
||||||
|
The command will:
|
||||||
|
1. **Import** — copies current subrepo content into the monorepo and creates import PRs (one per branch)
|
||||||
|
2. **Wait for merge** — shows PR numbers and waits for you to merge them
|
||||||
|
3. **Verify trees** — requires the existing subrepo tree to match the Josh-filtered monorepo tree
|
||||||
|
4. **Adopt** — creates a merge commit on the subrepo with Josh-filtered HEAD as parent 1 and existing subrepo HEAD as parent 2
|
||||||
|
5. **Push** — pushes the adoption merge with a normal fast-forward push, never force-push
|
||||||
|
|
||||||
|
The adoption merge preserves the old subrepo history while giving Josh a first-parent path back to the monorepo. If interrupted, re-run `josh-sync onboard billing` to resume. Use `--restart` to start over. See [ADR-013](adr/013-non-destructive-adoption.md) for the design rationale.
|
||||||
|
|
||||||
|
After adoption, developers can update existing clones with a normal fast-forward:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch origin
|
||||||
|
git checkout main
|
||||||
|
git merge --ff-only origin/main
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reset strategy (auto-selected for empty replacement repos)
|
||||||
|
|
||||||
|
Use the reset strategy when you intentionally want to archive the old subrepo and start fresh from a new empty repo.
|
||||||
|
|
||||||
**Before you start:**
|
**Before you start:**
|
||||||
|
|
||||||
@@ -239,7 +280,8 @@ The rename preserves the archived repo with all its history and open PRs. The ne
|
|||||||
**Run onboard:**
|
**Run onboard:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
josh-sync onboard billing
|
josh-sync onboard billing # auto-detect — picks reset for empty subrepo
|
||||||
|
josh-sync onboard billing --mode=reset # force reset explicitly
|
||||||
```
|
```
|
||||||
|
|
||||||
The command will:
|
The command will:
|
||||||
@@ -268,13 +310,13 @@ josh-sync migrate-pr billing 5 8 12
|
|||||||
|
|
||||||
PR migration works by fetching the diff from the archived repo's PR, applying it to the new repo, and creating a new PR. File content is identical after reset, so patches apply cleanly.
|
PR migration works by fetching the diff from the archived repo's PR, applying it to the new repo, and creating a new PR. File content is identical after reset, so patches apply cleanly.
|
||||||
|
|
||||||
### Option B: Manual import → merge → reset
|
### Manual: import → merge → reset
|
||||||
|
|
||||||
Use this when the subrepo has no open PRs to preserve, or for scripted automation.
|
Use this for scripted automation or when you intentionally want to replace subrepo history without the interactive wrapper.
|
||||||
|
|
||||||
> Do this **one target at a time** to keep PRs reviewable.
|
> Do this **one target at a time** to keep PRs reviewable.
|
||||||
|
|
||||||
#### 5b-1. Import
|
#### Manual: Import
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
josh-sync import billing
|
josh-sync import billing
|
||||||
@@ -289,13 +331,13 @@ This:
|
|||||||
|
|
||||||
Review the import PR — check for leaked credentials, environment-specific config, or files that shouldn't be in the monorepo.
|
Review the import PR — check for leaked credentials, environment-specific config, or files that shouldn't be in the monorepo.
|
||||||
|
|
||||||
#### 5b-2. Merge the import PR
|
#### Manual: Merge the import PR
|
||||||
|
|
||||||
Merge the PR using your Git platform's UI. This lands the subrepo content into the monorepo's main branch.
|
Merge the PR using your Git platform's UI. This lands the subrepo content into the monorepo's main branch.
|
||||||
|
|
||||||
> At this point, the monorepo has the content but the histories are disconnected. Sync will **not** work until you complete the reset step.
|
> At this point, the monorepo has the content but the histories are disconnected. Sync will **not** work until you complete the reset step.
|
||||||
|
|
||||||
#### 5b-3. Reset
|
#### Manual: Reset
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
josh-sync reset billing
|
josh-sync reset billing
|
||||||
@@ -322,7 +364,7 @@ git checkout stage && git reset --hard origin/stage # repeat for each branch
|
|||||||
|
|
||||||
Or simply delete and re-clone the subrepo. Local-only branches (not pushed to the remote) will be lost either way.
|
Or simply delete and re-clone the subrepo. Local-only branches (not pushed to the remote) will be lost either way.
|
||||||
|
|
||||||
#### 5b-4. Repeat for each target
|
#### Manual: Repeat for each target
|
||||||
|
|
||||||
```
|
```
|
||||||
For each target:
|
For each target:
|
||||||
@@ -333,13 +375,13 @@ For each target:
|
|||||||
|
|
||||||
### Verify
|
### Verify
|
||||||
|
|
||||||
After all targets are imported and reset (whichever option you used):
|
After all targets are adopted or reset:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check all targets show state
|
# Check all targets show state
|
||||||
josh-sync status
|
josh-sync status
|
||||||
|
|
||||||
# Test forward sync — should return "skip" (trees are identical after reset)
|
# Test forward sync — should return "skip" (trees are identical after adoption/reset)
|
||||||
josh-sync sync --forward --target billing
|
josh-sync sync --forward --target billing
|
||||||
|
|
||||||
# Test reverse sync — should return "skip" (no new human commits)
|
# Test reverse sync — should return "skip" (no new human commits)
|
||||||
@@ -512,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.
|
||||||
|
|
||||||
### State tracking
|
### State tracking
|
||||||
|
|
||||||
@@ -616,12 +660,13 @@ To add a new subrepo after initial setup:
|
|||||||
1. Add the target to `.josh-sync.yml`
|
1. Add the target to `.josh-sync.yml`
|
||||||
2. Update the forward workflow's `paths:` list to include the new subfolder
|
2. Update the forward workflow's `paths:` list to include the new subfolder
|
||||||
3. Commit and push
|
3. Commit and push
|
||||||
4. Import the target:
|
4. Onboard the target — `josh-sync onboard` auto-picks `adopt` for non-empty subrepos and `reset` for empty ones; override with `--mode={reset,adopt}` or pin via the target's `history_lock` config field.
|
||||||
```bash
|
```bash
|
||||||
# Recommended: interactive onboard (preserves open PRs)
|
josh-sync onboard new-target # auto-detect strategy
|
||||||
josh-sync onboard new-target
|
josh-sync onboard new-target --mode=adopt # force adopt
|
||||||
|
josh-sync onboard new-target --mode=reset # force reset
|
||||||
|
|
||||||
# Or manual: import → merge PR → reset
|
# Manual lower-level path (when scripting):
|
||||||
josh-sync import new-target
|
josh-sync import new-target
|
||||||
# merge the PR
|
# merge the PR
|
||||||
josh-sync reset new-target
|
josh-sync reset new-target
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
# .josh-sync.yml — Multi-target configuration example
|
# .josh-sync.yml — Multi-target configuration example
|
||||||
# Place this at the root of your monorepo.
|
# Place this at the root of your monorepo.
|
||||||
|
|
||||||
|
# Required since v2.0.0. Bump alongside the josh-sync major version when
|
||||||
|
# breaking config changes ship.
|
||||||
|
schema_version: 2
|
||||||
|
|
||||||
josh:
|
josh:
|
||||||
# Your josh-proxy instance URL (no trailing slash)
|
# Your josh-proxy instance URL (no trailing slash)
|
||||||
proxy_url: "https://josh.example.com"
|
proxy_url: "https://josh.example.com"
|
||||||
# Repo path as josh sees it (org/repo on your Gitea/GitHub)
|
# Repo path as josh-proxy sees it (org/repo)
|
||||||
monorepo_path: "org/monorepo"
|
monorepo_path: "org/monorepo"
|
||||||
|
# Monorepo git clone URL — git@host:org/repo.git (SSH) or https://host/org/repo.git.
|
||||||
|
# The gitea host is derived from this URL (used for the PR API and direct clones).
|
||||||
|
# The monorepo and any subrepo may live on different gitea hosts.
|
||||||
|
monorepo_url: "git@gitea.example.com:org/monorepo.git"
|
||||||
|
# Optional. Default "https". Use "ssh" to clone the monorepo via SSH (no token
|
||||||
|
# needed for the clone — PR creation still requires GITEA_TOKEN).
|
||||||
|
monorepo_auth: "ssh"
|
||||||
|
|
||||||
targets:
|
targets:
|
||||||
- name: "billing"
|
- name: "billing"
|
||||||
|
|||||||
109
lib/adopt.sh
Normal file
109
lib/adopt.sh
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# lib/adopt.sh — Non-destructive adoption merge primitive
|
||||||
|
#
|
||||||
|
# Provides:
|
||||||
|
# adopt_branch() — Create one adoption merge commit and push it fast-forward
|
||||||
|
#
|
||||||
|
# The orchestration (state machine, import → wait → finalize) lives in
|
||||||
|
# lib/onboard.sh under strategy="adopt". `josh-sync adopt` is now a CLI alias
|
||||||
|
# for `josh-sync onboard --mode=adopt`; see ADR-013 for design rationale.
|
||||||
|
#
|
||||||
|
# Requires: lib/core.sh, lib/config.sh, lib/auth.sh sourced
|
||||||
|
|
||||||
|
# ─── Adopt Branch ────────────────────────────────────────────────
|
||||||
|
# Establishes shared ancestry without rewriting the existing subrepo branch.
|
||||||
|
#
|
||||||
|
# Preconditions:
|
||||||
|
# - The subrepo branch and josh-filtered monorepo branch must have identical trees.
|
||||||
|
#
|
||||||
|
# Resulting commit:
|
||||||
|
# - parent 1: josh-filtered HEAD, so josh can follow first-parent history
|
||||||
|
# - parent 2: existing subrepo HEAD, so old subrepo history stays reachable
|
||||||
|
# - tree: josh-filtered tree
|
||||||
|
#
|
||||||
|
# Returns: adopted | already-adopted | tree-mismatch | push-rejected | missing-branch
|
||||||
|
|
||||||
|
adopt_branch() (
|
||||||
|
# Subshell function: contains the function-level EXIT trap, the internal cd,
|
||||||
|
# and any set -e propagation so it cannot pollute the caller's shell. Without
|
||||||
|
# this, the trap below would clobber callers' EXIT handlers (bats's own test
|
||||||
|
# reporting in particular silently disappears).
|
||||||
|
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" "=== Adopt: subrepo/${subrepo_branch} ↔ mono/${mono_branch} ==="
|
||||||
|
|
||||||
|
local remote_subrepo_sha
|
||||||
|
remote_subrepo_sha=$(git ls-remote "$(subrepo_auth_url)" "refs/heads/${subrepo_branch}" | awk '{print $1}') \
|
||||||
|
|| die "Failed to reach subrepo (check SSH key / auth)"
|
||||||
|
if [ -z "$remote_subrepo_sha" ]; then
|
||||||
|
log "ERROR" "Subrepo branch ${subrepo_branch} does not exist"
|
||||||
|
echo "missing-branch"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
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_tree
|
||||||
|
subrepo_head=$(git rev-parse HEAD)
|
||||||
|
# shellcheck disable=SC1083 # {tree} is git syntax, not shell brace expansion
|
||||||
|
subrepo_tree=$(git rev-parse "HEAD^{tree}")
|
||||||
|
log "INFO" "Subrepo HEAD: ${subrepo_head:0:12}"
|
||||||
|
|
||||||
|
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
|
||||||
|
josh_tree=$(git rev-parse "josh-filtered/${mono_branch}^{tree}")
|
||||||
|
log "INFO" "Josh-filtered HEAD: ${josh_head:0:12}"
|
||||||
|
|
||||||
|
if [ "$subrepo_tree" != "$josh_tree" ]; then
|
||||||
|
log "ERROR" "Tree mismatch: merge the import PR and ensure subrepo/${subrepo_branch} matches mono/${mono_branch} before adopting"
|
||||||
|
echo "tree-mismatch"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if git merge-base --is-ancestor "$josh_head" "$subrepo_head" \
|
||||||
|
&& git merge-base --is-ancestor "$subrepo_head" "$josh_head"; then
|
||||||
|
log "INFO" "Subrepo and josh-filtered histories already point to the same commit"
|
||||||
|
echo "already-adopted"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if git merge-base --is-ancestor "$josh_head" "$subrepo_head"; then
|
||||||
|
log "INFO" "Josh-filtered HEAD is already an ancestor of subrepo HEAD"
|
||||||
|
echo "already-adopted"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local merge_commit
|
||||||
|
merge_commit=$(git commit-tree "$josh_tree" \
|
||||||
|
-p "$josh_head" \
|
||||||
|
-p "$subrepo_head" \
|
||||||
|
-m "Adopt ${JOSH_SYNC_TARGET_NAME} for josh-sync
|
||||||
|
|
||||||
|
${BOT_TRAILER}: adopt/${mono_branch}/$(date -u +%Y-%m-%dT%H:%M:%SZ)")
|
||||||
|
|
||||||
|
git reset --hard "$merge_commit" >&2
|
||||||
|
log "INFO" "Created adoption merge: ${merge_commit:0:12}"
|
||||||
|
|
||||||
|
if git push "$(subrepo_auth_url)" "HEAD:refs/heads/${subrepo_branch}"; then
|
||||||
|
log "INFO" "Adoption complete: pushed fast-forward merge to subrepo/${subrepo_branch}"
|
||||||
|
echo "adopted"
|
||||||
|
else
|
||||||
|
log "WARN" "Fast-forward push rejected — subrepo changed during adoption"
|
||||||
|
echo "push-rejected"
|
||||||
|
fi
|
||||||
|
)
|
||||||
24
lib/auth.sh
24
lib/auth.sh
@@ -2,7 +2,8 @@
|
|||||||
# lib/auth.sh — Authenticated URLs, remote queries, and PR creation
|
# lib/auth.sh — Authenticated URLs, remote queries, and PR creation
|
||||||
#
|
#
|
||||||
# Requires: lib/core.sh and lib/config.sh sourced first
|
# Requires: lib/core.sh and lib/config.sh sourced first
|
||||||
# Expects: JOSH_PROXY_URL, MONOREPO_PATH, BOT_USER, GITEA_TOKEN, JOSH_FILTER,
|
# Expects: JOSH_PROXY_URL, MONOREPO_PATH, MONOREPO_URL, MONOREPO_AUTH,
|
||||||
|
# BOT_USER, GITEA_TOKEN, JOSH_FILTER,
|
||||||
# SUBREPO_URL, SUBREPO_AUTH, SUBREPO_TOKEN (set by parse_config + load_target)
|
# SUBREPO_URL, SUBREPO_AUTH, SUBREPO_TOKEN (set by parse_config + load_target)
|
||||||
|
|
||||||
# ─── Josh-Proxy Auth URL ───────────────────────────────────────────
|
# ─── Josh-Proxy Auth URL ───────────────────────────────────────────
|
||||||
@@ -28,6 +29,27 @@ subrepo_auth_url() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ─── Monorepo Auth URL ─────────────────────────────────────────────
|
||||||
|
# HTTPS: injects user:token into MONOREPO_URL (auto-converts git@ form to https)
|
||||||
|
# SSH: returns bare MONOREPO_URL (auth via user's ssh-agent / ~/.ssh/config,
|
||||||
|
# or via GIT_SSH_COMMAND if the caller wired one up for the monorepo host)
|
||||||
|
|
||||||
|
mono_auth_url() {
|
||||||
|
if [ "${MONOREPO_AUTH:-https}" = "ssh" ]; then
|
||||||
|
echo "$MONOREPO_URL"
|
||||||
|
else
|
||||||
|
local https_url="$MONOREPO_URL"
|
||||||
|
# Convert git@host:org/repo.git → https://host/org/repo.git so token injection works
|
||||||
|
if [[ "$https_url" == git@* ]]; then
|
||||||
|
https_url=$(echo "$https_url" | sed -E 's|^git@([^:]+):(.+)$|https://\1/\2|')
|
||||||
|
elif [[ "$https_url" == ssh://* ]]; then
|
||||||
|
https_url=$(echo "$https_url" | sed -E 's|^ssh://[^@]*@([^/]+)/(.+)$|https://\1/\2|')
|
||||||
|
fi
|
||||||
|
# shellcheck disable=SC2001
|
||||||
|
echo "$https_url" | sed "s|https://|https://${BOT_USER}:${GITEA_TOKEN}@|"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# ─── Remote Queries ─────────────────────────────────────────────────
|
# ─── Remote Queries ─────────────────────────────────────────────────
|
||||||
|
|
||||||
subrepo_ls_remote() {
|
subrepo_ls_remote() {
|
||||||
|
|||||||
@@ -16,16 +16,21 @@ parse_config() {
|
|||||||
local config_json
|
local config_json
|
||||||
config_json=$(yq -o json "$config_file") || die "Failed to parse ${config_file}"
|
config_json=$(yq -o json "$config_file") || die "Failed to parse ${config_file}"
|
||||||
|
|
||||||
# Schema version check (optional field — absent means v1)
|
# Schema version check — required, must be 2 (v1 configs are no longer supported)
|
||||||
local schema_version
|
local schema_version
|
||||||
schema_version=$(echo "$config_json" | jq -r '.schema_version // 1')
|
schema_version=$(echo "$config_json" | jq -r '.schema_version // empty')
|
||||||
[ "$schema_version" = "1" ] || die "Unsupported schema_version ${schema_version} in ${config_file} (this version of josh-sync supports schema_version 1 — see CHANGELOG.md for migration instructions)"
|
[ -n "$schema_version" ] || die "schema_version missing in ${config_file} (required since v2.0.0 — set 'schema_version: 2' at the top of the file; see CHANGELOG.md for migration instructions)"
|
||||||
|
[ "$schema_version" = "2" ] || die "Unsupported schema_version ${schema_version} in ${config_file} (this version of josh-sync requires schema_version 2 — see CHANGELOG.md for migration instructions)"
|
||||||
|
|
||||||
# Export global values
|
# Export global values
|
||||||
export JOSH_PROXY_URL
|
export JOSH_PROXY_URL
|
||||||
JOSH_PROXY_URL=$(echo "$config_json" | jq -r '.josh.proxy_url')
|
JOSH_PROXY_URL=$(echo "$config_json" | jq -r '.josh.proxy_url')
|
||||||
export MONOREPO_PATH
|
export MONOREPO_PATH
|
||||||
MONOREPO_PATH=$(echo "$config_json" | jq -r '.josh.monorepo_path')
|
MONOREPO_PATH=$(echo "$config_json" | jq -r '.josh.monorepo_path')
|
||||||
|
export MONOREPO_URL
|
||||||
|
MONOREPO_URL=$(echo "$config_json" | jq -r '.josh.monorepo_url')
|
||||||
|
export MONOREPO_AUTH
|
||||||
|
MONOREPO_AUTH=$(echo "$config_json" | jq -r '.josh.monorepo_auth // "https"')
|
||||||
export BOT_NAME
|
export BOT_NAME
|
||||||
BOT_NAME=$(echo "$config_json" | jq -r '.bot.name')
|
BOT_NAME=$(echo "$config_json" | jq -r '.bot.name')
|
||||||
export BOT_EMAIL
|
export BOT_EMAIL
|
||||||
@@ -35,8 +40,27 @@ parse_config() {
|
|||||||
|
|
||||||
[ -n "$JOSH_PROXY_URL" ] && [ "$JOSH_PROXY_URL" != "null" ] || die "josh.proxy_url missing in config"
|
[ -n "$JOSH_PROXY_URL" ] && [ "$JOSH_PROXY_URL" != "null" ] || die "josh.proxy_url missing in config"
|
||||||
[ -n "$MONOREPO_PATH" ] && [ "$MONOREPO_PATH" != "null" ] || die "josh.monorepo_path missing in config"
|
[ -n "$MONOREPO_PATH" ] && [ "$MONOREPO_PATH" != "null" ] || die "josh.monorepo_path missing in config"
|
||||||
|
[ -n "$MONOREPO_URL" ] && [ "$MONOREPO_URL" != "null" ] || die "josh.monorepo_url is required (added in schema_version 2). Set it to your monorepo's git clone URL, e.g. 'git@code.example.io:org/repo.git'."
|
||||||
|
[ "$MONOREPO_AUTH" = "ssh" ] || [ "$MONOREPO_AUTH" = "https" ] || die "josh.monorepo_auth must be 'ssh' or 'https' (got '${MONOREPO_AUTH}')"
|
||||||
[ -n "$BOT_TRAILER" ] && [ "$BOT_TRAILER" != "null" ] || die "bot.trailer missing in config"
|
[ -n "$BOT_TRAILER" ] && [ "$BOT_TRAILER" != "null" ] || die "bot.trailer missing in config"
|
||||||
|
|
||||||
|
# Derive monorepo gitea_host and repo_path from monorepo_url (mirrors target derivation below)
|
||||||
|
local monorepo_meta
|
||||||
|
monorepo_meta=$(jq -nr --arg url "$MONOREPO_URL" '
|
||||||
|
$url as $u |
|
||||||
|
if ($u | test("^ssh://")) then
|
||||||
|
($u | capture("ssh://[^@]*@(?<h>[^/]+)/(?<p>.+?)(\\.git)?$") // {h: "", p: ""})
|
||||||
|
elif ($u | test("^git@")) then
|
||||||
|
($u | capture("git@(?<h>[^:/]+)[:/](?<p>.+?)(\\.git)?$") // {h: "", p: ""})
|
||||||
|
elif ($u | test("^https?://")) then
|
||||||
|
($u | capture("https?://(?<h>[^/]+)/(?<p>.+?)(\\.git)?$") // {h: "", p: ""})
|
||||||
|
else
|
||||||
|
{h: "", p: ""}
|
||||||
|
end | "\(.h) \(.p)"')
|
||||||
|
export MONOREPO_GITEA_HOST="${monorepo_meta% *}"
|
||||||
|
export MONOREPO_REPO_PATH="${monorepo_meta#* }"
|
||||||
|
[ -n "$MONOREPO_GITEA_HOST" ] || die "Could not derive gitea host from josh.monorepo_url='${MONOREPO_URL}' (expected git@host:org/repo.git or https://host/org/repo.git)"
|
||||||
|
|
||||||
# Enrich targets with derived fields (gitea_host, subrepo_repo_path, auto josh_filter)
|
# Enrich targets with derived fields (gitea_host, subrepo_repo_path, auto josh_filter)
|
||||||
export JOSH_SYNC_TARGETS
|
export JOSH_SYNC_TARGETS
|
||||||
JOSH_SYNC_TARGETS=$(echo "$config_json" | jq '[.targets[] | . +
|
JOSH_SYNC_TARGETS=$(echo "$config_json" | jq '[.targets[] | . +
|
||||||
@@ -64,6 +88,15 @@ parse_config() {
|
|||||||
)
|
)
|
||||||
]')
|
]')
|
||||||
|
|
||||||
|
# Validate targets[].history_lock against the schema enum (preserve|rewrite).
|
||||||
|
# Caught at config parse time so typos don't sit silently until `onboard` runs.
|
||||||
|
local bad_lock
|
||||||
|
bad_lock=$(echo "$JOSH_SYNC_TARGETS" | jq -r '
|
||||||
|
.[] | select(.history_lock != null)
|
||||||
|
| select(.history_lock != "preserve" and .history_lock != "rewrite")
|
||||||
|
| "\(.name): \(.history_lock)"' | head -1)
|
||||||
|
[ -z "$bad_lock" ] || die "Invalid targets[].history_lock value (must be 'preserve' or 'rewrite'): ${bad_lock}"
|
||||||
|
|
||||||
# Load .env credentials (if present, not required — CI sets these via secrets)
|
# Load .env credentials (if present, not required — CI sets these via secrets)
|
||||||
if [ -f .env ]; then
|
if [ -f .env ]; then
|
||||||
# shellcheck source=/dev/null
|
# shellcheck source=/dev/null
|
||||||
@@ -73,10 +106,8 @@ parse_config() {
|
|||||||
export GITEA_TOKEN="${GITEA_TOKEN:-${SYNC_BOT_TOKEN:-}}"
|
export GITEA_TOKEN="${GITEA_TOKEN:-${SYNC_BOT_TOKEN:-}}"
|
||||||
export BOT_USER="${BOT_USER:-${SYNC_BOT_USER:-}}"
|
export BOT_USER="${BOT_USER:-${SYNC_BOT_USER:-}}"
|
||||||
|
|
||||||
# Monorepo API URL (derived from first target's host, overridable via env)
|
# Monorepo API URL (derived from monorepo_url's host, overridable via env for custom API endpoints)
|
||||||
local gitea_host
|
export MONOREPO_API="${MONOREPO_API:-https://${MONOREPO_GITEA_HOST}/api/v1/repos/${MONOREPO_PATH}}"
|
||||||
gitea_host=$(echo "$JOSH_SYNC_TARGETS" | jq -r '.[0].gitea_host')
|
|
||||||
export MONOREPO_API="${MONOREPO_API:-https://${gitea_host}/api/v1/repos/${MONOREPO_PATH}}"
|
|
||||||
|
|
||||||
log "INFO" "Config loaded: $(echo "$JOSH_SYNC_TARGETS" | jq 'length') target(s)"
|
log "INFO" "Config loaded: $(echo "$JOSH_SYNC_TARGETS" | jq 'length') target(s)"
|
||||||
}
|
}
|
||||||
|
|||||||
467
lib/onboard.sh
467
lib/onboard.sh
@@ -1,23 +1,56 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# lib/onboard.sh — Onboard orchestration and PR migration
|
# lib/onboard.sh — Unified onboard orchestration (reset + adopt strategies)
|
||||||
#
|
#
|
||||||
# Provides:
|
# Provides:
|
||||||
# onboard_flow() — Interactive: import → wait for merge → reset to new repo
|
# onboard_flow() — Interactive: import → wait → finalize
|
||||||
# migrate_one_pr() — Migrate a single PR from archived repo to new repo
|
# finalize = subrepo_reset (rewrite) or
|
||||||
|
# adopt_branch (preserve)
|
||||||
|
# resolve_onboard_strategy() — Pick reset|adopt from --mode / config /
|
||||||
|
# ls-remote auto-detection
|
||||||
|
# migrate_one_pr() — Migrate one PR from archived to new subrepo
|
||||||
#
|
#
|
||||||
# Onboard state is stored on the josh-sync-state branch at <target>/onboard.json.
|
# State is stored on the josh-sync-state branch at <target>/onboard.json:
|
||||||
# Steps: start → importing → waiting-for-merge → resetting → complete
|
# { step, strategy, import_prs, ... }
|
||||||
|
# read_onboard_state falls back to legacy <target>/adopt.json (with implicit
|
||||||
|
# strategy="adopt") so in-flight v2.1 adoptions continue to resume correctly.
|
||||||
#
|
#
|
||||||
# Requires: lib/core.sh, lib/config.sh, lib/auth.sh, lib/state.sh, lib/sync.sh sourced
|
# Steps:
|
||||||
# Expects: JOSH_SYNC_TARGET_NAME, BOT_NAME, BOT_EMAIL, SUBREPO_API, SUBREPO_TOKEN, etc.
|
# start → importing → waiting-for-merge → resetting | adopting → complete
|
||||||
|
#
|
||||||
|
# Requires: lib/core.sh, lib/config.sh, lib/auth.sh, lib/state.sh, lib/sync.sh,
|
||||||
|
# lib/adopt.sh sourced
|
||||||
|
# Expects: JOSH_SYNC_TARGET_NAME, BOT_NAME, BOT_EMAIL, SUBREPO_API,
|
||||||
|
# SUBREPO_TOKEN, etc.
|
||||||
|
|
||||||
# ─── Onboard State Helpers ────────────────────────────────────────
|
# ─── State Helpers ────────────────────────────────────────────────
|
||||||
# Follow the same pattern as read_state()/write_state() in lib/state.sh.
|
# Same pattern as read_state()/write_state() in lib/state.sh.
|
||||||
|
|
||||||
read_onboard_state() {
|
read_onboard_state() {
|
||||||
local target_name="${1:-$JOSH_SYNC_TARGET_NAME}"
|
local target_name="${1:-$JOSH_SYNC_TARGET_NAME}"
|
||||||
git fetch origin "$STATE_BRANCH" 2>/dev/null || true
|
git fetch origin "$STATE_BRANCH" 2>/dev/null || true
|
||||||
git show "origin/${STATE_BRANCH}:${target_name}/onboard.json" 2>/dev/null || echo '{}'
|
|
||||||
|
local json
|
||||||
|
json=$(git show "origin/${STATE_BRANCH}:${target_name}/onboard.json" 2>/dev/null || echo "")
|
||||||
|
if [ -n "$json" ]; then
|
||||||
|
# Pre-unification onboard.json had no .strategy field — the only flow that
|
||||||
|
# existed was the destructive reset path; treat missing/empty .strategy as
|
||||||
|
# reset so resume doesn't silently flip via re-resolution.
|
||||||
|
echo "$json" | jq '. + {strategy: ((.strategy // "") | if . == "" then "reset" else . end)}'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Backward-compat: fall back to v2.1 <target>/adopt.json so in-flight
|
||||||
|
# adoptions started before the unification continue to resume. The legacy
|
||||||
|
# schema lacks `.strategy`; inject "adopt" so resolve/dispatch downstream
|
||||||
|
# treats it correctly. Also drop the legacy `.mode` field (v2.1 carried it
|
||||||
|
# redundantly) to avoid future schema collisions.
|
||||||
|
json=$(git show "origin/${STATE_BRANCH}:${target_name}/adopt.json" 2>/dev/null || echo "")
|
||||||
|
if [ -n "$json" ]; then
|
||||||
|
echo "$json" | jq 'del(.mode) + {strategy: ((.strategy // "") | if . == "" then "adopt" else . end)}'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo '{}'
|
||||||
}
|
}
|
||||||
|
|
||||||
write_onboard_state() {
|
write_onboard_state() {
|
||||||
@@ -50,18 +83,97 @@ write_onboard_state() {
|
|||||||
git worktree remove "$tmp_dir" 2>/dev/null || rm -rf "$tmp_dir"
|
git worktree remove "$tmp_dir" 2>/dev/null || rm -rf "$tmp_dir"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Remove the v2.1 <target>/adopt.json from the state branch if present, so
|
||||||
|
# that a --restart durably clears legacy state on the next read. Called by
|
||||||
|
# onboard_flow's restart path.
|
||||||
|
_onboard_state_remove_legacy() {
|
||||||
|
local target_name="${1:-$JOSH_SYNC_TARGET_NAME}"
|
||||||
|
git fetch origin "$STATE_BRANCH" 2>/dev/null || true
|
||||||
|
git show "origin/${STATE_BRANCH}:${target_name}/adopt.json" >/dev/null 2>&1 || return 0
|
||||||
|
|
||||||
|
local tmp_dir
|
||||||
|
tmp_dir=$(mktemp -d)
|
||||||
|
if ! git worktree add "$tmp_dir" "origin/${STATE_BRANCH}" 2>/dev/null; then
|
||||||
|
rm -rf "$tmp_dir"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
(
|
||||||
|
cd "$tmp_dir" || exit
|
||||||
|
git rm -q "${target_name}/adopt.json" 2>/dev/null || true
|
||||||
|
if ! git diff --cached --quiet 2>/dev/null; then
|
||||||
|
git -c user.name="$BOT_NAME" -c user.email="$BOT_EMAIL" \
|
||||||
|
commit -q -m "onboard: drop legacy ${target_name}/adopt.json on --restart"
|
||||||
|
git push -q origin "HEAD:${STATE_BRANCH}" || log "WARN" "Failed to push legacy adopt.json removal"
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
git worktree remove "$tmp_dir" 2>/dev/null || rm -rf "$tmp_dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Strategy Resolution ──────────────────────────────────────────
|
||||||
|
# Precedence (highest first):
|
||||||
|
# 1. explicit --mode flag
|
||||||
|
# 2. targets[].history_lock (preserve → adopt, rewrite → reset)
|
||||||
|
# 3. auto-detect: subrepo has any branches → adopt, otherwise reset
|
||||||
|
#
|
||||||
|
# Prints the chosen strategy (reset|adopt) on stdout; logs the source on
|
||||||
|
# stderr so the preflight line in cmd_onboard surfaces the choice.
|
||||||
|
|
||||||
|
resolve_onboard_strategy() {
|
||||||
|
local target_json="$1"
|
||||||
|
local explicit_mode="${2:-}"
|
||||||
|
|
||||||
|
if [ -n "$explicit_mode" ]; then
|
||||||
|
case "$explicit_mode" in
|
||||||
|
reset|adopt) ;;
|
||||||
|
*) die "Invalid --mode '${explicit_mode}' (must be 'reset' or 'adopt')" ;;
|
||||||
|
esac
|
||||||
|
log "INFO" "Strategy: ${explicit_mode} (from --mode flag)"
|
||||||
|
echo "$explicit_mode"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local history_lock
|
||||||
|
history_lock=$(echo "$target_json" | jq -r '.history_lock // empty')
|
||||||
|
if [ -n "$history_lock" ]; then
|
||||||
|
case "$history_lock" in
|
||||||
|
preserve)
|
||||||
|
log "INFO" "Strategy: adopt (from config history_lock: preserve)"
|
||||||
|
echo "adopt"; return ;;
|
||||||
|
rewrite)
|
||||||
|
log "INFO" "Strategy: reset (from config history_lock: rewrite)"
|
||||||
|
echo "reset"; return ;;
|
||||||
|
*)
|
||||||
|
die "Invalid history_lock '${history_lock}' in target config (must be 'preserve' or 'rewrite')" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Critical: distinguish a genuinely empty subrepo from a failed ls-remote.
|
||||||
|
# Treating an auth/network failure as "empty" would silently pick reset and
|
||||||
|
# force-push a live subrepo's history away.
|
||||||
|
local heads
|
||||||
|
if heads=$(git ls-remote --heads "$(subrepo_auth_url)" 2>/dev/null); then
|
||||||
|
if [ -n "$heads" ]; then
|
||||||
|
log "INFO" "Strategy: adopt (auto-detected — subrepo has branches)"
|
||||||
|
echo "adopt"
|
||||||
|
else
|
||||||
|
log "INFO" "Strategy: reset (auto-detected — subrepo has no branches)"
|
||||||
|
echo "reset"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
die "Cannot auto-detect strategy: 'git ls-remote' against the subrepo failed (auth/network). Pass --mode=reset|adopt explicitly, or set targets[].history_lock for this target."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# ─── Derive Archived API URL ─────────────────────────────────────
|
# ─── Derive Archived API URL ─────────────────────────────────────
|
||||||
# Given a URL like "git@host:org/repo-archived.git" or
|
# Given a URL like "git@host:org/repo-archived.git" or
|
||||||
# "https://host/org/repo-archived.git", derive the Gitea API URL.
|
# "https://host/org/repo-archived.git", derive the Gitea API URL.
|
||||||
|
|
||||||
_archived_api_from_url() {
|
_archived_api_from_url() {
|
||||||
local url="$1"
|
local url="$1"
|
||||||
# Strip .git suffix first — avoids non-greedy regex issues in POSIX ERE
|
|
||||||
url="${url%.git}"
|
url="${url%.git}"
|
||||||
local host repo_path
|
local host repo_path
|
||||||
|
|
||||||
if echo "$url" | grep -qE '^(ssh://|git@)'; then
|
if echo "$url" | grep -qE '^(ssh://|git@)'; then
|
||||||
# SSH URL
|
|
||||||
if echo "$url" | grep -q '^ssh://'; then
|
if echo "$url" | grep -q '^ssh://'; then
|
||||||
host=$(echo "$url" | sed -E 's|ssh://[^@]*@([^/]+)/.*|\1|')
|
host=$(echo "$url" | sed -E 's|ssh://[^@]*@([^/]+)/.*|\1|')
|
||||||
repo_path=$(echo "$url" | sed -E 's|ssh://[^@]*@[^/]+/(.+)$|\1|')
|
repo_path=$(echo "$url" | sed -E 's|ssh://[^@]*@[^/]+/(.+)$|\1|')
|
||||||
@@ -70,7 +182,6 @@ _archived_api_from_url() {
|
|||||||
repo_path=$(echo "$url" | sed -E 's|git@[^:/]+[:/](.+)$|\1|')
|
repo_path=$(echo "$url" | sed -E 's|git@[^:/]+[:/](.+)$|\1|')
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
# HTTPS URL
|
|
||||||
host=$(echo "$url" | sed -E 's|https?://([^/]+)/.*|\1|')
|
host=$(echo "$url" | sed -E 's|https?://([^/]+)/.*|\1|')
|
||||||
repo_path=$(echo "$url" | sed -E 's|https?://[^/]+/(.+)$|\1|')
|
repo_path=$(echo "$url" | sed -E 's|https?://[^/]+/(.+)$|\1|')
|
||||||
fi
|
fi
|
||||||
@@ -80,104 +191,131 @@ _archived_api_from_url() {
|
|||||||
|
|
||||||
# ─── Onboard Flow ────────────────────────────────────────────────
|
# ─── Onboard Flow ────────────────────────────────────────────────
|
||||||
# Interactive orchestrator with checkpoint/resume.
|
# Interactive orchestrator with checkpoint/resume.
|
||||||
# Usage: onboard_flow <target_json> <restart>
|
# Usage: onboard_flow <target_json> <restart> [explicit_mode]
|
||||||
|
# explicit_mode — "reset", "adopt", or "" for auto-resolve.
|
||||||
|
|
||||||
onboard_flow() {
|
onboard_flow() {
|
||||||
local target_json="$1"
|
local target_json="$1"
|
||||||
local restart="${2:-false}"
|
local restart="${2:-false}"
|
||||||
|
local explicit_mode="${3:-}"
|
||||||
local target_name="$JOSH_SYNC_TARGET_NAME"
|
local target_name="$JOSH_SYNC_TARGET_NAME"
|
||||||
|
|
||||||
# Load existing onboard state (or empty)
|
local state current_step strategy
|
||||||
local onboard_state
|
state=$(read_onboard_state "$target_name")
|
||||||
onboard_state=$(read_onboard_state "$target_name")
|
current_step=$(echo "$state" | jq -r '.step // "start"')
|
||||||
local current_step
|
strategy=$(echo "$state" | jq -r '.strategy // empty')
|
||||||
current_step=$(echo "$onboard_state" | jq -r '.step // "start"')
|
|
||||||
|
|
||||||
if [ "$restart" = true ]; then
|
if [ "$restart" = true ]; then
|
||||||
log "INFO" "Restarting onboard from scratch"
|
log "INFO" "Restarting onboard from scratch"
|
||||||
current_step="start"
|
current_step="start"
|
||||||
onboard_state='{}'
|
state='{}'
|
||||||
|
strategy=""
|
||||||
|
# Durably clear any legacy v2.1 adopt.json so the next read doesn't
|
||||||
|
# silently fall back to it.
|
||||||
|
_onboard_state_remove_legacy "$target_name"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -z "$strategy" ]; then
|
||||||
|
strategy=$(resolve_onboard_strategy "$target_json" "$explicit_mode")
|
||||||
|
elif [ -n "$explicit_mode" ] && [ "$strategy" != "$explicit_mode" ]; then
|
||||||
|
die "Saved strategy is '${strategy}' (mid-flow); cannot switch to --mode=${explicit_mode} without --restart."
|
||||||
|
else
|
||||||
|
log "INFO" "Strategy: ${strategy} (resumed from state)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$strategy" in
|
||||||
|
reset|adopt) ;;
|
||||||
|
*) die "Unknown strategy '${strategy}' in state (corrupt?). Re-run with --restart to start over." ;;
|
||||||
|
esac
|
||||||
|
|
||||||
log "INFO" "Onboard step: ${current_step}"
|
log "INFO" "Onboard step: ${current_step}"
|
||||||
|
|
||||||
# ── Step 1: Prerequisites + archived repo info ──
|
# ── Step 1: Strategy-specific intro and (for reset) archived repo info ──
|
||||||
if [ "$current_step" = "start" ]; then
|
if [ "$current_step" = "start" ]; then
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
echo "=== Onboarding ${target_name} ===" >&2
|
echo "=== Onboarding ${target_name} (strategy: ${strategy}) ===" >&2
|
||||||
echo "" >&2
|
|
||||||
echo "Before proceeding, you should have:" >&2
|
|
||||||
echo " 1. Renamed the existing subrepo (e.g., storefront → storefront-archived)" >&2
|
|
||||||
echo " 2. Created a new EMPTY repo at the original URL" >&2
|
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
|
|
||||||
# Verify the new (empty) subrepo is reachable (no HEAD ref — works on empty repos)
|
if [ "$strategy" = "reset" ]; then
|
||||||
if git ls-remote "$(subrepo_auth_url)" >/dev/null 2>&1; then
|
echo "Before proceeding, you should have:" >&2
|
||||||
# shellcheck disable=SC2001 # sed is clearer for URL pattern replacement
|
echo " 1. Renamed the existing subrepo (e.g., storefront → storefront-archived)" >&2
|
||||||
log "INFO" "New subrepo is reachable at $(echo "$SUBREPO_URL" | sed 's|://[^@]*@|://***@|')"
|
echo " 2. Created a new EMPTY repo at the original URL" >&2
|
||||||
|
echo "" >&2
|
||||||
|
|
||||||
|
if git ls-remote "$(subrepo_auth_url)" >/dev/null 2>&1; then
|
||||||
|
# shellcheck disable=SC2001 # sed is clearer for URL pattern replacement
|
||||||
|
log "INFO" "New subrepo is reachable at $(echo "$SUBREPO_URL" | sed 's|://[^@]*@|://***@|')"
|
||||||
|
else
|
||||||
|
log "WARN" "New subrepo is not reachable — make sure you created the new empty repo"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Enter the archived repo URL (e.g., git@host:org/repo-archived.git):" >&2
|
||||||
|
local archived_url
|
||||||
|
read -r archived_url
|
||||||
|
[ -n "$archived_url" ] || die "Archived URL is required"
|
||||||
|
|
||||||
|
local archived_auth="${SUBREPO_AUTH:-https}"
|
||||||
|
local archived_api
|
||||||
|
archived_api=$(_archived_api_from_url "$archived_url")
|
||||||
|
|
||||||
|
if curl -sf -H "Authorization: token ${SUBREPO_TOKEN}" \
|
||||||
|
"${archived_api}" >/dev/null 2>&1; then
|
||||||
|
log "INFO" "Archived repo reachable: ${archived_api}"
|
||||||
|
else
|
||||||
|
log "WARN" "Cannot reach archived repo API — check URL and token"
|
||||||
|
echo "Continue anyway? (y/N):" >&2
|
||||||
|
local confirm
|
||||||
|
read -r confirm
|
||||||
|
[ "$confirm" = "y" ] || [ "$confirm" = "Y" ] || die "Aborted"
|
||||||
|
fi
|
||||||
|
|
||||||
|
state=$(jq -n \
|
||||||
|
--arg step "importing" \
|
||||||
|
--arg strategy "reset" \
|
||||||
|
--arg archived_api "$archived_api" \
|
||||||
|
--arg archived_url "$archived_url" \
|
||||||
|
--arg archived_auth "$archived_auth" \
|
||||||
|
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
|
'{step:$step, strategy:$strategy, archived_api:$archived_api,
|
||||||
|
archived_url:$archived_url, archived_auth:$archived_auth,
|
||||||
|
import_prs:{}, reset_branches:[], migrated_prs:[], timestamp:$ts}')
|
||||||
else
|
else
|
||||||
log "WARN" "New subrepo is not reachable — make sure you created the new empty repo"
|
# strategy = adopt
|
||||||
|
echo "This keeps the existing subrepo history and adds one adoption merge commit per branch." >&2
|
||||||
|
echo "No force-push is used. Existing open PR branches remain based on the old history." >&2
|
||||||
|
echo "" >&2
|
||||||
|
|
||||||
|
state=$(jq -n \
|
||||||
|
--arg step "importing" \
|
||||||
|
--arg strategy "adopt" \
|
||||||
|
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
|
'{step:$step, strategy:$strategy, import_prs:{}, adopted_branches:[], timestamp:$ts}')
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Enter the archived repo URL (e.g., git@host:org/repo-archived.git):" >&2
|
write_onboard_state "$target_name" "$state"
|
||||||
local archived_url
|
|
||||||
read -r archived_url
|
|
||||||
[ -n "$archived_url" ] || die "Archived URL is required"
|
|
||||||
|
|
||||||
# Determine auth type for archived repo (same as current subrepo)
|
|
||||||
local archived_auth="${SUBREPO_AUTH:-https}"
|
|
||||||
|
|
||||||
# Derive API URL
|
|
||||||
local archived_api
|
|
||||||
archived_api=$(_archived_api_from_url "$archived_url")
|
|
||||||
|
|
||||||
# Verify archived repo is reachable via API
|
|
||||||
if curl -sf -H "Authorization: token ${SUBREPO_TOKEN}" \
|
|
||||||
"${archived_api}" >/dev/null 2>&1; then
|
|
||||||
log "INFO" "Archived repo reachable: ${archived_api}"
|
|
||||||
else
|
|
||||||
log "WARN" "Cannot reach archived repo API — check URL and token"
|
|
||||||
echo "Continue anyway? (y/N):" >&2
|
|
||||||
local confirm
|
|
||||||
read -r confirm
|
|
||||||
[ "$confirm" = "y" ] || [ "$confirm" = "Y" ] || die "Aborted"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Save state
|
|
||||||
onboard_state=$(jq -n \
|
|
||||||
--arg step "importing" \
|
|
||||||
--arg archived_api "$archived_api" \
|
|
||||||
--arg archived_url "$archived_url" \
|
|
||||||
--arg archived_auth "$archived_auth" \
|
|
||||||
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
||||||
'{step:$step, archived_api:$archived_api, archived_url:$archived_url,
|
|
||||||
archived_auth:$archived_auth, import_prs:{}, reset_branches:[],
|
|
||||||
migrated_prs:[], timestamp:$ts}')
|
|
||||||
write_onboard_state "$target_name" "$onboard_state"
|
|
||||||
current_step="importing"
|
current_step="importing"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Step 2: Import (reuses initial_import()) ──
|
# ── Step 2: Import (reset uses archived clone URL; adopt uses subrepo URL) ──
|
||||||
if [ "$current_step" = "importing" ]; then
|
if [ "$current_step" = "importing" ]; then
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
log "INFO" "Step 2: Importing subrepo content into monorepo..."
|
log "INFO" "Step 2: Importing subrepo content into monorepo..."
|
||||||
|
|
||||||
local branches
|
local branches import_prs
|
||||||
branches=$(echo "$target_json" | jq -r '.branches | keys[]')
|
branches=$(echo "$target_json" | jq -r '.branches | keys[]')
|
||||||
|
import_prs=$(echo "$state" | jq -r '.import_prs // {}')
|
||||||
|
|
||||||
# Load existing import_prs from state (resume support)
|
local archived_clone_url=""
|
||||||
local import_prs
|
if [ "$strategy" = "reset" ]; then
|
||||||
import_prs=$(echo "$onboard_state" | jq -r '.import_prs // {}')
|
local archived_url
|
||||||
|
archived_url=$(echo "$state" | jq -r '.archived_url // empty')
|
||||||
# Build the archived repo clone URL for initial_import().
|
[ -n "$archived_url" ] || die "Reset strategy: archived_url missing from state (corrupt or interrupted before start step finished). Re-run with --restart."
|
||||||
# The content lives in the archived repo — the new repo at SUBREPO_URL is empty.
|
if [ "${SUBREPO_AUTH:-https}" = "ssh" ]; then
|
||||||
local archived_url archived_clone_url
|
archived_clone_url="$archived_url"
|
||||||
archived_url=$(echo "$onboard_state" | jq -r '.archived_url')
|
else
|
||||||
if [ "${SUBREPO_AUTH:-https}" = "ssh" ]; then
|
# shellcheck disable=SC2001
|
||||||
archived_clone_url="$archived_url"
|
archived_clone_url=$(echo "$archived_url" | sed "s|https://|https://${BOT_USER}:${SUBREPO_TOKEN}@|")
|
||||||
else
|
fi
|
||||||
# shellcheck disable=SC2001
|
|
||||||
archived_clone_url=$(echo "$archived_url" | sed "s|https://|https://${BOT_USER}:${SUBREPO_TOKEN}@|")
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
for branch in $branches; do
|
for branch in $branches; do
|
||||||
@@ -185,7 +323,6 @@ onboard_flow() {
|
|||||||
mapped=$(echo "$target_json" | jq -r --arg b "$branch" '.branches[$b] // empty')
|
mapped=$(echo "$target_json" | jq -r --arg b "$branch" '.branches[$b] // empty')
|
||||||
[ -z "$mapped" ] && continue
|
[ -z "$mapped" ] && continue
|
||||||
|
|
||||||
# Skip branches that already have an import PR recorded
|
|
||||||
if echo "$import_prs" | jq -e --arg b "$branch" 'has($b)' >/dev/null 2>&1; then
|
if echo "$import_prs" | jq -e --arg b "$branch" 'has($b)' >/dev/null 2>&1; then
|
||||||
log "INFO" "Import PR already recorded for ${branch} — skipping"
|
log "INFO" "Import PR already recorded for ${branch} — skipping"
|
||||||
continue
|
continue
|
||||||
@@ -196,11 +333,14 @@ onboard_flow() {
|
|||||||
|
|
||||||
log "INFO" "Importing branch: ${branch} (subrepo: ${mapped})"
|
log "INFO" "Importing branch: ${branch} (subrepo: ${mapped})"
|
||||||
local result
|
local result
|
||||||
result=$(initial_import "$archived_clone_url")
|
if [ -n "$archived_clone_url" ]; then
|
||||||
|
result=$(initial_import "$archived_clone_url")
|
||||||
|
else
|
||||||
|
result=$(initial_import)
|
||||||
|
fi
|
||||||
log "INFO" "Import result for ${branch}: ${result}"
|
log "INFO" "Import result for ${branch}: ${result}"
|
||||||
|
|
||||||
if [ "$result" = "pr-created" ]; then
|
if [ "$result" = "pr-created" ]; then
|
||||||
# Find the import PR number via API
|
|
||||||
local prs pr_number
|
local prs pr_number
|
||||||
prs=$(list_open_prs "$MONOREPO_API" "$GITEA_TOKEN")
|
prs=$(list_open_prs "$MONOREPO_API" "$GITEA_TOKEN")
|
||||||
pr_number=$(echo "$prs" | jq -r --arg t "$target_name" --arg b "$branch" \
|
pr_number=$(echo "$prs" | jq -r --arg t "$target_name" --arg b "$branch" \
|
||||||
@@ -210,37 +350,35 @@ onboard_flow() {
|
|||||||
import_prs=$(echo "$import_prs" | jq --arg b "$branch" --arg n "$pr_number" '. + {($b): ($n | tonumber)}')
|
import_prs=$(echo "$import_prs" | jq --arg b "$branch" --arg n "$pr_number" '. + {($b): ($n | tonumber)}')
|
||||||
log "INFO" "Import PR for ${branch}: #${pr_number}"
|
log "INFO" "Import PR for ${branch}: #${pr_number}"
|
||||||
else
|
else
|
||||||
log "WARN" "Could not find import PR number for ${branch} — check monorepo PRs"
|
# Same hazard for both strategies: continuing without recording the
|
||||||
|
# PR number means the next resume re-runs initial_import and creates
|
||||||
|
# a duplicate import PR. Bail out and let the user reconcile.
|
||||||
|
die "Could not find import PR number for ${branch} on monorepo. Re-running would create a duplicate import PR — check the monorepo's open PRs first."
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Save progress after each branch (resume support)
|
state=$(echo "$state" | jq --argjson prs "$import_prs" '.import_prs = $prs')
|
||||||
onboard_state=$(echo "$onboard_state" | jq --argjson prs "$import_prs" '.import_prs = $prs')
|
write_onboard_state "$target_name" "$state"
|
||||||
write_onboard_state "$target_name" "$onboard_state"
|
|
||||||
done
|
done
|
||||||
|
|
||||||
# Update state
|
state=$(echo "$state" | jq \
|
||||||
onboard_state=$(echo "$onboard_state" | jq \
|
|
||||||
--arg step "waiting-for-merge" \
|
--arg step "waiting-for-merge" \
|
||||||
--argjson prs "$import_prs" \
|
--argjson prs "$import_prs" \
|
||||||
'.step = $step | .import_prs = $prs')
|
'.step = $step | .import_prs = $prs')
|
||||||
write_onboard_state "$target_name" "$onboard_state"
|
write_onboard_state "$target_name" "$state"
|
||||||
current_step="waiting-for-merge"
|
current_step="waiting-for-merge"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Step 3: Wait for merge ──
|
# ── Step 3: Wait for import PR(s) to merge ──
|
||||||
if [ "$current_step" = "waiting-for-merge" ]; then
|
if [ "$current_step" = "waiting-for-merge" ]; then
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
log "INFO" "Step 3: Waiting for import PR(s) to be merged..."
|
log "INFO" "Step 3: Waiting for import PR(s) to be merged..."
|
||||||
|
|
||||||
local import_prs
|
local import_prs pr_count
|
||||||
import_prs=$(echo "$onboard_state" | jq -r '.import_prs')
|
import_prs=$(echo "$state" | jq -r '.import_prs')
|
||||||
local pr_count
|
|
||||||
pr_count=$(echo "$import_prs" | jq 'length')
|
pr_count=$(echo "$import_prs" | jq 'length')
|
||||||
|
|
||||||
if [ "$pr_count" -eq 0 ]; then
|
if [ "$pr_count" -gt 0 ]; then
|
||||||
log "WARN" "No import PRs recorded — skipping merge check"
|
|
||||||
else
|
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
echo "Import PRs to merge:" >&2
|
echo "Import PRs to merge:" >&2
|
||||||
echo "$import_prs" | jq -r 'to_entries[] | " \(.key): PR #\(.value)"' >&2
|
echo "$import_prs" | jq -r 'to_entries[] | " \(.key): PR #\(.value)"' >&2
|
||||||
@@ -248,12 +386,10 @@ onboard_flow() {
|
|||||||
echo "Merge the import PR(s) on the monorepo, then press Enter..." >&2
|
echo "Merge the import PR(s) on the monorepo, then press Enter..." >&2
|
||||||
read -r
|
read -r
|
||||||
|
|
||||||
# Verify each PR is merged
|
|
||||||
local all_merged=true
|
local all_merged=true
|
||||||
for branch in $(echo "$import_prs" | jq -r 'keys[]'); do
|
for branch in $(echo "$import_prs" | jq -r 'keys[]'); do
|
||||||
local pr_number
|
local pr_number pr_json merged
|
||||||
pr_number=$(echo "$import_prs" | jq -r --arg b "$branch" '.[$b]')
|
pr_number=$(echo "$import_prs" | jq -r --arg b "$branch" '.[$b]')
|
||||||
local pr_json merged
|
|
||||||
pr_json=$(get_pr "$MONOREPO_API" "$GITEA_TOKEN" "$pr_number")
|
pr_json=$(get_pr "$MONOREPO_API" "$GITEA_TOKEN" "$pr_number")
|
||||||
merged=$(echo "$pr_json" | jq -r '.merged // false')
|
merged=$(echo "$pr_json" | jq -r '.merged // false')
|
||||||
|
|
||||||
@@ -268,26 +404,31 @@ onboard_flow() {
|
|||||||
if [ "$all_merged" = false ]; then
|
if [ "$all_merged" = false ]; then
|
||||||
die "Not all import PRs are merged. Re-run 'josh-sync onboard ${target_name}' after merging."
|
die "Not all import PRs are merged. Re-run 'josh-sync onboard ${target_name}' after merging."
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
log "INFO" "No import PRs recorded — subfolder already matched subrepo"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Update state
|
local next_step
|
||||||
onboard_state=$(echo "$onboard_state" | jq '.step = "resetting"')
|
if [ "$strategy" = "reset" ]; then
|
||||||
write_onboard_state "$target_name" "$onboard_state"
|
next_step="resetting"
|
||||||
current_step="resetting"
|
else
|
||||||
|
next_step="adopting"
|
||||||
|
fi
|
||||||
|
state=$(echo "$state" | jq --arg s "$next_step" '.step = $s')
|
||||||
|
write_onboard_state "$target_name" "$state"
|
||||||
|
current_step="$next_step"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Step 4: Reset (pushes josh-filtered history to new repo) ──
|
# ── Step 4a: Finalize via reset (force-push josh-filtered history) ──
|
||||||
if [ "$current_step" = "resetting" ]; then
|
if [ "$current_step" = "resetting" ]; then
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
log "INFO" "Step 4: Pushing josh-filtered history to new subrepo..."
|
log "INFO" "Step 4: Pushing josh-filtered history to new subrepo..."
|
||||||
|
|
||||||
local branches
|
local branches already_reset
|
||||||
branches=$(echo "$target_json" | jq -r '.branches | keys[]')
|
branches=$(echo "$target_json" | jq -r '.branches | keys[]')
|
||||||
local already_reset
|
already_reset=$(echo "$state" | jq -r '.reset_branches // []')
|
||||||
already_reset=$(echo "$onboard_state" | jq -r '.reset_branches // []')
|
|
||||||
|
|
||||||
for branch in $branches; do
|
for branch in $branches; do
|
||||||
# Skip branches already reset (resume support)
|
|
||||||
if echo "$already_reset" | jq -e --arg b "$branch" 'index($b) != null' >/dev/null 2>&1; then
|
if echo "$already_reset" | jq -e --arg b "$branch" 'index($b) != null' >/dev/null 2>&1; then
|
||||||
log "INFO" "Branch ${branch} already reset — skipping"
|
log "INFO" "Branch ${branch} already reset — skipping"
|
||||||
continue
|
continue
|
||||||
@@ -304,18 +445,69 @@ onboard_flow() {
|
|||||||
result=$(subrepo_reset)
|
result=$(subrepo_reset)
|
||||||
log "INFO" "Reset result for ${branch}: ${result}"
|
log "INFO" "Reset result for ${branch}: ${result}"
|
||||||
|
|
||||||
# Track progress
|
state=$(echo "$state" | jq --arg b "$branch" '.reset_branches += [$b]')
|
||||||
onboard_state=$(echo "$onboard_state" | jq --arg b "$branch" \
|
write_onboard_state "$target_name" "$state"
|
||||||
'.reset_branches += [$b]')
|
|
||||||
write_onboard_state "$target_name" "$onboard_state"
|
|
||||||
done
|
done
|
||||||
|
|
||||||
# Update state
|
state=$(echo "$state" | jq \
|
||||||
onboard_state=$(echo "$onboard_state" | jq \
|
|
||||||
--arg step "complete" \
|
--arg step "complete" \
|
||||||
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
'.step = $step | .timestamp = $ts')
|
'.step = $step | .timestamp = $ts')
|
||||||
write_onboard_state "$target_name" "$onboard_state"
|
write_onboard_state "$target_name" "$state"
|
||||||
|
current_step="complete"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Step 4b: Finalize via adoption merge ──
|
||||||
|
if [ "$current_step" = "adopting" ]; then
|
||||||
|
echo "" >&2
|
||||||
|
log "INFO" "Step 4: Creating adoption merge commit(s)..."
|
||||||
|
|
||||||
|
local branches already_adopted
|
||||||
|
branches=$(echo "$target_json" | jq -r '.branches | keys[]')
|
||||||
|
already_adopted=$(echo "$state" | jq -r '.adopted_branches // []')
|
||||||
|
|
||||||
|
for branch in $branches; do
|
||||||
|
if echo "$already_adopted" | jq -e --arg b "$branch" 'index($b) != null' >/dev/null 2>&1; then
|
||||||
|
log "INFO" "Branch ${branch} already adopted — skipping"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
local mapped
|
||||||
|
mapped=$(echo "$target_json" | jq -r --arg b "$branch" '.branches[$b] // empty')
|
||||||
|
[ -z "$mapped" ] && continue
|
||||||
|
|
||||||
|
export SYNC_BRANCH_MONO="$branch"
|
||||||
|
export SYNC_BRANCH_SUBREPO="$mapped"
|
||||||
|
|
||||||
|
local result
|
||||||
|
result=$(adopt_branch)
|
||||||
|
log "INFO" "Adopt result for ${branch}: ${result}"
|
||||||
|
|
||||||
|
case "$result" in
|
||||||
|
adopted|already-adopted)
|
||||||
|
state=$(echo "$state" | jq --arg b "$branch" '.adopted_branches += [$b]')
|
||||||
|
write_onboard_state "$target_name" "$state"
|
||||||
|
;;
|
||||||
|
tree-mismatch)
|
||||||
|
die "Tree mismatch for ${branch}. Merge the import PR and make sure subrepo/${mapped} matches the Josh-filtered monorepo tree."
|
||||||
|
;;
|
||||||
|
push-rejected)
|
||||||
|
die "Subrepo branch ${mapped} changed during adoption. Re-run 'josh-sync onboard ${target_name} --mode=adopt' to retry."
|
||||||
|
;;
|
||||||
|
missing-branch)
|
||||||
|
die "Subrepo branch ${mapped} does not exist."
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
die "Unexpected adopt result: ${result}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
state=$(echo "$state" | jq \
|
||||||
|
--arg step "complete" \
|
||||||
|
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
|
'.step = $step | .timestamp = $ts')
|
||||||
|
write_onboard_state "$target_name" "$state"
|
||||||
current_step="complete"
|
current_step="complete"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -324,14 +516,20 @@ onboard_flow() {
|
|||||||
echo "" >&2
|
echo "" >&2
|
||||||
echo "=== Onboarding complete! ===" >&2
|
echo "=== Onboarding complete! ===" >&2
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
echo "The new subrepo now has josh-filtered history." >&2
|
if [ "$strategy" = "reset" ]; then
|
||||||
echo "Developers should re-clone or reset their local copies:" >&2
|
echo "The new subrepo now has josh-filtered history." >&2
|
||||||
echo " git fetch origin && git reset --hard origin/main" >&2
|
echo "Developers should re-clone or reset their local copies:" >&2
|
||||||
echo "" >&2
|
echo " git fetch origin && git reset --hard origin/main" >&2
|
||||||
echo "To migrate open PRs from the archived repo:" >&2
|
echo "" >&2
|
||||||
echo " josh-sync migrate-pr ${target_name} # interactive picker" >&2
|
echo "To migrate open PRs from the archived repo:" >&2
|
||||||
echo " josh-sync migrate-pr ${target_name} --all # migrate all" >&2
|
echo " josh-sync migrate-pr ${target_name} # interactive picker" >&2
|
||||||
echo " josh-sync migrate-pr ${target_name} 5 8 12 # specific PRs" >&2
|
echo " josh-sync migrate-pr ${target_name} --all # migrate all" >&2
|
||||||
|
echo " josh-sync migrate-pr ${target_name} 5 8 12 # specific PRs" >&2
|
||||||
|
else
|
||||||
|
echo "The subrepo keeps its existing history and now has a josh-sync adoption merge." >&2
|
||||||
|
echo "Developers can keep their clones; they only need to fast-forward active branches:" >&2
|
||||||
|
echo " git fetch origin && git checkout main && git merge --ff-only origin/main" >&2
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,15 +544,17 @@ migrate_one_pr() {
|
|||||||
local pr_number="$1"
|
local pr_number="$1"
|
||||||
local target_name="$JOSH_SYNC_TARGET_NAME"
|
local target_name="$JOSH_SYNC_TARGET_NAME"
|
||||||
|
|
||||||
# Read archived repo info from onboard state
|
local onboard_state strategy archived_api
|
||||||
local onboard_state archived_api
|
|
||||||
onboard_state=$(read_onboard_state "$target_name")
|
onboard_state=$(read_onboard_state "$target_name")
|
||||||
|
strategy=$(echo "$onboard_state" | jq -r '.strategy // "reset"')
|
||||||
|
if [ "$strategy" = "adopt" ]; then
|
||||||
|
die "Target '${target_name}' was onboarded with the adopt strategy, which does not record an archived repo. migrate-pr only applies to the reset strategy."
|
||||||
|
fi
|
||||||
archived_api=$(echo "$onboard_state" | jq -r '.archived_api')
|
archived_api=$(echo "$onboard_state" | jq -r '.archived_api')
|
||||||
if [ -z "$archived_api" ] || [ "$archived_api" = "null" ]; then
|
if [ -z "$archived_api" ] || [ "$archived_api" = "null" ]; then
|
||||||
die "No archived repo info found. Run 'josh-sync onboard ${target_name}' first."
|
die "No archived repo info found for '${target_name}'. Run 'josh-sync onboard ${target_name} --mode=reset' first."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check if this PR was already migrated
|
|
||||||
local already_migrated
|
local already_migrated
|
||||||
already_migrated=$(echo "$onboard_state" | jq -r \
|
already_migrated=$(echo "$onboard_state" | jq -r \
|
||||||
--argjson num "$pr_number" '.migrated_prs // [] | map(select(.old_number == $num)) | length')
|
--argjson num "$pr_number" '.migrated_prs // [] | map(select(.old_number == $num)) | length')
|
||||||
@@ -363,10 +563,8 @@ migrate_one_pr() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Same credentials — the repo was just renamed
|
|
||||||
local archived_token="$SUBREPO_TOKEN"
|
local archived_token="$SUBREPO_TOKEN"
|
||||||
|
|
||||||
# 1. Get PR metadata from archived repo
|
|
||||||
local pr_json title base head body
|
local pr_json title base head body
|
||||||
pr_json=$(get_pr "$archived_api" "$archived_token" "$pr_number") \
|
pr_json=$(get_pr "$archived_api" "$archived_token" "$pr_number") \
|
||||||
|| die "Failed to fetch PR #${pr_number} from archived repo"
|
|| die "Failed to fetch PR #${pr_number} from archived repo"
|
||||||
@@ -377,8 +575,6 @@ migrate_one_pr() {
|
|||||||
|
|
||||||
log "INFO" "Migrating PR #${pr_number}: \"${title}\" (${base} <- ${head})"
|
log "INFO" "Migrating PR #${pr_number}: \"${title}\" (${base} <- ${head})"
|
||||||
|
|
||||||
# 2. Clone new subrepo, add archived repo as second remote
|
|
||||||
# Save cwd so we can restore it (function runs in caller's shell, not subshell)
|
|
||||||
local original_dir
|
local original_dir
|
||||||
original_dir=$(pwd)
|
original_dir=$(pwd)
|
||||||
|
|
||||||
@@ -394,7 +590,6 @@ migrate_one_pr() {
|
|||||||
git config user.name "$BOT_NAME"
|
git config user.name "$BOT_NAME"
|
||||||
git config user.email "$BOT_EMAIL"
|
git config user.email "$BOT_EMAIL"
|
||||||
|
|
||||||
# Build authenticated URL for the archived repo
|
|
||||||
local archived_url archived_clone_url
|
local archived_url archived_clone_url
|
||||||
archived_url=$(echo "$onboard_state" | jq -r '.archived_url')
|
archived_url=$(echo "$onboard_state" | jq -r '.archived_url')
|
||||||
if [ "${SUBREPO_AUTH:-https}" = "ssh" ]; then
|
if [ "${SUBREPO_AUTH:-https}" = "ssh" ]; then
|
||||||
@@ -404,12 +599,10 @@ migrate_one_pr() {
|
|||||||
archived_clone_url=$(echo "$archived_url" | sed "s|https://|https://${BOT_USER}:${SUBREPO_TOKEN}@|")
|
archived_clone_url=$(echo "$archived_url" | sed "s|https://|https://${BOT_USER}:${SUBREPO_TOKEN}@|")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Fetch the PR's head and base branches from the archived repo
|
|
||||||
git remote add archived "$archived_clone_url"
|
git remote add archived "$archived_clone_url"
|
||||||
git fetch archived "$head" "$base" 2>&1 \
|
git fetch archived "$head" "$base" 2>&1 \
|
||||||
|| die "Failed to fetch branches from archived repo"
|
|| die "Failed to fetch branches from archived repo"
|
||||||
|
|
||||||
# 3. Compute diff locally and apply with --3way
|
|
||||||
git checkout -B "$head" >&2
|
git checkout -B "$head" >&2
|
||||||
|
|
||||||
local diff
|
local diff
|
||||||
@@ -428,13 +621,11 @@ Migrated from archived repo PR #${pr_number}" >&2
|
|||||||
git push "$(subrepo_auth_url)" "$head" >&2 \
|
git push "$(subrepo_auth_url)" "$head" >&2 \
|
||||||
|| die "Failed to push branch ${head}"
|
|| die "Failed to push branch ${head}"
|
||||||
|
|
||||||
# 4. Create PR on new repo
|
|
||||||
local new_number
|
local new_number
|
||||||
new_number=$(create_pr_number "$SUBREPO_API" "$SUBREPO_TOKEN" \
|
new_number=$(create_pr_number "$SUBREPO_API" "$SUBREPO_TOKEN" \
|
||||||
"$base" "$head" "$title" "$body")
|
"$base" "$head" "$title" "$body")
|
||||||
log "INFO" "Migrated PR #${pr_number} -> #${new_number}: \"${title}\""
|
log "INFO" "Migrated PR #${pr_number} -> #${new_number}: \"${title}\""
|
||||||
|
|
||||||
# 5. Record in onboard state
|
|
||||||
cd "$original_dir" || true
|
cd "$original_dir" || true
|
||||||
onboard_state=$(read_onboard_state "$target_name")
|
onboard_state=$(read_onboard_state "$target_name")
|
||||||
onboard_state=$(echo "$onboard_state" | jq \
|
onboard_state=$(echo "$onboard_state" | jq \
|
||||||
|
|||||||
62
lib/sync.sh
62
lib/sync.sh
@@ -240,6 +240,8 @@ reverse_sync() {
|
|||||||
|
|
||||||
log "INFO" "=== Reverse sync: subrepo/${subrepo_branch} → mono/${mono_branch} ==="
|
log "INFO" "=== Reverse sync: subrepo/${subrepo_branch} → mono/${mono_branch} ==="
|
||||||
|
|
||||||
|
local force_mode="${SYNC_REVERSE_FORCE:-0}"
|
||||||
|
|
||||||
# 1. Clone subrepo
|
# 1. Clone subrepo
|
||||||
git clone "$(subrepo_auth_url)" \
|
git clone "$(subrepo_auth_url)" \
|
||||||
--branch "$subrepo_branch" --single-branch \
|
--branch "$subrepo_branch" --single-branch \
|
||||||
@@ -286,9 +288,13 @@ reverse_sync() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "$human_commits" ]; then
|
if [ -z "$human_commits" ]; then
|
||||||
log "INFO" "No new human commits in subrepo — nothing to sync"
|
if [ "$force_mode" = "1" ]; then
|
||||||
echo "skip-dirty"
|
log "WARN" "No human commits found — force mode: continuing anyway"
|
||||||
return
|
else
|
||||||
|
log "INFO" "No new human commits in subrepo — nothing to sync"
|
||||||
|
echo "skip-dirty"
|
||||||
|
return
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "INFO" "New human commits to sync:"
|
log "INFO" "New human commits to sync:"
|
||||||
@@ -400,16 +406,28 @@ reverse_sync() {
|
|||||||
|
|
||||||
log "INFO" "Pushed to staging branch via josh: ${staging_branch}${linearized:+ (linearized)}"
|
log "INFO" "Pushed to staging branch via josh: ${staging_branch}${linearized:+ (linearized)}"
|
||||||
|
|
||||||
# 6. Create PR on monorepo (NEVER direct push)
|
# 6. Publish: force-push directly (--force) or create PR (default)
|
||||||
local pr_body
|
if [ "$force_mode" = "1" ]; then
|
||||||
local linearize_note=""
|
log "WARN" "Force mode: pushing ${staging_branch} directly to mono/${mono_branch} — bypassing PR"
|
||||||
if [ "$linearized" = true ]; then
|
local mono_dir="${work_dir}/monorepo"
|
||||||
linearize_note="
|
git clone "$(mono_auth_url)" \
|
||||||
|
--branch "$staging_branch" --single-branch \
|
||||||
|
"$mono_dir" || die "Failed to clone monorepo staging branch for force-push"
|
||||||
|
git -C "$mono_dir" push --force "$(mono_auth_url)" \
|
||||||
|
"HEAD:refs/heads/${mono_branch}" \
|
||||||
|
|| die "Failed to force-push to mono/${mono_branch}"
|
||||||
|
log "INFO" "Force-pushed mono/${staging_branch} → mono/${mono_branch}"
|
||||||
|
echo "forced"
|
||||||
|
else
|
||||||
|
local pr_body
|
||||||
|
local linearize_note=""
|
||||||
|
if [ "$linearized" = true ]; then
|
||||||
|
linearize_note="
|
||||||
> **Note:** Merge commits were squashed during sync because the subrepo
|
> **Note:** Merge commits were squashed during sync because the subrepo
|
||||||
> history contained merges that josh-proxy cannot map (see ADR-011).
|
> history contained merges that josh-proxy cannot map (see ADR-011).
|
||||||
> Regular commits are preserved individually."
|
> Regular commits are preserved individually."
|
||||||
fi
|
fi
|
||||||
pr_body="## Subrepo changes
|
pr_body="## Subrepo changes
|
||||||
|
|
||||||
New commits from subrepo \`${subrepo_branch}\`:
|
New commits from subrepo \`${subrepo_branch}\`:
|
||||||
|
|
||||||
@@ -422,14 +440,15 @@ ${linearize_note}
|
|||||||
- [ ] No leaked credentials or environment-specific config
|
- [ ] No leaked credentials or environment-specific config
|
||||||
- [ ] CI passes"
|
- [ ] CI passes"
|
||||||
|
|
||||||
create_pr "${MONOREPO_API}" "${GITEA_TOKEN}" \
|
create_pr "${MONOREPO_API}" "${GITEA_TOKEN}" \
|
||||||
"$mono_branch" "$staging_branch" \
|
"$mono_branch" "$staging_branch" \
|
||||||
"[Subrepo Sync] ${subrepo_branch} → ${mono_branch}" \
|
"[Subrepo Sync] ${subrepo_branch} → ${mono_branch}" \
|
||||||
"$pr_body" \
|
"$pr_body" \
|
||||||
|| die "Failed to create PR on monorepo (check GITEA_TOKEN)"
|
|| die "Failed to create PR on monorepo (check GITEA_TOKEN)"
|
||||||
|
|
||||||
log "INFO" "Reverse sync PR created on monorepo"
|
log "INFO" "Reverse sync PR created on monorepo"
|
||||||
echo "pr-created"
|
echo "pr-created"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# ─── Initial Import: Subrepo → Monorepo (first time) ───────────────
|
# ─── Initial Import: Subrepo → Monorepo (first time) ───────────────
|
||||||
@@ -456,12 +475,7 @@ initial_import() {
|
|||||||
log "INFO" "=== Initial import: subrepo/${subrepo_branch} → mono/${mono_branch}:${subfolder}/ ==="
|
log "INFO" "=== Initial import: subrepo/${subrepo_branch} → mono/${mono_branch}:${subfolder}/ ==="
|
||||||
|
|
||||||
# 1. Clone monorepo (directly, not through josh — we need the real repo)
|
# 1. Clone monorepo (directly, not through josh — we need the real repo)
|
||||||
local mono_auth_url
|
git clone "$(mono_auth_url)" \
|
||||||
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"
|
|
||||||
|
|
||||||
git clone "$mono_auth_url" \
|
|
||||||
--branch "$mono_branch" --single-branch \
|
--branch "$mono_branch" --single-branch \
|
||||||
"${work_dir}/monorepo" || die "Failed to clone monorepo"
|
"${work_dir}/monorepo" || die "Failed to clone monorepo"
|
||||||
|
|
||||||
@@ -486,7 +500,7 @@ initial_import() {
|
|||||||
|
|
||||||
mkdir -p "$subfolder"
|
mkdir -p "$subfolder"
|
||||||
rsync -a --exclude='.git' "${work_dir}/subrepo/" "${subfolder}/"
|
rsync -a --exclude='.git' "${work_dir}/subrepo/" "${subfolder}/"
|
||||||
git add "$subfolder"
|
git add -f -- "$subfolder"
|
||||||
|
|
||||||
if git diff --cached --quiet; then
|
if git diff --cached --quiet; then
|
||||||
log "INFO" "No changes — subfolder already matches subrepo"
|
log "INFO" "No changes — subfolder already matches subrepo"
|
||||||
|
|||||||
@@ -3,16 +3,16 @@
|
|||||||
"title": "josh-sync configuration",
|
"title": "josh-sync configuration",
|
||||||
"description": "Configuration for bidirectional monorepo ↔ subrepo sync via josh-proxy",
|
"description": "Configuration for bidirectional monorepo ↔ subrepo sync via josh-proxy",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["josh", "targets", "bot"],
|
"required": ["schema_version", "josh", "targets", "bot"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"schema_version": {
|
"schema_version": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"const": 1,
|
"const": 2,
|
||||||
"description": "Schema version. Bump when config fields are removed or renamed (matches josh-sync major version)."
|
"description": "Schema version. Required. Must be 2 for josh-sync v2.x configs."
|
||||||
},
|
},
|
||||||
"josh": {
|
"josh": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["proxy_url", "monorepo_path"],
|
"required": ["proxy_url", "monorepo_path", "monorepo_url"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"proxy_url": {
|
"proxy_url": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -21,7 +21,17 @@
|
|||||||
},
|
},
|
||||||
"monorepo_path": {
|
"monorepo_path": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Repo path as josh sees it (org/repo)"
|
"description": "Repo path as josh-proxy sees it (org/repo)"
|
||||||
|
},
|
||||||
|
"monorepo_url": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Monorepo git clone URL — git@host:org/repo.git (SSH) or https://host/org/repo.git. The gitea host is derived from this URL."
|
||||||
|
},
|
||||||
|
"monorepo_auth": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["https", "ssh"],
|
||||||
|
"default": "https",
|
||||||
|
"description": "Auth method for cloning the monorepo. SSH uses the user's ssh-agent / ~/.ssh/config; HTTPS injects ${SYNC_BOT_USER}:${SYNC_BOT_TOKEN} into the URL."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -81,6 +91,11 @@
|
|||||||
"items": { "type": "string" },
|
"items": { "type": "string" },
|
||||||
"default": [],
|
"default": [],
|
||||||
"description": "File/directory patterns to exclude from sync via josh :exclude filter. Josh pattern syntax: 'dir/' for directories, '*.ext' for globs, '**/dir/' for nested matches. Patterns are embedded inline in the josh-proxy URL."
|
"description": "File/directory patterns to exclude from sync via josh :exclude filter. Josh pattern syntax: 'dir/' for directories, '*.ext' for globs, '**/dir/' for nested matches. Patterns are embedded inline in the josh-proxy URL."
|
||||||
|
},
|
||||||
|
"history_lock": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["rewrite", "preserve"],
|
||||||
|
"description": "Onboarding strategy override. 'preserve' selects the non-destructive adopt strategy (keeps subrepo history via an adoption merge); 'rewrite' selects the destructive reset strategy (force-pushes josh-filtered history). When omitted, `josh-sync onboard` auto-detects: subrepos with branches → adopt, empty subrepos → reset. The CLI flag `--mode={reset,adopt}` overrides this."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
tests/fixtures/minimal.yml
vendored
2
tests/fixtures/minimal.yml
vendored
@@ -1,7 +1,9 @@
|
|||||||
# Test fixture: minimal single-target config
|
# Test fixture: minimal single-target config
|
||||||
|
schema_version: 2
|
||||||
josh:
|
josh:
|
||||||
proxy_url: "https://josh.test.local"
|
proxy_url: "https://josh.test.local"
|
||||||
monorepo_path: "org/repo"
|
monorepo_path: "org/repo"
|
||||||
|
monorepo_url: "git@gitea.test.local:org/repo.git"
|
||||||
|
|
||||||
targets:
|
targets:
|
||||||
- name: "example"
|
- name: "example"
|
||||||
|
|||||||
2
tests/fixtures/multi-target.yml
vendored
2
tests/fixtures/multi-target.yml
vendored
@@ -1,7 +1,9 @@
|
|||||||
# Test fixture: multi-target config
|
# Test fixture: multi-target config
|
||||||
|
schema_version: 2
|
||||||
josh:
|
josh:
|
||||||
proxy_url: "https://josh.test.local"
|
proxy_url: "https://josh.test.local"
|
||||||
monorepo_path: "org/monorepo"
|
monorepo_path: "org/monorepo"
|
||||||
|
monorepo_url: "git@gitea.test.local:org/monorepo.git"
|
||||||
|
|
||||||
targets:
|
targets:
|
||||||
- name: "app-a"
|
- name: "app-a"
|
||||||
|
|||||||
17
tests/unit/action.bats
Normal file
17
tests/unit/action.bats
Normal 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"
|
||||||
|
}
|
||||||
92
tests/unit/adopt.bats
Normal file
92
tests/unit/adopt.bats
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env bats
|
||||||
|
# tests/unit/adopt.bats — Non-destructive adoption merge tests
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
export JOSH_SYNC_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
|
||||||
|
source "$JOSH_SYNC_ROOT/lib/core.sh"
|
||||||
|
source "$JOSH_SYNC_ROOT/lib/adopt.sh"
|
||||||
|
|
||||||
|
TEST_ROOT="$(mktemp -d)"
|
||||||
|
export BOT_NAME="test-bot"
|
||||||
|
export BOT_EMAIL="test-bot@test.local"
|
||||||
|
export BOT_TRAILER="Josh-Sync-Origin"
|
||||||
|
export JOSH_SYNC_TARGET_NAME="example"
|
||||||
|
export SYNC_BRANCH_MONO="main"
|
||||||
|
export SYNC_BRANCH_SUBREPO="main"
|
||||||
|
}
|
||||||
|
|
||||||
|
teardown() {
|
||||||
|
rm -rf "$TEST_ROOT"
|
||||||
|
}
|
||||||
|
|
||||||
|
make_bare_repo() {
|
||||||
|
local name="$1"
|
||||||
|
local content="$2"
|
||||||
|
local message="$3"
|
||||||
|
local work="${TEST_ROOT}/${name}-work"
|
||||||
|
local bare="${TEST_ROOT}/${name}.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 '%s\n' "$content" > "${work}/README.md"
|
||||||
|
git -C "$work" add README.md
|
||||||
|
git -C "$work" commit -m "$message" >/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"
|
||||||
|
}
|
||||||
|
|
||||||
|
subrepo_auth_url() {
|
||||||
|
printf '%s\n' "$SUBREPO_BARE"
|
||||||
|
}
|
||||||
|
|
||||||
|
josh_auth_url() {
|
||||||
|
printf '%s\n' "$JOSH_BARE"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "adopt_branch creates merge with josh head first parent and subrepo head second parent" {
|
||||||
|
SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo history")"
|
||||||
|
JOSH_BARE="$(make_bare_repo josh "same tree" "josh filtered history")"
|
||||||
|
|
||||||
|
local old_subrepo_head josh_head josh_tree result adopt_head
|
||||||
|
old_subrepo_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
|
||||||
|
josh_head=$(git --git-dir="$JOSH_BARE" rev-parse main)
|
||||||
|
josh_tree=$(git --git-dir="$JOSH_BARE" rev-parse 'main^{tree}')
|
||||||
|
|
||||||
|
result=$(adopt_branch)
|
||||||
|
|
||||||
|
[ "$result" = "adopted" ]
|
||||||
|
adopt_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
|
||||||
|
[ "$adopt_head" != "$old_subrepo_head" ]
|
||||||
|
[ "$(git --git-dir="$SUBREPO_BARE" rev-parse 'main^1')" = "$josh_head" ]
|
||||||
|
[ "$(git --git-dir="$SUBREPO_BARE" rev-parse 'main^2')" = "$old_subrepo_head" ]
|
||||||
|
[ "$(git --git-dir="$SUBREPO_BARE" rev-parse 'main^{tree}')" = "$josh_tree" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "adopt_branch aborts when trees differ and leaves subrepo head unchanged" {
|
||||||
|
SUBREPO_BARE="$(make_bare_repo subrepo "subrepo tree" "subrepo history")"
|
||||||
|
JOSH_BARE="$(make_bare_repo josh "josh tree" "josh filtered history")"
|
||||||
|
|
||||||
|
local old_subrepo_head result
|
||||||
|
old_subrepo_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
|
||||||
|
|
||||||
|
result=$(adopt_branch)
|
||||||
|
|
||||||
|
[ "$result" = "tree-mismatch" ]
|
||||||
|
[ "$(git --git-dir="$SUBREPO_BARE" rev-parse main)" = "$old_subrepo_head" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "adopt_branch reports missing subrepo branch before cloning" {
|
||||||
|
SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo history")"
|
||||||
|
JOSH_BARE="$(make_bare_repo josh "same tree" "josh filtered history")"
|
||||||
|
export SYNC_BRANCH_SUBREPO="missing"
|
||||||
|
|
||||||
|
local result
|
||||||
|
result=$(adopt_branch)
|
||||||
|
|
||||||
|
[ "$result" = "missing-branch" ]
|
||||||
|
}
|
||||||
697
tests/unit/adopt_e2e.bats
Normal file
697
tests/unit/adopt_e2e.bats
Normal file
@@ -0,0 +1,697 @@
|
|||||||
|
#!/usr/bin/env bats
|
||||||
|
|
||||||
|
bats_require_minimum_version 1.5.0
|
||||||
|
|
||||||
|
# tests/unit/adopt_e2e.bats — End-to-end adopt behaviour and ADR cross-cuts
|
||||||
|
#
|
||||||
|
# Covers the goal-1 scenarios from the adopt unification plan:
|
||||||
|
# - tree-equality rejection (clear hint + no commit on subrepo)
|
||||||
|
# - already-adopted idempotency
|
||||||
|
# - merge commit shape (ADR-005 trailer, ADR-008 first-parent walk)
|
||||||
|
# - fast-forward push semantics (success + push-rejected surfacing)
|
||||||
|
# - reverse-sync loop guard (ADR-005)
|
||||||
|
# - linearize-fallback selection invariant (ADR-011)
|
||||||
|
# - checkpoint/resume at each adopt_flow step (ADR-010)
|
||||||
|
#
|
||||||
|
# Tests run against real local bare repos — no mocking of the git layer.
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
export JOSH_SYNC_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
|
||||||
|
source "$JOSH_SYNC_ROOT/lib/core.sh"
|
||||||
|
source "$JOSH_SYNC_ROOT/lib/state.sh"
|
||||||
|
source "$JOSH_SYNC_ROOT/lib/auth.sh"
|
||||||
|
source "$JOSH_SYNC_ROOT/lib/adopt.sh"
|
||||||
|
source "$JOSH_SYNC_ROOT/lib/onboard.sh"
|
||||||
|
|
||||||
|
TEST_ROOT="$(mktemp -d)"
|
||||||
|
export BOT_NAME="test-bot"
|
||||||
|
export BOT_EMAIL="test-bot@test.local"
|
||||||
|
export BOT_TRAILER="Josh-Sync-Origin"
|
||||||
|
export JOSH_SYNC_TARGET_NAME="example"
|
||||||
|
export SYNC_BRANCH_MONO="main"
|
||||||
|
export SYNC_BRANCH_SUBREPO="main"
|
||||||
|
|
||||||
|
# Test-local auth shims must be defined AFTER sourcing lib/auth.sh, otherwise
|
||||||
|
# the lib's real subrepo_auth_url / josh_auth_url (which need SUBREPO_URL,
|
||||||
|
# BOT_USER, etc.) overrides our bare-repo shortcut.
|
||||||
|
subrepo_auth_url() { printf '%s\n' "${SUBREPO_BARE:-}"; }
|
||||||
|
josh_auth_url() { printf '%s\n' "${JOSH_BARE:-}"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
teardown() {
|
||||||
|
# Restore cwd before the rm — setup_state_monorepo cd's into a dir that's
|
||||||
|
# about to be deleted, and bats doesn't reset cwd between tests.
|
||||||
|
cd "$BATS_TEST_DIRNAME" 2>/dev/null || true
|
||||||
|
rm -rf "$TEST_ROOT"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Build a fresh bare repo seeded with one commit; returns the bare path on stdout.
|
||||||
|
make_bare_repo() {
|
||||||
|
local name="$1"
|
||||||
|
local content="$2"
|
||||||
|
local message="$3"
|
||||||
|
local work="${TEST_ROOT}/${name}-work"
|
||||||
|
local bare="${TEST_ROOT}/${name}.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 '%s\n' "$content" > "${work}/README.md"
|
||||||
|
git -C "$work" add README.md
|
||||||
|
git -C "$work" commit -m "$message" >/dev/null
|
||||||
|
|
||||||
|
git init --bare "$bare" >/dev/null
|
||||||
|
# Make HEAD on the bare point at main so later clones don't land on master.
|
||||||
|
git -C "$bare" symbolic-ref HEAD refs/heads/main
|
||||||
|
git -C "$work" remote add origin "$bare"
|
||||||
|
git -C "$work" push origin main >/dev/null
|
||||||
|
printf '%s\n' "$bare"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Append a commit to an existing bare repo. If $3 is empty, makes an empty commit
|
||||||
|
# (tree unchanged) so first-parent walks have something to observe.
|
||||||
|
add_commit_to_bare() {
|
||||||
|
local bare="$1"
|
||||||
|
local message="$2"
|
||||||
|
local content="${3:-}"
|
||||||
|
local work="${TEST_ROOT}/add-${RANDOM}-$$"
|
||||||
|
git clone "$bare" "$work" >/dev/null 2>&1
|
||||||
|
git -C "$work" config user.name "Test User"
|
||||||
|
git -C "$work" config user.email "test@test.local"
|
||||||
|
if [ -n "$content" ]; then
|
||||||
|
printf '%s\n' "$content" >> "${work}/README.md"
|
||||||
|
git -C "$work" commit -am "$message" >/dev/null
|
||||||
|
else
|
||||||
|
git -C "$work" commit --allow-empty -m "$message" >/dev/null
|
||||||
|
fi
|
||||||
|
git -C "$work" push origin main >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stand up a local working "monorepo" with an `origin` bare remote and cd into
|
||||||
|
# it. Gives lib/state.sh helpers (worktree + push to origin) something real to
|
||||||
|
# work against.
|
||||||
|
setup_state_monorepo() {
|
||||||
|
local mono_work="${TEST_ROOT}/mono"
|
||||||
|
local mono_bare="${TEST_ROOT}/mono.git"
|
||||||
|
git init -q "$mono_work"
|
||||||
|
git -C "$mono_work" checkout -q -b main
|
||||||
|
git -C "$mono_work" config user.name "Test User"
|
||||||
|
git -C "$mono_work" config user.email "test@test.local"
|
||||||
|
printf 'mono\n' > "${mono_work}/README.md"
|
||||||
|
git -C "$mono_work" add README.md
|
||||||
|
git -C "$mono_work" commit -q -m "initial"
|
||||||
|
git init -q --bare "$mono_bare"
|
||||||
|
git -C "$mono_work" remote add origin "$mono_bare"
|
||||||
|
git -C "$mono_work" push -q origin main
|
||||||
|
cd "$mono_work"
|
||||||
|
}
|
||||||
|
|
||||||
|
# adopt_branch is defined as a subshell function (`adopt_branch() ( ... )`) in
|
||||||
|
# lib/adopt.sh so its EXIT trap stays contained — no helper needed here.
|
||||||
|
adopt_branch_quietly() {
|
||||||
|
adopt_branch >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Tree-mismatch surfacing ────────────────────────────────────────
|
||||||
|
|
||||||
|
@test "adopt_branch tree-mismatch error message points the user at the import PR" {
|
||||||
|
SUBREPO_BARE="$(make_bare_repo subrepo "subrepo content" "subrepo history")"
|
||||||
|
JOSH_BARE="$(make_bare_repo josh "josh content" "josh filtered history")"
|
||||||
|
|
||||||
|
local combined
|
||||||
|
combined=$(adopt_branch 2>&1)
|
||||||
|
|
||||||
|
[[ "$combined" == *"merge the import PR"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "adopt_branch tree-mismatch creates no commit on the subrepo (clean abort)" {
|
||||||
|
SUBREPO_BARE="$(make_bare_repo subrepo "subrepo content" "subrepo history")"
|
||||||
|
JOSH_BARE="$(make_bare_repo josh "josh content" "josh filtered history")"
|
||||||
|
|
||||||
|
local before_count before_head after_count after_head
|
||||||
|
before_count=$(git --git-dir="$SUBREPO_BARE" rev-list --count main)
|
||||||
|
before_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
|
||||||
|
|
||||||
|
adopt_branch_quietly 2>&1
|
||||||
|
|
||||||
|
after_count=$(git --git-dir="$SUBREPO_BARE" rev-list --count main)
|
||||||
|
after_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
|
||||||
|
[ "$before_count" = "$after_count" ]
|
||||||
|
[ "$before_head" = "$after_head" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Idempotency ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@test "adopt_branch is idempotent — second invocation returns already-adopted, no new commit" {
|
||||||
|
SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo history")"
|
||||||
|
JOSH_BARE="$(make_bare_repo josh "same tree" "josh filtered history")"
|
||||||
|
|
||||||
|
local first_result first_head first_count
|
||||||
|
first_result=$(adopt_branch)
|
||||||
|
first_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
|
||||||
|
first_count=$(git --git-dir="$SUBREPO_BARE" rev-list --count main)
|
||||||
|
|
||||||
|
local second_result second_head second_count
|
||||||
|
second_result=$(adopt_branch)
|
||||||
|
second_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
|
||||||
|
second_count=$(git --git-dir="$SUBREPO_BARE" rev-list --count main)
|
||||||
|
|
||||||
|
[ "$first_result" = "adopted" ]
|
||||||
|
[ "$second_result" = "already-adopted" ]
|
||||||
|
[ "$first_head" = "$second_head" ]
|
||||||
|
[ "$first_count" = "$second_count" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Merge commit shape ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@test "adopt_branch merge carries the configured bot trailer (ADR-005)" {
|
||||||
|
SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo history")"
|
||||||
|
JOSH_BARE="$(make_bare_repo josh "same tree" "josh filtered history")"
|
||||||
|
|
||||||
|
adopt_branch_quietly
|
||||||
|
|
||||||
|
local merge_msg
|
||||||
|
merge_msg=$(git --git-dir="$SUBREPO_BARE" log -1 --format=%B main)
|
||||||
|
[[ "$merge_msg" == *"${BOT_TRAILER}: adopt/${SYNC_BRANCH_MONO}/"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "adopt_branch merge places josh-filtered HEAD as parent 1 — first-parent walk reaches monorepo (ADR-008)" {
|
||||||
|
SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo c1")"
|
||||||
|
add_commit_to_bare "$SUBREPO_BARE" "subrepo c2"
|
||||||
|
|
||||||
|
JOSH_BARE="$(make_bare_repo josh "same tree" "josh c1")"
|
||||||
|
add_commit_to_bare "$JOSH_BARE" "josh c2"
|
||||||
|
|
||||||
|
adopt_branch_quietly
|
||||||
|
|
||||||
|
# First-parent walk from the adoption merge follows the josh chain (parent 1)
|
||||||
|
# and never visits subrepo-only history. If parent ordering ever flipped, the
|
||||||
|
# subrepo commits would appear here — which is exactly the failure mode that
|
||||||
|
# corrupted the monorepo branch in ADR-008.
|
||||||
|
local first_parent_msgs
|
||||||
|
first_parent_msgs=$(git --git-dir="$SUBREPO_BARE" log --first-parent --format=%s main)
|
||||||
|
[[ "$first_parent_msgs" == *"josh c1"* ]]
|
||||||
|
[[ "$first_parent_msgs" == *"josh c2"* ]]
|
||||||
|
[[ "$first_parent_msgs" != *"subrepo c1"* ]]
|
||||||
|
[[ "$first_parent_msgs" != *"subrepo c2"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Fast-forward push semantics ────────────────────────────────────
|
||||||
|
|
||||||
|
@test "adopt_branch fast-forwards without --force, leaving old subrepo HEAD reachable" {
|
||||||
|
SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo history")"
|
||||||
|
JOSH_BARE="$(make_bare_repo josh "same tree" "josh filtered history")"
|
||||||
|
|
||||||
|
local old_head result new_head
|
||||||
|
old_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
|
||||||
|
result=$(adopt_branch)
|
||||||
|
new_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
|
||||||
|
|
||||||
|
[ "$result" = "adopted" ]
|
||||||
|
[ "$new_head" != "$old_head" ]
|
||||||
|
# The original subrepo HEAD must still be an ancestor of the new HEAD —
|
||||||
|
# nothing was rewritten. This is what makes the push a real fast-forward.
|
||||||
|
git --git-dir="$SUBREPO_BARE" merge-base --is-ancestor "$old_head" "$new_head"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "adopt_branch surfaces push-rejected when the bare subrepo rejects the push (simulated 'subrepo moved')" {
|
||||||
|
SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo history")"
|
||||||
|
JOSH_BARE="$(make_bare_repo josh "same tree" "josh filtered history")"
|
||||||
|
|
||||||
|
# Equivalent observable behaviour to "subrepo HEAD changed mid-flight": the
|
||||||
|
# fast-forward push fails. Install a pre-receive hook that rejects
|
||||||
|
# deterministically — adopt_branch's else-branch should surface push-rejected.
|
||||||
|
printf '%s\n' \
|
||||||
|
'#!/usr/bin/env bash' \
|
||||||
|
'echo "subrepo changed during adoption" >&2' \
|
||||||
|
'exit 1' \
|
||||||
|
> "${SUBREPO_BARE}/hooks/pre-receive"
|
||||||
|
chmod +x "${SUBREPO_BARE}/hooks/pre-receive"
|
||||||
|
|
||||||
|
local before_head result after_head
|
||||||
|
before_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
|
||||||
|
result=$(adopt_branch 2>/dev/null)
|
||||||
|
after_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
|
||||||
|
|
||||||
|
[ "$result" = "push-rejected" ]
|
||||||
|
[ "$before_head" = "$after_head" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Reverse-sync loop guard (ADR-005) ──────────────────────────────
|
||||||
|
|
||||||
|
@test "adoption merge is invisible to reverse-sync bot-trailer filter (no re-ingestion loop)" {
|
||||||
|
SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo history")"
|
||||||
|
JOSH_BARE="$(make_bare_repo josh "same tree" "josh filtered history")"
|
||||||
|
|
||||||
|
local josh_head
|
||||||
|
josh_head=$(git --git-dir="$JOSH_BARE" rev-parse main)
|
||||||
|
|
||||||
|
adopt_branch_quietly
|
||||||
|
|
||||||
|
# Mirror reverse_sync's exact query (lib/sync.sh):
|
||||||
|
# base = merge-base(mono-filtered/<branch>, HEAD)
|
||||||
|
# human_commits = git log --ancestry-path "$base..HEAD" \
|
||||||
|
# --invert-grep --grep="^${BOT_TRAILER}:"
|
||||||
|
# --ancestry-path is critical: without it, the subrepo's parent-2 orphan
|
||||||
|
# commit (no trailer, real user commit) would show up as a "human commit"
|
||||||
|
# and reverse sync would try to re-ingest the adoption merge's other side.
|
||||||
|
local merge_sha base human_commits
|
||||||
|
merge_sha=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
|
||||||
|
base=$(git --git-dir="$SUBREPO_BARE" merge-base "$josh_head" "$merge_sha")
|
||||||
|
human_commits=$(git --git-dir="$SUBREPO_BARE" log \
|
||||||
|
--ancestry-path "${base}..${merge_sha}" \
|
||||||
|
--invert-grep --grep="^${BOT_TRAILER}:" \
|
||||||
|
--oneline)
|
||||||
|
|
||||||
|
[ -z "$human_commits" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Linearize fallback (ADR-011) ───────────────────────────────────
|
||||||
|
|
||||||
|
@test "linearize-fallback commit selection excludes the adopt merge but keeps human commits on top" {
|
||||||
|
SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo history")"
|
||||||
|
JOSH_BARE="$(make_bare_repo josh "same tree" "josh filtered history")"
|
||||||
|
|
||||||
|
local josh_head
|
||||||
|
josh_head=$(git --git-dir="$JOSH_BARE" rev-parse main)
|
||||||
|
|
||||||
|
adopt_branch_quietly
|
||||||
|
|
||||||
|
# Real human commit on top of the adoption merge.
|
||||||
|
add_commit_to_bare "$SUBREPO_BARE" "human: add note" "human change"
|
||||||
|
|
||||||
|
# Replicate reverse_sync's linearize walk exactly (lib/sync.sh):
|
||||||
|
# base = merge-base(mono-filtered/<branch>, HEAD)
|
||||||
|
# commits = git log --ancestry-path "$base..HEAD" --reverse \
|
||||||
|
# --format=%H --invert-grep --grep="^${BOT_TRAILER}:"
|
||||||
|
# With messy upstream (adopt-merge in the path), linearize must skip the
|
||||||
|
# merge (trailer-filtered) and cherry-pick only the human commit.
|
||||||
|
local work base picked
|
||||||
|
work="${TEST_ROOT}/linearize-walk"
|
||||||
|
git clone "$SUBREPO_BARE" "$work" >/dev/null 2>&1
|
||||||
|
base=$(git -C "$work" merge-base "$josh_head" HEAD)
|
||||||
|
picked=$(git -C "$work" log --ancestry-path "${base}..HEAD" \
|
||||||
|
--reverse --format=%s --invert-grep --grep="^${BOT_TRAILER}:")
|
||||||
|
|
||||||
|
[[ "$picked" == *"human: add note"* ]]
|
||||||
|
[[ "$picked" != *"Adopt ${JOSH_SYNC_TARGET_NAME} for josh-sync"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Checkpoint / resume (ADR-010) — strategy=adopt path through onboard_flow
|
||||||
|
|
||||||
|
# Stubs that run inside $(...) subshells can't increment shell variables in
|
||||||
|
# the parent. Track invocations via append-only files instead and count bytes.
|
||||||
|
call_log() { printf '.' >> "${TEST_ROOT}/${1}.log"; }
|
||||||
|
call_count() {
|
||||||
|
local f="${TEST_ROOT}/${1}.log"
|
||||||
|
if [ -f "$f" ]; then wc -c < "$f" | tr -d ' '; else echo 0; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "write_onboard_state then read_onboard_state round-trips the JSON" {
|
||||||
|
setup_state_monorepo
|
||||||
|
|
||||||
|
write_onboard_state "$JOSH_SYNC_TARGET_NAME" \
|
||||||
|
'{"step":"adopting","strategy":"adopt","import_prs":{"main":42},"adopted_branches":[]}'
|
||||||
|
|
||||||
|
local got
|
||||||
|
got=$(read_onboard_state "$JOSH_SYNC_TARGET_NAME")
|
||||||
|
[ "$(echo "$got" | jq -r '.step')" = "adopting" ]
|
||||||
|
[ "$(echo "$got" | jq -r '.strategy')" = "adopt" ]
|
||||||
|
[ "$(echo "$got" | jq -r '.import_prs.main')" = "42" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "read_onboard_state falls back to legacy <target>/adopt.json with implicit strategy=adopt" {
|
||||||
|
setup_state_monorepo
|
||||||
|
|
||||||
|
# Simulate a state branch where only the v2.1-era adopt.json exists (e.g.,
|
||||||
|
# an adoption started before the unification). Write directly to the state
|
||||||
|
# branch under the legacy filename, without the new strategy field.
|
||||||
|
local tmp
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
git worktree add --detach "$tmp" >/dev/null 2>&1
|
||||||
|
( cd "$tmp" && git checkout -q --orphan "$STATE_BRANCH" && { git rm -rfq . 2>/dev/null || true; }
|
||||||
|
mkdir -p "$JOSH_SYNC_TARGET_NAME"
|
||||||
|
printf '%s\n' '{"step":"adopting","import_prs":{},"adopted_branches":[]}' \
|
||||||
|
> "${JOSH_SYNC_TARGET_NAME}/adopt.json"
|
||||||
|
git add -A
|
||||||
|
git -c user.name="$BOT_NAME" -c user.email="$BOT_EMAIL" \
|
||||||
|
commit -q -m "legacy adopt state"
|
||||||
|
git push -q origin "HEAD:${STATE_BRANCH}" )
|
||||||
|
git worktree remove "$tmp" 2>/dev/null || rm -rf "$tmp"
|
||||||
|
|
||||||
|
local got
|
||||||
|
got=$(read_onboard_state "$JOSH_SYNC_TARGET_NAME")
|
||||||
|
[ "$(echo "$got" | jq -r '.step')" = "adopting" ]
|
||||||
|
[ "$(echo "$got" | jq -r '.strategy')" = "adopt" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "onboard_flow resumes from step=importing (strategy=adopt) — runs import, falls through wait, finalizes via adopt_branch" {
|
||||||
|
setup_state_monorepo
|
||||||
|
write_onboard_state "$JOSH_SYNC_TARGET_NAME" \
|
||||||
|
'{"step":"importing","strategy":"adopt","import_prs":{},"adopted_branches":[]}'
|
||||||
|
|
||||||
|
initial_import() { call_log import; echo "skip"; }
|
||||||
|
adopt_branch() { call_log adopt; echo "adopted"; }
|
||||||
|
subrepo_reset() { call_log reset; echo "reset"; }
|
||||||
|
get_pr() { echo '{"merged":true}'; }
|
||||||
|
list_open_prs() { echo '[]'; }
|
||||||
|
|
||||||
|
local target_json='{"name":"example","branches":{"main":"main"}}'
|
||||||
|
onboard_flow "$target_json" "false" "" >/dev/null 2>&1
|
||||||
|
|
||||||
|
[ "$(call_count import)" = "1" ]
|
||||||
|
[ "$(call_count adopt)" = "1" ]
|
||||||
|
[ "$(call_count reset)" = "0" ]
|
||||||
|
local final
|
||||||
|
final=$(read_onboard_state "$JOSH_SYNC_TARGET_NAME")
|
||||||
|
[ "$(echo "$final" | jq -r '.step')" = "complete" ]
|
||||||
|
[ "$(echo "$final" | jq -r '.strategy')" = "adopt" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "onboard_flow resumes from step=waiting-for-merge (strategy=adopt) — confirms merged PR, finalizes via adopt_branch" {
|
||||||
|
setup_state_monorepo
|
||||||
|
write_onboard_state "$JOSH_SYNC_TARGET_NAME" \
|
||||||
|
'{"step":"waiting-for-merge","strategy":"adopt","import_prs":{"main":42},"adopted_branches":[]}'
|
||||||
|
|
||||||
|
initial_import() { call_log import; echo "skip"; }
|
||||||
|
adopt_branch() { call_log adopt; echo "adopted"; }
|
||||||
|
subrepo_reset() { call_log reset; echo "reset"; }
|
||||||
|
get_pr() { echo '{"merged":true}'; }
|
||||||
|
list_open_prs() { echo '[]'; }
|
||||||
|
export MONOREPO_API="http://stub" GITEA_TOKEN="stub"
|
||||||
|
|
||||||
|
local target_json='{"name":"example","branches":{"main":"main"}}'
|
||||||
|
onboard_flow "$target_json" "false" "" <<< "" >/dev/null 2>&1
|
||||||
|
|
||||||
|
[ "$(call_count import)" = "0" ]
|
||||||
|
[ "$(call_count adopt)" = "1" ]
|
||||||
|
[ "$(call_count reset)" = "0" ]
|
||||||
|
local final
|
||||||
|
final=$(read_onboard_state "$JOSH_SYNC_TARGET_NAME")
|
||||||
|
[ "$(echo "$final" | jq -r '.step')" = "complete" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "onboard_flow resumes from step=adopting (strategy=adopt) — skips importing and waiting-for-merge entirely" {
|
||||||
|
setup_state_monorepo
|
||||||
|
write_onboard_state "$JOSH_SYNC_TARGET_NAME" \
|
||||||
|
'{"step":"adopting","strategy":"adopt","import_prs":{},"adopted_branches":[]}'
|
||||||
|
|
||||||
|
initial_import() { call_log import; echo "skip"; }
|
||||||
|
adopt_branch() { call_log adopt; echo "adopted"; }
|
||||||
|
subrepo_reset() { call_log reset; echo "reset"; }
|
||||||
|
get_pr() { echo '{"merged":true}'; }
|
||||||
|
list_open_prs() { echo '[]'; }
|
||||||
|
|
||||||
|
local target_json='{"name":"example","branches":{"main":"main"}}'
|
||||||
|
onboard_flow "$target_json" "false" "" >/dev/null 2>&1
|
||||||
|
|
||||||
|
[ "$(call_count import)" = "0" ]
|
||||||
|
[ "$(call_count adopt)" = "1" ]
|
||||||
|
[ "$(call_count reset)" = "0" ]
|
||||||
|
local final
|
||||||
|
final=$(read_onboard_state "$JOSH_SYNC_TARGET_NAME")
|
||||||
|
[ "$(echo "$final" | jq -r '.step')" = "complete" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "onboard_flow with step=complete is a no-op (no import, no finalize)" {
|
||||||
|
setup_state_monorepo
|
||||||
|
write_onboard_state "$JOSH_SYNC_TARGET_NAME" \
|
||||||
|
'{"step":"complete","strategy":"adopt","import_prs":{},"adopted_branches":["main"]}'
|
||||||
|
|
||||||
|
initial_import() { call_log import; echo "skip"; }
|
||||||
|
adopt_branch() { call_log adopt; echo "adopted"; }
|
||||||
|
subrepo_reset() { call_log reset; echo "reset"; }
|
||||||
|
|
||||||
|
local target_json='{"name":"example","branches":{"main":"main"}}'
|
||||||
|
onboard_flow "$target_json" "false" "" >/dev/null 2>&1
|
||||||
|
|
||||||
|
[ "$(call_count import)" = "0" ]
|
||||||
|
[ "$(call_count adopt)" = "0" ]
|
||||||
|
[ "$(call_count reset)" = "0" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "onboard_flow --restart with explicit strategy=adopt wipes state and re-runs the full sequence" {
|
||||||
|
setup_state_monorepo
|
||||||
|
write_onboard_state "$JOSH_SYNC_TARGET_NAME" \
|
||||||
|
'{"step":"complete","strategy":"adopt","import_prs":{"main":7},"adopted_branches":["main"]}'
|
||||||
|
|
||||||
|
initial_import() { call_log import; echo "skip"; }
|
||||||
|
adopt_branch() { call_log adopt; echo "adopted"; }
|
||||||
|
subrepo_reset() { call_log reset; echo "reset"; }
|
||||||
|
get_pr() { echo '{"merged":true}'; }
|
||||||
|
list_open_prs() { echo '[]'; }
|
||||||
|
|
||||||
|
local target_json='{"name":"example","branches":{"main":"main"}}'
|
||||||
|
onboard_flow "$target_json" "true" "adopt" >/dev/null 2>&1
|
||||||
|
|
||||||
|
[ "$(call_count import)" = "1" ]
|
||||||
|
[ "$(call_count adopt)" = "1" ]
|
||||||
|
[ "$(call_count reset)" = "0" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Goal 2: strategy resolution precedence ─────────────────────────
|
||||||
|
|
||||||
|
@test "resolve_onboard_strategy honours explicit --mode flag above config and auto-detect" {
|
||||||
|
# Config says preserve (would map to adopt); flag should win.
|
||||||
|
local target_json='{"name":"x","history_lock":"preserve"}'
|
||||||
|
local picked
|
||||||
|
picked=$(resolve_onboard_strategy "$target_json" "reset")
|
||||||
|
[ "$picked" = "reset" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "resolve_onboard_strategy maps history_lock=preserve to adopt" {
|
||||||
|
local target_json='{"name":"x","history_lock":"preserve"}'
|
||||||
|
local picked
|
||||||
|
picked=$(resolve_onboard_strategy "$target_json" "")
|
||||||
|
[ "$picked" = "adopt" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "resolve_onboard_strategy maps history_lock=rewrite to reset" {
|
||||||
|
local target_json='{"name":"x","history_lock":"rewrite"}'
|
||||||
|
local picked
|
||||||
|
picked=$(resolve_onboard_strategy "$target_json" "")
|
||||||
|
[ "$picked" = "reset" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "resolve_onboard_strategy auto-detects adopt when subrepo has any branches" {
|
||||||
|
SUBREPO_BARE="$(make_bare_repo subrepo "any" "any")"
|
||||||
|
local target_json='{"name":"x"}'
|
||||||
|
local picked
|
||||||
|
picked=$(resolve_onboard_strategy "$target_json" "")
|
||||||
|
[ "$picked" = "adopt" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "resolve_onboard_strategy auto-detects reset when subrepo has no branches (empty repo)" {
|
||||||
|
SUBREPO_BARE="${TEST_ROOT}/empty.git"
|
||||||
|
git init -q --bare "$SUBREPO_BARE"
|
||||||
|
local target_json='{"name":"x"}'
|
||||||
|
local picked
|
||||||
|
picked=$(resolve_onboard_strategy "$target_json" "")
|
||||||
|
[ "$picked" = "reset" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "resolve_onboard_strategy rejects unknown --mode value" {
|
||||||
|
local target_json='{"name":"x"}'
|
||||||
|
run resolve_onboard_strategy "$target_json" "weird"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"Invalid --mode"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "resolve_onboard_strategy rejects unknown history_lock value" {
|
||||||
|
local target_json='{"name":"x","history_lock":"freeze"}'
|
||||||
|
run resolve_onboard_strategy "$target_json" ""
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"Invalid history_lock"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Goal 2: onboard_flow with strategy=reset (regression-preserving) ──
|
||||||
|
|
||||||
|
@test "onboard_flow resumes from step=resetting (strategy=reset) — finalizes via subrepo_reset" {
|
||||||
|
setup_state_monorepo
|
||||||
|
write_onboard_state "$JOSH_SYNC_TARGET_NAME" \
|
||||||
|
'{"step":"resetting","strategy":"reset","import_prs":{},"reset_branches":[]}'
|
||||||
|
|
||||||
|
initial_import() { call_log import; echo "skip"; }
|
||||||
|
adopt_branch() { call_log adopt; echo "adopted"; }
|
||||||
|
subrepo_reset() { call_log reset; echo "reset"; }
|
||||||
|
|
||||||
|
local target_json='{"name":"example","branches":{"main":"main"}}'
|
||||||
|
onboard_flow "$target_json" "false" "" >/dev/null 2>&1
|
||||||
|
|
||||||
|
[ "$(call_count import)" = "0" ]
|
||||||
|
[ "$(call_count adopt)" = "0" ]
|
||||||
|
[ "$(call_count reset)" = "1" ]
|
||||||
|
local final
|
||||||
|
final=$(read_onboard_state "$JOSH_SYNC_TARGET_NAME")
|
||||||
|
[ "$(echo "$final" | jq -r '.step')" = "complete" ]
|
||||||
|
[ "$(echo "$final" | jq -r '.strategy')" = "reset" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "onboard_flow finalize step (strategy=adopt) drives the real adopt_branch end-to-end against bare repos" {
|
||||||
|
# No stubbing of adopt_branch: this test exercises the full unified
|
||||||
|
# dispatch by pre-seeding state at step=adopting and letting onboard_flow
|
||||||
|
# call adopt_branch for real against local bare repos via the auth-URL
|
||||||
|
# shims. Closes the spec's end-to-end criterion for `onboard --mode=adopt`.
|
||||||
|
setup_state_monorepo
|
||||||
|
SUBREPO_BARE="$(make_bare_repo subrepo "same tree" "subrepo history")"
|
||||||
|
JOSH_BARE="$(make_bare_repo josh "same tree" "josh filtered history")"
|
||||||
|
|
||||||
|
local old_subrepo_head josh_head
|
||||||
|
old_subrepo_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
|
||||||
|
josh_head=$(git --git-dir="$JOSH_BARE" rev-parse main)
|
||||||
|
|
||||||
|
write_onboard_state "$JOSH_SYNC_TARGET_NAME" \
|
||||||
|
'{"step":"adopting","strategy":"adopt","import_prs":{},"adopted_branches":[]}'
|
||||||
|
|
||||||
|
local target_json='{"name":"example","branches":{"main":"main"}}'
|
||||||
|
onboard_flow "$target_json" "false" "" >/dev/null 2>&1
|
||||||
|
|
||||||
|
# 1. Subrepo HEAD advanced to a real adoption merge.
|
||||||
|
local new_subrepo_head
|
||||||
|
new_subrepo_head=$(git --git-dir="$SUBREPO_BARE" rev-parse main)
|
||||||
|
[ "$new_subrepo_head" != "$old_subrepo_head" ]
|
||||||
|
|
||||||
|
# 2. Merge shape matches ADR-013: parent 1 = josh_head, parent 2 = old subrepo head.
|
||||||
|
[ "$(git --git-dir="$SUBREPO_BARE" rev-parse 'main^1')" = "$josh_head" ]
|
||||||
|
[ "$(git --git-dir="$SUBREPO_BARE" rev-parse 'main^2')" = "$old_subrepo_head" ]
|
||||||
|
|
||||||
|
# 3. Merge message carries the ADR-005 bot trailer.
|
||||||
|
local merge_msg
|
||||||
|
merge_msg=$(git --git-dir="$SUBREPO_BARE" log -1 --format=%B main)
|
||||||
|
[[ "$merge_msg" == *"${BOT_TRAILER}: adopt/${SYNC_BRANCH_MONO}/"* ]]
|
||||||
|
|
||||||
|
# 4. State advanced to complete and recorded the adopted branch.
|
||||||
|
local final
|
||||||
|
final=$(read_onboard_state "$JOSH_SYNC_TARGET_NAME")
|
||||||
|
[ "$(echo "$final" | jq -r '.step')" = "complete" ]
|
||||||
|
[ "$(echo "$final" | jq -r '.strategy')" = "adopt" ]
|
||||||
|
[ "$(echo "$final" | jq -r '.adopted_branches[0]')" = "main" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Goal 2: legacy adopt.json → onboard.json migration on write ────
|
||||||
|
|
||||||
|
@test "after read_onboard_state falls back to legacy adopt.json, the next write lands in onboard.json" {
|
||||||
|
setup_state_monorepo
|
||||||
|
|
||||||
|
# Seed the state branch with ONLY the legacy file (no onboard.json yet).
|
||||||
|
local tmp
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
git worktree add --detach "$tmp" >/dev/null 2>&1
|
||||||
|
( cd "$tmp" && git checkout -q --orphan "$STATE_BRANCH" && { git rm -rfq . 2>/dev/null || true; }
|
||||||
|
mkdir -p "$JOSH_SYNC_TARGET_NAME"
|
||||||
|
printf '%s\n' '{"step":"adopting","import_prs":{},"adopted_branches":[]}' \
|
||||||
|
> "${JOSH_SYNC_TARGET_NAME}/adopt.json"
|
||||||
|
git add -A
|
||||||
|
git -c user.name="$BOT_NAME" -c user.email="$BOT_EMAIL" \
|
||||||
|
commit -q -m "legacy adopt state"
|
||||||
|
git push -q origin "HEAD:${STATE_BRANCH}" )
|
||||||
|
git worktree remove "$tmp" 2>/dev/null || rm -rf "$tmp"
|
||||||
|
|
||||||
|
# Read → mutate → write back via the unified helpers.
|
||||||
|
local state
|
||||||
|
state=$(read_onboard_state "$JOSH_SYNC_TARGET_NAME")
|
||||||
|
state=$(echo "$state" | jq '.step = "complete"')
|
||||||
|
write_onboard_state "$JOSH_SYNC_TARGET_NAME" "$state"
|
||||||
|
|
||||||
|
# Fetch a fresh copy of the state branch to see what's actually committed.
|
||||||
|
git fetch origin "$STATE_BRANCH" >/dev/null 2>&1
|
||||||
|
|
||||||
|
# onboard.json now exists with the updated step, strategy preserved.
|
||||||
|
local onboard_json
|
||||||
|
onboard_json=$(git show "origin/${STATE_BRANCH}:${JOSH_SYNC_TARGET_NAME}/onboard.json")
|
||||||
|
[ "$(echo "$onboard_json" | jq -r '.step')" = "complete" ]
|
||||||
|
[ "$(echo "$onboard_json" | jq -r '.strategy')" = "adopt" ]
|
||||||
|
|
||||||
|
# Legacy adopt.json is left in place (harmless; read fallback still resolves).
|
||||||
|
git show "origin/${STATE_BRANCH}:${JOSH_SYNC_TARGET_NAME}/adopt.json" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "onboard_flow importing step dies (does not warn-and-continue) when pr_number lookup fails — prevents duplicate import PR on resume" {
|
||||||
|
setup_state_monorepo
|
||||||
|
write_onboard_state "$JOSH_SYNC_TARGET_NAME" \
|
||||||
|
'{"step":"importing","strategy":"adopt","import_prs":{},"adopted_branches":[]}'
|
||||||
|
|
||||||
|
# initial_import claims a PR was created, but list_open_prs returns nothing
|
||||||
|
# matching → pr_number ends up empty. The fix turns the old WARN-and-continue
|
||||||
|
# into a die so a subsequent resume can't re-import and duplicate the PR.
|
||||||
|
initial_import() { echo "pr-created"; }
|
||||||
|
list_open_prs() { echo '[]'; }
|
||||||
|
adopt_branch() { echo "adopted"; }
|
||||||
|
export MONOREPO_API="http://stub" GITEA_TOKEN="stub"
|
||||||
|
|
||||||
|
local target_json='{"name":"example","branches":{"main":"main"}}'
|
||||||
|
run --separate-stderr onboard_flow "$target_json" "false" ""
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$stderr" == *"Could not find import PR number"* ]]
|
||||||
|
[[ "$stderr" == *"duplicate import PR"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "resolve_onboard_strategy dies (does not silently pick reset) when ls-remote against the subrepo fails" {
|
||||||
|
# Point auth at a nonexistent local path so ls-remote fails (exit non-zero).
|
||||||
|
SUBREPO_BARE="${TEST_ROOT}/this-does-not-exist.git"
|
||||||
|
local target_json='{"name":"x"}'
|
||||||
|
run --separate-stderr resolve_onboard_strategy "$target_json" ""
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$stderr" == *"Cannot auto-detect strategy"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "onboard_flow rejects --mode that conflicts with a strategy already saved in state" {
|
||||||
|
setup_state_monorepo
|
||||||
|
write_onboard_state "$JOSH_SYNC_TARGET_NAME" \
|
||||||
|
'{"step":"adopting","strategy":"adopt","import_prs":{},"adopted_branches":[]}'
|
||||||
|
|
||||||
|
initial_import() { echo "skip"; }
|
||||||
|
adopt_branch() { echo "adopted"; }
|
||||||
|
subrepo_reset() { echo "reset"; }
|
||||||
|
|
||||||
|
local target_json='{"name":"example","branches":{"main":"main"}}'
|
||||||
|
run --separate-stderr onboard_flow "$target_json" "false" "reset"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$stderr" == *"Saved strategy is 'adopt'"* ]]
|
||||||
|
[[ "$stderr" == *"--restart"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "read_onboard_state injects strategy=reset for legacy v2.1 onboard.json (no .strategy field)" {
|
||||||
|
setup_state_monorepo
|
||||||
|
|
||||||
|
# Seed only a legacy-format onboard.json (no .strategy field).
|
||||||
|
local tmp
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
git worktree add --detach "$tmp" >/dev/null 2>&1
|
||||||
|
( cd "$tmp" && git checkout -q --orphan "$STATE_BRANCH" && { git rm -rfq . 2>/dev/null || true; }
|
||||||
|
mkdir -p "$JOSH_SYNC_TARGET_NAME"
|
||||||
|
printf '%s\n' '{"step":"resetting","import_prs":{"main":42},"reset_branches":["main"]}' \
|
||||||
|
> "${JOSH_SYNC_TARGET_NAME}/onboard.json"
|
||||||
|
git add -A
|
||||||
|
git -c user.name="$BOT_NAME" -c user.email="$BOT_EMAIL" \
|
||||||
|
commit -q -m "legacy onboard state"
|
||||||
|
git push -q origin "HEAD:${STATE_BRANCH}" )
|
||||||
|
git worktree remove "$tmp" 2>/dev/null || rm -rf "$tmp"
|
||||||
|
|
||||||
|
local got
|
||||||
|
got=$(read_onboard_state "$JOSH_SYNC_TARGET_NAME")
|
||||||
|
[ "$(echo "$got" | jq -r '.step')" = "resetting" ]
|
||||||
|
[ "$(echo "$got" | jq -r '.strategy')" = "reset" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "onboard_flow resumes from step=waiting-for-merge (strategy=reset) — transitions to resetting" {
|
||||||
|
setup_state_monorepo
|
||||||
|
write_onboard_state "$JOSH_SYNC_TARGET_NAME" \
|
||||||
|
'{"step":"waiting-for-merge","strategy":"reset","import_prs":{"main":42},"reset_branches":[],"archived_url":"git@host:org/old.git","archived_api":"https://host/api/v1/repos/org/old","archived_auth":"https"}'
|
||||||
|
|
||||||
|
initial_import() { call_log import; echo "skip"; }
|
||||||
|
adopt_branch() { call_log adopt; echo "adopted"; }
|
||||||
|
subrepo_reset() { call_log reset; echo "reset"; }
|
||||||
|
get_pr() { echo '{"merged":true}'; }
|
||||||
|
list_open_prs() { echo '[]'; }
|
||||||
|
export MONOREPO_API="http://stub" GITEA_TOKEN="stub"
|
||||||
|
|
||||||
|
local target_json='{"name":"example","branches":{"main":"main"}}'
|
||||||
|
onboard_flow "$target_json" "false" "" <<< "" >/dev/null 2>&1
|
||||||
|
|
||||||
|
[ "$(call_count adopt)" = "0" ]
|
||||||
|
[ "$(call_count reset)" = "1" ]
|
||||||
|
}
|
||||||
@@ -37,3 +37,39 @@ setup() {
|
|||||||
url=$(subrepo_auth_url)
|
url=$(subrepo_auth_url)
|
||||||
[ "$url" = "git@gitea.test.local:ext/app-a.git" ]
|
[ "$url" = "git@gitea.test.local:ext/app-a.git" ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "mono_auth_url returns bare URL for SSH" {
|
||||||
|
export MONOREPO_AUTH="ssh"
|
||||||
|
export MONOREPO_URL="git@code.example.io:org/monorepo.git"
|
||||||
|
|
||||||
|
local url
|
||||||
|
url=$(mono_auth_url)
|
||||||
|
[ "$url" = "git@code.example.io:org/monorepo.git" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "mono_auth_url injects credentials for HTTPS git@-form monorepo_url" {
|
||||||
|
export MONOREPO_AUTH="https"
|
||||||
|
export MONOREPO_URL="git@code.example.io:org/monorepo.git"
|
||||||
|
|
||||||
|
local url
|
||||||
|
url=$(mono_auth_url)
|
||||||
|
[ "$url" = "https://sync-bot:test-token-123@code.example.io/org/monorepo.git" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "mono_auth_url injects credentials for HTTPS https-form monorepo_url" {
|
||||||
|
export MONOREPO_AUTH="https"
|
||||||
|
export MONOREPO_URL="https://code.example.io/org/monorepo.git"
|
||||||
|
|
||||||
|
local url
|
||||||
|
url=$(mono_auth_url)
|
||||||
|
[ "$url" = "https://sync-bot:test-token-123@code.example.io/org/monorepo.git" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "mono_auth_url defaults to HTTPS when MONOREPO_AUTH unset" {
|
||||||
|
unset MONOREPO_AUTH
|
||||||
|
export MONOREPO_URL="https://code.example.io/org/monorepo.git"
|
||||||
|
|
||||||
|
local url
|
||||||
|
url=$(mono_auth_url)
|
||||||
|
[ "$url" = "https://sync-bot:test-token-123@code.example.io/org/monorepo.git" ]
|
||||||
|
}
|
||||||
|
|||||||
123
tests/unit/cli.bats
Normal file
123
tests/unit/cli.bats
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env bats
|
||||||
|
|
||||||
|
bats_require_minimum_version 1.5.0
|
||||||
|
|
||||||
|
# tests/unit/cli.bats — bin/josh-sync CLI surface tests
|
||||||
|
#
|
||||||
|
# Invokes the CLI as a subprocess (not by sourcing libs) so flag parsing,
|
||||||
|
# command dispatch, help/usage text, and the `adopt → onboard --mode=adopt`
|
||||||
|
# alias regress cleanly. These tests deliberately stay at the parse/dispatch
|
||||||
|
# layer — anything that would reach out to git remotes is set up to fail at
|
||||||
|
# target-lookup time, before any side effects.
|
||||||
|
#
|
||||||
|
# Uses `run --separate-stderr` so we can assert on stderr separately (most of
|
||||||
|
# josh-sync's output is logged via core.sh's `log()` which writes to stderr).
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
JOSH_SYNC_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
|
||||||
|
JOSH_BIN="${JOSH_SYNC_ROOT}/bin/josh-sync"
|
||||||
|
FIXTURES="${JOSH_SYNC_ROOT}/tests/fixtures"
|
||||||
|
TEST_ROOT="$(mktemp -d)"
|
||||||
|
}
|
||||||
|
|
||||||
|
teardown() {
|
||||||
|
cd "$BATS_TEST_DIRNAME" 2>/dev/null || true
|
||||||
|
rm -rf "$TEST_ROOT"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Drop the minimal fixture config into a clean cwd. Most cmd_* paths read
|
||||||
|
# .josh-sync.yml from the working directory.
|
||||||
|
seed_minimal_config() {
|
||||||
|
cp "${FIXTURES}/minimal.yml" "${TEST_ROOT}/.josh-sync.yml"
|
||||||
|
cd "$TEST_ROOT"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Global flags ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@test "--version exits 0 and prints the version" {
|
||||||
|
run "$JOSH_BIN" --version
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"josh-sync"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "--help exits 0 and advertises onboard, the adopt alias, and --mode" {
|
||||||
|
run "$JOSH_BIN" --help
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
# Help text is printed to stderr via `cat >&2`, so combine with --separate.
|
||||||
|
run --separate-stderr "$JOSH_BIN" --help
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$stderr" == *"onboard <target>"* ]]
|
||||||
|
[[ "$stderr" == *"adopt <target>"* ]]
|
||||||
|
[[ "$stderr" == *"Alias for 'onboard --mode=adopt'"* ]]
|
||||||
|
[[ "$stderr" == *"--mode={reset,adopt}"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── onboard flag parsing & dispatch ────────────────────────────────
|
||||||
|
|
||||||
|
@test "onboard with no target exits non-zero and prints usage including --mode" {
|
||||||
|
seed_minimal_config
|
||||||
|
run --separate-stderr "$JOSH_BIN" onboard
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$stderr" == *"Usage: josh-sync onboard"* ]]
|
||||||
|
[[ "$stderr" == *"--mode=reset|adopt"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "onboard --mode=invalid is rejected with a clear error" {
|
||||||
|
seed_minimal_config
|
||||||
|
run --separate-stderr "$JOSH_BIN" onboard --mode=invalid example
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$stderr" == *"Invalid --mode"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "onboard --mode=adopt against an unknown target reports target-not-found" {
|
||||||
|
seed_minimal_config
|
||||||
|
# Proves --mode parses correctly: parse_config succeeds, then cmd_onboard
|
||||||
|
# fails at target lookup, before any git/network activity.
|
||||||
|
run --separate-stderr "$JOSH_BIN" onboard --mode=adopt nonexistent
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$stderr" == *"not found"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── adopt alias ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@test "adopt alias forwards to onboard --mode=adopt (visible via alias log line + target lookup)" {
|
||||||
|
seed_minimal_config
|
||||||
|
run --separate-stderr "$JOSH_BIN" adopt nonexistent
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
# Alias notice from cmd_adopt:
|
||||||
|
[[ "$stderr" == *"alias for: josh-sync onboard --mode=adopt"* ]]
|
||||||
|
# And the forwarded call lands in cmd_onboard's target lookup:
|
||||||
|
[[ "$stderr" == *"not found"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "onboard usage listing shows --restart and --mode flags together" {
|
||||||
|
seed_minimal_config
|
||||||
|
run --separate-stderr "$JOSH_BIN" onboard
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$stderr" == *"--restart"* ]]
|
||||||
|
[[ "$stderr" == *"--mode"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── --mode value-parsing guards ────────────────────────────────────
|
||||||
|
|
||||||
|
@test "onboard --mode (no value) fails with a helpful message instead of crashing on unbound \$2" {
|
||||||
|
seed_minimal_config
|
||||||
|
run --separate-stderr "$JOSH_BIN" onboard example --mode
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$stderr" == *"--mode requires a value"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "onboard --mode= (empty after equals) is rejected explicitly, not silently ignored" {
|
||||||
|
seed_minimal_config
|
||||||
|
run --separate-stderr "$JOSH_BIN" onboard example --mode=
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$stderr" == *"Empty --mode="* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "adopt alias dies when a conflicting --mode=reset is also passed (instead of silently honouring reset)" {
|
||||||
|
seed_minimal_config
|
||||||
|
run --separate-stderr "$JOSH_BIN" adopt example --mode=reset
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$stderr" == *"alias for --mode=adopt"* ]]
|
||||||
|
[[ "$stderr" == *"conflicting"* ]]
|
||||||
|
}
|
||||||
@@ -104,3 +104,151 @@ setup() {
|
|||||||
run bash -c 'source "$JOSH_SYNC_ROOT/lib/core.sh"; source "$JOSH_SYNC_ROOT/lib/config.sh"; parse_config "nonexistent.yml"'
|
run bash -c 'source "$JOSH_SYNC_ROOT/lib/core.sh"; source "$JOSH_SYNC_ROOT/lib/config.sh"; parse_config "nonexistent.yml"'
|
||||||
[ "$status" -ne 0 ]
|
[ "$status" -ne 0 ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "parse_config fails when schema_version is missing (no v1 fallback)" {
|
||||||
|
cd "$(mktemp -d)"
|
||||||
|
cat > .josh-sync.yml <<'YAML'
|
||||||
|
josh:
|
||||||
|
proxy_url: "https://josh.test.local"
|
||||||
|
monorepo_path: "org/repo"
|
||||||
|
monorepo_url: "git@gitea.test.local:org/repo.git"
|
||||||
|
targets:
|
||||||
|
- name: "x"
|
||||||
|
subfolder: "x"
|
||||||
|
subrepo_url: "git@gitea.test.local:ext/x.git"
|
||||||
|
branches: { main: main }
|
||||||
|
bot:
|
||||||
|
name: "b"
|
||||||
|
email: "b@b"
|
||||||
|
trailer: "T"
|
||||||
|
YAML
|
||||||
|
run bash -c 'source "$JOSH_SYNC_ROOT/lib/core.sh"; source "$JOSH_SYNC_ROOT/lib/config.sh"; parse_config ".josh-sync.yml"'
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"schema_version missing"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "parse_config rejects schema_version: 1" {
|
||||||
|
cd "$(mktemp -d)"
|
||||||
|
cat > .josh-sync.yml <<'YAML'
|
||||||
|
schema_version: 1
|
||||||
|
josh:
|
||||||
|
proxy_url: "https://josh.test.local"
|
||||||
|
monorepo_path: "org/repo"
|
||||||
|
monorepo_url: "git@gitea.test.local:org/repo.git"
|
||||||
|
targets:
|
||||||
|
- name: "x"
|
||||||
|
subfolder: "x"
|
||||||
|
subrepo_url: "git@gitea.test.local:ext/x.git"
|
||||||
|
branches: { main: main }
|
||||||
|
bot:
|
||||||
|
name: "b"
|
||||||
|
email: "b@b"
|
||||||
|
trailer: "T"
|
||||||
|
YAML
|
||||||
|
run bash -c 'source "$JOSH_SYNC_ROOT/lib/core.sh"; source "$JOSH_SYNC_ROOT/lib/config.sh"; parse_config ".josh-sync.yml"'
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"Unsupported schema_version"* ]] || [[ "$output" == *"requires schema_version 2"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "parse_config fails when josh.monorepo_url is missing" {
|
||||||
|
cd "$(mktemp -d)"
|
||||||
|
cat > .josh-sync.yml <<'YAML'
|
||||||
|
schema_version: 2
|
||||||
|
josh:
|
||||||
|
proxy_url: "https://josh.test.local"
|
||||||
|
monorepo_path: "org/repo"
|
||||||
|
targets:
|
||||||
|
- name: "x"
|
||||||
|
subfolder: "x"
|
||||||
|
subrepo_url: "git@gitea.test.local:ext/x.git"
|
||||||
|
branches: { main: main }
|
||||||
|
bot:
|
||||||
|
name: "b"
|
||||||
|
email: "b@b"
|
||||||
|
trailer: "T"
|
||||||
|
YAML
|
||||||
|
run bash -c 'source "$JOSH_SYNC_ROOT/lib/core.sh"; source "$JOSH_SYNC_ROOT/lib/config.sh"; parse_config ".josh-sync.yml"'
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"monorepo_url"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "parse_config exports MONOREPO_URL, MONOREPO_AUTH, MONOREPO_GITEA_HOST, MONOREPO_REPO_PATH from SSH form" {
|
||||||
|
cd "$(mktemp -d)"
|
||||||
|
cp "$FIXTURES/minimal.yml" .josh-sync.yml
|
||||||
|
|
||||||
|
parse_config ".josh-sync.yml"
|
||||||
|
|
||||||
|
[ "$MONOREPO_URL" = "git@gitea.test.local:org/repo.git" ]
|
||||||
|
[ "$MONOREPO_AUTH" = "https" ]
|
||||||
|
[ "$MONOREPO_GITEA_HOST" = "gitea.test.local" ]
|
||||||
|
[ "$MONOREPO_REPO_PATH" = "org/repo" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "parse_config derives MONOREPO_GITEA_HOST from HTTPS monorepo_url" {
|
||||||
|
cd "$(mktemp -d)"
|
||||||
|
cat > .josh-sync.yml <<'YAML'
|
||||||
|
schema_version: 2
|
||||||
|
josh:
|
||||||
|
proxy_url: "https://josh.test.local"
|
||||||
|
monorepo_path: "org/repo"
|
||||||
|
monorepo_url: "https://gitea.example.io/org/repo.git"
|
||||||
|
targets:
|
||||||
|
- name: "x"
|
||||||
|
subfolder: "x"
|
||||||
|
subrepo_url: "git@gitea.test.local:ext/x.git"
|
||||||
|
branches: { main: main }
|
||||||
|
bot:
|
||||||
|
name: "b"
|
||||||
|
email: "b@b"
|
||||||
|
trailer: "T"
|
||||||
|
YAML
|
||||||
|
parse_config ".josh-sync.yml"
|
||||||
|
[ "$MONOREPO_GITEA_HOST" = "gitea.example.io" ]
|
||||||
|
[ "$MONOREPO_REPO_PATH" = "org/repo" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "parse_config derives MONOREPO_API from monorepo_url's host (cross-host: monorepo and target on different hosts)" {
|
||||||
|
cd "$(mktemp -d)"
|
||||||
|
cat > .josh-sync.yml <<'YAML'
|
||||||
|
schema_version: 2
|
||||||
|
josh:
|
||||||
|
proxy_url: "https://josh.example.io"
|
||||||
|
monorepo_path: "org/repo"
|
||||||
|
monorepo_url: "git@code.example.io:org/repo.git"
|
||||||
|
targets:
|
||||||
|
- name: "x"
|
||||||
|
subfolder: "x"
|
||||||
|
subrepo_url: "git@other-host.io:ext/x.git"
|
||||||
|
branches: { main: main }
|
||||||
|
bot:
|
||||||
|
name: "b"
|
||||||
|
email: "b@b"
|
||||||
|
trailer: "T"
|
||||||
|
YAML
|
||||||
|
parse_config ".josh-sync.yml"
|
||||||
|
[ "$MONOREPO_API" = "https://code.example.io/api/v1/repos/org/repo" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "parse_config rejects invalid monorepo_auth value" {
|
||||||
|
cd "$(mktemp -d)"
|
||||||
|
cat > .josh-sync.yml <<'YAML'
|
||||||
|
schema_version: 2
|
||||||
|
josh:
|
||||||
|
proxy_url: "https://josh.test.local"
|
||||||
|
monorepo_path: "org/repo"
|
||||||
|
monorepo_url: "git@gitea.test.local:org/repo.git"
|
||||||
|
monorepo_auth: "weird"
|
||||||
|
targets:
|
||||||
|
- name: "x"
|
||||||
|
subfolder: "x"
|
||||||
|
subrepo_url: "git@gitea.test.local:ext/x.git"
|
||||||
|
branches: { main: main }
|
||||||
|
bot:
|
||||||
|
name: "b"
|
||||||
|
email: "b@b"
|
||||||
|
trailer: "T"
|
||||||
|
YAML
|
||||||
|
run bash -c 'source "$JOSH_SYNC_ROOT/lib/core.sh"; source "$JOSH_SYNC_ROOT/lib/config.sh"; parse_config ".josh-sync.yml"'
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ "$output" == *"monorepo_auth"* ]]
|
||||||
|
}
|
||||||
|
|||||||
90
tests/unit/sync.bats
Normal file
90
tests/unit/sync.bats
Normal 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$'
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user