Metadata-Version: 2.5
Name: path-sync
Version: 0.10.0
Summary: Sync files from a source repo to multiple destination repos
Author-email: EspenAlbert <espen.albert1@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: cli,repository,sync,template
Classifier: Development Status :: 4 - Beta
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.13
Requires-Dist: gitpython>=3.1.0
Requires-Dist: pydantic>=2.0
Requires-Dist: pyyaml>=6.0
Requires-Dist: typer>=0.16.0
Requires-Dist: zero-3rdparty>=0.104.2
Description-Content-Type: text/markdown

# path-sync

[![PyPI](https://img.shields.io/pypi/v/path-sync)](https://pypi.org/project/path-sync/)
[![GitHub](https://img.shields.io/github/license/EspenAlbert/path-sync)](https://github.com/EspenAlbert/path-sync)
[![codecov](https://codecov.io/gh/EspenAlbert/path-sync/graph/badge.svg)](https://codecov.io/gh/EspenAlbert/path-sync)
[![Docs](https://img.shields.io/badge/docs-GitHub%20Pages-blue)](https://espenalbert.github.io/path-sync/)

Sync files from a source repo to multiple destination repos.

## Overview

**Problem**: You have shared config files (linter rules, CI templates, editor settings) that should be consistent across multiple repositories. Manual copying leads to drift.

**Solution**: path-sync provides one-way file syncing with clear ownership:

| Term        | Definition                                            |
| ----------- | ----------------------------------------------------- |
| **SRC**     | Source repository containing the canonical files      |
| **DEST**    | Destination repository receiving synced files         |
| **Header**  | Comment added to synced files marking them as managed |
| **Section** | Marked region within a file for partial syncing       |

**Key behaviors**:

- SRC owns synced content; DEST should not edit it
- Files with headers are updated on each sync
- Remove a header to opt-out (file becomes DEST-owned)
- Orphaned files (removed from SRC) are deleted in DEST
- Dest-only extras under dir or glob mappings survive `copy`; `pull --dest-only` harvests them; `prune` deletes them
- Dest improvements can be harvested back to SRC with `pull` (local working tree only; you commit SRC yourself)
- Idempotent: PR body is left unchanged when already synced from an equal or newer source commit
- Stale PRs auto-close when source and destination are already in sync (zero file changes)

## Installation

```bash
# From PyPI
uvx path-sync --help

# Or install in project
uv pip install path-sync
```

## Quick Start

### 1. Bootstrap a source config

```bash
path-sync boot -n myconfig -d ../dest-repo1 -d ../dest-repo2 -p '.cursor/**/*.mdc'
```

Creates `.github/myconfig.src.yaml` with auto-detected git remote and destinations.

### 2. Copy files to destinations

```bash
path-sync copy -n myconfig
```

By default, prompts before each git operation. See [Usage Scenarios](#usage-scenarios) for common patterns.

| Flag                      | Description                                                 |
| ------------------------- | ----------------------------------------------------------- |
| `-d dest1,dest2`          | Filter specific destinations                                |
| `--dry-run`               | Preview without writing (requires existing repos)           |
| `-y, --no-prompt`         | Skip confirmations (for CI)                                 |
| `--skip-commit`           | No git ops after sync (no commit/push/PR). Alias: `--local` |
| `--no-checkout`           | Skip branch switching (assumes already on correct branch)   |
| `--checkout-from-default` | Reset to origin/default before sync                         |
| `--no-pr`                 | Push but skip PR creation                                   |
| `--force-overwrite`       | Overwrite files even if header removed (opted out)          |
| `--detailed-exit-code`    | Exit 0=no changes, 1=changes, 2=error                       |
| `--skip-orphan-cleanup`   | Skip deletion of orphaned synced files                      |
| `--skip-verify`           | Skip verification steps after syncing                       |
| `--pr-title`              | Override PR title (supports `{name}`, `{dest_name}`)        |
| `--pr-labels`             | Comma-separated PR labels                                   |
| `--pr-reviewers`          | Comma-separated PR reviewers                                |
| `--pr-assignees`          | Comma-separated PR assignees                                |

### 3. Pull changes from a destination

Harvest newer dest content into SRC. Reads one dest working tree and writes into SRC. Does not commit, push, open a PR, or run `copy`. The dest repo must already exist at `dest_path_relative`.

```bash
path-sync pull -n myconfig -d dest1
path-sync pull -n myconfig -d dest1 --dest-only
path-sync pull -n myconfig -d dest1 --dest-only -i '.cursor/*'
```

Unlike `copy`, `pull` never auto-applies. `--dry-run` and non-TTY list candidates and write nothing.

|                          | `copy`                            | `pull`                                                       |
| ------------------------ | --------------------------------- | ------------------------------------------------------------ |
| Direction                | SRC → DEST                        | DEST → SRC                                                   |
| `-d`                     | Comma-separated filter (optional) | Exactly one dest (required)                                  |
| Non-TTY / no-write flags | `-y` auto-confirms git ops        | Never writes (`--dry-run` or non-TTY lists only)             |
| Default                  | Overwrites dest managed content   | Only newer mapped content (git timestamp after content diff) |

| Flag              | Description                                                                                                     |
| ----------------- | --------------------------------------------------------------------------------------------------------------- |
| `-d dest`         | Required. Exactly one destination name (not comma-separated).                                                   |
| `--dest-only`     | Also harvest dest files with no SRC counterpart (additive on mapped pull).                                      |
| `--dry-run`       | Print candidates; no prompt; no write. Non-TTY behaves the same.                                                |
| `-i`, `--include` | Allowlist on SRC-relative path ([fnmatch](https://docs.python.org/3/library/fnmatch.html); quote globs in zsh). |
| `-e`, `--exclude` | Denylist after include (same matcher).                                                                          |

| Scenario                | Command                          |
| ----------------------- | -------------------------------- |
| Interactive harvest     | `pull -n cfg -d dest1`           |
| Preview candidates      | `pull -n cfg -d dest1 --dry-run` |
| Scripted / CI list only | `pull -n cfg -d dest1` (non-TTY) |

**Pull behavior**:

- **Mapped files**: Content compare first; equal content skips git. Pull only when dest git time is newer than SRC. Dirty dest or SRC path: warn and skip.
- **Sectioned dest files**: Copies managed section bodies only; dest text after `OK_EDIT` stays in dest; `skip_sections` honored.
- **Whole-file dest**: Copies file with path-sync header stripped; no header added on SRC.
- **Dest-only**: Whole file to mapped SRC path; does not edit `.src.yaml`.
- **Path filters**: `-i` / `-e` apply after collect; match SRC-relative path; empty after filter prints `No pull candidates.`
- **Confirm**: One list, one `y/n`; yes applies all rows.

**Dest-only example** (dir mapping; config unchanged):

```yaml
# .github/cursor.src.yaml (excerpt)
paths:
  - src_path: .cursor/**/*.mdc
destinations:
  - name: lz
    dest_path_relative: ../lz
```

```bash
# ../lz/.cursor/rules/bar.mdc exists; SRC has no bar.mdc yet
path-sync pull -n cursor -d lz --dest-only
```

Creates `.cursor/rules/bar.mdc` in SRC. To limit a mixed dest-only list:

```bash
path-sync pull -n cursor -d lz --dest-only -i '.cursor/*'
```

See also: [pull command reference](docs/pull/index.md).

### 4. Merge a destination into SRC

Reconcile a destination into SRC before the next `copy`. Per file and per managed section, `merge` recovers the last-synced base from the two repos' histories, prefers the changed side, and combines non-overlapping changes with `git merge-file`. A real conflict fails loudly and writes nothing. Use it when a destination moved ahead, so the next `copy` does not revert those edits.

```bash
path-sync merge -n myconfig -d dest1
path-sync merge -n myconfig -d dest1 --dry-run
```

Unlike `pull`, `merge` ranks sides by the recovered history base rather than commit timestamp, so it detects when both sides changed. It writes SRC, prompts to commit SRC, and prints the `path-sync copy` hint. `merge` never runs `copy`; run it separately. The `path-sync-cursor` recipe chains `merge` then `copy`.

| Flag              | Description                                                                                                     |
| ----------------- | --------------------------------------------------------------------------------------------------------------- |
| `-d dest`         | Optional filter. Repeatable or comma-separated; omit to merge every destination.                                |
| `--dry-run`       | Print classifications; no write; no commit.                                                                     |
| `-i`, `--include` | Allowlist on SRC-relative path ([fnmatch](https://docs.python.org/3/library/fnmatch.html); quote globs in zsh). |
| `-e`, `--exclude` | Denylist after include (same matcher).                                                                          |

**Merge behavior**:

- **Base from history**: A unit is the whole body for a plain file, or one managed section when the file has sections. The base is the most recent body present in both histories.
- **Classifications**: `dest-changed` harvests the destination body; `merge` combines both sides; `conflict: no common base` and `conflict: overlapping edit` write nothing and exit non-zero.
- **Sectioned files**: Only changed sections are replaced in SRC; untouched sections and dest-only text stay put. `skip_sections` honored.
- **Commit**: After writing SRC, `merge` prompts to commit and prints the suggested message `chore: merge destination {dest}`. A non-TTY prints the message instead of committing.

See also: [merge command reference](docs/merge/index.md).

### 5. Prune dest-only files

Delete dest files whose SRC counterpart is missing. Uses the same collect as `pull --dest-only`, then skips opted-out dest files before listing.

The following dest extras differ:

- **copy orphans:** Dest files that still have a path-sync header for this config and are no longer in this copy run. `_cleanup_orphans` deletes them unless `--skip-orphan-cleanup`.
- **prune dest-only:** Dest files under a dir, glob, or single-file mapping whose SRC counterpart is missing. No header required (`sync_mode: replace` extras such as `.cursor/`). `path-sync prune` unlinks after confirm.
- **pull dest-only:** Same collect as prune, then copy dest → SRC. Opted-out dest files (`sync_mode: sync`, header removed) stay on dest for prune; pull `--dest-only` can still harvest them.

```bash
path-sync prune -n myconfig -d dest1
path-sync prune -n myconfig -d dest1 --dry-run
path-sync prune -n myconfig -d dest1 -i '.cursor/*'
```

Unlike `copy`, `prune` never auto-deletes. `--dry-run` and non-TTY list candidates and write nothing.

- **`-d dest`:** Required. Exactly one destination name (not comma-separated).
- **`--dry-run`:** Print candidates; no prompt; no delete. Non-TTY behaves the same.
- **`-i`, `--include`:** Allowlist on dest-relative path ([fnmatch](https://docs.python.org/3/library/fnmatch.html); quote globs in zsh).
- **`-e`, `--exclude`:** Denylist after include (same matcher).

**Dest-only example** (same cursor config as pull):

```bash
# ../lz/.cursor/rules/bar.mdc exists; SRC has no bar.mdc yet
path-sync prune -n cursor -d lz
```

After `copy`, when prune-eligible dest-only files remain, path-sync prints the count plus the pull and prune commands. Copy does not delete them.

See also: [prune command reference](docs/prune/index.md).

### 6. Validate (run in dest repo)

```bash
uvx path-sync validate-no-changes -b main
```

In CI, when the workflow runs on `pull_request`, the comparison branch is the PR base. On `push` or locally, pass `-b` with the branch to compare against (e.g. `-b SDLC` for a branch based on SDLC).

Options:

- `-b, --branch` - Branch to compare against (default: main). When `GITHUB_BASE_REF` is set (e.g. in GitHub Actions), it overrides the default so CI can use the PR base without passing `-b`. If you set `GITHUB_BASE_REF`, use a non-empty branch name.
- `--skip-sections` - Comma-separated `path:section_id` pairs to skip (e.g., `justfile:coverage`)

## Usage Scenarios

| Scenario          | Command                                  |
| ----------------- | ---------------------------------------- |
| Interactive sync  | `copy -n cfg`                            |
| CI fresh sync     | `copy -n cfg --checkout-from-default -y` |
| Local preview     | `copy -n cfg --dry-run`                  |
| Local test files  | `copy -n cfg --skip-commit`              |
| Already on branch | `copy -n cfg --no-checkout`              |
| Push, manual PR   | `copy -n cfg --no-pr -y`                 |
| Force opted-out   | `copy -n cfg --force-overwrite`          |

**Interactive prompt behavior**: Each git operation (checkout, commit, push, PR) prompts independently. Use `--no-checkout` to skip the branch switch prompt. Use `--skip-commit` to skip all git operations after sync.

## Section Markers

For partial file syncing (e.g., `justfile`, `pyproject.toml`), wrap sections with markers:

```makefile
# === DO_NOT_EDIT: path-sync default ===
lint:
    ruff check .
# === OK_EDIT ===
```

- **`DO_NOT_EDIT: path-sync {id}`** - Start of managed section with identifier
- **`OK_EDIT`** - End marker (content below is editable)

During sync, only content within markers is replaced. Destination can have extra sections.

Use `skip_sections` in destination config to exclude specific sections from sync:

```yaml
destinations:
  - name: dest1
    dest_path_relative: ../dest1
    skip_sections:
      justfile: [coverage] # keep local coverage recipe
```

## Wrapping Synced Files

For files without section markers, `wrap_synced_files` automatically wraps content in a `synced` section. This lets destinations add content before/after the synced content.

**Without wrapping** (default):

```python
# path-sync copy -n myconfig
def hello():
    pass
```

**With wrapping** (`wrap_synced_files: true`):

```python
# path-sync copy -n myconfig
# === DO_NOT_EDIT: path-sync synced ===
def hello():
    pass


# === OK_EDIT: path-sync synced ===
```

Destinations can add content outside the section markers. Per-path override via `wrap: false`:

```yaml
wrap_synced_files: true
paths:
  - src_path: templates/base.py # wrapped
  - src_path: .editorconfig
    wrap: false # not wrapped
```

## Skipping Files per Destination

Use `skip_file_patterns` to exclude files for specific destinations. Patterns match against the **destination path** (after `dest_path` remapping):

```yaml
paths:
  - src_path: scripts/
    dest_path: tools/ # remapped in destination
destinations:
  - name: dest1
    dest_path_relative: ../dest1
    skip_file_patterns:
      - "tools/internal/*" # matches destination path, not src
      - "*.test.py"
      - "docs/draft.md"
```

Patterns use [fnmatch](https://docs.python.org/3/library/fnmatch.html) syntax (`*` matches any characters, `?` matches single character).

## Config Reference

**Source config** (`.github/{name}.src.yaml`):

```yaml
name: cursor
src_repo_url: https://github.com/user/src-repo
schedule: "0 6 * * *"
paths:
  - src_path: .cursor/**/*.mdc
  - src_path: templates/justfile
    dest_path: justfile
  - src_path: scripts/
    exclude_file_patterns:
      - "*.pyc"
      - "test_*.py"
destinations:
  - name: dest1
    repo_url: https://github.com/user/dest1
    dest_path_relative: ../dest1
    # copy_branch: sync/cursor  # defaults to sync/{config_name}
    default_branch: main
    skip_sections:
      justfile: [coverage]
    skip_file_patterns:
      - "scripts/internal/*"
```

| Field                   | Description                                                                                         |
| ----------------------- | --------------------------------------------------------------------------------------------------- |
| `name`                  | Config identifier                                                                                   |
| `src_repo_url`          | Source repo URL (auto-detected from git remote)                                                     |
| `schedule`              | Cron for scheduled sync workflow                                                                    |
| `paths`                 | Files/globs to sync (see path options below)                                                        |
| `destinations`          | Target repos with sync settings                                                                     |
| `header_config`         | Comment style per extension (has defaults)                                                          |
| `pr_defaults`           | PR title, body template, labels, reviewers, assignees                                               |
| `wrap_synced_files`     | Wrap synced files in section markers (default: `false`)                                             |
| `keep_pr_on_no_changes` | Keep stale PR open instead of auto-closing when sync produces zero changes (default: `false`)       |
| `force_resync`          | Ignore the "PR already synced from newer commit" check, always run the full sync (default: `false`) |
| `verify`                | Verification steps to run after syncing (see [Verify Steps](#verify-steps-in-copy))                 |

**`body_template` variables** (available in `pr_defaults.body_template`):

| Variable          | Description                                    |
| ----------------- | ---------------------------------------------- |
| `{src_repo_name}` | Source repo name (derived from remote URL)     |
| `{src_repo_url}`  | Source repo URL                                |
| `{src_sha}`       | Full SHA of the source commit                  |
| `{src_sha_short}` | Short SHA (first 8 chars) of the source commit |
| `{src_commit_ts}` | ISO 8601 timestamp of the source commit        |
| `{sync_log}`      | Log of synced file operations                  |
| `{dest_name}`     | Destination name                               |

**Path options**:

| Field                   | Description                                                              |
| ----------------------- | ------------------------------------------------------------------------ |
| `src_path`              | Source file, directory, or glob pattern (required)                       |
| `dest_path`             | Destination path (defaults to `src_path`)                                |
| `sync_mode`             | `sync` (default), `replace`, or `scaffold`                               |
| `exclude_dirs`          | Directory names to skip (defaults: `__pycache__`, `.git`, `.venv`, etc.) |
| `exclude_file_patterns` | Filename patterns to skip, supports globs (`*.pyc`, `test_*.py`)         |
| `wrap`                  | Override global `wrap_synced_files` for this path (`true`/`false`)       |

**Destination options**:

| Field                | Description                                                               |
| -------------------- | ------------------------------------------------------------------------- |
| `name`               | Destination identifier (required)                                         |
| `repo_url`           | Repo URL for cloning if not found locally                                 |
| `dest_path_relative` | Path to destination repo relative to source (required)                    |
| `copy_branch`        | Branch for sync (defaults to `sync/{config_name}`)                        |
| `default_branch`     | Default branch to compare against (defaults to `main`)                    |
| `skip_sections`      | Map of `{dest_path: [section_ids]}` to preserve locally                   |
| `skip_file_patterns` | Patterns to skip for this destination (matches dest path, fnmatch syntax) |
| `verify`             | Per-destination verify config (overrides source-level verify)             |

## Verify Steps in Copy

Run verification steps after syncing files. Synced files are committed first, then verify steps run and can make additional commits.

```yaml
name: myconfig
verify:
  on_fail: warn # default: warn (also: skip, fail)
  steps:
    - run: just fmt
      commit:
        message: "style: format synced files"
        add_paths: ["."]
      on_fail: warn
    - run: just test
```

Per-destination override:

```yaml
destinations:
  - name: dest1
    dest_path_relative: ../dest1
    verify:
      steps:
        - run: npm run build
```

Use `--skip-verify` to disable verification steps.

## Header Format

Synced files have a header comment identifying the source config:

```python
# path-sync copy -n myconfig
```

Comment style is extension-aware:

| Extension              | Format                              |
| ---------------------- | ----------------------------------- |
| `.py`, `.sh`, `.yaml`  | `# path-sync copy -n {name}`        |
| `.go`, `.js`, `.ts`    | `// path-sync copy -n {name}`       |
| `.md`, `.mdc`, `.html` | `<!-- path-sync copy -n {name} -->` |

Remove this header to opt-out of future syncs for that file.

## PR Body Metadata

path-sync embeds a hidden HTML comment in PR bodies to track the source commit:

```html
<!-- path-sync: sha=abc1234 ts=2026-02-12T10:00:00+00:00 -->
```

This is primarily useful when syncing from a feature/PR branch instead of `main`. When a scheduled CI job runs against `main`, the source commit timestamp will be older than the PR branch sync. path-sync detects this and skips the PR body overwrite, preserving the newer sync state.

If `copy` or `dep-update` finds zero file changes, any open PR for the sync branch is closed automatically (disable with `keep_pr_on_no_changes: true`). The timestamp check can be bypassed with `force_resync: true` (copy only). Existing PRs without metadata are always overwritten.

## GitHub Actions

### Source repo workflow

Create `.github/workflows/path_sync_copy.yaml`:

```yaml
name: path-sync copy
on:
  schedule:
    - cron: "0 6 * * *"
  workflow_dispatch:

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v5
      - run: uvx path-sync copy -n myconfig --checkout-from-default -y
        env:
          GH_TOKEN: ${{ secrets.GH_PAT }}
```

### Destination repo validation

Create `.github/workflows/path_sync_validate.yaml`:

```yaml
name: path-sync validate
on:
  push:
    branches-ignore:
      - main
      - sync/**
  pull_request:
    branches:
      - main

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: astral-sh/setup-uv@v5
      - name: Validate no changes to synced files
        env:
          GITHUB_BASE_REF: ${{ github.base_ref || 'main' }}
        run: uvx path-sync validate-no-changes
```

**Validation skips automatically when:**

- On a `sync/*` branch (path-sync uses `sync/{config_name}` by default)
- On the default branch (comparing against itself)

The workflow triggers exclude these branches too, reducing unnecessary CI runs.

### PAT Requirements

Create a **Fine-grained PAT** at <https://github.com/settings/tokens?type=beta>

| Permission    | Scope                                        |
| ------------- | -------------------------------------------- |
| Contents      | Read/write (push branches)                   |
| Pull requests | Read/write (create PRs)                      |
| Workflows     | Read/write (if syncing `.github/workflows/`) |
| Metadata      | Read (always required)                       |

Add as repository secret: `GH_PAT`

### Common Errors

| Error                               | Fix                                      |
| ----------------------------------- | ---------------------------------------- |
| `HTTP 404: Not Found`               | Add repo to PAT's repository access      |
| `HTTP 403: Resource not accessible` | Add Contents + Pull requests permissions |
| `GraphQL: Resource not accessible`  | Use GH_PAT, not GITHUB_TOKEN             |
| `HTTP 422: Required status check`   | Exclude `sync/*` from branch protection  |

## Dependency Updates

The `dep-update` command runs dependency updates across multiple repositories. It clones repos, runs update commands, verifies changes, and creates PRs.

### Quick Start

```bash
# Create config at .github/myconfig.dep.yaml (see example below)
# Then run:
path-sync dep-update -n myconfig

# Preview without creating PRs
path-sync dep-update -n myconfig --dry-run

# Filter specific destinations
path-sync dep-update -n myconfig -d repo1,repo2
```

### Dep Config Reference

**Config file**: `.github/{name}.dep.yaml`

```yaml
name: uv-deps
from_config: python-template # references .github/python-template.src.yaml for destinations
exclude_destinations:
  - path-sync # skip self

updates:
  - command: uv lock --upgrade
  - workdir: packages/sub # optional subdirectory
    command: uv lock --upgrade

verify:
  on_fail: skip # default strategy: skip, fail, warn
  steps:
    - run: uv sync
    - run: just fmt
      commit:
        message: "chore: format after update"
        add_paths: [".", "!uv.lock"] # ! prefix excludes
      on_fail: warn
    - run: just test

pr:
  branch: deps/uv-lock-update
  title: "chore(deps): update uv.lock"
  labels: [dependencies]
  reviewers: [] # optional
  assignees: [] # optional
  auto_merge: true
```

| Field                   | Description                                                                   |
| ----------------------- | ----------------------------------------------------------------------------- |
| `from_config`           | Source config name for destination list                                       |
| `include_destinations`  | Only process these destinations                                               |
| `exclude_destinations`  | Skip these destinations                                                       |
| `updates`               | Commands to run (in order)                                                    |
| `verify.on_fail`        | Default failure strategy: `skip`, `fail`, `warn`                              |
| `verify.steps`          | Verification commands with optional commit/on_fail                            |
| `keep_pr_on_no_changes` | Keep stale PR open instead of auto-closing when no changes (default: `false`) |
| `pr.auto_merge`         | Enable GitHub auto-merge after PR creation                                    |

### CLI Flags

| Flag             | Description                                            |
| ---------------- | ------------------------------------------------------ |
| `-n, --name`     | Config name (required)                                 |
| `-d, --dest`     | Filter destinations (comma-separated)                  |
| `--work-dir`     | Clone directory for repos without `dest_path_relative` |
| `--dry-run`      | Preview without creating PRs                           |
| `--skip-verify`  | Skip verification steps                                |
| `--pr-reviewers` | Override PR reviewers (comma-separated)                |
| `--pr-assignees` | Override PR assignees (comma-separated)                |

### Failure Strategies

- **skip**: Skip PR for this repo, continue with others (default)
- **fail**: Stop all processing immediately
- **warn**: Create PR anyway with warning in body

Per-step `on_fail` overrides the verify-level default.

### GitHub Actions

```yaml
name: dep-update
on:
  schedule:
    - cron: "0 6 * * 1" # Weekly Monday
  workflow_dispatch:

jobs:
  update:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v5
      - run: uvx path-sync dep-update -n myconfig
        env:
          GH_TOKEN: ${{ secrets.GH_PAT }}
```

## Alternatives Considered

| Tool                                                                       | Why Not                                |
| -------------------------------------------------------------------------- | -------------------------------------- |
| [repo-file-sync-action](https://github.com/BetaHuhn/repo-file-sync-action) | No local CLI, no validation            |
| [Copier](https://copier.readthedocs.io/)                                   | Merge-based (conflicts), no multi-dest |
| [Cruft](https://cruft.github.io/cruft/)                                    | Patch-based, single dest               |

**Why path-sync:**

- One SRC to many DEST repos
- Local CLI + CI support
- Section-level sync for shared files
- Validation enforced across repos
- Clear ownership (no merge conflicts)
