Compare commits
5 Commits
v1
...
cb14cf9bd4
| Author | SHA1 | Date | |
|---|---|---|---|
| cb14cf9bd4 | |||
| 0363b0ee77 | |||
| 72430714af | |||
| 105216a27e | |||
| 405e5f4535 |
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 '' >> dist/josh-sync
|
||||
@# 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; do \
|
||||
@for f in lib/core.sh lib/config.sh lib/auth.sh lib/state.sh lib/sync.sh lib/onboard.sh; do \
|
||||
echo "# --- $$f ---" >> dist/josh-sync; \
|
||||
grep -v '^#!/' "$$f" | grep -v '^# shellcheck source=' >> dist/josh-sync; \
|
||||
echo '' >> dist/josh-sync; \
|
||||
|
||||
@@ -68,6 +68,8 @@ josh-sync sync [--forward|--reverse] [--target NAME[,NAME]] [--branch BRANCH]
|
||||
josh-sync preflight
|
||||
josh-sync import <target>
|
||||
josh-sync reset <target>
|
||||
josh-sync onboard <target> [--restart]
|
||||
josh-sync migrate-pr <target> [PR#...] [--all]
|
||||
josh-sync status
|
||||
josh-sync state show <target> [branch]
|
||||
josh-sync state reset <target> [branch]
|
||||
|
||||
148
bin/josh-sync
148
bin/josh-sync
@@ -9,6 +9,8 @@
|
||||
# preflight Validate config, connectivity, auth
|
||||
# import <target> Initial import: pull subrepo into monorepo
|
||||
# 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
|
||||
# status Show target config and sync state
|
||||
# state show|reset Manage sync state directly
|
||||
#
|
||||
@@ -39,6 +41,8 @@ source "${JOSH_LIB_DIR}/auth.sh"
|
||||
source "${JOSH_LIB_DIR}/state.sh"
|
||||
# shellcheck source=../lib/sync.sh
|
||||
source "${JOSH_LIB_DIR}/sync.sh"
|
||||
# shellcheck source=../lib/onboard.sh
|
||||
source "${JOSH_LIB_DIR}/onboard.sh"
|
||||
|
||||
# ─── Version ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -69,6 +73,8 @@ Commands:
|
||||
preflight Validate config, connectivity, auth, workflow coverage
|
||||
import <target> Initial import: pull existing subrepo into monorepo (creates 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
|
||||
status Show target config and sync state
|
||||
state show <target> [branch] Show sync state JSON
|
||||
state reset <target> [branch] Reset sync state to {}
|
||||
@@ -643,6 +649,146 @@ cmd_state() {
|
||||
esac
|
||||
}
|
||||
|
||||
# ─── Onboard Command ──────────────────────────────────────────────
|
||||
|
||||
cmd_onboard() {
|
||||
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 onboard <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" "══════ Onboard target: ${target_name} ══════"
|
||||
load_target "$target_json"
|
||||
onboard_flow "$target_json" "$restart"
|
||||
}
|
||||
|
||||
# ─── Migrate PR Command ──────────────────────────────────────────
|
||||
|
||||
cmd_migrate_pr() {
|
||||
local config_file=".josh-sync.yml"
|
||||
local target_name=""
|
||||
local all=false
|
||||
local pr_numbers=()
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--config) config_file="$2"; shift 2 ;;
|
||||
--debug) export JOSH_SYNC_DEBUG=1; shift ;;
|
||||
--all) all=true; shift ;;
|
||||
-*) die "Unknown flag: $1" ;;
|
||||
*)
|
||||
if [ -z "$target_name" ]; then
|
||||
target_name="$1"
|
||||
else
|
||||
pr_numbers+=("$1")
|
||||
fi
|
||||
shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$target_name" ]; then
|
||||
echo "Usage: josh-sync migrate-pr <target> [PR#...] [--all]" >&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"
|
||||
|
||||
load_target "$target_json"
|
||||
|
||||
# Load archived repo info from onboard state
|
||||
local onboard_state archived_api
|
||||
onboard_state=$(read_onboard_state "$target_name")
|
||||
archived_api=$(echo "$onboard_state" | jq -r '.archived_api')
|
||||
if [ -z "$archived_api" ] || [ "$archived_api" = "null" ]; then
|
||||
die "No archived repo info found. Run 'josh-sync onboard ${target_name}' first."
|
||||
fi
|
||||
|
||||
log "INFO" "Archived repo: ${archived_api}"
|
||||
|
||||
if [ "$all" = true ]; then
|
||||
# Migrate all open PRs from archived repo
|
||||
local prs
|
||||
prs=$(list_open_prs "$archived_api" "$SUBREPO_TOKEN") \
|
||||
|| die "Failed to list PRs on archived repo"
|
||||
local count
|
||||
count=$(echo "$prs" | jq 'length')
|
||||
log "INFO" "Found ${count} open PR(s) on archived repo"
|
||||
|
||||
echo "$prs" | jq -r '.[] | .number' | while read -r num; do
|
||||
migrate_one_pr "$num" || true
|
||||
done
|
||||
|
||||
elif [ ${#pr_numbers[@]} -gt 0 ]; then
|
||||
# Migrate specific PR numbers
|
||||
for num in "${pr_numbers[@]}"; do
|
||||
migrate_one_pr "$num" || true
|
||||
done
|
||||
|
||||
else
|
||||
# Interactive: list open PRs, let user pick
|
||||
local prs
|
||||
prs=$(list_open_prs "$archived_api" "$SUBREPO_TOKEN") \
|
||||
|| die "Failed to list PRs on archived repo"
|
||||
local count
|
||||
count=$(echo "$prs" | jq 'length')
|
||||
|
||||
if [ "$count" -eq 0 ]; then
|
||||
log "INFO" "No open PRs on archived repo"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "" >&2
|
||||
echo "Open PRs on archived repo:" >&2
|
||||
echo "$prs" | jq -r '.[] | " #\(.number): \(.title) (\(.base.ref) <- \(.head.ref))"' >&2
|
||||
echo "" >&2
|
||||
echo "Enter PR numbers to migrate (space-separated), or 'all':" >&2
|
||||
local selection
|
||||
read -r selection
|
||||
|
||||
if [ "$selection" = "all" ]; then
|
||||
echo "$prs" | jq -r '.[] | .number' | while read -r num; do
|
||||
migrate_one_pr "$num" || true
|
||||
done
|
||||
else
|
||||
for num in $selection; do
|
||||
migrate_one_pr "$num" || true
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
log "INFO" "PR migration complete"
|
||||
}
|
||||
|
||||
# ─── Main ───────────────────────────────────────────────────────────
|
||||
|
||||
main() {
|
||||
@@ -666,6 +812,8 @@ main() {
|
||||
preflight) cmd_preflight "$@" ;;
|
||||
import) cmd_import "$@" ;;
|
||||
reset) cmd_reset "$@" ;;
|
||||
onboard) cmd_onboard "$@" ;;
|
||||
migrate-pr) cmd_migrate_pr "$@" ;;
|
||||
status) cmd_status "$@" ;;
|
||||
state) cmd_state "$@" ;;
|
||||
*)
|
||||
|
||||
129
docs/guide.md
129
docs/guide.md
@@ -165,6 +165,34 @@ SUBREPO_SSH_KEY="-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
# AUTH_REPO_TOKEN=<auth-specific-token>
|
||||
```
|
||||
|
||||
### Updating josh-sync in devenv
|
||||
|
||||
To update to the latest version:
|
||||
|
||||
```bash
|
||||
devenv update josh-sync
|
||||
```
|
||||
|
||||
Or with plain Nix flakes:
|
||||
|
||||
```bash
|
||||
nix flake lock --update-input josh-sync
|
||||
```
|
||||
|
||||
To pin to a specific version, use a tag ref in `devenv.yaml`:
|
||||
|
||||
```yaml
|
||||
josh-sync:
|
||||
url: git+https://your-gitea.example.com/org/josh-sync?ref=v1.1
|
||||
flake: true
|
||||
```
|
||||
|
||||
After updating, verify the version:
|
||||
|
||||
```bash
|
||||
josh-sync --version
|
||||
```
|
||||
|
||||
### Option B: Manual installation
|
||||
|
||||
Install the required tools, then either:
|
||||
@@ -189,11 +217,61 @@ For a new monorepo before import, preflight may warn that subfolders don't exist
|
||||
|
||||
## Step 5: Import Existing Subrepos
|
||||
|
||||
This is the critical onboarding step. For each existing subrepo, you run a three-step cycle: **import → merge → reset**.
|
||||
This is the critical onboarding step. There are two approaches:
|
||||
|
||||
- **`josh-sync onboard`** (recommended) — interactive, resumable, preserves open PRs
|
||||
- **Manual `import` → merge → `reset`** — lower-level, for automation or when there are no open PRs to preserve
|
||||
|
||||
### Option A: Onboard (recommended)
|
||||
|
||||
The `onboard` command walks you through the entire process interactively, with checkpoint/resume at every step.
|
||||
|
||||
**Before you start:**
|
||||
|
||||
1. **Rename** the existing subrepo on your Git server (e.g., `stores/storefront` → `stores/storefront-archived`)
|
||||
2. **Create a new empty repo** at the original path (e.g., a new `stores/storefront` with no commits)
|
||||
|
||||
The rename preserves the archived repo with all its history and open PRs. The new empty repo will receive josh-filtered history.
|
||||
|
||||
**Run onboard:**
|
||||
|
||||
```bash
|
||||
josh-sync onboard billing
|
||||
```
|
||||
|
||||
The command will:
|
||||
1. **Verify prerequisites** — checks the new empty repo is reachable, asks for the archived repo URL
|
||||
2. **Import** — copies subrepo content into monorepo and creates import PRs (one per branch)
|
||||
3. **Wait for merge** — shows PR numbers and waits for you to merge them
|
||||
4. **Reset** — pushes josh-filtered history to the new subrepo (per-branch, with resume)
|
||||
5. **Done** — prints instructions for developers and PR migration
|
||||
|
||||
If the process is interrupted at any point, re-run `josh-sync onboard billing` to resume from where it left off. Use `--restart` to start over.
|
||||
|
||||
**Migrate open PRs:**
|
||||
|
||||
After onboard completes, migrate PRs from the archived repo to the new one:
|
||||
|
||||
```bash
|
||||
# Interactive — lists open PRs and lets you pick
|
||||
josh-sync migrate-pr billing
|
||||
|
||||
# Migrate all open PRs at once
|
||||
josh-sync migrate-pr billing --all
|
||||
|
||||
# Migrate specific PRs by number
|
||||
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.
|
||||
|
||||
### Option B: Manual import → merge → reset
|
||||
|
||||
Use this when the subrepo has no open PRs to preserve, or for scripted automation.
|
||||
|
||||
> Do this **one target at a time** to keep PRs reviewable.
|
||||
|
||||
### 5a. Import
|
||||
#### 5b-1. Import
|
||||
|
||||
```bash
|
||||
josh-sync import billing
|
||||
@@ -208,13 +286,13 @@ This:
|
||||
|
||||
Review the import PR — check for leaked credentials, environment-specific config, or files that shouldn't be in the monorepo.
|
||||
|
||||
### 5b. Merge the import PR
|
||||
#### 5b-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.
|
||||
|
||||
> At this point, the monorepo has the content but the histories are disconnected. Sync will **not** work until you complete the reset step.
|
||||
|
||||
### 5c. Reset
|
||||
#### 5b-3. Reset
|
||||
|
||||
```bash
|
||||
josh-sync reset billing
|
||||
@@ -228,9 +306,20 @@ This:
|
||||
|
||||
This establishes **shared commit ancestry** between josh's filtered view and the subrepo. Without this, josh-proxy can't compute diffs between the two.
|
||||
|
||||
> **Warning:** This is a destructive force-push that replaces the subrepo's history. Back up any important branches or tags in the subrepo beforehand.
|
||||
> **Warning:** This is a destructive force-push that replaces the subrepo's history. Back up any important branches or tags in the subrepo beforehand. Merge or close all open pull requests on the subrepo first — they will be invalidated.
|
||||
|
||||
### 5d. Repeat for each target
|
||||
After reset, **every developer with a local clone of the subrepo** must update their local copy to match the new history:
|
||||
|
||||
```bash
|
||||
cd /path/to/local-subrepo
|
||||
git fetch origin
|
||||
git checkout main && git reset --hard origin/main
|
||||
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.
|
||||
|
||||
#### 5b-4. Repeat for each target
|
||||
|
||||
```
|
||||
For each target:
|
||||
@@ -239,9 +328,9 @@ For each target:
|
||||
3. josh-sync reset <target>
|
||||
```
|
||||
|
||||
### 5e. Verify
|
||||
### Verify
|
||||
|
||||
After all targets are imported and reset:
|
||||
After all targets are imported and reset (whichever option you used):
|
||||
|
||||
```bash
|
||||
# Check all targets show state
|
||||
@@ -433,8 +522,12 @@ To add a new subrepo after initial setup:
|
||||
1. Add the target to `.josh-sync.yml`
|
||||
2. Update the forward workflow's `paths:` list to include the new subfolder
|
||||
3. Commit and push
|
||||
4. Run the import-merge-reset cycle for the new target:
|
||||
4. Import the target:
|
||||
```bash
|
||||
# Recommended: interactive onboard (preserves open PRs)
|
||||
josh-sync onboard new-target
|
||||
|
||||
# Or manual: import → merge PR → reset
|
||||
josh-sync import new-target
|
||||
# merge the PR
|
||||
josh-sync reset new-target
|
||||
@@ -471,6 +564,24 @@ The subfolder already contains the same content as the subrepo. This is fine —
|
||||
|
||||
Verify `bot.trailer` in config matches what's in commit messages. Check the loop guard in the CI workflow is active.
|
||||
|
||||
### "cannot lock ref" or "expected X but got Y"
|
||||
|
||||
**After reset (subrepo):** The subrepo's history was replaced by force-push. Local clones still have the old history:
|
||||
|
||||
```bash
|
||||
cd /path/to/subrepo
|
||||
git fetch origin
|
||||
git checkout main && git reset --hard origin/main
|
||||
```
|
||||
|
||||
Or simply delete and re-clone.
|
||||
|
||||
**After import/reset cycle (monorepo):** The import and reset steps create and update branches rapidly (`auto-sync/import-*`, `josh-sync-state`). If your local clone fetched partway through, tracking refs go stale:
|
||||
|
||||
```bash
|
||||
git remote prune origin && git pull
|
||||
```
|
||||
|
||||
### State issues
|
||||
|
||||
```bash
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
installPhase = ''
|
||||
mkdir -p $out/{bin,lib}
|
||||
cp VERSION $out/
|
||||
cp lib/*.sh $out/lib/
|
||||
cp bin/josh-sync $out/bin/
|
||||
chmod +x $out/bin/josh-sync
|
||||
|
||||
48
lib/auth.sh
48
lib/auth.sh
@@ -39,16 +39,15 @@ subrepo_ls_remote() {
|
||||
}
|
||||
|
||||
# ─── PR Creation ────────────────────────────────────────────────────
|
||||
# Shared helper for creating PRs on Gitea/GitHub API.
|
||||
# Shared helpers for creating PRs on Gitea/GitHub API.
|
||||
# Usage: create_pr <api_url> <token> <base> <head> <title> <body>
|
||||
# number=$(create_pr_number <api_url> <token> <base> <head> <title> <body>)
|
||||
#
|
||||
# create_pr — fire-and-forget (stdout suppressed, safe inside sync functions)
|
||||
# create_pr_number — returns the new PR number via stdout
|
||||
|
||||
create_pr() {
|
||||
local api_url="$1"
|
||||
local token="$2"
|
||||
local base="$3"
|
||||
local head="$4"
|
||||
local title="$5"
|
||||
local body="$6"
|
||||
create_pr_number() {
|
||||
local api_url="$1" token="$2" base="$3" head="$4" title="$5" body="$6"
|
||||
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${token}" \
|
||||
@@ -59,5 +58,36 @@ create_pr() {
|
||||
--arg title "$title" \
|
||||
--arg body "$body" \
|
||||
'{base:$base, head:$head, title:$title, body:$body}')" \
|
||||
"${api_url}/pulls" >/dev/null
|
||||
"${api_url}/pulls" | jq -r '.number'
|
||||
}
|
||||
|
||||
create_pr() {
|
||||
create_pr_number "$@" >/dev/null
|
||||
}
|
||||
|
||||
# ─── PR API Helpers ──────────────────────────────────────────────
|
||||
# Used by onboard and migrate-pr commands.
|
||||
|
||||
# List open PRs on a repo. Returns JSON array.
|
||||
# Usage: list_open_prs <api_url> <token>
|
||||
list_open_prs() {
|
||||
local api_url="$1" token="$2"
|
||||
curl -sf -H "Authorization: token ${token}" \
|
||||
"${api_url}/pulls?state=open&limit=50"
|
||||
}
|
||||
|
||||
# Get PR diff as plain text.
|
||||
# Usage: get_pr_diff <api_url> <token> <pr_number>
|
||||
get_pr_diff() {
|
||||
local api_url="$1" token="$2" pr_number="$3"
|
||||
curl -sf -H "Authorization: token ${token}" \
|
||||
"${api_url}/pulls/${pr_number}.diff"
|
||||
}
|
||||
|
||||
# Get single PR as JSON (for checking merge status, metadata, etc.).
|
||||
# Usage: get_pr <api_url> <token> <pr_number>
|
||||
get_pr() {
|
||||
local api_url="$1" token="$2" pr_number="$3"
|
||||
curl -sf -H "Authorization: token ${token}" \
|
||||
"${api_url}/pulls/${pr_number}"
|
||||
}
|
||||
|
||||
425
lib/onboard.sh
Normal file
425
lib/onboard.sh
Normal file
@@ -0,0 +1,425 @@
|
||||
#!/usr/bin/env bash
|
||||
# lib/onboard.sh — Onboard orchestration and PR migration
|
||||
#
|
||||
# Provides:
|
||||
# onboard_flow() — Interactive: import → wait for merge → reset to new repo
|
||||
# migrate_one_pr() — Migrate a single PR from archived repo to new repo
|
||||
#
|
||||
# Onboard state is stored on the josh-sync-state branch at <target>/onboard.json.
|
||||
# Steps: start → importing → waiting-for-merge → resetting → complete
|
||||
#
|
||||
# Requires: lib/core.sh, lib/config.sh, lib/auth.sh, lib/state.sh, lib/sync.sh sourced
|
||||
# Expects: JOSH_SYNC_TARGET_NAME, BOT_NAME, BOT_EMAIL, SUBREPO_API, SUBREPO_TOKEN, etc.
|
||||
|
||||
# ─── Onboard State Helpers ────────────────────────────────────────
|
||||
# Follow the same pattern as read_state()/write_state() in lib/state.sh.
|
||||
|
||||
read_onboard_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}/onboard.json" 2>/dev/null || echo '{}'
|
||||
}
|
||||
|
||||
write_onboard_state() {
|
||||
local target_name="${1:-$JOSH_SYNC_TARGET_NAME}"
|
||||
local state_json="$2"
|
||||
local key="${target_name}/onboard"
|
||||
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 "onboard: update ${target_name}"
|
||||
git push origin "HEAD:${STATE_BRANCH}" || log "WARN" "Failed to push onboard state"
|
||||
fi
|
||||
)
|
||||
|
||||
git worktree remove "$tmp_dir" 2>/dev/null || rm -rf "$tmp_dir"
|
||||
}
|
||||
|
||||
# ─── Derive Archived API URL ─────────────────────────────────────
|
||||
# Given a URL like "git@host:org/repo-archived.git" or
|
||||
# "https://host/org/repo-archived.git", derive the Gitea API URL.
|
||||
|
||||
_archived_api_from_url() {
|
||||
local url="$1"
|
||||
# Strip .git suffix first — avoids non-greedy regex issues in POSIX ERE
|
||||
url="${url%.git}"
|
||||
local host repo_path
|
||||
|
||||
if echo "$url" | grep -qE '^(ssh://|git@)'; then
|
||||
# SSH URL
|
||||
if echo "$url" | grep -q '^ssh://'; then
|
||||
host=$(echo "$url" | sed -E 's|ssh://[^@]*@([^/]+)/.*|\1|')
|
||||
repo_path=$(echo "$url" | sed -E 's|ssh://[^@]*@[^/]+/(.+)$|\1|')
|
||||
else
|
||||
host=$(echo "$url" | sed -E 's|git@([^:/]+)[:/].*|\1|')
|
||||
repo_path=$(echo "$url" | sed -E 's|git@[^:/]+[:/](.+)$|\1|')
|
||||
fi
|
||||
else
|
||||
# HTTPS URL
|
||||
host=$(echo "$url" | sed -E 's|https?://([^/]+)/.*|\1|')
|
||||
repo_path=$(echo "$url" | sed -E 's|https?://[^/]+/(.+)$|\1|')
|
||||
fi
|
||||
|
||||
echo "https://${host}/api/v1/repos/${repo_path}"
|
||||
}
|
||||
|
||||
# ─── Onboard Flow ────────────────────────────────────────────────
|
||||
# Interactive orchestrator with checkpoint/resume.
|
||||
# Usage: onboard_flow <target_json> <restart>
|
||||
|
||||
onboard_flow() {
|
||||
local target_json="$1"
|
||||
local restart="${2:-false}"
|
||||
local target_name="$JOSH_SYNC_TARGET_NAME"
|
||||
|
||||
# Load existing onboard state (or empty)
|
||||
local onboard_state
|
||||
onboard_state=$(read_onboard_state "$target_name")
|
||||
local current_step
|
||||
current_step=$(echo "$onboard_state" | jq -r '.step // "start"')
|
||||
|
||||
if [ "$restart" = true ]; then
|
||||
log "INFO" "Restarting onboard from scratch"
|
||||
current_step="start"
|
||||
onboard_state='{}'
|
||||
fi
|
||||
|
||||
log "INFO" "Onboard step: ${current_step}"
|
||||
|
||||
# ── Step 1: Prerequisites + archived repo info ──
|
||||
if [ "$current_step" = "start" ]; then
|
||||
echo "" >&2
|
||||
echo "=== Onboarding ${target_name} ===" >&2
|
||||
echo "" >&2
|
||||
echo "Before proceeding, you should have:" >&2
|
||||
echo " 1. Renamed the existing subrepo (e.g., storefront → storefront-archived)" >&2
|
||||
echo " 2. Created a new EMPTY repo at the original URL" >&2
|
||||
echo "" >&2
|
||||
|
||||
# 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
|
||||
# shellcheck disable=SC2001 # sed is clearer for URL pattern replacement
|
||||
log "INFO" "New subrepo is reachable at $(echo "$SUBREPO_URL" | sed 's|://[^@]*@|://***@|')"
|
||||
else
|
||||
log "WARN" "New subrepo is not reachable — make sure you created the new empty repo"
|
||||
fi
|
||||
|
||||
echo "Enter the archived repo URL (e.g., git@host:org/repo-archived.git):" >&2
|
||||
local archived_url
|
||||
read -r archived_url
|
||||
[ -n "$archived_url" ] || die "Archived URL is required"
|
||||
|
||||
# Determine auth type for archived repo (same as current subrepo)
|
||||
local archived_auth="${SUBREPO_AUTH:-https}"
|
||||
|
||||
# Derive API URL
|
||||
local archived_api
|
||||
archived_api=$(_archived_api_from_url "$archived_url")
|
||||
|
||||
# Verify archived repo is reachable via API
|
||||
if curl -sf -H "Authorization: token ${SUBREPO_TOKEN}" \
|
||||
"${archived_api}" >/dev/null 2>&1; then
|
||||
log "INFO" "Archived repo reachable: ${archived_api}"
|
||||
else
|
||||
log "WARN" "Cannot reach archived repo API — check URL and token"
|
||||
echo "Continue anyway? (y/N):" >&2
|
||||
local confirm
|
||||
read -r confirm
|
||||
[ "$confirm" = "y" ] || [ "$confirm" = "Y" ] || die "Aborted"
|
||||
fi
|
||||
|
||||
# Save state
|
||||
onboard_state=$(jq -n \
|
||||
--arg step "importing" \
|
||||
--arg archived_api "$archived_api" \
|
||||
--arg archived_url "$archived_url" \
|
||||
--arg archived_auth "$archived_auth" \
|
||||
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
'{step:$step, archived_api:$archived_api, archived_url:$archived_url,
|
||||
archived_auth:$archived_auth, import_prs:{}, reset_branches:[],
|
||||
migrated_prs:[], timestamp:$ts}')
|
||||
write_onboard_state "$target_name" "$onboard_state"
|
||||
current_step="importing"
|
||||
fi
|
||||
|
||||
# ── Step 2: Import (reuses initial_import()) ──
|
||||
if [ "$current_step" = "importing" ]; then
|
||||
echo "" >&2
|
||||
log "INFO" "Step 2: Importing subrepo content into monorepo..."
|
||||
|
||||
local branches
|
||||
branches=$(echo "$target_json" | jq -r '.branches | keys[]')
|
||||
|
||||
# Load existing import_prs from state (resume support)
|
||||
local import_prs
|
||||
import_prs=$(echo "$onboard_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
|
||||
|
||||
# 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
|
||||
log "INFO" "Import PR already recorded for ${branch} — skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
export SYNC_BRANCH_MONO="$branch"
|
||||
export SYNC_BRANCH_SUBREPO="$mapped"
|
||||
|
||||
log "INFO" "Importing branch: ${branch} (subrepo: ${mapped})"
|
||||
local result
|
||||
result=$(initial_import)
|
||||
log "INFO" "Import result for ${branch}: ${result}"
|
||||
|
||||
if [ "$result" = "pr-created" ]; then
|
||||
# Find the import PR number via API
|
||||
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')
|
||||
|
||||
if [ -n "$pr_number" ]; then
|
||||
import_prs=$(echo "$import_prs" | jq --arg b "$branch" --arg n "$pr_number" '. + {($b): ($n | tonumber)}')
|
||||
log "INFO" "Import PR for ${branch}: #${pr_number}"
|
||||
else
|
||||
log "WARN" "Could not find import PR number for ${branch} — check monorepo PRs"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Save progress after each branch (resume support)
|
||||
onboard_state=$(echo "$onboard_state" | jq --argjson prs "$import_prs" '.import_prs = $prs')
|
||||
write_onboard_state "$target_name" "$onboard_state"
|
||||
done
|
||||
|
||||
# Update state
|
||||
onboard_state=$(echo "$onboard_state" | jq \
|
||||
--arg step "waiting-for-merge" \
|
||||
--argjson prs "$import_prs" \
|
||||
'.step = $step | .import_prs = $prs')
|
||||
write_onboard_state "$target_name" "$onboard_state"
|
||||
current_step="waiting-for-merge"
|
||||
fi
|
||||
|
||||
# ── Step 3: Wait for merge ──
|
||||
if [ "$current_step" = "waiting-for-merge" ]; then
|
||||
echo "" >&2
|
||||
log "INFO" "Step 3: Waiting for import PR(s) to be merged..."
|
||||
|
||||
local import_prs
|
||||
import_prs=$(echo "$onboard_state" | jq -r '.import_prs')
|
||||
local pr_count
|
||||
pr_count=$(echo "$import_prs" | jq 'length')
|
||||
|
||||
if [ "$pr_count" -eq 0 ]; then
|
||||
log "WARN" "No import PRs recorded — skipping merge check"
|
||||
else
|
||||
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
|
||||
|
||||
# Verify each PR is merged
|
||||
local all_merged=true
|
||||
for branch in $(echo "$import_prs" | jq -r 'keys[]'); do
|
||||
local pr_number
|
||||
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")
|
||||
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 onboard ${target_name}' after merging."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Update state
|
||||
onboard_state=$(echo "$onboard_state" | jq '.step = "resetting"')
|
||||
write_onboard_state "$target_name" "$onboard_state"
|
||||
current_step="resetting"
|
||||
fi
|
||||
|
||||
# ── Step 4: Reset (pushes josh-filtered history to new repo) ──
|
||||
if [ "$current_step" = "resetting" ]; then
|
||||
echo "" >&2
|
||||
log "INFO" "Step 4: Pushing josh-filtered history to new subrepo..."
|
||||
|
||||
local branches
|
||||
branches=$(echo "$target_json" | jq -r '.branches | keys[]')
|
||||
local already_reset
|
||||
already_reset=$(echo "$onboard_state" | jq -r '.reset_branches // []')
|
||||
|
||||
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
|
||||
log "INFO" "Branch ${branch} already reset — 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=$(subrepo_reset)
|
||||
log "INFO" "Reset result for ${branch}: ${result}"
|
||||
|
||||
# Track progress
|
||||
onboard_state=$(echo "$onboard_state" | jq --arg b "$branch" \
|
||||
'.reset_branches += [$b]')
|
||||
write_onboard_state "$target_name" "$onboard_state"
|
||||
done
|
||||
|
||||
# Update state
|
||||
onboard_state=$(echo "$onboard_state" | jq \
|
||||
--arg step "complete" \
|
||||
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
'.step = $step | .timestamp = $ts')
|
||||
write_onboard_state "$target_name" "$onboard_state"
|
||||
current_step="complete"
|
||||
fi
|
||||
|
||||
# ── Step 5: Done ──
|
||||
if [ "$current_step" = "complete" ]; then
|
||||
echo "" >&2
|
||||
echo "=== Onboarding complete! ===" >&2
|
||||
echo "" >&2
|
||||
echo "The new subrepo now has josh-filtered history." >&2
|
||||
echo "Developers should re-clone or reset their local copies:" >&2
|
||||
echo " git fetch origin && git reset --hard origin/main" >&2
|
||||
echo "" >&2
|
||||
echo "To migrate open PRs from the archived repo:" >&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} 5 8 12 # specific PRs" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Migrate One PR ──────────────────────────────────────────────
|
||||
# Applies a PR's diff from the archived repo to the new subrepo.
|
||||
# Usage: migrate_one_pr <pr_number>
|
||||
#
|
||||
# Expects: JOSH_SYNC_TARGET_NAME, SUBREPO_API, SUBREPO_TOKEN, BOT_NAME, BOT_EMAIL loaded
|
||||
|
||||
migrate_one_pr() {
|
||||
local pr_number="$1"
|
||||
local target_name="$JOSH_SYNC_TARGET_NAME"
|
||||
|
||||
# Read archived repo info from onboard state
|
||||
local onboard_state archived_api
|
||||
onboard_state=$(read_onboard_state "$target_name")
|
||||
archived_api=$(echo "$onboard_state" | jq -r '.archived_api')
|
||||
if [ -z "$archived_api" ] || [ "$archived_api" = "null" ]; then
|
||||
die "No archived repo info found. Run 'josh-sync onboard ${target_name}' first."
|
||||
fi
|
||||
|
||||
# Check if this PR was already migrated
|
||||
local already_migrated
|
||||
already_migrated=$(echo "$onboard_state" | jq -r \
|
||||
--argjson num "$pr_number" '.migrated_prs // [] | map(select(.old_number == $num)) | length')
|
||||
if [ "$already_migrated" -gt 0 ]; then
|
||||
log "INFO" "PR #${pr_number} already migrated — skipping"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Same credentials — the repo was just renamed
|
||||
local archived_token="$SUBREPO_TOKEN"
|
||||
|
||||
# 1. Get PR metadata from archived repo
|
||||
local pr_json title base head body
|
||||
pr_json=$(get_pr "$archived_api" "$archived_token" "$pr_number") \
|
||||
|| die "Failed to fetch PR #${pr_number} from archived repo"
|
||||
title=$(echo "$pr_json" | jq -r '.title')
|
||||
base=$(echo "$pr_json" | jq -r '.base.ref')
|
||||
head=$(echo "$pr_json" | jq -r '.head.ref')
|
||||
body=$(echo "$pr_json" | jq -r '.body // ""')
|
||||
|
||||
log "INFO" "Migrating PR #${pr_number}: \"${title}\" (${base} <- ${head})"
|
||||
|
||||
# 2. Get diff from archived repo
|
||||
local diff
|
||||
diff=$(get_pr_diff "$archived_api" "$archived_token" "$pr_number")
|
||||
if [ -z "$diff" ]; then
|
||||
log "WARN" "Empty diff for PR #${pr_number} — skipping"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 3. Clone new subrepo, apply patch
|
||||
# Save cwd so we can restore it (function runs in caller's shell, not subshell)
|
||||
local original_dir
|
||||
original_dir=$(pwd)
|
||||
|
||||
local work_dir
|
||||
work_dir=$(mktemp -d)
|
||||
# shellcheck disable=SC2064 # Intentional early expansion
|
||||
trap "cd '$original_dir' 2>/dev/null; rm -rf '$work_dir'" RETURN
|
||||
|
||||
git clone "$(subrepo_auth_url)" --branch "$base" --single-branch \
|
||||
"${work_dir}/subrepo" 2>&1 || die "Failed to clone new subrepo (branch: ${base})"
|
||||
|
||||
cd "${work_dir}/subrepo" || exit
|
||||
git config user.name "$BOT_NAME"
|
||||
git config user.email "$BOT_EMAIL"
|
||||
|
||||
git checkout -B "$head" >&2
|
||||
|
||||
if echo "$diff" | git apply --check 2>/dev/null; then
|
||||
echo "$diff" | git apply
|
||||
git add -A
|
||||
git commit -m "${title}
|
||||
|
||||
Migrated from archived repo PR #${pr_number}" >&2
|
||||
|
||||
git push "$(subrepo_auth_url)" "$head" >&2 \
|
||||
|| die "Failed to push branch ${head}"
|
||||
|
||||
# 4. Create PR on new repo
|
||||
local new_number
|
||||
new_number=$(create_pr_number "$SUBREPO_API" "$SUBREPO_TOKEN" \
|
||||
"$base" "$head" "$title" "$body")
|
||||
log "INFO" "Migrated PR #${pr_number} -> #${new_number}: \"${title}\""
|
||||
|
||||
# 5. Record in onboard state
|
||||
cd "$original_dir" || true
|
||||
onboard_state=$(read_onboard_state "$target_name")
|
||||
onboard_state=$(echo "$onboard_state" | jq \
|
||||
--argjson old "$pr_number" \
|
||||
--argjson new_num "${new_number}" \
|
||||
--arg title "$title" \
|
||||
'.migrated_prs += [{"old_number":$old, "new_number":$new_num, "title":$title}]')
|
||||
write_onboard_state "$target_name" "$onboard_state"
|
||||
else
|
||||
log "ERROR" "Patch doesn't apply cleanly for PR #${pr_number} — skipping"
|
||||
log "ERROR" "Manual migration needed: get diff from archived repo and resolve conflicts"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
Reference in New Issue
Block a user