Skip to content
datapointchrisPublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

1 watching

Forks

Repository files navigation

syncer

Check if local git repos are fully synced before switching machines.

Syncer fetches every configured repo concurrently, classifies each branch (ahead/behind/gone/…), and shows what a per-machine sync policy would do. syncer check reports and never writes; syncer apply executes the safe actions (fast-forward, push, clone, prune). Only the repos with something to report are listed — the summary line counts the rest and -v lists them — and they are ordered so anything needing attention lands at the bottom, nearest the prompt.

Installing

uv tool install --index https://pypi.ichrisbirch.com/simple/ syncer

Every release is published to that package index. Name it on the install: without it uv asks PyPI, where syncer is a different project. uv records the index beside the tool, which is where syncer update reads it back. A branch install carries no release version, so syncer update refuses it.

Updating

syncer update

This finds the latest release and reinstalls it from the index via uv tool install.

Starting from scratch

On a machine that has never run syncer:

syncer config init                     # minimal config + empty registry, at the paths syncer reads
syncer config scan ~/code ~/tools      # build registry entries from the repos already on disk
syncer config edit registry            # or name them by hand
syncer doctor                          # can this machine actually run syncer? names what is wrong
syncer check                           # report only, never writes
syncer apply                           # execute the safe actions

config init writes a minimal starter — nothing in either file needs deleting. The fully annotated versions, with every option exercised, are reference material: syncer config example config and syncer config example registry.

config scan reads each repo's real origin to work out its owner and host, so a directory holding both your own repos and third-party clones scans correctly. It prints to stdout for review; --write puts it at the path syncer reads, and only when no registry is there.

