Create GitHub repositories with safe defaults applied, in one command.
gh-safe-repo new <owner/repo>
Branch protection, immutable tags, locked-down Actions, Dependabot, secret scanning with push protection, and private vulnerability reporting are all configured before you push your first commit. The same tool audits and fixes repos you already have, and scans local code for secrets before it leaves your machine.
Status: the core workflow is stable and verified end to end, but the CLI is still being polished. Expect occasional breaking changes before 1.0.
- What you get
- Install
- Quick start
- Commands
- CLI reference
- Output — plan table, JSON, verification, exit codes
- Safe defaults
- Pushing code —
pushandcopy - Pre-flight scanner
- Configuration
- GitHub plan limitations
- Using with an AI agent
- How it works
- Development
GitHub's defaults favour discoverability over safety: a new repo has no
branch protection, no Dependabot, and Actions that can write to the repo and
approve their own pull requests. gh-safe-repo changes that, and shows you
the plan before it touches anything:
- Private by default. Pass
--publicto make it public. - Branch protection. PR required, one approving review, stale reviews dismissed, conversations resolved, no force pushes, no branch deletion.
- Immutable tags. Release tags cannot be rewritten or deleted.
- Actions locked down. Read-only token, no self-approving PRs, SHA-pinned actions, GitHub-owned and verified publishers only.
- Dependabot on. Vulnerability alerts plus automatic fix PRs.
- Secret scanning with push protection. Commits containing supported secrets are blocked.
- Private vulnerability reporting. Researchers can reach you privately.
- Pre-flight secret scan. When pushing existing code, the working tree and git history are scanned locally first.
- Opt-in extras. Wiki, projects and issues are left alone unless you manage them in config.
Every setting is configurable. See Safe defaults for the exact values and Configuration for how to change them.
Requirements:
- Python 3.10+
ghCLI logged in (gh auth login), orGITHUB_TOKENset in the environmentuvto install from source- truffleHog v3 (optional) for the pre-flight scanner. It is found on
PATHor run via podman/docker; without either, a regex scanner is used.
Pushing code with push or copy also needs your normal git
credentials for GitHub. See Pushing code.
git clone https://github.com/AriESQ/gh-safe-repo
cd gh-safe-repo
uv tool install .
gh-safe-repo --helpTo run without installing:
uv sync
./gh-safe-repo new <owner/repo>1. Preview. --dry-run makes no API calls and creates nothing.
$ gh-safe-repo new AriESQ/demo-repo --dry-run
Configuring AriESQ/demo-repo...
Planned Changes
Type Category Setting Value / Note
------ ----------------- -------------------------------------- ----------------------------------------------------------------
ADD repo repository AriESQ/demo-repo
DELETE file readme README.md
UPDATE actions allowed_actions 'all' → 'selected'
UPDATE actions verified_allowed False → True
UPDATE actions sha_pinning_required False → True
UPDATE actions default_workflow_permissions 'write' → 'read'
UPDATE actions can_approve_pull_request_reviews True → False
SKIP branch_protection branch_protection Branch protection requires a public repo or paid GitHub plan
SKIP security dependabot_alerts Requires a public repo or paid GitHub plan
SKIP security secret_scanning Requires a public repo or paid GitHub plan
SKIP security dependabot_security_updates Requires a public repo or paid GitHub plan
SKIP security private_vulnerability_reporting Requires a public repo or paid GitHub plan
SKIP security enable_dependency_graph Requires a public repo or paid GitHub plan
SKIP security enable_secret_scanning_push_protection Requires a public repo or paid GitHub plan
SKIP tag_protection tag_protection Tag protection rulesets require a public repo or paid GitHub plan
7 change(s) to apply, 8 skipped
Dry run — no changes made.
The SKIP rows are what a private repo on a free plan looks like. Those
features need a public repo or a paid plan, and the tool says so rather than
failing silently. See GitHub plan limitations.
2. Apply. Drop --dry-run. You see the same plan, then a confirmation
prompt (--yes skips it).
gh-safe-repo new AriESQ/demo-repo3. Start working. On success you get a Verified section (every setting read back from GitHub) and a banner with ready-to-paste commands:
╭─ Done ───────────────────────────────────────────────────╮
│ Repository created successfully! │
│ https://github.com/AriESQ/demo-repo │
│ │
│ Add remote to existing repo: │
│ SSH : git remote add origin git@github.com:AriESQ/… │
│ HTTPS: git remote add origin https://github.com/… │
│ │
│ Clone fresh: │
│ SSH : git clone git@github.com:AriESQ/demo-repo.git │
│ HTTPS: git clone https://github.com/AriESQ/demo-… │
╰──────────────────────────────────────────────────────────╯
Your preferred protocol (gh config get -h github.com git_protocol) is
listed first.
gh-safe-repo new <owner/repo> # private (default)
gh-safe-repo new <owner/repo> --public # public: branch and tag protection applyThe repo is initialized so a default branch exists (branch protection needs
one), then the auto-generated README.md is deleted so you start clean. Set
auto_init = true in config to keep it. The owner must match your
authenticated account; this guards against mistakes on multi-account
systems.
gh-safe-repo push ~/projects/myapp <owner/repo>Scans the directory for secrets, creates the repo, pushes every branch and
tag, then sets origin and upstream tracking in your original directory so
git push works immediately. An origin that already points elsewhere is
left alone and the banner tells you what to run instead. Afterwards every
pushed branch and tag is checked against GitHub SHA for SHA. Details in
Pushing code.
gh-safe-repo copy <owner/source> <owner/repo>
gh-safe-repo copy <owner/priv> <owner/pub> --public # scanned thoroughly firstClones the source with full history, scans it, mirrors it into the new repo, and copies the source's default branch, description and topics. The result is a new repository, not a GitHub fork: it has no "forked from" link. If you abort at the scan, no code reaches GitHub.
gh-safe-repo audit <owner/repo> # what is out of compliance? exit 3 if anything
gh-safe-repo audit <owner/repo> --json # the same, as a plan documentRead-only. audit fetches each setting and prints UPDATE for what differs,
PRESERVE for what differs but is left alone (see What fix leaves
alone), or one line, All settings compliant., when
nothing does. It never prompts and never changes anything. The exit code
makes it a CI gate: 0 when compliant, 3 when fix would change
something; PRESERVE rows are not drift. You need admin on the repo, which
may be owned by an org or another account.
gh-safe-repo fix <owner/repo> # apply the missing defaults
gh-safe-repo fix <owner/repo> --yes # no prompt, for scripts
gh-safe-repo fix <owner/repo> --dry-run # the audit, exit 0 like every dry runfix runs the same audit, shows the plan, confirms, and only calls the API
for real changes. It is settings-only (no secret scanning). Afterwards every
changed setting is read back from GitHub.
An existing repo has history. If a setting differs from what a brand-new
repo would have, a person (or an upstream project you copy'd) chose it.
fix and audit compare each setting three ways, like a merge: GitHub's
default (the base), the repo's current value (theirs) and the safe default
or your config (ours).
| Current value | Tier | fix does |
|---|---|---|
| Equals the desired value | any | nothing (SKIP, already correct) |
| Still GitHub's default | any | UPDATE. Nobody chose it |
| Changed from the default | preference | PRESERVE. Someone chose it; the value is kept |
| Changed from the default, stricter than ours | security | PRESERVE. Never weakens a stricter posture |
| Changed from the default, weaker than ours | security | UPDATE, marked ⚠ weakened from default |
Security-tier settings are branch and tag protection rules, Actions
enforcement (allowed_actions, SHA pinning, workflow permissions, fork PR
approval) and everything under [security]. Preference-tier settings are
everything under [repo] (wiki, merge strategies, ...), whether Actions is
enabled at all, and the branch-protection policy knobs (enforce_admins,
require_conversation_resolution, use_rulesets).
A setting that only takes effect under a particular parent value is tied
to that parent. The selected-actions sub-settings (github_owned_allowed,
verified_allowed, patterns_allowed) only apply while allowed_actions
is selected, so when the parent is preserved at local_only or all they
are reported as SKIP (Not applicable while allowed_actions is 'local_only' (preserved)) rather than as drift.
If GitHub Actions is disabled for the repo, nothing runs, which is stricter
than any Actions policy. allowed_actions and sha_pinning_required are
then reported as SKIP (GitHub Actions is disabled for this repository),
because writing either one would switch Actions back on. Workflow
permissions and fork PR approval are still checked.
A ruleset counts as protection only when its enforcement is active. If the
gh-safe-repo branch or tag ruleset has been set to disabled (or
evaluate), audit reports its rules as off and fix re-enables the
existing ruleset. It does not create a second one.
Actions in use. When the plan changes which actions may run
(allowed_actions, patterns_allowed, verified_allowed,
github_owned_allowed or sha_pinning_required), the plan gets a
Warnings block before the confirmation prompt:
⚠ removes whitelisted action <pattern>for each entry the newpatterns_alloweddrops.⚠ would block N actions in M workflows: theuses:references in.github/workflows/*.yml/*.yamlon the default branch (read through the contents API, read-only; local composite actions are followed) that run today and would not under the new policy. Each one names the file and line and why: not GitHub-owned or whitelisted,allowed_actions = local_only, or not pinned to a full-length commit SHA. For an unpinned reference the SHA its tag resolves to now is printed (→ pin: owner/action@<sha> # v4) so pinning is copy-paste.? cannot confirm N actions ... will still run: references allowed only if their creator is verified. GitHub has no API for verified-creator status, so these are reported as unknown, not blocked. Local./actions are always allowed and never reported.
docker:// references run only under allowed_actions = all; GitHub
blocks them under selected (even with verified creators allowed) and
local_only, so they are reported as blocked. Whitelist patterns are
matched against the full owner/repo@ref: astral-sh/setup-uv with no
@ part matches no version at all, so write astral-sh/setup-uv@*.
These are warnings only. The change is still applied as planned (the
whitelist is replaced, not merged), including under --yes, and the exit
code is unchanged. To keep an action, add it to patterns_allowed in your
config, pin it, or leave the section undeclared so a stricter or
hand-tuned value is preserved. Only the default branch is scanned;
workflows on other branches run under the same policy. push scans the
local default branch with git (also under --dry-run); copy scans the
source repo, and skips the scan under --dry-run (no API calls) with an
actions_scan_skipped warning.
Two ways to enforce a PRESERVE row:
- Declare the section in your config. A
[repo]section in your INI file, even an empty one, means "I want these enforced": every[repo]setting is then applied regardless of what someone changed. Only sections present in the file count; values that fell through from the built-in defaults do not. --all. Treat every difference as a change, onauditandfixalike. This is "apply my whole config", not a factory reset.
new, push and copy never preserve anything: a repo that does not exist
yet has no history to respect.
gh-safe-repo scan # the current directory
gh-safe-repo scan ./src && git push # exit 1 on critical findingsLocal only. Nothing is sent to GitHub. See Pre-flight scanner.
gh-safe-repo [--config PATH | --no-config] [--debug] <command> ...
new <owner/repo> [--public] [--dry-run] [--yes] [--json]
push <path> <owner/repo> [--public] [--dry-run] [--yes] [--json]
copy <src-owner/repo> <owner/repo> [--public] [--dry-run] [--yes] [--json]
audit <owner/repo> [--all] [--json]
fix <owner/repo> [--all] [--dry-run] [--yes] [--json] [--migrate-branch-protection]
scan [path] [--json]
Two rules cover the options. Every command that creates a repo takes
--public. Every command that changes GitHub takes --dry-run, --yes and
--json; audit and scan are read-only and take only --json. The two
existing-repo commands, audit and fix, share --all. The one
command-specific flag is fix --migrate-branch-protection.
Where a command takes two positionals, the source comes first and the new
repo second, as with cp. Every GitHub-targeting command takes owner/repo
(for example myuser/my-repo). For new, push and copy, the
owner of the new repo must be your authenticated account.
Global options
--config, --no-config and --debug may go before or after the command;
if given twice, the later one wins.
| Option | Description |
|---|---|
--config PATH |
Config file to use (default: ./gh-safe-repo.ini, then XDG; see Configuration) |
--no-config |
Ignore config files and use built-in defaults only. Cannot be combined with --config |
--debug |
Print every API call and response. For audit and fix, also the resolved repo identity (id, full name, owner type) |
new <owner/repo>, push <path> <owner/repo>, copy <src-owner/repo> <owner/repo>
| Option | Description |
|---|---|
--public |
Create a public repo (default: private) |
--dry-run |
Print the plan and make no API calls. push and copy skip the pre-flight scan too |
--yes, -y |
Skip the confirmation prompt. Scan warnings are accepted; critical findings still stop the run (exit 1). Required when stdin is not a terminal |
--json |
Emit the plan as JSON on stdout. On a real run it is printed once at the end with a verification block |
push requires path to be an initialized git repository and runs the
pre-flight scan on it. copy clones the source with full history and
scans the clone. Both work for private and public destinations.
audit <owner/repo>
| Option | Description |
|---|---|
--all |
Report every differing setting as UPDATE, including ones a person changed from GitHub's default (no PRESERVE rows). See What fix leaves alone |
--json |
Emit the plan as JSON on stdout |
Exits 3 when anything would change, 0 when compliant. PRESERVE rows
are not drift: a repo with only PRESERVE rows prints the table and exits 0.
fix <owner/repo>
| Option | Description |
|---|---|
--all |
Apply every differing setting, including ones a person changed from GitHub's default (no PRESERVE rows). See What fix leaves alone |
--dry-run |
Show the settings diff without applying it (exit 0) |
--yes, -y |
Skip the confirmation prompt. Required when stdin is not a terminal |
--migrate-branch-protection |
Convert classic branch protection to a ruleset. See Branch protection |
--json |
Emit the plan as JSON on stdout. On a real run it is printed once at the end with a verification block |
scan [path]
| Option | Description |
|---|---|
--json |
Emit findings as JSON on stdout |
path defaults to the current directory.
The Type column is colourised in a terminal. Colour is dropped when stdout
is not a terminal or NO_COLOR is set.
| Type | Meaning |
|---|---|
ADD |
New setting being applied |
UPDATE |
Existing setting being changed. ⚠ weakened from default after the arrow means someone relaxed a security control below GitHub's default and fix is restoring it |
DELETE |
Setting or file being removed |
PRESERVE |
audit/fix only: the setting differs but a person changed it from GitHub's default, so it is kept. The note says why: (changed from default) or (stricter than 1). See What fix leaves alone |
SKIP |
Nothing to do: already correct, or unavailable on this plan/visibility |
The summary line counts them separately: 2 change(s) to apply, 3 preserved, 4 skipped.
A Warnings block after the table lists what an Actions-policy change would
break (see Actions in use):
Warnings
⚠ removes whitelisted action astral-sh/setup-uv
⚠ would block 2 actions in 1 workflow (branch main):
actions/checkout@v4 .github/workflows/ci.yml:12
- not pinned to a full-length commit SHA
→ pin: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
astral-sh/setup-uv@v5 .github/workflows/ci.yml:15
- not GitHub-owned or in patterns_allowed
- not pinned to a full-length commit SHA
With --json, the document is the only thing on stdout. Progress, warnings,
prompts and the banner go to stderr.
audit --jsonand--dry-run --jsonprint the plan immediately.- A real
new/push/copy/fixrun prints the plan once at the end with averificationblock. Without--yesthe plan table is shown on stderr before the prompt. Declining the prompt prints the document withoutverification. - A run that stops with exit
1before or during the apply prints no document: stdout is empty and the error is on stderr.
{
"changes": [
{ "type": "update", "category": "actions", "key": "default_workflow_permissions", "old": "write", "new": "read", "reason": null,
"tier": "security", "github_default": "write", "weakened": false },
{ "type": "preserve", "category": "repo", "key": "has_wiki", "old": false, "new": true, "reason": "changed from default",
"tier": "preference", "github_default": true, "weakened": false },
{ "type": "skip", "category": "branch_protection", "key": "branch_protection", "old": null, "new": null, "reason": "Branch protection requires a public repo or paid GitHub plan",
"tier": null, "github_default": null, "weakened": false }
],
"summary": { "update": 1, "preserve": 1, "skip": 1 },
"warnings": [
{ "kind": "whitelist_pattern_removed", "message": "removes whitelisted action astral-sh/setup-uv",
"pattern": "astral-sh/setup-uv" },
{ "kind": "actions_blocked", "message": "would block 1 action in 1 workflow",
"branch": "main", "source": "alice/demo", "workflows_scanned": 2,
"actions": [
{ "uses": "actions/checkout@v4", "kind": "action", "workflow": ".github/workflows/ci.yml",
"file": ".github/workflows/ci.yml", "line": 12,
"reasons": ["not pinned to a full-length commit SHA"],
"pin": "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683" }
] }
]
}warnings is always present (often []) and never changes what is applied
or the exit code. kind is one of whitelist_pattern_removed (pattern),
actions_blocked and actions_unknown (branch, source,
workflows_scanned, actions[]: uses, kind = action /
reusable_workflow / docker, workflow, file (differs from workflow
for a step inside a local composite action), line, reasons[], optional
pin), or actions_scan_skipped (reason: copy --dry-run, or the
workflows could not be read). Under --json --yes the warnings are also
printed on stderr.
summary only lists types present in the plan; use .get("delete", 0)
rather than assuming all five keys. Every change carries the merge context
from What fix leaves alone: tier (security,
preference, or null for rows that are not a per-key setting),
github_default (what a new repo holds; null when not applicable) and
weakened (true on an UPDATE that restores a security control someone
relaxed).
scan --json:
{
"findings": [
{ "severity": "critical", "category": "secret", "file_path": "app/config.py", "line_number": 12,
"rule": "AWS Access Key", "match": "[redacted]", "commit": "", "timestamp": "" }
],
"summary": { "critical": 1, "warning": 0, "info": 0 },
"skipped_committed_dirs": [],
"engine": { "configured": "auto", "used": "trufflehog-native", "fell_back": false }
}All three severity keys are always present. Secret matches are [redacted].
commit and timestamp are set only for findings truffleHog located in git
history. engine records what actually ran: configured is the effective
trufflehog_mode, used is trufflehog-native, trufflehog-container or
regex, and fell_back is true when truffleHog was attempted but could
not run.
After applying, the creating commands and fix read every changed setting back from
GitHub and print one row per setting. Only settings the run changed are
re-read; SKIP rows are not. GitHub is briefly inconsistent after a write,
so a wrong or unreadable value is re-read after 1s, 2s and 4s before it is
reported.
Verified
✓ security/dependabot_alerts
✗ repo/has_wiki: expected False, GitHub has True
? actions/fork_pr_approval_policy: could not read it back
→ Run `gh-safe-repo fix alice/demo --yes` once more; if it still does not stick, ...
| Mark | Status | Meaning | Exit |
|---|---|---|---|
✓ |
ok |
GitHub holds the requested value | 0 |
✗ |
mismatch |
GitHub holds something else | 3 |
? |
unknown |
Could not be read back (permission or server error) | 0, with a warning |
The creating commands add checks that only a new repo needs:
| Check | Passes when | On failure |
|---|---|---|
repo/repository |
the new repo is found | exit 1; nothing else is checked |
repo/visibility |
visibility is what you asked for | exit 1, with the gh repo edit --visibility command |
file/readme |
the auto-generated README is gone (new without auto_init = true) |
exit 3, with the gh api command to delete it |
repo/description, repo/topics |
copy: copied from the source |
exit 3, with the gh repo edit command |
branch_protection/protected_branches, tag_protection/protected_tags |
the ruleset covers the default branch and configured tag patterns | exit 3 |
code/branches, code/tags |
push: every pushed ref is on GitHub at the pushed SHA. copy: every ref of the source is on the new repo at the source's SHA. Annotated tags compare by tag object SHA |
exit 1, with the push or mirror command to re-run |
repo/default_branch |
push/copy: the default branch is the one chosen |
exit 3, with the gh repo edit --default-branch command |
local/origin, local/tracking |
push: your repo's origin is the new repo and the current branch tracks it |
warning only ("advisory": true), with the git remote or git push -u command. An origin that already pointed elsewhere is reported as left alone |
Remote refs are read through the REST API, not git ls-remote, so
verification costs no extra SSH connection or hardware-key touch. The banner
is green Done when every check is ok (advisory warnings aside), yellow
Done with problems on a mismatch, and red Failed on a fatal check.
With --json:
"verification": {
"ok": false,
"checks": [
{ "category": "repo", "key": "has_wiki", "status": "mismatch",
"expected": false, "actual": true, "fatal": false, "advisory": false,
"remediation": "Run `gh-safe-repo fix alice/demo --yes` once more; ..." }
],
"summary": { "ok": 3, "mismatch": 1, "unknown": 0 }
}All three summary keys are always present. actual is null for
unknown. An advisory check counts as a mismatch and makes ok false but
never changes the exit code. For code/branches and code/tags, expected
is the {name: sha} map that was pushed and actual is GitHub's SHA for
each name (null when missing). The block is absent on --dry-run and when
you decline the prompt; when there is nothing to change it is present with
no checks and "ok": true.
| Code | Meaning |
|---|---|
0 |
Success, or the user declined at an interactive prompt. For audit, the repo is compliant (PRESERVE rows do not count). For the mutating commands, success means everything applied was read back and verified |
1 |
Operational failure: auth, permissions, API error, a pre-flight scan that blocked the run, a confirmation prompt that hit end of input without --yes, a credential pre-flight failure, or a failed code push (the repo exists but is empty; recovery commands are printed). For create, also a fatal verification check. For scan, also critical findings present |
2 |
Usage error: bad owner/repo, path is not a directory or not a git repo, --config with --no-config, an unknown flag |
3 |
audit found settings that fix would change (ADD/UPDATE/DELETE rows; never PRESERVE). Or a mutating command applied the changes but at least one did not stick when read back |
130 |
Interrupted with Ctrl-C |
An unattended run never exits 0 without doing the work. With no terminal
and no --yes, or with --yes and critical scan findings, the command
reports why and exits 1. If stdin is closed (< /dev/null, a CI job, an
agent subprocess) it prints --yes is required for unattended runs and
exits 1. An answer piped on stdin counts: echo y | gh-safe-repo fix <owner/repo> applies, and n, an empty line or Ctrl-C at the prompt
declines (exit 0).
| Setting | GitHub default | Safe default | Notes |
|---|---|---|---|
| Visibility | Public | Private | --public to override |
| Wiki, projects, issues | Enabled | Not managed | Opt in with has_wiki = false etc. in [repo] |
| Delete branch on merge | Off | Off | delete_branch_on_merge = true for auto-cleanup |
| Merge commit / squash / rebase | On | On | At least one must stay on; GitHub rejects all-off |
| Setting | GitHub default | Safe default |
|---|---|---|
| Allowed actions | All | Selected: GitHub-owned and verified creators |
| Default workflow permissions | Read/write | Read-only |
| Actions can approve PRs | Yes | No |
| Require SHA pinning | No | Yes |
| Fork PR approval policy | New GitHub accounts only | All external contributors need approval before fork PR workflows run |
Public repos on any plan, or any repo on a paid plan.
| Rule | Value |
|---|---|
| Require pull request before merge | Yes |
| Required approving reviews | 1 |
| Dismiss stale reviews on push | Yes |
| Require conversation resolution | Yes |
| Allow force pushes | No |
| Allow branch deletion | No |
| Enforce on admins | No, so owner tooling can push |
Protection is applied with the Rulesets API by default (use_rulesets = true): one gh-safe-repo defaults ruleset covers every configured branch,
and admin bypass is expressed as a bypass actor. Set use_rulesets = false
for the legacy classic per-branch API, kept for one release cycle.
Migrating from classic protection. When fix finds classic branch
protection it refuses to convert it unless you pass
--migrate-branch-protection, because some classic rules have no equivalent
in the ruleset this tool builds and would be dropped:
required_status_checks(required CI checks)restrictions(push restrictions by user or team; rulesets model this differently via bypass actors)- per-branch differences, since one ruleset applies the same rules to
masterandmain
With the flag, fix creates or updates the ruleset and then deletes the
classic protection on each branch so the two do not stack.
Public repos on any plan, or any repo on a paid plan. A ruleset targeting
* (configurable via protected_tags) enables restrict updates and
restrict deletions, so tags cannot be force-pushed or deleted. Repo
admins are on the bypass list, matching enforce_admins = false.
| Feature | Behaviour |
|---|---|
| Dependabot alerts | Enabled |
| Dependabot security updates | Enabled; opens PRs for vulnerable dependencies |
| Secret scanning | Automatic on public repos; enabled on private paid-plan repos |
| Push protection | Enabled; blocks commits containing supported secrets |
| Private vulnerability reporting | Enabled |
| Dependency graph | Automatic on public repos; private repos need the UI (no REST API) |
push and copy both create a new repo and populate it, from a local
git repository or from an existing GitHub repo. Both work for private and
public destinations. Run gh-safe-repo scan PATH first if you want to
inspect findings without creating anything.
Pushes use your own git credentials, not the API token: an SSH key in
ssh-agent when gh config get -h github.com git_protocol is ssh, or an
HTTPS credential helper (gh auth setup-git sets one up). This is
deliberate: the OAuth token would need the workflow scope to push
.github/workflows/*. Where neither exists (CI with only GITHUB_TOKEN),
the tool falls back to HTTPS with the token in the URL. See [git_transport] mode in Configuration. Token-bearing URLs are never
written to your .git/config and are redacted from all output.
SSH settings such as core.sshCommand are read from the git config of the
push path, or of the current directory for copy. If you pick a
key per directory (includeIf plus core.sshCommand), run copy from a
directory where that applies, or ssh falls back to your default key and
fails with Permission denied (publickey). The error then names the
directory and the core.sshCommand it found; --debug prints it up front.
path must be an initialized git repository: a main checkout, a linked
worktree (git worktree add) or a submodule checkout. A worktree shares its
refs with the main repository, so pushing from one pushes every branch of
the main repository, not only the worktree's branch; the plan's branch
count shows this. Uncommitted changes are never pushed or committed, in any
of the three.
- The pre-flight scanner runs on the directory.
- You review findings and confirm, or abort.
- Your git credentials for
github.comare checked (an SSH probe when the protocol isssh; HTTPS is trusted). This runs right before creation so a hardware key's touch is close to the real push. The check waits up to 45 seconds over SSH and 15 over HTTPS; a timeout says the key was probably waiting for a touch. - The repo is created; Actions and security settings are applied.
- Every local branch and tag is pushed in a single
git pushfrom a bare clone, so branches other than the checked-out one are included and a hardware key needs one touch. The default branch is pushed first and then set via the API. A missed key touch is retried once. - Branch and tag protection are applied, now that the branch exists.
originand upstream tracking are set in your original repo.- Verification: pushed refs, default branch, and local
origin/tracking.
The default branch is chosen from the local branches in this order:
init.defaultBranch if set, then main, then master, then the checked-out
branch. The plan shows the branch count and the chosen default.
- The source is cloned with full history, so truffleHog can walk every commit.
- The pre-flight scanner runs on the clone.
- You review findings and confirm, or abort.
- Git credentials are checked, as above.
- The repo is created; Actions and security settings are applied.
- History is mirrored (
git clone --mirror+git push --mirror) and the source's default branch is set. - Branch and tag protection are applied.
- Verification: the new repo's branches and tags are compared with the source's, SHA for SHA.
The scanner runs locally and never sends code anywhere. Use it standalone
(gh-safe-repo scan [path]) or let push/copy run it. The
[pre_flight_scan] config applies to both.
| Category | Severity | Examples |
|---|---|---|
| Hardcoded secrets | Critical | AWS keys (AKIA…), GitHub tokens (ghp_…, github_pat_…), private keys, database URLs |
| Banned strings | Critical | Literal strings you configure: usernames, internal hostnames, codenames |
| AI context files | Critical | CLAUDE.md, AGENTS.md, .cursorrules, copilot-instructions.md, .cursor/. Their git history may hold more than the current version |
| Email addresses | Warning | Any user@domain.tld in the working tree or git history |
| Large files | Warning | Over the configured threshold (default 100 MB) |
| TODO/FIXME comments | Info | # TODO, # FIXME, # HACK, # XXX |
Pre-flight scan: my-private-project
CRITICAL my_private_project/config.py:12 AWS Access Key ID
[redacted]
WARNING my_private_project/setup.py:3 Email address
author_email="alice@example.com"
1 critical finding, 1 warning.
Critical findings detected. Continue anyway? [y/N]:
- Critical findings default to abort; type
yto continue. - Warnings only default to continue; type
nto abort. - No findings: the workflow continues silently.
Secrets are redacted. When banned strings or AI context files are found, the
scanner prints a ready-to-run git filter-repo command to remove them from
history.
The engine is chosen in this order:
- truffleHog v3 on
PATH. A v2 or unrecognised version warns and falls through. - podman or docker. truffleHog runs in a container
(
ghcr.io/trufflesecurity/trufflehog:latest) with the scan path mounted read-only at the same absolute path, so output paths match a native run.CONTAINER_RUNTIMEoverrides the runtime andTRUFFLEHOG_IMAGEpins an image. A shell wrapper andContainerfilefor a pinned local image are intools/. - Regex fallback. Used when neither is available or truffleHog fails to run. The regex pass always runs alongside truffleHog for emails and TODOs, and catches lone key-ID patterns that truffleHog skips because it wants both halves of a credential pair.
The planned engine is shown in the scan header; the engine that actually ran is printed after the scan, because truffleHog can still fail mid-run:
Running pre-flight security scan... (truffleHog v3.93.4)
Scan engine: regex (fell back from truffleHog: truffleHog via podman exited with status 125)
Set trufflehog_mode to pin an engine. native and docker are strict: if
truffleHog cannot run, scan prints its findings and exits 1, and
push/copy exits 1 before the repo is created. Only auto
falls back to regex; off uses regex by choice.
In a git repo only files listed by git ls-files are scanned, which is what
gets pushed. Ignored and untracked files are not. Non-git directories are
walked in full. Build-artifact directories (node_modules, __pycache__,
.venv, venv, dist, build) are skipped unless files inside them are
tracked, in which case they are scanned and a warning notes that more is
committed than expected. A file whose first 8 KB contains a NUL byte is
treated as binary and not content-scanned. When stderr is a terminal, a
one-line progress indicator shows the file being scanned.
scan_exclude_paths skips files or directories from every check. Values are
newline- or comma-separated regex patterns matched against the relative
path, and are also passed to truffleHog via --exclude-paths.
[pre_flight_scan]
scan_exclude_paths = docs/api\.github\.com\.json
tests/fixtures/exclude_emails suppresses email findings for addresses or whole domains.
Case-insensitive. Entries starting with @ match a domain; others must match
the full address. Applies to the working tree and git history.
[pre_flight_scan]
exclude_emails = action@github.com, noreply@github.com, @example.comNo config file is needed; every value has a safe default. The lookup order is:
--config PATH./gh-safe-repo.ini$XDG_CONFIG_HOME/gh-safe-repo/gh-safe-repo.ini(~/.configwhen unset)
--no-config skips the lookup and uses built-in defaults only.
gh-safe-repo.ini.example is the canonical,
fully annotated reference, and a test fails if it drifts from the code. Copy
it to get started:
mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/gh-safe-repo"
cp gh-safe-repo.ini.example "${XDG_CONFIG_HOME:-$HOME/.config}/gh-safe-repo/gh-safe-repo.ini"Abridged, with defaults:
[repo]
private = true
delete_branch_on_merge = false
# Not managed unless set:
# has_wiki = false
# has_projects = false
# has_issues = false
# At least one must stay true:
allow_squash_merge = true
allow_merge_commit = true
allow_rebase_merge = true
# Keep the auto-generated README on `new` (ignored by push/copy)
auto_init = false
[actions]
enabled = true
allowed_actions = selected # all | local_only | selected
github_owned_allowed = true # only when selected
verified_allowed = true
# patterns_allowed = myorg/*
default_workflow_permissions = read # read | write
can_approve_pull_request_reviews = false
sha_pinning_required = true
# all_external_contributors | first_time_contributors | first_time_contributors_new_to_github
fork_pr_approval_policy = all_external_contributors
[branch_protection]
protected_branch = master, main # branches that don't exist are ignored
require_pull_request = true
required_approving_reviews = 1
dismiss_stale_reviews = true
require_conversation_resolution = true
allow_force_pushes = false
allow_deletions = false
enforce_admins = false
use_rulesets = true # false = classic per-branch API
[tag_protection]
protected_tags = * # comma-separated globs
prevent_tag_deletion = true
prevent_tag_update = true
[security]
enable_dependabot_alerts = true
enable_dependabot_security_updates = true
enable_private_vulnerability_reporting = true
enable_secret_scanning_push_protection = true
[pre_flight_scan]
scan_for_secrets = true
scan_for_emails = true
scan_for_todos = true
scan_email_history = true
max_file_size_mb = 100
warn_ai_context_files = true
trufflehog_mode = auto # auto | native | docker | off
# banned_strings = secret, password
# scan_exclude_paths = tests/fixtures/
# exclude_emails = noreply@github.com, @example.com
[git_transport]
# auto — your own credentials; token-in-URL only when there is no SSH
# setup and no credential helper (e.g. CI with GITHUB_TOKEN)
# user_creds — never use the API token for git
# token — always HTTPS with the API token in the URL
mode = auto| Feature | Free + Public | Free + Private | Pro/Team + Private |
|---|---|---|---|
| Branch protection / Rulesets | Yes | No | Yes |
| Tag protection (Rulesets) | Yes | No | Yes |
| Dependabot alerts | Yes | No | Yes |
| Dependabot security updates | Yes | No | Yes |
| Secret scanning | Auto | No | Yes |
| Push protection | Yes | No | Yes |
| Private vulnerability reporting | Yes | No | No (public repos only) |
| Dependency graph | Auto | No | Yes |
Plan level and visibility are detected at runtime. Unavailable features
appear as SKIP with a reason; nothing fails silently.
Everything is available non-interactively, and
skills/gh-safe-repo/SKILL.md teaches a
coding agent the flags, exit codes and failure modes. It does not live in
.claude/skills/, so it never auto-loads in this checkout. Either point a
session at the file, or install it once:
ln -s "$PWD/skills/gh-safe-repo" ~/.claude/skills/gh-safe-repo # then /gh-safe-repoThe recipe it enforces is plan, show the user, then apply:
gh-safe-repo push . alice/my-repo --dry-run --json # zero API calls
gh-safe-repo push . alice/my-repo --yes # apply
gh-safe-repo audit alice/my-repo --json # existing repo: exit 3 on driftgh-safe-repo new <owner/repo>
│
├─ Parse owner/repo; check the owner matches the authenticated user
├─ Load config, apply CLI overrides (--public, …)
├─ Authenticate (GITHUB_TOKEN, else `gh auth token`)
├─ GET /user → login + plan level (one cached call)
│
├─ Build plan: each plugin diffs desired vs. current state
│ ├─ RepositoryPlugin repo creation, basic settings
│ ├─ ActionsPlugin allowed actions, workflow permissions, SHA pinning
│ ├─ BranchProtectionPlugin Rulesets API (classic if use_rulesets = false)
│ ├─ SecurityPlugin Dependabot, secret scanning, push protection, PVR
│ └─ TagProtectionPlugin immutable tags via Rulesets API
│
├─ Print plan; confirm
│
├─ Apply (unless --dry-run)
│ ├─ POST /user/repos, PATCH /repos/{owner}/{repo}
│ ├─ PUT …/actions/permissions, …/selected-actions, …/workflow, …/fork-pr-contributor-approval
│ ├─ PUT …/vulnerability-alerts, …/automated-security-fixes, …/private-vulnerability-reporting
│ ├─ PATCH /repos/{owner}/{repo} (security_and_analysis)
│ ├─ git push (publish) or git push --mirror (mirror)
│ └─ POST/PATCH …/rulesets (branch and tag protection, after the push)
│
└─ Verify: read every changed setting back
Each settings category is a plugin in gh_safe_repo/plugins/ that fetches
current state, diffs it against config, returns a plan, and applies only real
changes. The creating commands and fix share this code path; the only difference is
whether current state comes from an existing repo or from GitHub's defaults.
See gh_safe_repo/README.md for the module map.
All API calls go through gh api in a subprocess, so authentication stays in
the gh CLI. The token comes from GITHUB_TOKEN if set (the only credential
needed in CI), otherwise gh auth token. It is passed to child processes as
GH_TOKEN and never logged.
git clone https://github.com/AriESQ/gh-safe-repo
cd gh-safe-repo
uv sync # creates .venv, installs pytest
uv run pytest tests/ -v
./gh-safe-repo new <owner/repo> --dry-run # run from source
uv tool install . # install from current sourceThere are no runtime dependencies; everything is standard library. Do not
add third-party packages without discussion. pytest is the only dev
dependency.
CI runs the test suite on every push to master and every pull request, on
Python 3.10 and 3.14, with read-only permissions and no token, so the
authenticated and live tiers skip. It sets trufflehog_mode = off so scan
tests use the regex engine. Every action is pinned to a commit SHA, as this
repo's own Actions policy requires.
gh-safe-repo/
├── gh-safe-repo # Thin launcher
├── gh_safe_repo/ # Package (see gh_safe_repo/README.md)
│ ├── cli.py # Subcommand dispatch
│ ├── commands/ # new (create.py), push, copy, audit, fix, scan
│ └── plugins/ # One plugin per settings category
├── skills/gh-safe-repo/ # Agent-facing SKILL.md
├── tools/ # trufflehog wrapper, history-scrubbing scripts
├── gh-safe-repo.ini.example # Annotated example config
└── tests/ # See tests/README.md
- github/safe-settings: org-level GitHub App. Source of the plugin pattern (one class per category: fetch, diff, apply).
- repository-settings/app: per-repo variant of the above; a cleaner reference for the base plugin.
- nicholasgasior/gh-repo-settings:
Go
ghextension with a plan/apply workflow. Inspiration for thegh apiwrapper and the dry-run plan output.