Skip to content

Repository files navigation

path-sync

PyPIGitHubcodecovDocs

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:

TermDefinition
SRCSource repository containing the canonical files
DESTDestination repository receiving synced files
HeaderComment added to synced files marking them as managed
SectionMarked 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)

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.

FlagDescription
-d dest1,dest2Filter specific destinations
--dry-runPreview without writing (requires existing repos)
-y, --no-promptSkip confirmations (for CI)
--skip-commitNo git ops after sync (no commit/push/PR). Alias: --local
--no-checkoutSkip branch switching (assumes already on correct branch)
--checkout-from-defaultReset to origin/default before sync
--no-prPush but skip PR creation
--force-overwriteOverwrite files even if header removed (opted out)
--detailed-exit-codeExit 0=no changes, 1=changes, 2=error
--skip-orphan-cleanupSkip deletion of orphaned synced files
--skip-verifySkip verification steps after syncing
--pr-titleOverride PR title (supports {name}, {dest_name})
--pr-labelsComma-separated PR labels
--pr-reviewersComma-separated PR reviewers
--pr-assigneesComma-separated PR assignees

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

ScenarioCommand
Interactive synccopy -n cfg
CI fresh synccopy -n cfg --checkout-from-default -y
Local previewcopy -n cfg --dry-run
Local test filescopy -n cfg --skip-commit
Already on branchcopy -n cfg --no-checkout
Push, manual PRcopy -n cfg --no-pr -y
Force opted-outcopy -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: dest1dest_path_relative: ../dest1skip_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 myconfigdefhello():
pass

With 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 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 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).

Config Reference

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/*"
FieldDescription
nameConfig identifier
src_repo_urlSource repo URL (auto-detected from git remote)
scheduleCron for scheduled sync workflow
pathsFiles/globs to sync (see path options below)
destinationsTarget repos with sync settings
header_configComment style per extension (has defaults)
pr_defaultsPR title, body template, labels, reviewers, assignees
wrap_synced_filesWrap synced files in section markers (default: false)
keep_pr_on_no_changesKeep stale PR open instead of auto-closing when sync produces zero changes (default: false)
force_resyncIgnore the "PR already synced from newer commit" check, always run the full sync (default: false)
verifyVerification steps to run after syncing (see Verify Steps)

body_template variables (available in pr_defaults.body_template):

VariableDescription
{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:

FieldDescription
src_pathSource file, directory, or glob pattern (required)
dest_pathDestination path (defaults to src_path)
sync_modesync (default), replace, or scaffold
exclude_dirsDirectory names to skip (defaults: __pycache__, .git, .venv, etc.)
exclude_file_patternsFilename patterns to skip, supports globs (*.pyc, test_*.py)
wrapOverride global wrap_synced_files for this path (true/false)

Destination options:

FieldDescription
nameDestination identifier (required)
repo_urlRepo URL for cloning if not found locally
dest_path_relativePath to destination repo relative to source (required)
copy_branchBranch for sync (defaults to sync/{config_name})
default_branchDefault branch to compare against (defaults to main)
skip_sectionsMap of {dest_path: [section_ids]} to preserve locally
skip_file_patternsPatterns to skip for this destination (matches dest path, fnmatch syntax)
verifyPer-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: 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 test

Per-destination override:

destinations:
- name: dest1dest_path_relative: ../dest1verify:
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:

ExtensionFormat
.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 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 }}

Destination repo validation

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

PermissionScope
ContentsRead/write (push branches)
Pull requestsRead/write (create PRs)
WorkflowsRead/write (if syncing .github/workflows/)
MetadataRead (always required)

Add as repository secret: GH_PAT

Common Errors

ErrorFix
HTTP 404: Not FoundAdd repo to PAT's repository access
HTTP 403: Resource not accessibleAdd Contents + Pull requests permissions
GraphQL: Resource not accessibleUse GH_PAT, not GITHUB_TOKEN
HTTP 422: Required status checkExclude 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-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
FieldDescription
from_configSource config name for destination list
include_destinationsOnly process these destinations
exclude_destinationsSkip these destinations
updatesCommands to run (in order)
verify.on_failDefault failure strategy: skip, fail, warn
verify.stepsVerification commands with optional commit/on_fail
keep_pr_on_no_changesKeep stale PR open instead of auto-closing when no changes (default: false)
pr.auto_mergeEnable GitHub auto-merge after PR creation

CLI Flags

FlagDescription
-n, --nameConfig name (required)
-d, --destFilter destinations (comma-separated)
--work-dirClone directory for repos without dest_path_relative
--dry-runPreview without creating PRs
--skip-verifySkip verification steps
--pr-reviewersOverride PR reviewers (comma-separated)
--pr-assigneesOverride 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-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 }}

Alternatives Considered

ToolWhy Not
repo-file-sync-actionNo local CLI, no validation
CopierMerge-based (conflicts), no multi-dest
CruftPatch-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)

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages