gh repo covers the operations you would otherwise perform on a repository’s settings pages, plus
cloning and forking.
Most are unremarkable. Two — delete and archive — deserve care, and one, set-default, prevents
a whole class of confusion in repositories with multiple remotes.
The subcommands
Section titled “The subcommands”Run gh repo --help for the authoritative list on your version. The ones worth knowing:
| Command | Does |
|---|---|
create | Create a repository, optionally from a local directory |
clone | Clone, with fork remotes configured automatically |
fork | Fork, optionally cloning and adding upstream |
view | Show a repository, or fields of it as JSON |
list | List repositories for a user or organisation |
edit | Change settings — description, visibility, features |
sync | Bring a fork up to date with its upstream |
rename | Rename a repository |
archive / unarchive | Make read-only, or restore |
delete | Delete permanently |
set-default | Choose which remote gh targets in this directory |
gitignore / license | List and view templates |
Creating
Section titled “Creating”gh repo create my-project --public --add-readme --license mit --clonegh repo create my-project --private --source=. --remote=origin --pushgh repo create my-org/service --internal --description "Shared tooling"gh repo create my-service --template my-org/service-template --private --cloneThe second form is the one to remember: it publishes a project that already exists locally, creating the repository, wiring the remote, and pushing in a single command.
Cloning and forking
Section titled “Cloning and forking”gh repo clone cli/cligh repo clone OWNER/REPO -- --depth=1gh repo fork OWNER/REPO --cloneArguments after -- are passed to git clone, so
shallow clones and other Git options work normally.
gh repo fork --clone does four things at once: forks, clones your fork, sets origin to it, and
adds upstream pointing at the original. That naming convention is the one
Forks relies on.
Viewing and listing with structured output
Section titled “Viewing and listing with structured output”This is where gh repo becomes useful for more than convenience.
gh repo view OWNER/REPO --json name,visibility,defaultBranchRef,licenseInfo,isArchivedWhat it doesPrints selected repository fields as JSON rather than a rendered page.
Why we run itStructured output is what makes gh scriptable. The same command with no --json prints a README preview, which is useless to a script and fine for a human.
Expected resultA JSON object with exactly the requested fields.
Output:
{ "defaultBranchRef": { "name": "main" }, "isArchived": false, "licenseInfo": { "key": "mit" }, "name": "REPO", "visibility": "PUBLIC"}Listing supports the same, plus filters that make inventory queries trivial:
gh repo list OWNER --limit 200 --json nameWithOwner,visibility,pushedAtgh repo list OWNER --visibility public --no-archived --json nameWithOwnergh repo list OWNER --fork --json nameWithOwner,parentgh repo list OWNER --language go --limit 50A genuinely useful audit — public repositories not touched in a long time:
gh repo list OWNER --visibility public --no-archived --limit 200 \ --json nameWithOwner,pushedAt \ --jq '.[] | select(.pushedAt < "2025-08-01") | .nameWithOwner'Editing settings
Section titled “Editing settings”gh repo edit --description "A better description"gh repo edit OWNER/REPO --visibility privategh repo edit OWNER/REPO --enable-wiki=false --enable-projects=falsegh repo edit OWNER/REPO --enable-discussionsgh repo edit OWNER/REPO --default-branch maingh repo edit OWNER/REPO --delete-branch-on-merge--delete-branch-on-merge is worth setting on every repository you create. Stale merged branches
accumulate quickly and make the branch list unusable.
Syncing a fork
Section titled “Syncing a fork”gh repo syncgh repo sync OWNER/FORK --branch maingh repo sync --source UPSTREAM_OWNER/REPO --branch mainRun inside a fork’s clone, gh repo sync updates it from upstream. It refuses when histories have
diverged, which is the correct behaviour — a fork’s main that has its own commits needs a decision,
not a silent merge.
Renaming, archiving and deleting
Section titled “Renaming, archiving and deleting”Three operations with increasing consequence.
Rename. GitHub redirects the old URL, but other people’s configured remotes, CI references and package names do not follow.
gh repo rename new-namegh repo rename new-name --repo OWNER/OLD-NAMEArchive. Makes a repository read-only: no pushes, no new Issues or pull requests, everything still readable. This is the right end state for a project that is finished rather than abandoned — it signals the state honestly and prevents the appearance of an unmaintained-but-open tracker.
gh repo archive OWNER/REPOgh repo unarchive OWNER/REPODelete. Permanent.
gh repo delete OWNER/REPO # prompts for confirmationDeleting also requires the delete_repo scope, which gh auth login does not grant by default —
a deliberate piece of friction. gh auth refresh --scopes delete_repo adds it.
set-default
Section titled “set-default”In a repository with several remotes — typically a fork, with origin and upstream — gh cannot
know which one you mean. It asks, once, and remembers:
gh repo set-defaultgh repo set-default OWNER/REPOWithout this, gh pr list in a fork may show pull requests from the wrong repository, which is
confusing precisely because it looks like it worked.
Inventory and audit queries
Section titled “Inventory and audit queries”gh repo list with --json turns repository management from clicking through settings pages into
answering questions.
Which repositories are public that should not be?
gh repo list ORG --visibility public --no-archived --limit 300 \ --json nameWithOwner,description,pushedAt \ --jq '.[] | [.nameWithOwner, .pushedAt] | @tsv'Run this quarterly. Repositories become public by accident more often than anyone expects, usually during a migration or a hurried “let’s just open-source it” decision that was never reviewed.
Which are unmaintained but still open?
gh repo list ORG --no-archived --limit 300 \ --json nameWithOwner,pushedAt,isFork \ --jq --arg cutoff "$(date -u -d '18 months ago' +%Y-%m-%d)" \ '.[] | select(.isFork | not) | select(.pushedAt < $cutoff) | [.pushedAt[:10], .nameWithOwner] | @tsv' \| sortAnything on that list should probably be archived. An unmaintained repository with an open Issue tracker invites reports nobody will read, which is worse for the reporter than an honest read-only archive.
Which have no licence?
gh repo list ORG --visibility public --limit 300 \ --json nameWithOwner,licenseInfo \ --jq '.[] | select(.licenseInfo == null) | .nameWithOwner'Public with no licence means nobody may legally use the code — a state that is almost never intentional.
Which are missing basic hygiene settings?
gh repo list ORG --no-archived --limit 300 \ --json nameWithOwner,deleteBranchOnMerge,hasWikiEnabled,hasIssuesEnabled \ --jq '.[] | select(.deleteBranchOnMerge | not) | .nameWithOwner'Each of these is one command and answers a question that would otherwise take an afternoon of clicking.
Cloning at scale
Section titled “Cloning at scale”Cloning an organisation’s repositories is a common setup task and a common way to accidentally download a hundred gigabytes.
gh repo list ORG --limit 200 --no-archived --source --json nameWithOwner \ --jq '.[].nameWithOwner' \| while read -r repo; do if [ -d "$(basename "$repo")" ]; then printf 'skipping %s (already cloned)\n' "$repo" else gh repo clone "$repo" -- --filter=blob:none fi doneTwo things make this survivable. --source excludes forks. And --filter=blob:none is a
partial clone — it fetches history and trees but downloads
file contents on demand, which for a large organisation is the difference between minutes and hours.
For genuinely large monorepos, combine it with sparse checkout so you materialise only the directories you work in.
Repository settings that matter
Section titled “Repository settings that matter”gh repo edit covers most of what you would otherwise click through. The settings worth deciding
deliberately on any repository people collaborate in:
gh repo edit OWNER/REPO \ --enable-issues \ --enable-wiki=false \ --enable-projects=false \ --delete-branch-on-merge \ --allow-update-branch \ --enable-squash-merge \ --enable-merge-commit=false \ --enable-rebase-merge=falseThat combination expresses a specific opinion: pull requests squash-merge, branches clean themselves up, and the wiki is off because documentation lives with the code. It suits many teams and not all — the point is that these are decisions, and leaving them at defaults is also a decision, just an unexamined one.
Restricting merge methods is more consequential than it looks. Allowing only squash means your
main history is one commit per pull request, permanently — the trade-offs are in
Squash Merging.
Topics and discoverability
Section titled “Topics and discoverability”Topics are the tags shown on a repository page and used by GitHub’s search.
gh api repos/OWNER/REPO/topics -H "Accept: application/vnd.github+json" --jq '.names'
gh api --method PUT repos/OWNER/REPO/topics \ -H "Accept: application/vnd.github+json" \ --input - <<'JSON'{"names": ["python", "cli", "automation"]}JSONNote this endpoint replaces the full list rather than appending, so read before writing if you are adding one.
For a public repository, topics plus a clear description are most of what determines whether anyone finds the project. They are worth two minutes and are usually skipped entirely.
Transferring ownership
Section titled “Transferring ownership”gh api --method POST repos/OWNER/REPO/transfer -f new_owner=NEW-OWNERTransfer moves a repository between accounts or organisations. GitHub redirects the old URL, and Issues, pull requests, releases and wiki content move with it.
What does not move cleanly: collaborator permissions are re-derived from the new owner’s model, webhook secrets may need reconfiguring, and anything referencing the repository by its old path through the API needs updating. Treat it as a migration with a checklist rather than a setting change.
Common mistakes
Section titled “Common mistakes”--yes on delete in a script. One bad variable is an unrecoverable deletion.
Deleting when archiving was meant. Archive preserves everything and is reversible.
Renaming without checking dependants. Redirects cover URLs, not everything else.
Not setting a default in a fork. gh targets the wrong repository silently.
Parsing gh repo list output. Use --json.
Forgetting --delete-branch-on-merge at creation. Cheap then, tedious later.
Troubleshooting
Section titled “Troubleshooting”“Could not resolve to a Repository.” Either the name is wrong or your token cannot see it. Those
are indistinguishable from the outside, by design — GitHub does not disclose the existence of private
repositories to unauthorised callers. Check gh auth status for the active account before assuming
the name is wrong.
gh repo clone produces an HTTPS URL when you expected SSH. The protocol comes from your gh
configuration, not from the repository. Change it with gh config set git_protocol ssh, or per
clone by passing the URL form you want.
Commands operate on the wrong repository. You are in a directory whose remotes point elsewhere,
or a fork without a default set. gh repo set-default fixes the second; being explicit with
--repo fixes both, and is what scripts should do regardless.
gh repo delete fails with a permissions error. The delete_repo scope is not granted by
default. gh auth refresh --scopes delete_repo adds it — deliberate friction on an unrecoverable
operation.
Settings changes silently do nothing. Some settings are constrained by organisation policy. The API accepts the request and the policy overrides it; reading the value back is the only way to confirm it took effect. This is a good argument for the read-after-write habit in any configuration sweep.
A rename appears to break clones. GitHub redirects the old URL for Git operations, so existing
clones keep working — but anything referencing the repository through the API by its old path does
not follow. Update remotes explicitly with git remote set-url rather than relying on the redirect
indefinitely.
Choosing between gh and the API
Section titled “Choosing between gh and the API”gh repo covers the common operations. The API covers everything, and there is a reasonable dividing
line.
Use gh repo | Use gh api / the REST API |
|---|---|
| Create, clone, fork, view, list | Topics, transfer, autolinks, deploy keys |
| Edit the settings it exposes | Settings with no flag |
| Interactive and scripted work | Anything needing a field the porcelain omits |
| Anything where you want validation | Bulk operations where you control the loop |
The practical test: if gh repo edit --help has a flag for it, use the flag — it is validated,
readable and stable. If it does not, gh api reaches the same endpoint with no guard rails, which is
covered in gh api.
A worked example of crossing that line — enabling automated security fixes, which has no porcelain flag:
gh api --method PUT repos/OWNER/REPO/automated-security-fixesgh api --method PUT repos/OWNER/REPO/vulnerability-alertsBoth return 204 No Content on success, which gh api prints as nothing at all. Silence is success
here, and a script checking for output rather than exit status will report a false failure.
Exercise
Section titled “Exercise”- Create a repository with
gh repo create --private --add-readme --clone. - Inspect it with
gh repo view --json name,visibility,defaultBranchRef. - Change the description and disable the wiki with
gh repo edit, then re-inspect. - Fork a public repository with
gh repo fork --cloneand confirmoriginandupstreamwithgit remote -v. - Run
gh repo set-defaultin the fork and note what it asks. - Archive your practice repository, confirm pushes are refused, then unarchive and delete it.
Step 6 demonstrates the difference between archive and delete more convincingly than any description.
What you learned
Section titled “What you learned”gh repo create --source=. --pushpublishes an existing local project in one command.gh repo fork --clonesets up theorigin/upstreamconvention automatically.--jsonwith no value lists available fields for the version you have installed.--delete-branch-on-mergeis worth enabling on every new repository.- Archive is reversible and preserves everything; delete is neither.
delete_repois not granted by default, which is deliberate friction.set-defaultpreventsghtargeting the wrong remote in a fork.
Repository provisioning as a script
Section titled “Repository provisioning as a script”The commands in this lesson combine into something worth keeping — a script that creates a repository configured the way your team expects, rather than the way GitHub defaults.
#!/usr/bin/env bashset -euo pipefail
NAME="${1:?usage: new-repo.sh NAME [TEAM]}"TEAM="${2:-}"ORG="${ORG:-acme}"
gh repo create "$ORG/$NAME" \ --private \ --template "$ORG/service-template" \ --description "TODO: describe this repository" \ --clone
gh repo edit "$ORG/$NAME" \ --delete-branch-on-merge \ --enable-wiki=false \ --enable-projects=false
gh label clone "$ORG/service-template" --repo "$ORG/$NAME"
if [ -n "$TEAM" ]; then gh api --method PUT "orgs/$ORG/teams/$TEAM/repos/$ORG/$NAME" -f permission=pushfi
echo "created $ORG/$NAME"Four decisions encoded once rather than remembered each time: private by default, a consistent skeleton from a template, branches that clean themselves up, and access granted to a team rather than to individuals.
The description placeholder is deliberate — it is visible and slightly annoying, which is more likely to get a real description written than an empty field is.
Autolinks
Section titled “Autolinks”One gh repo subcommand has no counterpart elsewhere in this pillar: autolink, which turns
references to an external tracker into clickable links throughout the repository.
gh repo autolink listgh repo autolink create "JIRA-" "https://acme.atlassian.net/browse/JIRA-<num>"gh repo autolink create "INC-" "https://acme.pagerduty.com/incidents/<num>" --numericgh repo autolink delete AUTOLINK_IDWith the first configured, writing JIRA-1234 in a commit message, an Issue, a pull request
description or a comment renders as a link to that ticket. Nothing is stored — the rendering is
computed from the prefix.
This is worth setting up on any repository whose work is tracked somewhere other than GitHub Issues. It costs one command and removes the friction of people pasting full URLs, which they mostly do not, leaving bare ticket numbers that later readers must look up by hand.
The --numeric flag constrains the suffix to digits; without it, alphanumeric suffixes match, which
suits systems using non-numeric identifiers.
Autolinks are per repository, so an organisation standardising on one tracker will want this in its provisioning script alongside the label scheme and the branch protection.