"#"
This commit is contained in:
16
CHANGELOG.md
16
CHANGELOG.md
@@ -1,5 +1,21 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
## 2.0.0
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -23,7 +23,7 @@ dist/josh-sync: bin/josh-sync lib/*.sh VERSION
|
|||||||
@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; \
|
||||||
|
|||||||
@@ -85,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]
|
||||||
@@ -97,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,6 +8,7 @@
|
|||||||
# 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
|
||||||
|
# adopt <target> Adopt existing subrepo history without rewriting it
|
||||||
# reset <target> Reset subrepo to josh-filtered view
|
# reset <target> Reset subrepo to josh-filtered view
|
||||||
# onboard <target> Import existing subrepo into monorepo (interactive)
|
# 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
|
||||||
@@ -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,6 +75,7 @@ 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)
|
||||||
|
adopt <target> Adopt existing subrepo history without rewriting it
|
||||||
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)
|
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
|
||||||
@@ -530,6 +534,42 @@ cmd_import() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ─── Adopt Command ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
cmd_adopt() {
|
||||||
|
local config_file=".josh-sync.yml"
|
||||||
|
local target_name=""
|
||||||
|
local restart=false
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--config) config_file="$2"; shift 2 ;;
|
||||||
|
--debug) export JOSH_SYNC_DEBUG=1; shift ;;
|
||||||
|
--restart) restart=true; shift ;;
|
||||||
|
-*) die "Unknown flag: $1" ;;
|
||||||
|
*) target_name="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$target_name" ]; then
|
||||||
|
echo "Usage: josh-sync adopt <target> [--restart]" >&2
|
||||||
|
parse_config "$config_file"
|
||||||
|
echo "Available targets:" >&2
|
||||||
|
echo "$JOSH_SYNC_TARGETS" | jq -r '.[].name' | sed 's/^/ /' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
parse_config "$config_file"
|
||||||
|
|
||||||
|
local target_json
|
||||||
|
target_json=$(echo "$JOSH_SYNC_TARGETS" | jq -c --arg n "$target_name" '.[] | select(.name == $n)')
|
||||||
|
[ -n "$target_json" ] || die "Target '${target_name}' not found in config"
|
||||||
|
|
||||||
|
log "INFO" "══════ Adopt target: ${target_name} ══════"
|
||||||
|
load_target "$target_json"
|
||||||
|
adopt_flow "$target_json" "$restart"
|
||||||
|
}
|
||||||
|
|
||||||
# ─── Reset Command ─────────────────────────────────────────────────
|
# ─── Reset Command ─────────────────────────────────────────────────
|
||||||
|
|
||||||
cmd_reset() {
|
cmd_reset() {
|
||||||
@@ -886,6 +926,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 "$@" ;;
|
||||||
|
|||||||
55
docs/adr/013-non-destructive-adoption.md
Normal file
55
docs/adr/013-non-destructive-adoption.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# ADR-013: Non-destructive adoption merge for existing subrepos
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Date:** 2026-04
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -18,3 +18,4 @@ This directory contains Architecture Decision Records (ADRs) for josh-sync. Each
|
|||||||
| [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 |
|
| [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 |
|
||||||
|
|||||||
@@ -224,14 +224,40 @@ For a new monorepo before import, preflight may warn that subfolders don't exist
|
|||||||
|
|
||||||
## Step 5: Import Existing Subrepos
|
## Step 5: Import Existing Subrepos
|
||||||
|
|
||||||
This is the critical onboarding step. There are two approaches:
|
This is the critical onboarding step. There are three approaches:
|
||||||
|
|
||||||
- **`josh-sync onboard`** (recommended) — interactive, resumable, preserves open PRs
|
- **`josh-sync adopt`** (recommended for active existing subrepos) — non-destructive, resumable, preserves existing subrepo history
|
||||||
- **Manual `import` → merge → `reset`** — lower-level, for automation or when there are no open PRs to preserve
|
- **`josh-sync onboard`** — destructive replacement-repo workflow for teams that intentionally archive the old repo
|
||||||
|
- **Manual `import` → merge → `reset`** — lower-level destructive path for automation or empty replacement repos
|
||||||
|
|
||||||
### Option A: Onboard (recommended)
|
### Option A: Adopt (recommended for active subrepos)
|
||||||
|
|
||||||
The `onboard` command walks you through the entire process interactively, with checkpoint/resume at every step.
|
Use `adopt` when the subrepo already exists, developers already clone it, or open PR branches should remain based on existing history.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
josh-sync adopt billing
|
||||||
|
```
|
||||||
|
|
||||||
|
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 adopt billing` to resume. Use `--restart` to start over.
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option B: Onboard with replacement repo
|
||||||
|
|
||||||
|
The `onboard` command walks through the destructive replacement-repo process interactively, with checkpoint/resume at every step.
|
||||||
|
|
||||||
**Before you start:**
|
**Before you start:**
|
||||||
|
|
||||||
@@ -272,13 +298,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
|
### Option C: 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.
|
||||||
|
|
||||||
> 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
|
#### 5c-1. Import
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
josh-sync import billing
|
josh-sync import billing
|
||||||
@@ -293,13 +319,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
|
#### 5c-2. 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
|
#### 5c-3. Reset
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
josh-sync reset billing
|
josh-sync reset billing
|
||||||
@@ -326,7 +352,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
|
#### 5c-4. Repeat for each target
|
||||||
|
|
||||||
```
|
```
|
||||||
For each target:
|
For each target:
|
||||||
@@ -337,13 +363,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)
|
||||||
@@ -620,9 +646,12 @@ 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. Import or adopt the target:
|
||||||
```bash
|
```bash
|
||||||
# Recommended: interactive onboard (preserves open PRs)
|
# Recommended for an existing active subrepo
|
||||||
|
josh-sync adopt new-target
|
||||||
|
|
||||||
|
# Replacement-repo workflow
|
||||||
josh-sync onboard new-target
|
josh-sync onboard new-target
|
||||||
|
|
||||||
# Or manual: import → merge PR → reset
|
# Or manual: import → merge PR → reset
|
||||||
|
|||||||
335
lib/adopt.sh
Normal file
335
lib/adopt.sh
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# lib/adopt.sh — Non-destructive adoption of existing subrepo history
|
||||||
|
#
|
||||||
|
# Provides:
|
||||||
|
# adopt_flow() — Import → wait for merge → adoption merge per branch
|
||||||
|
# adopt_branch() — Create the adoption merge and push it fast-forward only
|
||||||
|
#
|
||||||
|
# Adopt state is stored on the josh-sync-state branch at <target>/adopt.json.
|
||||||
|
# Steps: start → importing → waiting-for-merge → adopting → complete
|
||||||
|
#
|
||||||
|
# Requires: lib/core.sh, lib/config.sh, lib/auth.sh, lib/state.sh, lib/sync.sh sourced
|
||||||
|
|
||||||
|
# ─── Adopt State Helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
|
read_adopt_state() {
|
||||||
|
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" 2>/dev/null || echo '{}'
|
||||||
|
}
|
||||||
|
|
||||||
|
write_adopt_state() {
|
||||||
|
local target_name="${1:-$JOSH_SYNC_TARGET_NAME}"
|
||||||
|
local state_json="$2"
|
||||||
|
local key="${target_name}/adopt"
|
||||||
|
local tmp_dir
|
||||||
|
tmp_dir=$(mktemp -d)
|
||||||
|
|
||||||
|
if git rev-parse "origin/${STATE_BRANCH}" >/dev/null 2>&1; then
|
||||||
|
git worktree add "$tmp_dir" "origin/${STATE_BRANCH}" 2>/dev/null
|
||||||
|
else
|
||||||
|
git worktree add --detach "$tmp_dir" 2>/dev/null
|
||||||
|
(cd "$tmp_dir" && git checkout --orphan "$STATE_BRANCH" && git rm -rf . 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "${tmp_dir}/${key}.json")"
|
||||||
|
echo "$state_json" | jq '.' > "${tmp_dir}/${key}.json"
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$tmp_dir" || exit
|
||||||
|
git add -A
|
||||||
|
if ! git diff --cached --quiet 2>/dev/null; then
|
||||||
|
git -c user.name="$BOT_NAME" -c user.email="$BOT_EMAIL" \
|
||||||
|
commit -m "adopt: update ${target_name}"
|
||||||
|
git push origin "HEAD:${STATE_BRANCH}" || log "WARN" "Failed to push adopt state"
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
|
||||||
|
git worktree remove "$tmp_dir" 2>/dev/null || rm -rf "$tmp_dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── 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() {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Adopt Flow ──────────────────────────────────────────────────
|
||||||
|
# Interactive orchestrator with checkpoint/resume.
|
||||||
|
# Usage: adopt_flow <target_json> <restart>
|
||||||
|
|
||||||
|
adopt_flow() {
|
||||||
|
local target_json="$1"
|
||||||
|
local restart="${2:-false}"
|
||||||
|
local target_name="$JOSH_SYNC_TARGET_NAME"
|
||||||
|
|
||||||
|
local adopt_state current_step
|
||||||
|
adopt_state=$(read_adopt_state "$target_name")
|
||||||
|
current_step=$(echo "$adopt_state" | jq -r '.step // "start"')
|
||||||
|
|
||||||
|
if [ "$restart" = true ]; then
|
||||||
|
log "INFO" "Restarting adopt from scratch"
|
||||||
|
current_step="start"
|
||||||
|
adopt_state='{}'
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "INFO" "Adopt step: ${current_step}"
|
||||||
|
|
||||||
|
if [ "$current_step" = "start" ]; then
|
||||||
|
echo "" >&2
|
||||||
|
echo "=== Adopting ${target_name} ===" >&2
|
||||||
|
echo "" >&2
|
||||||
|
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
|
||||||
|
|
||||||
|
adopt_state=$(jq -n \
|
||||||
|
--arg step "importing" \
|
||||||
|
--arg mode "adopt" \
|
||||||
|
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
|
'{step:$step, mode:$mode, import_prs:{}, adopted_branches:[], timestamp:$ts}')
|
||||||
|
write_adopt_state "$target_name" "$adopt_state"
|
||||||
|
current_step="importing"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$current_step" = "importing" ]; then
|
||||||
|
echo "" >&2
|
||||||
|
log "INFO" "Step 1: Importing current subrepo content into monorepo..."
|
||||||
|
|
||||||
|
local branches import_prs
|
||||||
|
branches=$(echo "$target_json" | jq -r '.branches | keys[]')
|
||||||
|
import_prs=$(echo "$adopt_state" | jq -r '.import_prs // {}')
|
||||||
|
|
||||||
|
for branch in $branches; do
|
||||||
|
local mapped
|
||||||
|
mapped=$(echo "$target_json" | jq -r --arg b "$branch" '.branches[$b] // empty')
|
||||||
|
[ -z "$mapped" ] && continue
|
||||||
|
|
||||||
|
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"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
export SYNC_BRANCH_MONO="$branch"
|
||||||
|
export SYNC_BRANCH_SUBREPO="$mapped"
|
||||||
|
|
||||||
|
local result
|
||||||
|
result=$(initial_import)
|
||||||
|
log "INFO" "Import result for ${branch}: ${result}"
|
||||||
|
|
||||||
|
if [ "$result" = "pr-created" ]; then
|
||||||
|
local prs pr_number
|
||||||
|
prs=$(list_open_prs "$MONOREPO_API" "$GITEA_TOKEN")
|
||||||
|
pr_number=$(echo "$prs" | jq -r --arg t "$target_name" --arg b "$branch" \
|
||||||
|
'[.[] | select(.title | test("\\[Import\\] " + $t + ":")) | select(.base.ref == $b)] | .[0].number // empty')
|
||||||
|
|
||||||
|
[ -n "$pr_number" ] || die "Could not find import PR number for ${branch}; cannot safely continue adoption"
|
||||||
|
import_prs=$(echo "$import_prs" | jq --arg b "$branch" --arg n "$pr_number" '. + {($b): ($n | tonumber)}')
|
||||||
|
log "INFO" "Import PR for ${branch}: #${pr_number}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
adopt_state=$(echo "$adopt_state" | jq --argjson prs "$import_prs" '.import_prs = $prs')
|
||||||
|
write_adopt_state "$target_name" "$adopt_state"
|
||||||
|
done
|
||||||
|
|
||||||
|
adopt_state=$(echo "$adopt_state" | jq \
|
||||||
|
--arg step "waiting-for-merge" \
|
||||||
|
--argjson prs "$import_prs" \
|
||||||
|
'.step = $step | .import_prs = $prs')
|
||||||
|
write_adopt_state "$target_name" "$adopt_state"
|
||||||
|
current_step="waiting-for-merge"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$current_step" = "waiting-for-merge" ]; then
|
||||||
|
echo "" >&2
|
||||||
|
log "INFO" "Step 2: Waiting for import PR(s) to be merged..."
|
||||||
|
|
||||||
|
local import_prs pr_count
|
||||||
|
import_prs=$(echo "$adopt_state" | jq -r '.import_prs')
|
||||||
|
pr_count=$(echo "$import_prs" | jq 'length')
|
||||||
|
|
||||||
|
if [ "$pr_count" -gt 0 ]; then
|
||||||
|
echo "" >&2
|
||||||
|
echo "Import PRs to merge:" >&2
|
||||||
|
echo "$import_prs" | jq -r 'to_entries[] | " \(.key): PR #\(.value)"' >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo "Merge the import PR(s) on the monorepo, then press Enter..." >&2
|
||||||
|
read -r
|
||||||
|
|
||||||
|
local all_merged=true
|
||||||
|
for branch in $(echo "$import_prs" | jq -r 'keys[]'); do
|
||||||
|
local pr_number pr_json merged
|
||||||
|
pr_number=$(echo "$import_prs" | jq -r --arg b "$branch" '.[$b]')
|
||||||
|
pr_json=$(get_pr "$MONOREPO_API" "$GITEA_TOKEN" "$pr_number")
|
||||||
|
merged=$(echo "$pr_json" | jq -r '.merged // false')
|
||||||
|
|
||||||
|
if [ "$merged" = "true" ]; then
|
||||||
|
log "INFO" "PR #${pr_number} (${branch}): merged"
|
||||||
|
else
|
||||||
|
log "ERROR" "PR #${pr_number} (${branch}): NOT merged — merge it first"
|
||||||
|
all_merged=false
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$all_merged" = false ]; then
|
||||||
|
die "Not all import PRs are merged. Re-run 'josh-sync adopt ${target_name}' after merging."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "INFO" "No import PRs recorded — subfolder already matched subrepo"
|
||||||
|
fi
|
||||||
|
|
||||||
|
adopt_state=$(echo "$adopt_state" | jq '.step = "adopting"')
|
||||||
|
write_adopt_state "$target_name" "$adopt_state"
|
||||||
|
current_step="adopting"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$current_step" = "adopting" ]; then
|
||||||
|
echo "" >&2
|
||||||
|
log "INFO" "Step 3: Creating adoption merge commit(s)..."
|
||||||
|
|
||||||
|
local branches already_adopted
|
||||||
|
branches=$(echo "$target_json" | jq -r '.branches | keys[]')
|
||||||
|
already_adopted=$(echo "$adopt_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)
|
||||||
|
adopt_state=$(echo "$adopt_state" | jq --arg b "$branch" '.adopted_branches += [$b]')
|
||||||
|
write_adopt_state "$target_name" "$adopt_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 adopt ${target_name}' to retry."
|
||||||
|
;;
|
||||||
|
missing-branch)
|
||||||
|
die "Subrepo branch ${mapped} does not exist."
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
die "Unexpected adopt result: ${result}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
adopt_state=$(echo "$adopt_state" | jq \
|
||||||
|
--arg step "complete" \
|
||||||
|
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
|
'.step = $step | .timestamp = $ts')
|
||||||
|
write_adopt_state "$target_name" "$adopt_state"
|
||||||
|
current_step="complete"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$current_step" = "complete" ]; then
|
||||||
|
echo "" >&2
|
||||||
|
echo "=== Adoption complete! ===" >&2
|
||||||
|
echo "" >&2
|
||||||
|
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
|
||||||
|
}
|
||||||
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" ]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user