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-onlyharvests them;prunedeletes 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
# From PyPI
uvx path-sync --help
# Or install in project
uv pip install path-sync
Quick Start
1. Bootstrap a source config
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
path-sync copy -n myconfig
By 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 |
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.
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; 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_EDITstays in dest;skip_sectionshonored. - 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/-eapply after collect; match SRC-relative path; empty after filter printsNo pull candidates. - Confirm: One list, one
y/n; yes applies all rows.
Dest-only example (dir mapping; config unchanged):
# .github/cursor.src.yaml (excerpt)
paths:
- src_path: .cursor/**/*.mdc
destinations:
- name: lz
dest_path_relative: ../lz
# ../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:
path-sync pull -n cursor -d lz --dest-only -i '.cursor/*'
See also: pull command reference.
4. 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_orphansdeletes 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: replaceextras such as.cursor/).path-sync pruneunlinks 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-onlycan still harvest them.
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; quote globs in zsh).-e,--exclude: Denylist after include (same matcher).
Dest-only example (same cursor config as pull):
# ../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.
5. Validate (run in dest repo)
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). 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)
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:
# === 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: 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):
# path-sync copy -n myconfig
def hello():
pass
With wrapping (wrap_synced_files: true):
# 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:
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):
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 syntax (* matches any characters, ? matches single character).
Config Reference
Source config (.github/{name}.src.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) |
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.
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:
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:
# 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:
<!-- 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:
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:
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 usessync/{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
# 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
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
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 | 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)
Metadata
Release files for path-sync 0.9.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| path_sync-0.9.0.tar.gz | 32.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| path_sync-0.9.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 78.6 kB
Release files / path_sync-0.9.0.tar.gz
| Download URL | path_sync-0.9.0.tar.gz |
|---|---|
| Size | 32.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
94f2806425eeadaa3a2ac96d272feb1ee380d28aea5b0d5136d7d28a83853054
|
|
BLAKE2b-256 checksum How to use checksums |
fc8fc97df34cceb81563f8f09f7b7d478b5841262f2da843e4ba4a8b265a1d04
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 3, 2026.
Transparency logRelease files / path_sync-0.9.0-py3-none-any.whl
| Download URL | path_sync-0.9.0-py3-none-any.whl |
|---|---|
| Size | 45.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
97af1059b002c6695d61b674338c158e0d1913f436e5b9d9b88bad9e17409aea
|
|
BLAKE2b-256 checksum How to use checksums |
03283b02b44a5a85ddffc8748238aefcd9b9fe6240ac0e4496226c9601abe4f6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 3, 2026.
Transparency log