When something does not work, run syncer doctor first. It reports git, the resolved config and registry paths and what chose each of them, whether the remotes can actually be reached (with git's own error and what to do about it), and how many repos are cloned — in prerequisite order, so the first failure is the one to act on. It exits 1 on a real problem, so syncer doctor && syncer apply stops on a box that was never going to work.

config init creates whichever of the two files is missing and never rewrites one that exists — the registry is shared infrastructure other tools may also read, so syncer scaffolds one that is absent and modifies no existing one. syncer config init registry does just that file; syncer config path says where both landed.

Usage

syncer check              # classify every repo/branch and show what would happen; never writes
syncer apply              # execute each policy's safe actions (pull/push/ff/clone)
syncer check -v           # list every repo, including the ones with nothing to report
syncer check --per-branch # per-branch view: no lifecycle, cloning, or run history
syncer check --json       # emit the run as JSON on stdout instead of a report
syncer apply -p observe   # override the resolved policy for this run
syncer apply -j 8         # limit concurrency to 8 repos at a time (default 16)
syncer check -c work.json # use a different registry; replaces the default set entirely
syncer fetch              # refresh every clone's origin refs and tags; touches no branch or tree
syncer fetch -v           # the same, listing every repo and what moved
syncer doctor            # can this machine run syncer? git, paths, reachability, clones
syncer issues            # report path mismatches, missing/untracked repos, master branches
syncer apply --per-branch # execute the decided action per branch
syncer stats             # run history and repo insights (commits, age, dirty, stale)
syncer config            # inspect, edit, scan, and validate the config and registry
syncer policy            # list policies and show what each one decides
syncer demo              # run against temp repos to show each status state
syncer version           # print installed version

The repo-level view and --per-branch share the same policy engine and concurrency; the difference is that the repo-level one also handles repo lifecycle (clone missing repos, flag moved/untracked/no-remote repos), records a run in the history (syncer stats), and warns about repos left dirty for days.

syncer fetch runs only the fetch. It is the fetch check and apply start with, so it moves origin/* and adds new tags, and nothing else: no working tree, index, HEAD or local branch is touched, nothing is pushed, and a missing clone is reported rather than cloned. A tag origin moved is left alone, as every verb leaves it. Where git reports one (with fetch.pruneTags set), it is counted as stale and -v names it. It records no run history. A clean run prints one line — the counts of repos current and updated, and of stale tags — which suits a job run every few minutes.

A repo whose path already holds files is still cloned. Setting a machine up puts the gitignored files it cannot rebuild, such as .env, in place before the repos exist. git clone declines a non-empty destination outright, so a plain clone would leave those paths reporting not a git repository. apply builds the repo around what is there instead, and git refuses the whole checkout rather than write over any file already present, so nothing existing is replaced and nothing is deleted. A file the repo neither tracks nor ignores stays put and leaves the fresh clone dirty, which syncer reports like any other dirty tree. A path already holding a .git — a linked worktree keeps its own as a file — is reported and left alone.

Exit codes

Code Meaning
0 nothing reached an error — including repos that merely need a push
1 at least one repo reached an error state (check, apply, doctor), could not be fetched (fetch), or at least one issue was found (issues)
2 usage error — an unknown flag or argument
130 interrupted with Ctrl-C — nothing was reported and no run was recorded

A repo that is ahead exits 0. That is the normal state of a machine somebody works on, and an exit code that is non-zero every day is one nobody can automate against. 1 means something is genuinely wrong: a clone that failed, a repo whose state could not be verified, an action that was refused at write time.

--json on either verb writes the whole run to stdout as JSON with nothing else mixed in — the report, the per-repo detail, and the grouped failure causes with git's own stderr. The sync JSON is built from the same snapshots the event stream records, so it cannot disagree with syncer stats about what happened.

Config

Two files, deliberately split. syncer config path prints where both resolve to.

~/.config/syncer/config.toml — machine-local tool config: which registry to read, the default policy, custom policies, per-repo overrides, and the git timeout. syncer config init writes a three-line starter with nothing in it to delete; syncer config example config prints a fully annotated one showing every option. That annotated version carries a worked example policy named laptop — a teaching device, not a built-in, however much syncer policy list would make it look like one if you kept it.

# repos_registry = "~/shared/repos.json"  # defaults to ~/.config/syncer/repos.json
default_policy = "standard"
git_timeout = 120                        # ceiling on a single git call; clones get 5x this

repos_registry is worth setting only when another tool reads the same registry file, and then only on the machines that have it. Because it is machine-local, this file is the one thing that must not be shared between machines: a config naming a path only some of them have makes every run there fail on a registry that was never going to exist. Any message about a missing registry names what chose the path — (from repos_registry in ~/.config/syncer/config.toml) — and offers both exits, creating one there or dropping the pointer.

The registry resolves in three rungs, and -c/--repos-file beats all of them: $SYNCER_REPOS_REGISTRY for this shell, repos_registry for this machine, then syncer's own ~/.config/syncer/repos.json. Syncer reads no variable that is not prefixed SYNCER_ — an unprefixed one shared between tools is invisible to any run that sources no profile, which is every unattended one.

~/.config/syncer/repos.json — the repo registry, portable between machines. syncer config init registry writes an annotated one; syncer config example registry prints it without writing.

{
  "owner": "your-github-username",
  "host": "https://github.com",
  "search_paths": ["~/code", "~/tools"],
  "exclude_paths": ["~/code/refs"],
  "repos": [
    {"name": "my-repo", "path": "~/code/my-repo", "status": "active"}
  ]
}

Each repo declares a status: active, dormant, or retired. syncer keeps active and dormant repos in sync alike, because a dormant clone still falls behind its remote, and skips retired ones. An entry with no status is synced too, and syncer config validate names it.

search_paths are what syncer issues scans for repos that moved or aren't tracked; exclude_paths disclaims a subtree inside them, for directories another registry owns. Syncer never modifies a registry that holds repos — issues reports drift and you fix the paths by hand. Only config init and config scan --write write one, and both refuse the moment the file lists any.

issues also flags a clone whose origin disagrees with what the registry declares. gh repo clone <bare-name> resolves to the authenticated user, so a reference repo that also exists under your own account silently gets your fork as its upstream and pulls from it forever — which went unnoticed for three months on two repos. Comparison normalizes https, scp-style SSH and ssh:// with a port to the same thing, so cloning over SSH against an https registry isn't a false positive. Report-only: a deliberate fork is indistinguishable from a mistake without asking, so the remote is never rewritten.

A registry is a self-contained set: -c/--repos-file swaps the entire working set rather than merging with the default, and each registry gets its own run history, so syncer stats -c work.json reports only on that set.

owns_branch_naming (default true) controls whether syncer issues flags repos still defaulting to master. Turn it off for a registry whose default-branch naming isn't yours to change — at a company that is the org's decision, so the check would report forever with nothing to do about it.

Clone URLs default to {host}/{owner}/{name}. Hosts that path can't express — scp-style SSH has no slash after the host, Bitbucket Data Center wants a /scm prefix and a .git suffix — set url_template on the registry, or clone_url on a single repo that doesn't follow its host's convention:

{
  "owner": "myworkspace",
  "host": "bitbucket.org",
  "url_template": "git@{host}:{owner}/{name}.git",
  "owns_branch_naming": false,
  "repos": [
    {"name": "payments", "path": "~/code/work/payments"},
    {"name": "odd-one", "path": "~/code/work/odd-one", "clone_url": "ssh://git@other:7999/x/odd-one.git"}
  ]
}

Run history goes to $XDG_STATE_HOME/syncer/<registry>-events.jsonl — state rather than data, since nothing authors it and deleting it only resets what syncer stats can see.

Sync policies

Both views classify every branch (per-branch ahead/behind/gone/no_upstream/…, computed after fetch --prune and repointing origin/HEAD) and report the action a policy would take; syncer apply executes those actions. Policies are machine-local and live in config.toml, so the same repo can sync aggressively on an always-on box and report-only on a laptop.

apply is safe by construction: it enforces hard invariants no policy can override — never --force, never mutate a dirty working tree, fast-forward only under strict ancestry, rebase_push aborts cleanly on conflict, and any precondition that fails at write time is refused (never forced) rather than mutated.

Tags are fetched after the branches, and never decide whether a repo was measured. A tag the remote moved, such as a reusable workflow's v1, is left where it is locally and named under the repo. So is any other reason the tag fetch was refused. Neither changes the exit code.

Repos are fetched and processed concurrently (default 16 at a time, -j to tune), so a single run over many repos takes roughly as long as the slowest repo rather than the sum. A small random jitter staggers the initial fetches so they don't hit the remote all at once. Output is sorted by attention, so anything needing action lands nearest the prompt.

While it runs, a live line on stderr shows how far in it is, which repos are being fetched right now, and how long each has been going — so a slow host is visible as it happens rather than after the fact. It is a terminal affordance: nothing is drawn into a pipe, a log, or --json. Ctrl-C ends the git calls immediately and exits 130 without writing a run to the history, because a sweep that covered some unknown fraction of the registry is not a measurement.

Git is run with prompting, askpass, and credential-manager windows all disabled, so an expired credential fails instead of asking. Stored credentials still answer — only the window is refused. And the first proof that a host is unreachable (rejected credential, unverified host key, unresolvable name) closes it for the rest of the run: the remaining repos on that host are reported as not contacted rather than each waking a credential helper of their own. A host that has already answered successfully can never be closed, and ssh and https count as separate credentials on the same host.

Three policies are built in: standard (default-branch auto-sync, feature branches report-only), observe (report everything, mutate nothing), and mirror (auto everything safe, opt-in). Define your own under [policies.<name>] with a scope (default/current/tracked/all) and a rule table keyed by <selector>:<state>:

default_policy = "standard"

[policies.laptop]
scope = "all"
fallback = "report"
merge_target = "develop"                    # branch a gone branch must be integrated into before delete_local
protected = ["develop", "dev", "uat", "prod", "release/*"]
watch_remote = ["develop", "dev", "uat", "prod"]
[policies.laptop.rules]
"release/*:ahead" = "report"
"*:behind"        = "fast_forward"
"*:diverged"      = "report"

[repo_overrides]
"some-shared-repo" = "observe"   # per-repo, per-machine

Protected branches

protected names branches (fnmatch patterns, the same grammar the selectors use) that nothing may publish to or destroy. It is enforced centrally in the executor before any action runs, so it's a hard guard rather than a matter of writing the right exact-name rule for every branch — under a fallback like "*:ahead" = "push", one branch you forgot is one stray local commit on a shared branch.

push, rebase_push, set_upstream_push and delete_local are refused. fast_forward still runs: advancing a branch to what its upstream already contains publishes nothing and destroys nothing, and refusing it would make the setting useless for exactly the long-lived branches it exists to protect. The permitted set is an allowlist, so any action added later is refused on a protected branch by default.

Like every other policy setting, it is machine-local — it lives on a policy in config.toml, none of the built-ins protect anything, and the portable registry carries no policy settings at all. A check run marks the actions protection would refuse, so you don't have to run apply to see the guard working:

syncer policy show laptop --branch develop   # marks every action this stops

Watched remote branches

A fetch already brings down every branch on the remote, but syncer only classifies branches you have locally — so a long-lived branch you deliberately never check out is invisible, and nothing tells you origin/prod moved. watch_remote names patterns to report on anyway:

  origin/develop — remote only, last commit 2 hours ago
  origin/uat     — remote only, last commit 3 days ago

Purely informational, and it never affects a repo's severity: there is no local branch to sync, and a repo isn't unhealthy for having branches you deliberately don't keep. Opt-in and empty by default, because every repo has remote branches you'll never care about and a check that fires on all of them is one you learn to ignore.

Don't create local copies of these to read them. git log origin/uat, git show origin/uat:file and git diff origin/develop...HEAD all work offline against the fetched ref. A local branch you never check out is pinned at whenever you made it, so it silently shows stale history while looking like a normal branch.

Selectors resolve exact branch name → glob → role (default, then current) → *, and the first one with a rule for that state wins. Rules should name intents, not mechanisms: fast_forward dispatches to merge --ff-only when the branch is checked out and update-ref when it isn't, so naming either mechanism directly (pull_ff, ff_ref) is refused for half of all checkout states.

Rather than reading a rule table and working out what it produces, ask:

syncer policy list                            # every policy this machine resolves
syncer policy show laptop                     # its rules, plus the decision for every state
syncer policy show laptop --branch release/2  # ...resolved for a real branch name

That matrix is computed by calling the rules engine, so it is the policy rather than a description of one — it cannot drift from what apply will do.

Policy per repo resolves in order: CLI --policy → machine repo_overrides → optional sync_policy hint in repos.json → machine default_policy → built-in standard. syncer config show prints which one won for every repo. The sync_policy hint must name a built-in, since the registry travels between machines and config.toml doesn't; syncer config validate enforces that.

The state taxonomy is PrimaryState and the action catalog is Action, both in src/syncer/policy.py. The safety invariants are listed at the top of src/syncer/execute.py, which is the only module that writes.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages