Small, cautious special operations for whole collections of Git repositories.
- Archive Updater wants an archive folder to stay present and current. It inventories repositories, fast-forward pulls clean clones, and can explicitly reconcile an archive with its source namespace.
- GitHub Organization Duplicator wants a complete collection moved safely. It downloads, uploads, or migrates repositories with previews, conflict checks, typed confirmation, and resumable run files.
- Sync Suggester thinks about your working repositories as a fleet—and the fun part is that it can do that across computers. It wants every clone to be safely synchronized, remembers the last facts each machine reported, and explains what needs attention before you move from one computer to another.
The repository is intentionally plain and stdlib-first. The tools are regular Python scripts; optional convenience launchers are generated locally by setup and are never committed. The optional foreground fleet app keeps its shared view in a local SQLite database; it installs no service.
Working on this repo (human or agent)? Start with
.agents/README.md— the primary project brief — and the living.agents/working-notes.md.
Install Python 3.11+ and git. GitHub CLI (gh) is required for the organization duplicator and
GitHub-aware archive discovery. The live fleet app requires an existing working gh login and
Tailscale on every machine; uv is optional but recommended for setup. Authentication remains
yours to manage:
gh auth loginRun any tool directly—no setup or launcher is required:
python3 github-org-duplicator/github_org_duplicator.py
python3 git-archive-updater/archive_manager.py --help
python3 git-sync-suggester/sync_suggester.py --helpTo set up near-live monitoring across your own Tailscale devices, run
python3 git-sync-suggester/sync_suggester.py fleet setup. It walks through hosting or joining a
fleet and optional folder/GitHub replica schedules. The host serves a browser dashboard with
repository groups and machine status. One startup inventory is followed by native filesystem
events and targeted per-repository checks; there is no periodic library scan. See
the live fleet guide
for Windows setup, authentication boundaries, and the pilot's current limitations.
This remains a developer preview: a second machine currently needs a matching source checkout.
The intended public product is an installed, self-contained desktop app with graphical first run
and channel-appropriate updates. The concrete release path is recorded in
.agents/knowledge/distribution.md.
An OS-specific PyInstaller preview recipe is available in
git-sync-suggester/BUILD-DESKTOP.md.
For convenience, run_setup also writes one launcher per tool into the repo root — only for your OS (.sh on Linux/macOS, .ps1 + a .bat double-click shim on Windows):
update-archive,manage-archives,duplicate-github-org,suggest-sync
Launchers are generated, not committed — edit setup_gitspecops.py, never a launcher. Every launcher prefers the repo's .venv interpreter and falls back to uv run when .venv is absent. After setup:
./duplicate-github-org.sh # Linux/macOS
.\duplicate-github-org.bat # Windows
./suggest-sync.sh --help # Linux/macOS
.\suggest-sync.bat --help # WindowsRunning the bootstrap once creates .venv, after which the launchers call it directly — faster cold start and no dependency on uv staying on PATH:
.\run_setup.batrun_setup builds .venv with uv sync (or the stdlib venv module when uv is absent) and
reports whether git, gh, and uv are present. The project is not installed into .venv —
the tools are stdlib-only scripts and .venv is just a clean interpreter for the launchers. It is
entirely optional; you can always run python3 <tool>/<script>.py directly.
Three special operations currently have four entry-point Python scripts:
git-archive-updater\archive_manager.py: the front door. Installs archive-local launchers, tracks managed archives, refreshes them all, and manages the optional scheduled refresh.git-archive-updater\archive_updater.py: a standalone, dependency-free updater. Scans archive folders and fast-forward pulls eligible repos using onlygit(nogh, no provider).github-org-duplicator\github_org_duplicator.py: walks a user through copying repositories between GitHub orgs and local folders.git-sync-suggester\sync_suggester.py: the read-only observer/advisor. Its initialcheckpath inventories bounded roots, renders local advice, and can atomically publish a privacy-minimized machine record to a user-selected synchronized folder.
The manager and the archive-local launchers drive a small plan/apply engine made of single-purpose modules:
archive_sync.py: the plan/apply engine (detect -> plan -> decide -> execute -> review) for update, clone, reconcile, and rename.archive_diff.py: pure decision logic (no git, no network), matching local repos to the remote set by stable id so renames survive. Run it directly to execute its self-test.git_inspect.py: read-only, host-agnostic local git facts.remote_provider.py/provider_github.py: the cross-git seam. Remote discovery (listing an org, following renames) is host-specific and lives behind a provider. GitHub via theghCLI is the only provider today; any other host falls back to update-only.
Shared primitives used across the tools live in shared\ (see .agents\README.md):
remote_identity.py, git_facts.py, repo_discovery.py (the machine/root scanner), providers.py,
and gh_cli.py. Their standalone interfaces are read-only information operations.
Supporting files:
setup_gitspecops.py: optional bootstrap — writes the convenience launchers, builds.venv(viauv sync, or stdlibvenvas a fallback), and reports prerequisites. It never touches authentication.run_setup.bat,run_setup.ps1,run_setup.sh: convenient entry points for the optional bootstrap.tests\: synthetic offline tests; fixtures never use personal repository or organization names._legacy_sources\: older source snapshots kept only for reference.
Both fast-forward clean repos. The difference is remote discovery:
archive_updater.pyneeds onlygit. It never lists an org, so it never clones, reconciles, or renames. The top-levelupdate-archive.batlauncher uses it.archive_sync.pycan additionally discover an org's full repo set through a provider, so it can clone missing repos and reconcile drift. The manager and the archive-local launchers it installs use it. When no provider matches the host, or discovery fails (e.g.ghis missing or unauthenticated),archive_sync.pydegrades to the same update-only behavior asarchive_updater.py.
The archive updater is the low-level repo refresh tool. Give it one or more archive roots. Each archive root should be a folder whose direct children are Git repositories.
Example:
.\update-archive.bat --root T:\Git\Archive-Public --default-output-dirUnder the hood, archive_updater.py inspects each direct child folder and only marks it updateable when all of these are true:
- the child is a Git work tree rooted at that folder
- it has an
originremote - the remote starts with an approved prefix, defaulting to
https://github.com/ - the work tree is clean
- the index is clean
Repos that fail any check are skipped and explained in the console output and JSON report.
When updating, it runs:
git fetch --dry-run origin
git pull --ff-onlyIt does not merge, rebase, reset, force-push, install dependencies, run project code, or recurse into nested folders. Git commands time out after 45 seconds by default; use --git-timeout to change that.
Reports are written only when an output directory is provided. Archive-local launchers installed by the manager write reports here:
ARCHIVE_ROOT\.gitSpecOps\archive-updates\archive-update-YYYYMMDD-HHMMSS.json
Reports include the root, eligible repos, skipped repos, update results, elapsed time, and Git timeout setting.
The archive manager is the friendly front door for archive folders.
Run it with:
.\manage-archives.batThe dashboard shows known archive folders, install time, repo count at install, launcher status, last refresh result, elapsed time, and latest report.
Main actions:
- install or refresh an archive-local
update_archive.bat - scan all managed archives without pulling
- update all managed archives
- show detailed status
- write a refresh-all script
- create, inspect, or remove the monthly Windows scheduled refresh
When you install an archive, the manager:
- accepts an archive folder path
- verifies the folder exists and is not itself a Git repo
- scans direct child folders, and (if a provider matches the host) the authoritative remote set
- presents a plan and applies only the classes you approve (pull, clone, reconcile, rename)
- asks which mode future automated runs should use:
update(safe, default) orsync(auto-clone new repos) - writes
update_archive.batinto that archive folder - stores the archive in
git-archive-updater\managed_archives.json
The archive-local launcher calls archive_sync.py against the pinned archive root with the configured mode and --yes for unattended runs. It auto-detects the owner at run time, so it survives org and repo renames; the owner is never baked in. Scheduled runs only ever --update or --sync (never reconcile or rename), so they cannot silently rewrite origins or move folders. It writes that archive's reports into the archive itself:
ARCHIVE_ROOT\update_archive.bat
ARCHIVE_ROOT\.gitSpecOps\archive-updates\
The manager registry stays local to this repo:
git-archive-updater\managed_archives.json
Manager logs live here:
git-archive-updater\runs\archive-manager.log
Refresh-all runs call archive_sync.py once per managed archive in its configured mode, then update the registry with the last run time, result, elapsed time, and latest report path. If a repo's host has no provider, or discovery fails (for example gh is missing or unauthenticated), that archive degrades to update-only and still fast-forwards every clean repo; the discovery failure is recorded as an issue in the run's report.
On Windows, the manager can write git-archive-updater\refresh-managed-archives.bat and register it with Task Scheduler. The default task name is:
gitSpecOps Archive Refresh
Useful direct commands:
uv run python git-archive-updater\archive_manager.py --write-refresh-all-script
uv run python git-archive-updater\archive_manager.py --install-monthly-task --task-day 1 --task-time 09:00
uv run python git-archive-updater\archive_manager.py --task-status
uv run python git-archive-updater\archive_manager.py --remove-taskTask creation and removal are explicit. The manager does not silently install background jobs.
Everything else in the archive tools pulls. --publish is the one command that writes to a
remote, and it only ever does the provably safe thing: a push without --force, which git
refuses unless it is a fast-forward.
python3 git-archive-updater/archive_sync.py --root /path/to/archive --publish --dry-run
python3 git-archive-updater/archive_sync.py --root /path/to/archive --publishOnly repositories that are ahead-only and clean are eligible. Diverged goes to a human,
behind-only needs a pull first, and a detached HEAD or missing upstream is skipped as
direction-unknown. A repository that is ahead but has uncommitted work — including untracked
files — is reported and held back; --include-dirty pushes its committed work anyway. Nothing
is ever auto-committed.
Each repository is fetched and re-checked immediately before its push, so a remote that moved
since planning is reported as "needs a human" rather than forced. --publish refuses to run
alongside --update/--sync, and the generated launchers and scheduled task never pass it, so a
background job can never push on your behalf.
Sync Suggester is a status and memory operation. On one machine it is a useful whole-folder Git dashboard. Across machines it becomes much more valuable: each computer publishes a tiny report of what it last knew, and another computer can warn about committed-but-unpushed work, an old dirty clone, divergence, or a machine whose clean report is too stale to trust.
It synchronizes facts and advice, not unfinished source content. It does not commit, stash, pull, push, patch, or copy working files.
Set the machine up once. init records who this machine is, which roots it may look at, and the
folder your own sync client (OneDrive, Dropbox, Syncthing, a mounted share) replicates between
machines:
python3 git-sync-suggester/sync_suggester.py init \
--machine-id machine-a --machine-label workstation-a \
--root /path/to/repositories \
--state-dir /path/to/gitspecops-statePrefer not to depend on a cloud-sync client? Point it at a private GitHub repository instead — it is never cloned, so there is no working copy, no commit/pull/push cycle and no conflict resolution to think about:
python3 git-sync-suggester/sync_suggester.py init \
--machine-id machine-a --root /path/to/repositories \
--state-repo yourname/gitSpecOps-state --create-state-repoEach machine reads and writes only its own small JSON file through the GitHub Contents API,
using the gh login you already have. The blob SHA returned by a read is what the write sends
back, so if another machine wrote in between, GitHub rejects the write and this re-reads rather
than clobbering. A public state repository is refused outright — machine status and branch
names should not be world-readable — and creating the repository requires the explicit
--create-state-repo, because making a repository on your account is not something a status
command should ever do as a side effect.
Pick one: --state-dir or --state-repo, never both.
Add --compress-manifests if you track a very large number of repositories — it gzips published
manifests (a real 20-repository manifest goes from 6425 to 769 bytes). It is off by default,
because an uncompressed manifest is readable by anyone looking at the folder or repository, and
that is worth more than headroom most people never need.
That prints a fleet secret. Every other machine joins the same fleet with it:
python3 git-sync-suggester/sync_suggester.py init \
--machine-id machine-b --machine-label laptop \
--root /path/to/repositories \
--state-dir /path/to/gitspecops-state \
--fleet-secret <the 64-character value machine-a printed>Carry that secret yourself — a password manager, or typed by hand. Do not put it in the state directory. It is what stops anyone who obtains that folder from working out which repositories you have, and a secret stored beside the data it protects protects nothing. A machine that joins with the wrong secret is detected and named on the dashboard rather than silently appearing to share nothing.
Roots scan direct children by default; use --recursive-root for a nested development tree, and
--from-archives to reuse the roots Archive Updater already manages. Configuration lives in
~/.config/gitspecops/sync-suggester/ (%APPDATA% on Windows, or GITSPECOPS_SYNC_HOME).
After that, check observes, publishes this machine's manifest, and prints the fleet view:
python3 git-sync-suggester/sync_suggester.py checkRepository DESKTOP LAPTOP OLDPI Advice
------------ ------- ------ ------- ------------------------------------------------------
notebooks ✎3 ✎5 ✎5 (3d) ⚠ COLLISION: uncommitted work on DESKTOP, LAPTOP, OLDPI
website ⚠rebase ✓ ? (3d) finish rebase on DESKTOP
data-loader ✓ ✓ ✎7 (3d) last known uncommitted work on OLDPI (3d ago)
api ✓ ✓ ↑1 (3d) PUSH OLDPI ↑1 (last seen 3d ago)
experiments ↕2/4 ✓ ? (3d) ↕ diverged on DESKTOP — human decision
toolbox ↑3 ✓ ? (3d) PUSH DESKTOP ↑3
docs-site ↓2 ✓ ? (3d) PULL DESKTOP ↓2
11 repositor(ies) need no action (use --all to list them) — clean on every current machine, but
OLDPI has not reported, so their state there is unknown, not proven clean
Machines: DESKTOP current (4m ago), LAPTOP current (just now), OLDPI stale (3d ago)
Legend: ✓ clean ✎ uncommitted ↑ ahead ↓ behind ↕ diverged ⚑ stash ⚠ attention ? unknown - not present
By default every ↑/↓ is cached knowledge, read from remote-tracking refs that may be days old,
and the dashboard says so. check --fetch refreshes them first:
python3 git-sync-suggester/sync_suggester.py check --fetchThat is the only network activity the tool performs. It updates remote-tracking refs and nothing
else — never a branch, never your working tree — and it records when each repository was fetched,
because a manifest written a second ago says nothing about how old its remote knowledge is. Those
two kinds of freshness are tracked and displayed separately. A repository whose fetch fails keeps
its cached counts and says so, rather than the run failing. 20 repositories take about four seconds
over four workers (--fetch-workers, --fetch-timeout).
It is an exceptions view: rows nothing can be done about are folded into that summary line, which
still names any machine whose silence is the reason a row is quiet. A report that is not current
never produces an "all clear." A stale clean report becomes unknown; a stale dirty or ahead
report keeps its warning, because the last thing anyone knew was that unresolved work existed there.
Reports are current for 24 hours, stale for 7 days, and expired after that (--stale-hours /
--expired-days at init).
check tells you the state of repositories you have. converge answers the other half — which
repositories your other machines have that this one does not:
python3 git-sync-suggester/sync_suggester.py converge17 repositor(ies) reported by peers are not on this machine.
Repository Present on
----------------------------------- ----------
sb4ssman/Chess_App ALPHA, BRAVO
sb4ssman/Laboratory ALPHA, BRAVO
...
To clone them, hand each namespace to the archive engine, which already discovers and
clones through the same provider seam:
python3 git-archive-updater/archive_sync.py --root /memory-lambda/Github/sb4ssman \
--github-owner sb4ssman --sync
There is a neat problem here worth knowing about. Peers publish only hashed identities, so a
machine cannot clone what it cannot name. But the hash is deterministic: ask GitHub what exists in
the namespaces you already work in, hash each candidate under the same fleet secret, and match.
A repository you can see gets named without its name ever crossing the transport, and a repository
you genuinely cannot see stays an opaque identifier — which is the correct answer, not a failure.
Names the local catalog already knows cost no network call at all; --namespace OWNER searches
somewhere else, and --no-resolve skips the provider entirely.
converge never clones. Cloning is a mutation and it already has an owner — archive_sync.py —
so this command reports the gap and hands over the exact command.
python3 git-sync-suggester/sync_suggester.py dashboard # read peers, observe nothing
python3 git-sync-suggester/sync_suggester.py watch --interval 60 # keep publishing while you work
python3 git-sync-suggester/sync_suggester.py alias <id> <name> # name a repo only a peer has
python3 git-sync-suggester/sync_suggester.py doctor # inspect local state, change nothingwatch republishes only when the facts actually change, plus a periodic heartbeat proving the
machine is alive, and makes a best-effort final observation when you stop it (Ctrl-C or SIGTERM,
answered in well under a second). No tool can guarantee an external cloud client uploads that last
write before abrupt sleep or power loss, which is why an explicit check before you walk away is
still worth running.
The synchronized manifest (schema v2) identifies repositories by HMAC-SHA256(fleet secret, host/owner/name). It contains no display names, remote URLs, absolute paths, filenames, diffs,
commit messages, commit SHAs, or source content; readable names come from an unsynchronized local
catalog. Branch names are still published in the clear — a deliberate trade-off, since knowing
which branch each machine sits on is worth showing. See
.agents/knowledge/manifest-privacy.md for exactly what
that boundary does and does not protect.
handoff remains reserved: moving unfinished work between machines is a mutation flow and gets its
own design pass rather than hiding inside the watcher.
Run it with:
.\duplicate-github-org.batRun with no flags it is interactive. It checks for git, gh, GitHub authentication, and reminds
you that private Git credentials remain yours to configure before doing work.
It also takes flags for an unattended run — useful inside editors/CI where a terminal helper may type virtualenv-activation lines into an open prompt:
# Back up every namespace, working clones, no further questions:
python3 github-org-duplicator/github_org_duplicator.py --batch --namespaces all --dest /backups/github --yes
# Batch, skip one org, mirror clones:
python3 github-org-duplicator/github_org_duplicator.py --batch --namespaces 'all,!some-org' --dest /backups/github --format mirror --yes
# One repo:
python3 github-org-duplicator/github_org_duplicator.py --single owner/name --dest /backups/github --yes--yes requires --namespaces and --dest; it takes documented defaults for anything else
(private: yes, archived: no, forks: no, format: working, parallel: 3) and skips the typed
confirmation. For any prompt not covered by a flag — including in the other modes — --answers FILE
feeds answers one per line (a blank line accepts that prompt's default). See --help.
Modes:
- Remote to Local: clone every repo in a GitHub org to a local folder.
- Local to Remote: recursively scan a local folder for Git repos and push them into a GitHub org. Direct-child-only scanning remains available. Worktrees, linked worktrees, and bare/mirror repos are recognized; duplicate local basenames stop the run before remote writes.
- Remote to Remote: copy repos from one GitHub org to another.
- All My Orgs → Local (batch): pick namespaces — your own account plus your orgs — with the
print-style grammar (
all,1-5, 7, names,except/!), optionally pick individual repos per namespace, review one plan table, type YES once. Per-namespace resume; batch manifest inruns/. - One Repo → Local: any single repo by
owner/nameor full URL (public repos included), downloaded into the same<parent>/<owner>/<repo>layout. The spec is resolved and the matched repo shown before anything else is asked; a bare name with no owner is rejected with an explanation (GitHub would otherwise read it as one of your repos).
All download modes share the same selection grammar, the same
<parent>/<namespace>/<repo> arrival layout, the same limitations block before
confirmation, and resumable per-namespace run files.
The tool lists what it finds, warns about Git LFS, checks for existing repositories, and asks for a typed YES before it starts moving data.
Run files are kept here:
github-org-duplicator\runs\
Those files include completed-repo trackers, success logs, error logs, and operation session files. If a run is interrupted, rerun the same operation and completed repos are skipped.
The duplicator is meant for whole-org copies into clean destinations. If it finds name conflicts that are not verified duplicates, it stops instead of trying to reconcile unrelated repositories.
These are generated locally and ignored by Git:
.venv\*.egg-info\uv.lockgit-archive-updater\managed_archives.jsongit-archive-updater\runs\git-archive-updater\refresh-managed-archives.bat/.ps1/.shgithub-org-duplicator\runs\- generated root launchers (
update-archive.*,manage-archives.*,duplicate-github-org.*,suggest-sync.*— your OS's only)
The per-tool launchers are generated by run_setup and ignored; the per-archive update_archive
launchers and refresh-managed-archives are generated by the manager and ignored. Source scripts,
tests, documentation, and the run_setup.* bootstrap files are committed; generated launchers are not.
The ignored state is useful on one machine but should not be shared as repo source.
Archive updates are deliberately conservative: direct child folders only, approved remotes only, clean repos only, command timeouts, and fast-forward pulls only.
The GitHub org duplicator can create repositories and push refs, so it stays interactive and confirmation-driven. It uses GitHub CLI credentials instead of storing tokens itself.
Sync Suggester only reads repository state. Its sole write in the current scaffold is an explicitly requested, privacy-minimized machine manifest in the selected state folder; source repositories are never modified.
When in doubt, scan first:
uv run python git-archive-updater\archive_manager.py --refresh-all --scan-only