Compare commits
3 Commits
v1.5.0
...
dc2cdea360
| Author | SHA1 | Date | |
|---|---|---|---|
| dc2cdea360 | |||
| f2e39be5e1 | |||
| b28662d3b4 |
81
CHANGELOG.md
81
CHANGELOG.md
@@ -1,5 +1,86 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
## 1.5.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/
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -81,6 +85,7 @@ CI workflows pin to a floating major tag (e.g. `@v1`). A breaking change bumps t
|
|||||||
josh-sync sync [--forward|--reverse] [--force] [--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]
|
||||||
@@ -93,6 +98,7 @@ josh-sync state reset <target> [branch]
|
|||||||
|
|
||||||
- **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): creates a PR by default. Add `--force` to bypass the PR and push directly to the mono branch (destructive).
|
- **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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -92,6 +100,9 @@ Sync flags:
|
|||||||
--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)
|
||||||
@@ -530,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() {
|
||||||
@@ -703,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
|
||||||
@@ -730,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 ──────────────────────────────────────────
|
||||||
@@ -886,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 "$@" ;;
|
||||||
|
|||||||
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
|
||||||
|
josh: # josh-proxy + monorepo settings (required)
|
||||||
targets: # sync targets (required, at least 1)
|
targets: # sync targets (required, at least 1)
|
||||||
bot: # bot identity for sync commits (required)
|
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)
|
||||||
@@ -616,12 +658,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 ───────────────────────────────────────────
|
||||||
@@ -29,13 +30,24 @@ subrepo_auth_url() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ─── Monorepo Auth URL ─────────────────────────────────────────────
|
# ─── Monorepo Auth URL ─────────────────────────────────────────────
|
||||||
# Derives a push-capable HTTPS URL from MONOREPO_API.
|
# HTTPS: injects user:token into MONOREPO_URL (auto-converts git@ form to https)
|
||||||
# MONOREPO_API shape: https://<host>/api/v1/repos/<org/repo>
|
# 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() {
|
mono_auth_url() {
|
||||||
local api_host_path
|
if [ "${MONOREPO_AUTH:-https}" = "ssh" ]; then
|
||||||
api_host_path=$(echo "$MONOREPO_API" | sed 's|https://||; s|/api/v1/repos/|/|')
|
echo "$MONOREPO_URL"
|
||||||
echo "https://${BOT_USER}:${GITEA_TOKEN}@${api_host_path}.git"
|
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 ─────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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)"
|
||||||
}
|
}
|
||||||
|
|||||||
373
lib/onboard.sh
373
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,38 +191,57 @@ _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 "" >&2
|
||||||
|
|
||||||
|
if [ "$strategy" = "reset" ]; then
|
||||||
echo "Before proceeding, you should have:" >&2
|
echo "Before proceeding, you should have:" >&2
|
||||||
echo " 1. Renamed the existing subrepo (e.g., storefront → storefront-archived)" >&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. 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 git ls-remote "$(subrepo_auth_url)" >/dev/null 2>&1; then
|
if git ls-remote "$(subrepo_auth_url)" >/dev/null 2>&1; then
|
||||||
# shellcheck disable=SC2001 # sed is clearer for URL pattern replacement
|
# shellcheck disable=SC2001 # sed is clearer for URL pattern replacement
|
||||||
log "INFO" "New subrepo is reachable at $(echo "$SUBREPO_URL" | sed 's|://[^@]*@|://***@|')"
|
log "INFO" "New subrepo is reachable at $(echo "$SUBREPO_URL" | sed 's|://[^@]*@|://***@|')"
|
||||||
@@ -124,14 +254,10 @@ onboard_flow() {
|
|||||||
read -r archived_url
|
read -r archived_url
|
||||||
[ -n "$archived_url" ] || die "Archived URL is required"
|
[ -n "$archived_url" ] || die "Archived URL is required"
|
||||||
|
|
||||||
# Determine auth type for archived repo (same as current subrepo)
|
|
||||||
local archived_auth="${SUBREPO_AUTH:-https}"
|
local archived_auth="${SUBREPO_AUTH:-https}"
|
||||||
|
|
||||||
# Derive API URL
|
|
||||||
local archived_api
|
local archived_api
|
||||||
archived_api=$(_archived_api_from_url "$archived_url")
|
archived_api=$(_archived_api_from_url "$archived_url")
|
||||||
|
|
||||||
# Verify archived repo is reachable via API
|
|
||||||
if curl -sf -H "Authorization: token ${SUBREPO_TOKEN}" \
|
if curl -sf -H "Authorization: token ${SUBREPO_TOKEN}" \
|
||||||
"${archived_api}" >/dev/null 2>&1; then
|
"${archived_api}" >/dev/null 2>&1; then
|
||||||
log "INFO" "Archived repo reachable: ${archived_api}"
|
log "INFO" "Archived repo reachable: ${archived_api}"
|
||||||
@@ -143,49 +269,60 @@ onboard_flow() {
|
|||||||
[ "$confirm" = "y" ] || [ "$confirm" = "Y" ] || die "Aborted"
|
[ "$confirm" = "y" ] || [ "$confirm" = "Y" ] || die "Aborted"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Save state
|
state=$(jq -n \
|
||||||
onboard_state=$(jq -n \
|
|
||||||
--arg step "importing" \
|
--arg step "importing" \
|
||||||
|
--arg strategy "reset" \
|
||||||
--arg archived_api "$archived_api" \
|
--arg archived_api "$archived_api" \
|
||||||
--arg archived_url "$archived_url" \
|
--arg archived_url "$archived_url" \
|
||||||
--arg archived_auth "$archived_auth" \
|
--arg archived_auth "$archived_auth" \
|
||||||
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
'{step:$step, archived_api:$archived_api, archived_url:$archived_url,
|
'{step:$step, strategy:$strategy, archived_api:$archived_api,
|
||||||
archived_auth:$archived_auth, import_prs:{}, reset_branches:[],
|
archived_url:$archived_url, archived_auth:$archived_auth,
|
||||||
migrated_prs:[], timestamp:$ts}')
|
import_prs:{}, reset_branches:[], migrated_prs:[], timestamp:$ts}')
|
||||||
write_onboard_state "$target_name" "$onboard_state"
|
else
|
||||||
|
# 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
|
||||||
|
|
||||||
|
write_onboard_state "$target_name" "$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.
|
|
||||||
local archived_url archived_clone_url
|
|
||||||
archived_url=$(echo "$onboard_state" | jq -r '.archived_url')
|
|
||||||
if [ "${SUBREPO_AUTH:-https}" = "ssh" ]; then
|
if [ "${SUBREPO_AUTH:-https}" = "ssh" ]; then
|
||||||
archived_clone_url="$archived_url"
|
archived_clone_url="$archived_url"
|
||||||
else
|
else
|
||||||
# shellcheck disable=SC2001
|
# shellcheck disable=SC2001
|
||||||
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
|
||||||
|
fi
|
||||||
|
|
||||||
for branch in $branches; do
|
for branch in $branches; do
|
||||||
local mapped
|
local mapped
|
||||||
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
|
||||||
|
if [ -n "$archived_clone_url" ]; then
|
||||||
result=$(initial_import "$archived_clone_url")
|
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,6 +516,7 @@ onboard_flow() {
|
|||||||
echo "" >&2
|
echo "" >&2
|
||||||
echo "=== Onboarding complete! ===" >&2
|
echo "=== Onboarding complete! ===" >&2
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
|
if [ "$strategy" = "reset" ]; then
|
||||||
echo "The new subrepo now has josh-filtered history." >&2
|
echo "The new subrepo now has josh-filtered history." >&2
|
||||||
echo "Developers should re-clone or reset their local copies:" >&2
|
echo "Developers should re-clone or reset their local copies:" >&2
|
||||||
echo " git fetch origin && git reset --hard origin/main" >&2
|
echo " git fetch origin && git reset --hard origin/main" >&2
|
||||||
@@ -332,6 +525,11 @@ onboard_flow() {
|
|||||||
echo " josh-sync migrate-pr ${target_name} # interactive picker" >&2
|
echo " josh-sync migrate-pr ${target_name} # interactive picker" >&2
|
||||||
echo " josh-sync migrate-pr ${target_name} --all # migrate all" >&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
|
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 \
|
||||||
|
|||||||
@@ -475,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"
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
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"* ]]
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user