Metadata-Version: 2.4
Name: github-repository-sync
Version: 0.0.4
Summary: Synchronize local repositories for a GitHub organization.
Author: Kaizten Analytics
License-Expression: LicenseRef-Proprietary
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Requires-Python: >=3.10
Description-Content-Type: text/markdown

# GitHub Organization Repository Sync

`github-repository-sync.py` synchronizes all repositories for a GitHub
organization with the clones found under a recursive search root.

Repositories that are missing locally are cloned into `<root>/<org>/<repo>` by
default. Existing repositories with clean working trees are updated with
`git pull --no-rebase --no-edit`. This allows compatible remote changes to be
merged when the local branch also has commits that need to be pushed.
Repositories with staged, unstaged, or untracked changes are left untouched and
reported as needing push/local attention. Conflicted pull attempts are aborted
so the repository is not left in a merge-conflict state.

The script detects existing local repositories by inspecting their Git remotes,
so repositories can be located in nested folders. A checked-out branch without
a configured upstream is not pulled and is reported as both needing attention
and having an error.

The script uses named arguments only. There are no positional arguments.

## Dependencies

- Python 3.10 or newer
- Git
- OpenSSH client (`ssh` and `ssh-keyscan`)
- GitHub CLI (`gh`), recommended for private repositories

At startup, every run verifies the SSH host key returned by `github.com`
against GitHub's published fingerprints and tests SSH authentication with the
current user's key. The program prints an error and exits before searching or
cloning when GitHub's authenticity cannot be established or the SSH connection
cannot authenticate. This preflight is also required when `--protocol https` is
used.

Authentication options:

- Use an authenticated GitHub CLI session with `gh auth login`.
- Or set `GITHUB_TOKEN` or `GH_TOKEN`.
- Or pass a token with `--token`.

Without authentication, GitHub only returns public repositories.

## How to use

Run the script with the organization name:

```bash
./github-repository-sync.py --org kaizten
```

By default, the script searches recursively under `~` and clones missing
repositories into `~/kaizten`.

Use `--dry-run` to inspect repositories and preview all pulls, missing
repositories, and clone destinations without pulling or cloning anything:

```bash
./github-repository-sync.py --org kaizten --dry-run
```

Use `--limit` to process only the first repositories selected by the saved
unknown-first, newest-to-oldest ordering. Both argument forms are supported:

```bash
./github-repository-sync.py --org kaizten --limit=10
./github-repository-sync.py --org kaizten --limit 10
```

`LIMIT` must be a positive integer. If it exceeds the number of eligible
repositories, every eligible repository is processed.

Use `--root` to search and clone under a different directory:

```bash
./github-repository-sync.py --org kaizten --root /home/christopher
```

Use `--clone-dir` to clone missing repositories somewhere other than
`<root>/<org>`:

```bash
./github-repository-sync.py --org kaizten --clone-dir /home/christopher/repos
```

If a clone destination already exists and is not empty, the script chooses the
next available suffixed directory, such as `repo-1`.

## Repository processing order

The script stores change history separately for each GitHub organization at:

```text
~/.github-repository-sync/<organization>/state.json
```

Repositories without saved history are processed first. The remaining
repositories are processed from the newest observed change to the oldest, with
repository name used as a deterministic tie-breaker.

While processing, each repository is prefixed with its position in the current
run, for example `[3/42] Processing repository-name`. When `--limit` is used,
the denominator and all missing-repository and summary counts reflect only the
selected subset. Limited runs always select the strict beginning of the saved
ordering, so older repositories may remain unchecked across repeated runs.

The timestamp changes when the script observes a different Git-visible state:
the checked-out commit, staged or unstaged tracked content, or non-ignored
untracked files. An unchanged check does not refresh the timestamp. Because the
script can only observe changes while running, the timestamp records discovery
time rather than necessarily the exact time a change was made.

State files are written atomically with restrictive permissions. If a state
file is invalid or unreadable, the script prints a warning, processes every
repository as having no history, and replaces the file after processing.
`--dry-run` reads saved state to determine order but never creates or updates
state files.

## End-of-run summary

After repository processing starts, every run prints aggregate counts for:

- all repositories returned for the GitHub organization;
- repositories processed;
- repositories already local and missing locally;
- repositories cloned and pulled successfully;
- repositories whose Git-visible state changed during a successful pull, with
  their names and paths;
- repositories needing push/local attention;
- repositories skipped because their working trees are dirty; and
- repositories with errors.

Successful pulls include repositories that were already up to date. The
updated count includes only existing repositories whose Git-visible state
changed during a successful pull; newly cloned repositories are not included.
A repository is counted as needing push/local attention when it has commits ahead
of its upstream, staged or unstaged changes, untracked files, or no configured
upstream. Each repository is counted at most once in the error total, even if
more than one operation failed. Dry runs additionally report the numbers of
planned pulls and clones.

The GitHub organization total is the first summary metric. It includes every
repository returned by the current authenticated GitHub query, including
archived and disabled repositories, and is not reduced by `--exclude-archived`
or `--limit`.

Failures during repository processing produce a nonzero exit status after the
summary is printed. Startup failures, such as an invalid root or failed SSH
preflight, occur before processing and therefore do not print a summary.

## Examples

Preview missing repositories for `kaizten`:

```bash
./github-repository-sync.py --org kaizten --dry-run
```

Clone all missing repositories for `kaizten` under the default root:

```bash
./github-repository-sync.py --org kaizten
```

Search under `/home/christopher` and clone missing repositories under
`/home/christopher/kaizten`:

```bash
./github-repository-sync.py --org kaizten --root /home/christopher
```

Clone using SSH URLs:

```bash
./github-repository-sync.py --org kaizten --protocol ssh
```

Skip archived repositories:

```bash
./github-repository-sync.py --org kaizten --exclude-archived
```

Use an explicit GitHub token:

```bash
./github-repository-sync.py --org kaizten --token YOUR_GITHUB_TOKEN
```
