Skip to main content

path-sync

PyPI GitHub codecov Docs

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

# 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_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):

# .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_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.
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). 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:

# === 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:

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 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

# 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.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for path-sync 0.9.1
File Size Uploaded
path_sync-0.9.1.tar.gz 34.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for path-sync 0.9.1
File Interpreter ABI Platform
path_sync-0.9.1-py3-none-any.whl Python 3 none any Details

Total release size: 80.7 kB

Release files / path_sync-0.9.1.tar.gz

Download URL path_sync-0.9.1.tar.gz
Size 34.0 kB
Tags Source
SHA-256 checksum
How to use checksums
1a6ada517125f81af4975ecfc12ccd90657a48ae331ef24c4b06a694b8e67bc6
BLAKE2b-256 checksum
How to use checksums
448b4ed3fb6cd92b2fd546d8f6d16417504bbde576278a83a1f623496b16af2d
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 5, 2026.

Transparency log

Release files / path_sync-0.9.1-py3-none-any.whl

Download URL path_sync-0.9.1-py3-none-any.whl
Size 46.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
af0ef115c77dbea42c4ba81f72253ccee65e2caaa21953681a9425d66d101b2f
BLAKE2b-256 checksum
How to use checksums
2f2506d8a20f52e729e59423d8ca8b3bd9beb5fe14d2bb3b41461fdbc0e1e85d
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 5, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.9.1 This release

2 release files

0.9.0

2 release files

0.8.0

2 release files

0.7.8

2 release files

0.7.7

2 release files

0.7.6

2 release files

0.7.5

2 release files

0.7.4

2 release files

0.7.3

2 release files

0.7.2

2 release files

0.7.1

2 release files

0.7.0

2 release files

0.6.2

2 release files

0.6.1

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.5

2 release files

0.3.4

2 release files

0.3.3

2 release files

0.3.2

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.1

1 release file

0.2.0

1 release file

0.1.0

1 release file

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page