README
¶
vcopy — Verified GitHub Repository Copy
Copy GitHub repositories between organizations — Cloud, Enterprise, or both — with 5-layer cryptographic integrity verification.
vcopy mirrors branches, tags, commits, releases, and optionally issues/PRs/wiki, then runs five independent checks to prove the copy is bit-for-bit identical to the source. Available as a CLI tool and a GitHub Action.
Quick Start
Prerequisites: git in PATH and a token with repo scope on both source and target. Optionally, the gh CLI for automatic token detection. Go 1.21+ to build from source.
Install
go install github.com/jpmicrosoft/vcopy/cmd/vcopy@latest
Or download a pre-built binary from Releases.
Copy a repo
vcopy myorg/myrepo target-org
That's it. vcopy authenticates, creates the target as private, mirrors all branches/tags/history, syncs releases, runs 5-layer integrity verification, and prints a pass/fail report.
Common variations
# Copy to GitHub Enterprise
vcopy myorg/myrepo target-org --target-host github.mycompany.com
# Copy a public repo (no source token needed)
vcopy golang/go my-org --public-source
# Code only — no tags, releases, or metadata
vcopy myorg/myrepo target-org --code-only
# Strip CI/CD and Copilot config from the target
vcopy myorg/myrepo target-org --no-workflows --no-copilot
# Explicit tokens for scripts and CI
vcopy myorg/myrepo target-org --auth-method pat --source-token ghp_xxx --target-token ghp_yyy
# Dry run — preview without making changes
vcopy myorg/myrepo target-org --dry-run
As a GitHub Action
- uses: jpmicrosoft/vcopy@v1
with:
source-repo: source-org/my-repo
target-org: target-org
source-token: ${{ secrets.SOURCE_GITHUB_TOKEN }}
target-token: ${{ secrets.TARGET_GITHUB_TOKEN }}
See the Action documentation for full setup and examples.
Features
| Category | Capabilities |
|---|---|
| Mirroring | All branches, tags, commit history, and releases. Additive by default (preserves target-only content). |
| Integrity | 5-layer verification: object hashes, ref comparison, tree hashes, commit signatures, bundle integrity. |
| Batch copy | vcopy batch discovers and copies multiple repos by search filter with prefix/suffix naming and resumable runs. |
| Path control | --no-workflows, --no-actions, --no-copilot, --no-github, --exclude — strip org-specific files from the target. |
| Metadata | Optionally migrate issues, pull requests (as issues), and wiki. |
| Verification modes | --quick-verify (fast), --since (incremental), --skip-verify / --verify-only (deferred). |
| Attestation | --sign GPG-signs the JSON verification report for tamper-proof audit trails. |
| Reporting | JSON verification reports. In batch mode: combined report + optional per-repo reports. |
| LFS | --lfs includes Git LFS objects (auto-detects and warns). |
| Auth | Auto-detects gh CLI tokens, falls back to PAT. --public-source for open-source repos. |
| Resilience | Automatic retry with exponential backoff on network/API failures. Auto-retry on GitHub rate limits — 403s (primary) and 429s (secondary/abuse) — sleeps until reset. |
| Config | YAML config file (--config) for repeatable setups. |
| Cross-platform | Single binary for Windows, macOS, Linux (amd64 + arm64). |
| Action | Reusable GitHub Action with all CLI capabilities. |
Installation
Pre-built binaries (recommended)
Download the latest binary for your platform from Releases. Binaries are named by platform (e.g., vcopy-windows-amd64.exe, vcopy-linux-arm64). Rename to vcopy (vcopy.exe on Windows) for convenience, or run directly.
Build from source
git clone https://github.com/jpmicrosoft/vcopy.git
cd vcopy
go build -o vcopy ./cmd/vcopy
Cross-compile all platforms
make build-all # outputs to bin/
Authentication
vcopy supports three authentication methods:
Auto (default)
Tries gh CLI first, falls back to interactive PAT prompt:
vcopy myorg/myrepo target-org
gh CLI
Uses gh auth token exclusively:
vcopy myorg/myrepo target-org --auth-method gh
PAT (Personal Access Token)
Provide tokens via flags or interactive prompt:
vcopy myorg/myrepo target-org --auth-method pat --source-token ghp_xxx --target-token ghp_yyy
Required token scopes: repo (full control of private repositories).
Usage
Basic copy (GitHub Cloud to Cloud)
Copies all branches, tags, commits, and releases:
vcopy myorg/myrepo target-org
Code only (no tags or releases)
vcopy myorg/myrepo target-org --code-only
Copy to GitHub Enterprise Server
vcopy myorg/myrepo target-org --target-host github.mycompany.com
Copy between two Enterprise instances
vcopy myorg/myrepo target-org --source-host github.corp-a.com --target-host github.corp-b.com
Copy with different target repo name
vcopy myorg/myrepo target-org --target-name new-repo-name
Copy with all metadata
vcopy myorg/myrepo target-org --all-metadata
Copy with specific metadata
vcopy myorg/myrepo target-org --issues --wiki
Copy without GitHub Actions workflows
When copying a repo to a different org, you may not want the source's CI/CD workflows running in the target. The --no-workflows flag removes the .github/workflows/ directory after the copy:
vcopy myorg/myrepo target-org --no-workflows
This is especially useful when the source has workflows with secrets, environment references, or deployment steps that are specific to the source org and would fail or cause unintended side effects in the target.
Copy without Copilot instructions and skills
Organizations often configure Copilot behavior with .github/copilot-instructions.md and .github/copilot/ skill files. These are org-specific and usually shouldn't carry over:
vcopy myorg/myrepo target-org --no-copilot
Combine workflow and Copilot exclusion
vcopy myorg/myrepo target-org --no-workflows --no-copilot
Exclude custom paths
Use --exclude to remove any files or directories. Paths are comma-separated:
vcopy myorg/myrepo target-org --exclude vendor,docs/internal,scripts/deploy.sh
You can combine presets and custom exclusions:
vcopy myorg/myrepo target-org --no-workflows --no-copilot --exclude vendor,docs/internal
Verify only (no copy)
vcopy myorg/myrepo target-org --verify-only
Generate JSON audit report
vcopy myorg/myrepo target-org --report audit.json
Dry run
vcopy myorg/myrepo target-org --dry-run
Public Repositories
Use the --public-source flag when the source repository is publicly accessible. This skips source authentication entirely — you only need credentials for the target.
How --public-source works
| Feature | With --public-source |
Without --public-source |
|---|---|---|
| Git clone/push | ✅ No source token needed | Requires source token |
| Verification (refs, objects, trees, bundle) | ✅ No source token needed | Requires source token |
| Commit signature verification | ✅ No source token needed | Requires source token |
| Metadata copy (issues, PRs) | ⚠️ Works but rate-limited (60 req/hr) | Full rate limit (5,000 req/hr) |
| Wiki copy | ✅ No source token needed | Requires source token |
Examples
Copy a public repo (no source auth needed):
vcopy golang/go my-org --public-source
Copy a public repo with metadata (rate-limited for large repos):
vcopy golang/go my-org --public-source --all-metadata
Copy a public repo to an Enterprise instance:
vcopy kubernetes/kubernetes my-org --public-source --target-host github.mycompany.com
Copy a public repo as an internal repo in the target org:
vcopy kubernetes/kubernetes my-org --public-source --visibility internal
Rate limiting with public repos
When copying metadata from public repos without a source token, the GitHub API allows only 60 requests per hour (unauthenticated). This is usually fine for:
- Repos with fewer than ~50 issues/PRs/releases
For larger repos, provide a source token even with --public-source to get 5,000 req/hr:
vcopy large-org/big-repo my-org --public-source --issues --source-token ghp_xxx
The --public-source flag controls whether source auth is required — you can always optionally provide a --source-token alongside it for better rate limits on metadata operations.
Note: All API calls (authenticated or not) automatically retry on rate limit responses (403 primary and 429 secondary/abuse) by sleeping until the reset time. With unauthenticated access, this means metadata operations may pause frequently but will complete rather than fail. See Retry Behavior for details.
Integrity Verification
After copying, vcopy runs 5 independent checks to cryptographically prove nothing was lost or altered in transit:
| # | Check | What it proves |
|---|---|---|
| 1 | Ref Comparison | Every branch and tag points to the same commit SHA |
| 2 | Object Hashes | Every commit, file, and directory has identical content (SHA-based) |
| 3 | Tree Hashes | Each branch's directory structure and file contents match byte-for-byte |
| 4 | Commit Signatures | GPG/SSH signatures on commits survived the transfer |
| 5 | Bundle Integrity | Both repos produce structurally valid, equivalent git bundles |
All 5 pass → cryptographic proof the copy is identical. If any check reports WARN, all source content was verified but the target contains additional content or excluded refs. If any check reports FAIL, the report tells you exactly what differs.
In
--code-onlymode, checks 1 and 5 are skipped (they depend on tags). The remaining 3 checks still verify branch integrity.
WARN status — extra target content (additive mode): In additive mode (the default), the target may contain branches, tags, objects, or refs from prior copy runs or manual commits that don't exist in the source. These extras are reported as WARN, not FAIL, because they don't indicate data loss — all source content is still present. Only missing source content in the target causes FAIL.
WARN status — rejected branches: When the target remote rejects specific branches during push (e.g., due to org rulesets or branch protection rules), vcopy automatically excludes those branches from verification and reports WARN instead of FAIL. This means all successfully pushed content was verified, but some source branches could not be pushed. Rejected branches are listed in the push output.
Verification Technical Details
1. Branch/Tag Ref Comparison
Uses git ls-remote on both source and target to enumerate all refs/heads/* and refs/tags/*. Compares the SHA-1/SHA-256 commit hash each ref points to. Detects missing refs, extra refs, or refs pointing to different commits. GitHub's hidden refs/pull/* are excluded (see Hidden Refs). Branches rejected by the remote during push (due to branch protection or org rulesets) are automatically excluded from comparison and cause a WARN result.
2. Git Object Hash Verification
Clones both repos bare, then runs git rev-list --objects --all to enumerate every reachable object (commits, trees, blobs). Each object's SHA hash is its content-addressable identity — if even a single byte differs, the hash changes. All source object hashes must be present in the target (missing source objects = FAIL). Extra objects in the target (from prior runs or cleanup commits in additive mode) produce a WARN, not a failure.
3. Tree Hash Comparison
For each branch head, computes git rev-parse <branch>^{tree} on both repos. The tree hash is a recursive hash of the entire directory structure at that branch tip — file names, permissions, and content hashes. If the tree hashes match, the working directories are byte-for-byte identical.
4. Commit Signature Verification
Uses git log --all --format=%H %G? on both repos to enumerate all commits and their signature status (Good, Bad, None, etc.). Compares the set of signed commit SHAs to ensure no signatures were dropped or corrupted during the mirror. Reports as a warning (not failure) if signatures differ, since some transfer methods may strip them.
5. Bundle Integrity Verification
Creates a git bundle from each repo (a self-contained archive of all refs and objects). Verifies each bundle passes git bundle verify (structural integrity). Then compares the refs listed inside each bundle using git bundle list-heads — all source bundle refs must be present with matching SHAs in the target bundle (missing or mismatched = FAIL). Extra refs in the target bundle (from prior runs in additive mode) produce a WARN. SHA-256 checksums of each bundle file are included in the report for audit purposes, but are not compared directly because git bundle packing is non-deterministic.
Verification Failure Troubleshooting
When a verification check reports FAIL or WARN, the tables below list the most common causes and whether each represents a real integrity problem or a benign condition you can safely accept.
Why timing matters: Each verification check independently queries or clones the source and target repositories. The 5 checks run sequentially, so there is a window (typically seconds to minutes depending on repo size) during which the source may receive new commits. When this happens, earlier checks may see one state of the source while later checks see a different state. This is the most common cause of isolated failures — especially for the bundle check, which runs last.
Tip: Re-run the failing repo with
--verboseto see exactly which refs, objects, or SHAs differ.
1. Branch/Tag Ref Comparison — Failure Causes
| Cause | Real problem? | What to do |
|---|---|---|
| Target branch protection rejected a branch during push | No | vcopy auto-excludes rejected branches (WARN). If you see FAIL instead, check that the rejected branch is listed in the push output — it should be excluded automatically. Re-run on latest version if not. |
| Source repo was updated after the copy | No | The source received new commits between copy and verification. Re-run with --verify-only to compare current state, or accept if you know the source moved. |
| Incomplete push (network interruption) | Yes | Some branches or tags didn't reach the target. Re-run the copy (additive mode won't destroy existing content). |
| Token lacks push permissions | Yes | Target token needs repo scope with write access. Check for 403/permission errors in the output. |
2. Git Object Hash Verification — Failure Causes
| Cause | Real problem? | What to do |
|---|---|---|
| Source repo received new commits during verification | No | Each check clones independently. If the source was updated between the ref check and the object check, objects may differ. Re-run with --verify-only. |
| Incomplete push | Yes | Some objects weren't transferred. Re-run the copy. |
| Target garbage collection removed unreachable objects (self-hosted targets) | Depends | If a self-hosted GitHub Enterprise target ran git gc aggressively, unreachable objects may have been pruned. GitHub.com manages GC automatically. Re-run the copy to restore them. |
| Branch protection prevented pushing all content | No | Objects only reachable from rejected branches are excluded. If the check still fails, a non-standard ref namespace may contain source-only objects (see Bundle section below). |
3. Tree Hash Comparison — Failure Causes
| Cause | Real problem? | What to do |
|---|---|---|
| Source branch received new commits during verification | No | The tree hash at the branch tip changed. Re-run with --verify-only. |
| Branch was force-pushed on source after copy | No | Source history was rewritten — the tree at the branch tip differs. Re-copy if you need the latest state. |
| Incomplete push | Yes | The branch tip commit or its tree wasn't fully transferred. Re-run the copy. |
| CODEOWNERS or path exclusion applied | No | Post-copy cleanup commits change the target's tree. These run after verification, so they shouldn't cause failures. If they do, it indicates a bug — please report it. |
4. Commit Signature Verification — Failure and Warning Causes
This check produces WARN (not FAIL) when signatures are lost — this is expected because additive push preserves signatures in most cases. FAIL only occurs for infrastructure errors.
| Cause | Status | Real problem? | What to do |
|---|---|---|---|
| Clone or git command failure | FAIL | Yes | Infrastructure error (network, auth, corrupted repo). Re-run the copy. |
| Signatures not preserved during transfer | WARN | Depends | Some transfer methods can strip GPG/SSH commit signatures. If you need signatures preserved, verify with git log --show-signature on the target. |
| GPG/SSH keys not available in verification environment | — | No | Not a problem. The check compares signature presence (%G? status ≠ N), not cryptographic validity — missing keys return E (cannot check), which still counts as "signed." |
| Source has signed commits, target has same commits unsigned | WARN | Depends | Can happen if commits were recreated (rebase, amend). Additive push should preserve signatures — if missing, it may indicate the push method altered commits. |
5. Bundle Integrity Verification — Failure Causes
| Cause | Real problem? | What to do |
|---|---|---|
| Source repo updated during verification (most common) | No | The bundle check runs last. If the source received a push between earlier checks and the bundle check, the bundle sees newer source content the target doesn't have. All other checks pass but the bundle fails. Re-run with --verify-only to confirm. |
Non-standard refs in source (refs/notes/*, refs/replace/*) |
Unlikely | Bundle verification uses git clone --bare, which only fetches refs/heads/* and refs/tags/* — non-standard namespaces are not included. This scenario is theoretically possible if the remote advertises them, but uncommon in practice. |
| Branch protection rejected a branch | No | Rejected branches are excluded from the source bundle. If the check still fails, see "source updated during verification" above. |
| Corrupted packfile during transfer | Yes | Rare. The bundle verification (git bundle verify) catches structural corruption. If the bundle itself is invalid, the error message will say "bundle verify failed." Re-run the copy. |
| Bundle creation fails on empty repo | — | If the target has no refs at all (all branches rejected, no tags), bundle creation may fail. This is reported as "Target bundle failed" — not an integrity issue. |
Quick Reference: Is My Failure Safe to Accept?
| Scenario | Safe? | Explanation |
|---|---|---|
| All checks PASS except Bundle → FAIL | Usually yes | Most commonly caused by the source repo receiving a push during verification. Re-run --verify-only to confirm. |
| Ref Comparison → FAIL, others PASS | Investigate | A ref was missing or mismatched. Could be source updated or incomplete push. |
| Object Hashes → FAIL, others PASS | Investigate | Objects are missing. May need to re-copy. |
| Tree Comparison → FAIL | Investigate | Branch content differs. Check if source was force-pushed. |
| Signatures → WARN, others PASS | Yes | Signature preservation is best-effort. Content integrity is confirmed by the other checks. Lost signatures produce WARN, not FAIL. |
| Signatures → FAIL, others PASS | Investigate | FAIL in signatures means a clone or git command failed — infrastructure error, not a signature issue. |
| Multiple checks → FAIL | Investigate | Likely an incomplete copy or network issue. Re-run the copy. |
Mode-Dependent Skip Behavior
| Check | Full mode | Quick mode (--quick-verify) |
Code-only (--code-only) |
|---|---|---|---|
| Branch/Tag Ref Comparison | ✓ Runs | ✓ Runs | ⊘ Skipped (tags not copied) |
| Git Object Hash Verification | ✓ Runs | ⊘ Skipped | ✓ Runs |
| Tree Hash Comparison | ✓ Runs | ✓ Runs | ✓ Runs |
| Commit Signature Verification | ✓ Runs | ⊘ Skipped | ✓ Runs |
| Bundle Integrity Verification | ✓ Runs | ⊘ Skipped | ⊘ Skipped (tags not copied) |
Flags Reference
| Flag | Default | Description |
|---|---|---|
-h, --help |
Show help and usage information | |
-v, --version |
Show vcopy version | |
--config |
Path to a YAML config file for repeatable setups (see Config File) | |
--source-host |
github.com |
Hostname of the source GitHub instance (e.g., github.mycompany.com for Enterprise) |
--target-host |
github.com |
Hostname of the target GitHub instance |
--target-name |
same as source | Name for the repo in the target org (defaults to the source repo's name) |
--auth-method |
auto |
How to authenticate: auto tries gh CLI then PAT prompt, gh uses gh CLI only, pat uses tokens only |
--source-token |
Personal Access Token for the source instance (avoids interactive prompt) | |
--target-token |
Personal Access Token for the target instance (avoids interactive prompt) | |
--public-source |
false |
Source repo is publicly accessible — skips source authentication entirely, only target credentials needed |
--visibility |
private |
Target repo visibility: private, public, or internal |
--lfs |
false |
Include Git Large File Storage objects in the copy (requires git-lfs installed) |
--force |
false |
Destructive mirror push to an existing target repo — overwrites all branches, tags, and releases. Requires yes/no confirmation unless --non-interactive is set |
--code-only |
false |
Copy only branches and commits — skips tags, releases, and all metadata. Verification skips tag-dependent checks |
--issues |
false |
Also migrate issues (titles, bodies, labels, comments) from source to target |
--pull-requests |
false |
Also migrate pull requests as issues in the target (GitHub API does not support creating true PRs) |
--wiki |
false |
Also copy the repository wiki (if one exists) |
--all-metadata |
false |
Shorthand for --issues --pull-requests --wiki — copies all optional metadata |
--verify-only |
false |
Run verification checks against an existing source and target without copying anything. Cannot be combined with --skip-verify |
--skip-verify |
false |
Copy the repo but skip all verification checks. Verify later in a separate run with --verify-only. Cannot be combined with --verify-only |
--quick-verify |
false |
Run only ref comparison and tree hash checks — faster but less thorough than full verification |
--since |
Only verify objects created after this date (2025-06-01) or commit SHA — useful for incremental re-syncs |
|
--report |
File path to write a detailed JSON verification report (e.g., --report audit.json) |
|
--sign |
GPG key ID to sign the verification report for tamper-proof audit trails (requires gpg installed) |
|
--verbose |
false |
Show detailed output for every step (git commands, API calls, skipped items) |
--dry-run |
false |
Show what would happen without actually copying or modifying anything |
--non-interactive |
false |
Skip confirmation prompts — required for CI/CD and automation (the GitHub Action sets this automatically) |
--no-workflows |
false |
Exclude GitHub Actions workflows (.github/workflows/) from the target |
--no-actions |
false |
Exclude GitHub Actions custom actions (.github/actions/) from the target |
--no-copilot |
false |
Exclude Copilot instructions and skills (.github/copilot-instructions.md, .github/copilot/) from the target |
--no-github |
false |
Exclude entire .github/ directory from the target (supersedes --no-workflows, --no-actions, --no-copilot) |
--exclude |
Comma-separated list of additional paths to exclude from the target (e.g., --exclude vendor,docs/internal). Can be repeated |
Security
- Token input is hidden: When entering PATs interactively, terminal echo is disabled so tokens are never visible on screen.
- Git output is sanitized: All git command output (stdout/stderr) is filtered to replace tokens with
[REDACTED]before display, preventing credential leakage in verbose mode or error messages. - Tokens are never logged: Tokens embedded in git clone URLs are stripped from any output shown to the user.
- Temp files are cleaned up: All temporary directories (bare clones, bundles, asset uploads) are removed after use via
defer. - Private by default: Target repositories are created as private.
- Input validation on
--since: The--sincevalue is validated to prevent git flag injection; only hex SHAs and date strings are accepted. - Path traversal protection: Repository names are sanitized (
filepath.Base+..removal) before use in temp directory paths to prevent directory escape. - SSRF protection: Release asset downloads validate URL scheme (https only), block private/internal network addresses (localhost, 10.x, 192.168.x, 169.254.x), and use a timeout-limited HTTP client.
- Attestation uses proper GPG detached signatures: Signing produces an armored detached signature; verification uses separate temp files for signature and data, matching GPG's expected
--verify <sig-file> <data-file>protocol. - Nil-safe metadata migration: Issue, PR, and comment formatting guards against nil user pointers (deleted/ghost GitHub accounts) to prevent panics during metadata copy.
Existing Repository Safety (--force)
When the target repository already exists, vcopy uses additive mode by default:
| What happens | Default (additive) | With --force (destructive) |
|---|---|---|
| New branches from source | ✅ Added | ✅ Added |
| Existing branches in target | 🔄 Updated to match source | 🔄 Updated to match source |
| Branches only in target | ✅ Preserved | ❌ Deleted |
| New tags from source | ✅ Added | ✅ Added |
| Existing tags in target | ✅ Preserved | ❌ Overwritten |
| Existing releases in target | ✅ Preserved | ❌ Deleted and re-copied from source |
| New releases from source | ✅ Synced | ✅ Copied |
Additive mode (default when target exists):
# Safe: existing tags and releases in target are preserved
vcopy myorg/myrepo target-org
Destructive mode (--force): uses git push --mirror which replaces everything. Requires yes/no confirmation (skipped with --non-interactive or in the GitHub Action):
# WARNING: overwrites all branches, tags, and releases in target
vcopy myorg/myrepo target-org --force
⚠️ WARNING:
--forcewill permanently delete any branches, tags, or commits in the target that do not exist in the source. This cannot be undone.
Verification in additive mode: Because the target may contain extra content from prior runs (preserved branches, tags, objects), verification tolerates these extras and reports WARN rather than FAIL. Only missing source content in the target causes a failure. See Integrity Verification.
Path Exclusion
You can exclude specific files and directories from the target repository after copying. This is useful when the source contains GitHub Actions workflows, Copilot instructions, or other org-specific configuration that shouldn't carry over to the target.
Why exclude paths?
| Scenario | How it's handled |
|---|---|
| Source has CI/CD workflows that reference secrets, environments, or deploy targets specific to the source org | Use --no-workflows — removes .github/workflows/ |
| Source has custom composite actions specific to the source org | Use --no-actions — removes .github/actions/ |
| Source has Copilot instructions or custom skills that are org-specific | Use --no-copilot — removes .github/copilot-instructions.md and .github/copilot/ |
You want to remove the entire .github/ directory (issue templates, PR templates, workflows, actions, copilot, etc.) |
Use --no-github — removes .github/ (supersedes --no-workflows, --no-actions, --no-copilot) |
| Source has vendored dependencies, internal docs, or other paths you don't want in the target | Use --exclude — removes any paths you specify |
| Source has CODEOWNERS that reference teams/users from the source org | Automatic — CODEOWNERS, .github/CODEOWNERS, and docs/CODEOWNERS are always removed because they reference teams and users that won't exist in the target org. This prevents broken branch protection rules and review assignment failures |
How it works (step by step)
Path exclusion is a post-verification operation. Here's exactly what happens:
- vcopy mirrors the full repository — all branches, tags, commits, and history are pushed to the target (this is the standard copy step, identical to running without exclusion flags)
- Verification runs — the 5-layer integrity check compares source and target to confirm the mirror is exact. This happens before any paths are removed, so verification compares the 1:1 mirror
- vcopy shallow-clones the target — a
--depth 1clone of the target's default branch is made to a temp directory - Excluded paths are deleted —
git rm -rfremoves the specified files/directories from the working tree. CODEOWNERS files are always included in this step. - A cleanup commit is pushed — a single commit with the message
vcopy: remove excluded pathsis pushed to the target's default branch. The commit message lists every path that was removed - Temp directory is cleaned up — all temporary files are deleted
Source Repo Mirror + Verify Target Repo (final)
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ .github/ │────▶│ .github/ │ ✅ PASS │ (removed) │
│ workflows/ │ │ workflows/ │──verify──▶│ │
│ actions/ │ │ actions/ │ │ │
│ copilot/ │ │ copilot/ │ │ │
│ CODEOWNERS │ │ CODEOWNERS │ │ │
│ src/ │────▶│ src/ │ │ src/ │
│ README.md │────▶│ README.md │ │ README.md │
│ All history │────▶│ All history │ │ All history + │
│ │ │ │ │ cleanup commit │
└────────────────┘ └────────────────┘ └────────────────┘
Step 1: mirror Step 2: exclude
Step 2: verify ✅ (after verify)
What this means for you
- Full git history is preserved — every original commit, branch, and tag from the source exists in the target. The cleanup only affects the latest working tree on the default branch
- Verification passes — the 5-layer check runs against the exact mirror before any paths are removed, so it compares source-to-target 1:1
- The cleanup is transparent — anyone looking at the target repo can see exactly what was removed by inspecting the
vcopy: remove excluded pathscommit - Excluded paths exist in history — the files are removed from the current tree, but they still exist in older commits. If you need them scrubbed from history entirely, you'd need a separate tool like
git filter-repo - Non-existent paths are skipped — if a path doesn't exist in the source (e.g., the repo has no
.github/workflows/), the flag is silently ignored - CODEOWNERS is always removed — on every copy,
CODEOWNERSfiles are removed from all standard locations (CODEOWNERS,.github/CODEOWNERS,docs/CODEOWNERS) because they reference source org teams and users that won't exist in the target
Preset flags
| Flag | Paths removed | Use case |
|---|---|---|
--no-workflows |
.github/workflows/ |
Source CI/CD workflows shouldn't run in target org |
--no-actions |
.github/actions/ |
Custom composite actions are org-specific |
--no-copilot |
.github/copilot-instructions.md, .github/copilot/ |
Copilot config is org-specific |
--no-github |
.github/ (entire directory) |
Remove all GitHub config (supersedes the three flags above) |
| (always) | CODEOWNERS, .github/CODEOWNERS, docs/CODEOWNERS |
Source team/user references would break branch protection in target |
Custom paths with --exclude
Use --exclude to remove any files or directories by their repo-relative path:
# Remove a single directory
vcopy myorg/myrepo target-org --exclude vendor
# Remove multiple paths (comma-separated)
vcopy myorg/myrepo target-org --exclude vendor,docs/internal,scripts/deploy.sh
# Combine with presets
vcopy myorg/myrepo target-org --no-workflows --no-copilot --exclude vendor
Path validation rules:
- Paths must be relative to the repository root (no leading
/) - Paths must not contain
..(directory traversal is blocked) - Paths must not start with
-(flag injection is blocked) - Backslashes are normalised to forward slashes automatically
Config file
All exclusion flags are available in the YAML config:
exclude:
workflows: true # same as --no-workflows
copilot: true # same as --no-copilot
paths: # same as --exclude
- vendor
- docs/internal
- scripts/deploy.sh
Batch Copy
Copy multiple repositories from a source org to a target org in a single command. Repos are discovered automatically via GitHub API search.
Usage
vcopy batch <source-org> <target-org> --search "<name-filter>" [flags]
Azure Terraform AVM Example
# Preview all Azure Terraform AVM modules (dry-run)
vcopy batch Azure jpmicrosoft --search "terraform-azurerm-avm-" --public-source --no-github --dry-run
# Copy all AVM modules (resource + pattern)
vcopy batch Azure jpmicrosoft --search "terraform-azurerm-avm-" --public-source --no-github --skip-verify
# Copy only AVM resource modules
vcopy batch Azure jpmicrosoft --search "terraform-azurerm-avm-res-" --public-source --no-github
# Copy with a prefix on target names
vcopy batch Azure jpmicrosoft --search "terraform-azurerm-avm-" --prefix "avm-" --public-source --no-github
# Resume an interrupted batch
vcopy batch Azure jpmicrosoft --search "terraform-azurerm-avm-" --skip-existing --public-source --no-github
Generic Examples
# Copy all repos matching "service-" from any org
vcopy batch mycompany backup-org --search "service-" --skip-verify
# Cross-host batch (Enterprise to Cloud)
vcopy batch corp-org cloud-org --search "platform-" --source-host ghes.corp.com --target-host github.com
# Add suffix to all target names
vcopy batch source-org target-org --search "api-" --suffix "-imported"
# Batch copy with combined report
vcopy batch Azure jpmicrosoft --search "terraform-azurerm-avm-" --public-source --no-github --report batch-audit.json
# Batch copy with combined + per-repo reports
vcopy batch Azure jpmicrosoft --search "terraform-azurerm-avm-" --public-source --report batch-audit.json --per-repo-report
Batch Flags
| Flag | Default | Description |
|---|---|---|
--search |
(required) | Repository name filter (matched against repo names in source org) |
--prefix |
Prefix to prepend to each target repo name | |
--suffix |
Suffix to append to each target repo name | |
--skip-existing |
false |
Skip repos that already exist in the target org (useful for resuming interrupted batches) |
--sync |
false |
Update existing repos (additive push + incremental release sync) instead of skipping them |
--dry-run |
false |
Preview the full source→target mapping without copying anything |
--report |
Path to write a combined JSON batch report with all repo results | |
--per-repo-report |
false |
Also write individual JSON reports per repo (e.g., report-reponame.json) |
--batch-delay |
3s |
Delay between repos to avoid GitHub's secondary rate limits (e.g., 5s, 0s to disable) |
All standard vcopy flags (--public-source, --visibility, --no-github, --skip-verify, --code-only, etc.) are also available and apply to every repo in the batch.
Target Naming
Target repo names follow the pattern {prefix}{source-name}{suffix}:
| Source Name | Prefix | Suffix | Target Name |
|---|---|---|---|
terraform-azurerm-avm-res-cache-redis |
terraform-azurerm-avm-res-cache-redis |
||
terraform-azurerm-avm-res-cache-redis |
avm- |
avm-terraform-azurerm-avm-res-cache-redis |
|
terraform-azurerm-avm-res-cache-redis |
-internal |
terraform-azurerm-avm-res-cache-redis-internal |
|
my-service |
imported- |
-v2 |
imported-my-service-v2 |
Behavior
- Sequential execution: Repos are copied one at a time with a configurable delay (
--batch-delay, default 3s) to avoid secondary rate limits - Secondary rate limit recovery: If GitHub's secondary rate limit is hit, vcopy waits for the reset period and retries automatically
- Error handling: If one repo fails, the batch skips it and continues to the next
- Progress: Prints
[N/total] Copying repo-name...for each repo - Summary: At the end, prints a report of succeeded/failed/skipped counts (plus releases-skipped if any were skipped due to rate limits)
- Resumable: Use
--skip-existingto skip repos already created (e.g., after a partial run) - Sync mode: Use
--syncto update existing repos — branches are force-updated to match the source, while new tags and releases are added incrementally (existing tags and releases are preserved). New repos are created normally. Mutually exclusive with--skip-existing. - Reporting: Use
--reportto write a combined JSON report; add--per-repo-reportfor individual files per repo
Batch Report JSON Schema
The combined batch report (--report) has this structure:
{
"source_org": "Azure",
"target_org": "my-backup-org",
"source_host": "github.com",
"target_host": "github.com",
"search_filter": "terraform-azurerm-avm-",
"timestamp": "2025-07-15T12:00:00Z",
"summary": {
"total": 10,
"succeeded": 8,
"failed": 1,
"skipped": 1,
"releases_skipped": 2
},
"repos": [
{
"source_repo": "Azure/terraform-azurerm-avm-res-cache-redis",
"target_repo": "my-backup-org/terraform-azurerm-avm-res-cache-redis",
"status": "succeeded",
"checks": [ ]
}
]
}
| Summary Field | Description |
|---|---|
total |
Total number of repos in the batch |
succeeded |
Repos that copied and verified successfully |
failed |
Repos that encountered errors during copy |
skipped |
Repos skipped (e.g., already existed with --skip-existing) |
releases_skipped |
Repos whose releases were skipped due to rate-limit exhaustion (omitted when 0) |
Hidden Refs (refs/pull/*)
GitHub creates read-only refs/pull/*/head and refs/pull/*/merge refs for every pull request. These are internal to GitHub and cannot be pushed to another repository.
vcopy automatically handles this:
- During copy: PR refs are stripped from the bare clone before mirror push (main repo and wiki)
- During verification: PR refs are excluded from ref comparison, object/tree/signature verification clones, and bundle integrity checks
This means all branches, tags, and commit history are copied and verified, but PR refs are intentionally excluded. If you need PR metadata, use the --pull-requests flag to migrate PRs as issues.
Git LFS Support
Use --lfs to include Git LFS objects in the copy:
vcopy myorg/lfs-repo target-org --lfs
If a repository uses LFS but --lfs is not specified, vcopy will detect this and print a warning. Without --lfs, LFS pointer files are copied but the actual large files are not.
Requirements: git-lfs must be installed and available in PATH.
Quick and Incremental Verification
Quick verify (fast)
Runs only ref comparison and tree hash checks, skipping the slower object enumeration, signature verification, and bundle integrity:
vcopy myorg/myrepo target-org --quick-verify
Incremental verify
Only verifies objects created after a given date or commit SHA. Useful for repeat syncs:
vcopy myorg/myrepo target-org --verify-only --since "2025-06-01"
vcopy myorg/myrepo target-org --verify-only --since abc123def
Skip verification
Copy now, verify later:
vcopy myorg/myrepo target-org --skip-verify
# Later:
vcopy myorg/myrepo target-org --verify-only
Attestation Signature
Sign the verification report with GPG to create a tamper-proof audit trail:
vcopy myorg/myrepo target-org --report audit.json --sign "your-gpg-key-id"
The signed report includes an attestation field containing the GPG key ID and detached signature over the SHA-256 hash of the report data. This proves:
- Who verified the copy (the key owner)
- When the verification was performed
- That the report has not been modified since signing
Requirements: gpg must be installed with the specified key available.
Config File
For repeated use or scheduled syncs, use a YAML config file instead of flags:
vcopy --config mirror.yaml
CLI flags override config file values. Example mirror.yaml:
source:
repo: myorg/myrepo
host: github.com
public: false
target:
org: target-org
host: github.mycompany.com
name: my-mirror
visibility: private # Target repo visibility: private, public, or internal
auth:
method: auto
copy:
issues: true
wiki: true
verify:
quick: true
lfs: true
force: false # Destructive mirror push (prompts unless non_interactive is true)
code_only: false # Copy only branches/commits (no tags or releases)
exclude:
workflows: true # Exclude .github/workflows/ from target
copilot: true # Exclude Copilot instructions/skills from target
paths: # Additional paths to exclude
- vendor
report:
path: audit.json
sign_key: ABCD1234
See example-config.yaml in the repository for a complete template.
Retry Behavior
vcopy automatically retries failed git operations and API calls with exponential backoff:
- Max attempts: 3
- Initial wait: 1 second
- Max wait: 30 seconds
In addition, all GitHub API calls automatically handle rate limit responses:
- Primary rate limit (403): When a request receives a
403withX-RateLimit-Remaining: 0, vcopy sleeps until theX-RateLimit-Resettime (plus a 5-second buffer) and retries. - Secondary/abuse rate limit (403 + Retry-After): When a request receives a
403with aRetry-Afterheader (GitHub's content creation throttle), vcopy sleeps for the specified duration (plus a 2-second buffer) and retries. - Secondary/abuse rate limit (429): When a request receives a
429 Too Many Requests, vcopy reads theRetry-Afterheader and sleeps for that duration (plus a 2-second buffer). If noRetry-Afterheader is present, it waits 60 seconds.
All three types retry up to 3 times per request, with a maximum single wait of 2 minutes. This applies to both authenticated and unauthenticated (--public-source) clients, so even with the 60 req/hr unauthenticated limit, operations will pause and resume rather than fail.
Batch-level recovery: In batch mode, if a secondary rate limit is hit during repo creation, vcopy waits for the full reset period (up to 10 minutes) and retries the failed repo automatically. The --batch-delay flag (default 3s) spaces out repo creations to prevent triggering secondary limits in the first place.
This handles transient network failures and GitHub API rate limits gracefully.
GitHub Action
vcopy is available as a reusable GitHub Action with all CLI capabilities. Use it directly in your workflows:
Quick Start
- uses: jpmicrosoft/vcopy@v1
with:
source-repo: source-org/my-repo
target-org: target-org
source-token: ${{ secrets.SOURCE_GITHUB_TOKEN }}
target-token: ${{ secrets.TARGET_GITHUB_TOKEN }}
Setup
- Reference the action from any workflow:
uses: jpmicrosoft/vcopy@v1 - All CLI flags are available as action inputs (
force,code-only,lfs,all-metadata, etc.) - The action runs in
--non-interactivemode automatically
For full documentation, inputs/outputs reference, and example workflows, see action/README.md.
Troubleshooting / FAQ
The release binary is named vcopy-windows-amd64.exe, not vcopy.exe.
Pre-built binaries include the platform and architecture in the filename. You can either run it directly (./vcopy-windows-amd64.exe myorg/repo target-org) or rename it to vcopy.exe (or vcopy on Linux/macOS) so the examples in this README work as-is. No installation step is required — it's a standalone executable.
Authentication fails with "401 Unauthorized".
Ensure your token has the repo scope. For public source repos, use --public-source to skip source authentication entirely.
"Must have admin rights to Repository" when using --force.
Your target token needs admin-level access to the target repo. For organization-owned repos, ensure the token has repo scope and the user is an org owner or has admin role on the repo.
Verification shows WARN instead of PASS. WARN can occur for two reasons:
- Extra target content (additive mode): The target contains branches, objects, or refs from prior copy runs that don't exist in the source. This is expected in additive mode and does not indicate data loss — all source content was verified as present.
- Rejected branches: Some branches were rejected by the target during push — typically because of org rulesets or branch protection rules. vcopy automatically excludes rejected branches from verification so they don't cause false failures.
In both cases, WARN means all source content that was expected in the target was verified successfully.
Verification fails on refs/pull/* refs.
GitHub creates hidden refs/pull/* refs for pull requests. These are read-only and cannot be pushed. vcopy automatically excludes them during verification — if you see failures, ensure you're on the latest version.
Windows SmartScreen warns "Windows protected your PC" when running the binary. This is expected for any unsigned executable downloaded from the internet. SmartScreen uses reputation-based filtering — new binaries with few downloads will trigger the warning. Two options:
- Run anyway: Click "More info" → "Run anyway". The binary is safe — you can verify its integrity by comparing the SHA-256 checksum from the release's
checksums.txtagainst the downloaded file:(Get-FileHash vcopy-windows-amd64.exe -Algorithm SHA256).Hash - Build from source (no SmartScreen warning): Building locally produces a binary that Windows trusts since it wasn't downloaded from the internet:
git clone https://github.com/jpmicrosoft/vcopy.git cd vcopy go build -o vcopy.exe ./cmd/vcopy
Contributing
Contributions are welcome! Please open an issue or pull request on GitHub.