Sync files from a source repo to multiple destination repos.
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
- 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)
# From PyPI
uvx path-sync --help
# Or install in project
uv pip install path-syncpath-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.
path-sync copy -n myconfigBy default, prompts before each git operation. See 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 |
uvx path-sync validate-no-changes -b mainIn 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). WhenGITHUB_BASE_REFis set (e.g. in GitHub Actions), it overrides the default so CI can use the PR base without passing-b. If you setGITHUB_BASE_REF, use a non-empty branch name.--skip-sections- Comma-separatedpath:section_idpairs to skip (e.g.,justfile:coverage)
| 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.
For partial file syncing (e.g., justfile, pyproject.toml), wrap sections with markers:
# === DO_NOT_EDIT: path-sync default ===lint:
ruff check .
# === OK_EDIT ===DO_NOT_EDIT: path-sync {id}- Start of managed section with identifierOK_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:
destinations:
- name: dest1dest_path_relative: ../dest1skip_sections:
justfile: [coverage] # keep local coverage recipeFor 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):
# path-sync copy -n myconfigdefhello():
passWith wrapping (wrap_synced_files: true):
# path-sync copy -n myconfig# === DO_NOT_EDIT: path-sync synced ===defhello():
pass# === OK_EDIT: path-sync synced ===Destinations can add content outside the section markers. Per-path override via wrap: false:
wrap_synced_files: truepaths:
- src_path: templates/base.py # wrapped
- src_path: .editorconfigwrap: false # not wrappedUse skip_file_patterns to exclude files for specific destinations. Patterns match against the destination path (after dest_path remapping):
paths:
- src_path: scripts/dest_path: tools/ # remapped in destinationdestinations:
- name: dest1dest_path_relative: ../dest1skip_file_patterns:
- "tools/internal/*"# matches destination path, not src
- "*.test.py"
- "docs/draft.md"Patterns use fnmatch syntax (* matches any characters, ? matches single character).
Source config (.github/{name}.src.yaml):
name: cursorsrc_repo_url: https://github.com/user/src-reposchedule: "0 6 * * *"paths:
- src_path: .cursor/**/*.mdc
- src_path: templates/justfiledest_path: justfile
- src_path: scripts/exclude_file_patterns:
- "*.pyc"
- "test_*.py"destinations:
- name: dest1repo_url: https://github.com/user/dest1dest_path_relative: ../dest1# copy_branch: sync/cursor # defaults to sync/{config_name}default_branch: mainskip_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) |
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) |
Run verification steps after syncing files. Synced files are committed first, then verify steps run and can make additional commits.
name: myconfigverify:
on_fail: warn # default: warn (also: skip, fail)steps:
- run: just fmtcommit:
message: "style: format synced files"add_paths: ["."]on_fail: warn
- run: just testPer-destination override:
destinations:
- name: dest1dest_path_relative: ../dest1verify:
steps:
- run: npm run buildUse --skip-verify to disable verification steps.
Synced files have a header comment identifying the source config:
# path-sync copy -n myconfigComment 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.
path-sync embeds a hidden HTML comment in PR bodies to track the source commit:
<!-- 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.
Create .github/workflows/path_sync_copy.yaml:
name: path-sync copyon:
schedule:
- cron: "0 6 * * *"workflow_dispatch:
jobs:
sync:
runs-on: ubuntu-lateststeps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- run: uvx path-sync copy -n myconfig --checkout-from-default -yenv:
GH_TOKEN: ${{ secrets.GH_PAT }}Create .github/workflows/path_sync_validate.yaml:
name: path-sync validateon:
push:
branches-ignore:
- main
- sync/**pull_request:
branches:
- mainjobs:
validate:
runs-on: ubuntu-lateststeps:
- uses: actions/checkout@v4with:
fetch-depth: 0
- uses: astral-sh/setup-uv@v5
- name: Validate no changes to synced filesenv:
GITHUB_BASE_REF: ${{ github.base_ref || 'main' }}run: uvx path-sync validate-no-changesValidation skips automatically when:
- On a
sync/*branch (path-sync usessync/{config_name}by default) - On the default branch (comparing against itself)
The workflow triggers exclude these branches too, reducing unnecessary CI runs.
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
| 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 |
The dep-update command runs dependency updates across multiple repositories. It clones repos, runs update commands, verifies changes, and creates PRs.
# 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,repo2Config file: .github/{name}.dep.yaml
name: uv-depsfrom_config: python-template # references .github/python-template.src.yaml for destinationsexclude_destinations:
- path-sync # skip selfupdates:
- command: uv lock --upgrade
- workdir: packages/sub # optional subdirectorycommand: uv lock --upgradeverify:
on_fail: skip # default strategy: skip, fail, warnsteps:
- run: uv sync
- run: just fmtcommit:
message: "chore: format after update"add_paths: [".", "!uv.lock"] # ! prefix excludeson_fail: warn
- run: just testpr:
branch: deps/uv-lock-updatetitle: "chore(deps): update uv.lock"labels: [dependencies]reviewers: [] # optionalassignees: [] # optionalauto_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 |
| 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) |
- 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.
name: dep-updateon:
schedule:
- cron: "0 6 * * 1"# Weekly Mondayworkflow_dispatch:
jobs:
update:
runs-on: ubuntu-lateststeps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- run: uvx path-sync dep-update -n myconfigenv:
GH_TOKEN: ${{ secrets.GH_PAT }}| Tool | Why Not |
|---|---|
| repo-file-sync-action | No local CLI, no validation |
| Copier | Merge-based (conflicts), no multi-dest |
| 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)