# Setup Guide Step-by-step guide to setting up josh-sync for a new monorepo with existing subrepos. ## Overview josh-sync provides bidirectional sync between a monorepo and N external subrepos via [josh-proxy](https://josh-project.github.io/josh/): ``` MONOREPO SUBREPOS ├── services/billing/ ──── forward ────► billing-repo/ ├── services/auth/ (push or cron) auth-repo/ └── libs/shared/ ◄──── reverse ───── shared-lib-repo/ (cron → always PR) via josh-proxy (filtered git views) ``` **Key safety properties:** - Forward sync (mono → subrepo) uses `--force-with-lease` — never overwrites concurrent changes - Reverse sync (subrepo → mono) always creates a PR — never pushes directly - Git trailers (`Josh-Sync-Origin:`) prevent infinite sync loops - State tracked on an orphan branch (`josh-sync-state`) — survives CI runner teardown ## Prerequisites Before you begin, you need: ### josh-proxy instance A running [josh-proxy](https://josh-project.github.io/josh/) that can access your monorepo's Git server. Verify connectivity: ```bash git ls-remote https://josh.example.com/org/monorepo.git HEAD ``` ### Bot account A dedicated Git user (e.g., `josh-sync-bot`) with: - Write access to the monorepo - Write access to all subrepos - Ability to create PRs on both monorepo and subrepo platforms ### Credentials | Variable | Purpose | Required | |----------|---------|----------| | `SYNC_BOT_USER` | Bot's Git username | Yes | | `SYNC_BOT_TOKEN` | API token with repo scope (monorepo + josh-proxy auth) | Yes | | `SUBREPO_SSH_KEY` | SSH private key for subrepo access (if using SSH auth) | If SSH | | `SUBREPO_TOKEN` | HTTPS token for subrepo access (defaults to `SYNC_BOT_TOKEN`) | No | Per-target credential overrides are supported — see [Configuration Reference](config-reference.md). ### Tool dependencies `bash >=4`, `git`, `curl`, `jq`, `yq` ([mikefarah/yq](https://github.com/mikefarah/yq) v4+), `openssh`, `rsync` > The Nix flake bundles all dependencies automatically. ## Step 1: Create the Monorepo Create a new repository on your Git server (e.g., `org/monorepo`). Create subdirectories for each subrepo you want to sync: ```bash mkdir -p services/billing services/auth libs/shared ``` These directories will be populated during the import step. They can be empty or contain `.gitkeep` files for now. Verify josh-proxy can see the monorepo: ```bash git ls-remote https://josh.example.com/org/monorepo.git HEAD ``` ## Step 2: Configure `.josh-sync.yml` Create `.josh-sync.yml` at the monorepo root. Each target maps a monorepo subfolder to an external subrepo: ```yaml schema_version: 2 josh: proxy_url: "https://josh.example.com" # josh-proxy URL (no trailing slash) 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: - name: "billing" # unique identifier subfolder: "services/billing" # monorepo subfolder # josh_filter auto-derived as ":/services/billing" if omitted subrepo_url: "git@gitea.example.com:ext/billing.git" subrepo_auth: "ssh" # "https" (default) or "ssh" branches: main: main # mono_branch: subrepo_branch forward_only: [] exclude: # files excluded from subrepo (optional) - ".monorepo/" # monorepo-only config dir - "**/internal/" # internal dirs at any depth - name: "auth" subfolder: "services/auth" subrepo_url: "https://gitea.example.com/ext/auth.git" subrepo_auth: "https" subrepo_token_var: "AUTH_REPO_TOKEN" # per-target credential override branches: main: main develop: develop # multiple branches supported forward_only: [] - name: "shared-lib" subfolder: "libs/shared" subrepo_url: "https://gitea.example.com/ext/shared-lib.git" branches: main: main forward_only: [main] # one-way: mono → subrepo only bot: name: "josh-sync-bot" email: "josh-sync-bot@example.com" trailer: "Josh-Sync-Origin" # git trailer for loop prevention ``` For the full field reference, see [Configuration Reference](config-reference.md). ## Step 3: Set Up Local Dev Environment ### Option A: Nix + devenv (recommended) **`devenv.yaml`** — declare josh-sync as a flake input: ```yaml inputs: nixpkgs: url: github:cachix/devenv-nixpkgs/rolling josh-sync: url: git+https://your-gitea.example.com/org/josh-sync?ref=main flake: true ``` **`devenv.nix`** — import the josh-sync module: ```nix { inputs, ... }: { imports = [ inputs.josh-sync.devenvModules.default ]; name = "my-monorepo"; # .env contains secrets, not devenv config dotenv.disableHint = true; } ``` **`.envrc`** — activate devenv automatically: ```bash DEVENV_WARN_TIMEOUT=20 use devenv ``` **`.env`** — local credentials (add to `.gitignore`): ```bash SYNC_BOT_USER=sync-bot SYNC_BOT_TOKEN= SUBREPO_SSH_KEY="-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY-----" # Per-target overrides: # AUTH_REPO_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=refs/tags/v1.2 flake: true ``` After updating, verify the version: ```bash josh-sync --version ``` ### Option B: Manual installation Install the required tools, then either: - Clone the josh-sync repo and add `bin/` to your `PATH` - Run `make build` to create a single bundled script at `dist/josh-sync` ## Step 4: Validate with Preflight ```bash josh-sync preflight ``` This validates: - Config syntax and required fields - josh-proxy connectivity (via `git ls-remote` through josh) - Subrepo connectivity and authentication - Branch mappings - CI workflow path coverage (checks if `.gitea/workflows/josh-sync-forward.yml` paths match target subfolders) For a new monorepo before import, preflight may warn that subfolders don't exist yet — that's expected. ## Step 5: Onboard Existing Subrepos `josh-sync onboard ` 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: - **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. - **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`). ### Strategy selection `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 ` 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:** 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 # auto-detect — picks reset for empty subrepo josh-sync onboard billing --mode=reset # force reset explicitly ``` 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. ### Manual: import → merge → reset 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. #### Manual: Import ```bash josh-sync import billing ``` This: 1. Clones the monorepo directly (not through josh) 2. Clones the subrepo 3. Copies subrepo content into the monorepo subfolder via `rsync` 4. Creates a branch `auto-sync/import-billing-` 5. Pushes it and creates a PR on the monorepo Review the import PR — check for leaked credentials, environment-specific config, or files that shouldn't be in the monorepo. #### 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. > At this point, the monorepo has the content but the histories are disconnected. Sync will **not** work until you complete the reset step. #### Manual: Reset ```bash josh-sync reset billing ``` > **You do NOT need to `git pull` locally before running reset.** The reset command clones fresh from josh-proxy — it never uses your local working copy. This: 1. Clones the monorepo through josh-proxy with the josh filter (the "filtered view") 2. Force-pushes that filtered view to the subrepo, replacing its history 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. Merge or close all open pull requests on the subrepo first — they will be invalidated. 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. #### Manual: Repeat for each target ``` For each target: 1. josh-sync import 2. Review and merge the import PR on the monorepo 3. josh-sync reset ``` ### Verify After all targets are adopted or reset: ```bash # Check all targets show state josh-sync status # Test forward sync — should return "skip" (trees are identical after adoption/reset) josh-sync sync --forward --target billing # Test reverse sync — should return "skip" (no new human commits) josh-sync sync --reverse --target billing ``` ## Step 6: Set Up CI Workflows ### Forward sync (mono → subrepo) Create `.gitea/workflows/josh-sync-forward.yml`: ```yaml name: "Josh Sync → Subrepo" on: push: branches: [main] paths: # List ALL target subfolders: - "services/billing/**" - "services/auth/**" - "libs/shared/**" schedule: - cron: "0 */6 * * *" # every 6 hours as fallback workflow_dispatch: inputs: target: description: "Target to sync (empty = detect from push or all)" required: false default: "" branch: description: "Branch to sync (empty = triggered branch or all)" required: false default: "" concurrency: group: josh-sync-fwd-${{ github.ref_name }} cancel-in-progress: false jobs: sync: runs-on: docker container: node:20-bookworm timeout-minutes: 10 steps: - uses: actions/checkout@v4 with: fetch-depth: 2 # needed for target detection - name: Install tools run: | apt-get update -qq && apt-get install -y -qq jq curl git openssh-client >/dev/null 2>&1 curl -sL "https://github.com/mikefarah/yq/releases/download/v4.44.6/yq_linux_amd64" \ -o /usr/local/bin/yq && chmod +x /usr/local/bin/yq - name: Detect changed target if: github.event_name == 'push' id: detect run: | CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || echo "") TARGETS=$(yq -o json '.targets' .josh-sync.yml \ | jq -r '.[] | "\(.name):\(.subfolder)"' \ | while IFS=: read -r name prefix; do echo "$CHANGED" | grep -q "^${prefix}/" && echo "$name" done | sort -u | paste -sd ',' -) echo "targets=${TARGETS}" >> "$GITHUB_OUTPUT" - uses: https://your-gitea.example.com/org/josh-sync@v1 with: direction: forward target: ${{ github.event.inputs.target || steps.detect.outputs.targets }} branch: ${{ github.event.inputs.branch || github.ref_name }} env: SYNC_BOT_USER: ${{ secrets.SYNC_BOT_USER }} SYNC_BOT_TOKEN: ${{ secrets.SYNC_BOT_TOKEN }} SUBREPO_TOKEN: ${{ secrets.SUBREPO_TOKEN || secrets.SYNC_BOT_TOKEN }} SUBREPO_SSH_KEY: ${{ secrets.SUBREPO_SSH_KEY }} ``` ### Reverse sync (subrepo → mono) Create `.gitea/workflows/josh-sync-reverse.yml`: ```yaml name: "Josh Sync ← Subrepo" on: schedule: - cron: "0 1,7,13,19 * * *" # every 6h, offset from forward workflow_dispatch: inputs: target: description: "Target to sync (empty = all)" required: false default: "" branch: description: "Branch to sync (empty = all eligible)" required: false default: "" concurrency: group: josh-sync-rev-${{ github.event.inputs.target || 'all' }} cancel-in-progress: false jobs: sync: runs-on: docker container: node:20-bookworm timeout-minutes: 10 steps: - uses: actions/checkout@v4 - name: Install tools run: | apt-get update -qq && apt-get install -y -qq jq curl git openssh-client >/dev/null 2>&1 curl -sL "https://github.com/mikefarah/yq/releases/download/v4.44.6/yq_linux_amd64" \ -o /usr/local/bin/yq && chmod +x /usr/local/bin/yq - uses: https://your-gitea.example.com/org/josh-sync@v1 with: direction: reverse target: ${{ github.event.inputs.target || '' }} branch: ${{ github.event.inputs.branch || '' }} env: SYNC_BOT_USER: ${{ secrets.SYNC_BOT_USER }} SYNC_BOT_TOKEN: ${{ secrets.SYNC_BOT_TOKEN }} SUBREPO_TOKEN: ${{ secrets.SUBREPO_TOKEN || secrets.SYNC_BOT_TOKEN }} SUBREPO_SSH_KEY: ${{ secrets.SUBREPO_SSH_KEY }} ``` ### Required CI secrets | Secret | Purpose | |--------|---------| | `SYNC_BOT_USER` | Bot username | | `SYNC_BOT_TOKEN` | Bot API token (monorepo access + josh-proxy auth) | | `SUBREPO_SSH_KEY` | SSH private key for subrepo push (if using SSH auth) | | `SUBREPO_TOKEN` | Optional separate subrepo token (defaults to `SYNC_BOT_TOKEN`) | > **GitHub Actions note:** These examples target Gitea Actions. For GitHub Actions, change the `uses:` reference to a GitHub repo (e.g., `org/josh-sync@v1`) and `runs-on:` to a GitHub runner (e.g., `ubuntu-latest`). ## How Ongoing Sync Works Once set up, sync runs automatically: ### Forward sync (mono → subrepo) Triggered by pushes to target subfolders or on a cron schedule: 1. Clones the monorepo through josh-proxy (filtered view of the subfolder) 2. Fetches the subrepo branch for comparison 3. If trees are identical → skip 4. If subrepo branch doesn't exist → fresh push 5. Merges mono changes on top of subrepo state 6. If clean merge → pushes with `--force-with-lease` (protects against concurrent changes) 7. If lease rejected → retries on next run (subrepo changed during sync) 8. If merge conflict → creates a conflict PR on the subrepo ### Reverse sync (subrepo → mono) Runs on a cron schedule (never triggered by subrepo pushes): 1. Clones the subrepo 2. Fetches the monorepo's josh-filtered view for comparison 3. Finds new human commits (filters out bot commits by checking for the `Josh-Sync-Origin:` trailer) 4. If no new human commits → skip 5. Pushes through josh-proxy to a staging branch 6. Creates a PR on the monorepo — **never pushes directly** ### Loop prevention Bot commits include a git trailer like `Josh-Sync-Origin: forward/main/2024-02-12T10:30:00Z`. Reverse sync filters out commits with this trailer, preventing changes from bouncing back from the target repo into the source repo. The CI action does not skip forward sync solely because the checked-out HEAD commit has a sync trailer. A repo can be a valid middle hop: it may receive a sync commit from one repository and still need to forward its filtered subtree to another repository. Forward sync relies on tree comparison, sync state, and merge checks to decide whether anything should be pushed. ### State tracking Sync state is stored as JSON files on an orphan branch (`josh-sync-state`), one file per target/branch. This tracks the last-synced commit SHAs and timestamps to avoid re-syncing the same changes. ## Recommended Git Workflow Josh-proxy maps commits through a filter per-branch. It handles linear history and simple merges (short-lived feature branches) without issues. However, it **cannot map merge commits whose parents were created on the subrepo side** — because those commits were never pushed through josh and have no monorepo-side mapping. This means cross-branch merges (e.g., `stage` → `main`) must happen on the monorepo side, where josh can filter the result cleanly. ### The rule **The monorepo owns branch topology. The subrepo owns feature development.** ### What to do where | Action | Where to do it | Why | |--------|---------------|-----| | Feature branch → `main` | **Subrepo** (PR, any merge strategy) | Short-lived branch with clean lineage — josh handles it | | `stage` → `main` (promotion) | **Monorepo** | Cross-branch merge — forward sync propagates the result to both subrepo branches | | `main` → `stage` (catch-up) | **Monorepo** | Same reason — avoids criss-cross merge history on subrepo | | Hotfix to `main` | **Either side** | Single commit or small PR — works everywhere | | Config/CI changes (monorepo-only) | **Monorepo** | Not synced to subrepo (use `exclude` for monorepo-only files) | ### What to avoid - **Don't merge `stage` into `main` on the subrepo with a merge commit.** The merge parents include commits created on the subrepo side (forward sync merges, criss-cross merges) that josh has no mapping for. Josh rejects the push with a 500 error. - **Don't merge `main` into `stage` on the subrepo.** Creates criss-cross merge history that causes the same josh mapping failure when `stage` is later merged back. - **Don't rebase synced branches on the subrepo.** This rewrites commit SHAs that josh has already mapped, breaking the sync relationship. ### If you must merge cross-branch on the subrepo Use a **squash merge**. A squash merge produces a single commit with one parent — josh can always map it. You lose the individual commit history on the target branch, but the sync goes through cleanly. As a safety net, josh-sync automatically falls back to linearizing the history when josh rejects a push — cherry-picking regular commits individually and squashing only the problematic merge commits. See the [troubleshooting section](#josh-rejected-push-reverse-sync) and [ADR-011](adr/011-linearize-fallback-reverse.md). ## Excluding Files from Sync Some files in the monorepo subfolder may not belong in the subrepo (e.g., monorepo-specific CI configs, internal tooling). The `exclude` config field removes these at the josh-proxy layer — excluded files never appear in the subrepo. ### Configuration Add an `exclude` list to any target: ```yaml targets: - name: "billing" subfolder: "services/billing" subrepo_url: "git@host:org/billing.git" exclude: - ".monorepo/" # directory at subfolder root - "**/internal/" # directory at any depth - "*.secret" # files by extension branches: main: main ``` ### How it works When `exclude` is present, josh-sync appends an inline `:exclude` filter to the josh-proxy URL. For the example above, the josh filter becomes: ``` :/services/billing:exclude[::.monorepo/,::**/internal/,::*.secret] ``` Josh-proxy applies this filter at the transport layer — no extra files to generate or commit. This means: - **Forward sync**: the filtered clone already excludes the files - **Reverse sync**: pushes through josh also respect the exclusion - **Reset**: the subrepo history never contains excluded files - **Tree comparison**: `skip` detection works correctly (excluded files are not in the diff) ### Pattern syntax Josh uses `::` patterns inside `:exclude[...]`: | Pattern | Matches | |---------|---------| | `dir/` | Directory at subfolder root | | `file` | File at subfolder root | | `**/dir/` | Directory at any depth | | `**/file` | File at any depth | | `*.ext` | Glob pattern (single `*` only) | ### Setup 1. Add `exclude` to the target in `.josh-sync.yml` 2. Run `josh-sync preflight` to verify the filter works 3. Forward sync will now exclude the specified files No extra files to generate or commit — the exclusion is embedded directly in the josh-proxy URL. ### Changing the exclude list You can safely add or remove patterns from `exclude` at any time. When josh-sync detects that the filter has changed since the last sync, it automatically creates a reconciliation merge commit on the subrepo that connects the old and new histories — no manual reset or force-push required. Developers do not need to re-clone the subrepo. ## Adding a New Target 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. 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 josh-sync onboard new-target # auto-detect strategy josh-sync onboard new-target --mode=adopt # force adopt josh-sync onboard new-target --mode=reset # force reset # Manual lower-level path (when scripting): josh-sync import new-target # merge the PR josh-sync reset new-target ``` 5. Verify with `josh-sync status` ## Troubleshooting ### "Failed to clone through josh-proxy" - Check josh-proxy is running and accessible - Verify `monorepo_path` matches what josh-proxy expects - Test manually: `git ls-remote https://:@josh.example.com/org/repo.git:/services/app.git` ### SSH authentication failures - `SUBREPO_SSH_KEY` must contain the actual key content, not a file path - For per-target keys, ensure `subrepo_ssh_key_var` in config matches the env var name - Check the key has write access to the subrepo ### "Force-with-lease rejected" Normal: the subrepo changed while sync was running. The next sync run will pick it up. If persistent, check for another process pushing to the subrepo simultaneously. ### "Josh rejected push" (reverse sync) Josh-proxy couldn't map the push back to the monorepo. This has two common causes: #### Merge commits with unmappable parents **Symptom:** Josh returns `500 Internal Server Error` with a message like: ``` rejecting merge with 2 parents: "Merge pull request 'stage' (#30) from stage into main" (c4fa3c9...) 1) "Merge branch 'auto-sync/import-...'" (4bf8704...) 2) "Merge branch 'main' into stage" (d021654...) ``` **Cause:** The subrepo has a merge commit whose parents josh-proxy cannot trace through its filter. This typically happens when: - A long-lived branch (e.g., `stage`) is merged into `main` via a merge commit (not squash) - That branch contains auto-sync merge commits or other history that doesn't exist in josh's filtered view - Someone merges `main` into a feature/staging branch and then merges it back — the criss-cross parents confuse josh's mapping **Automatic handling:** josh-sync automatically falls back to linearizing the history when the direct push fails. Regular commits are cherry-picked individually (preserving authorship and messages), while merge commits are squashed into single commits via `cherry-pick -m 1`. The PR notes when this fallback was used. See [ADR-011](adr/011-linearize-fallback-reverse.md). **Prevention:** In josh-synced subrepos, prefer **squash merges** when merging long-lived branches (stage, develop) into the synced branch. Squash merges produce a single commit with no merge parents, which josh can always map. Regular feature branch merges (short-lived, no auto-sync history) are usually fine. **Manual resolution (if automatic fallback also fails):** This indicates a more fundamental history issue. Options: 1. Cherry-pick the desired changes manually onto a clean branch and push through josh 2. Run `josh-sync reset ` to re-establish history (destructive — all subrepo clones must re-fetch) #### Filter or path mismatch Josh-proxy couldn't map the push due to an incorrect filter. Check josh-proxy logs, verify the `josh_filter` or `subfolder` in `.josh-sync.yml` is correct, and ensure the subfolder exists in the monorepo. ### Import PR shows "No changes" The subfolder already contains the same content as the subrepo. This is fine — the import is a no-op. ### Duplicate/looping commits 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 # View current state josh-sync state show [branch] # Reset state (forces next sync to run regardless of SHA comparison) josh-sync state reset [branch] ```