GitHub Repository Structure

A GitHub repository looks like a simple folder of files on the Code tab, but it is really two layers stacked together: a plain Git repository (the same objects and refs you’d have on your own laptop) and a web product GitHub builds on top of it (issues, pull requests, Actions, wikis, and settings). Knowing where the line between those two layers falls — and which files and folders GitHub treats specially — makes it much faster to navigate any project, set up new repositories correctly, and avoid the mistakes that quietly bloat or break a repo’s history.

Overview: What a GitHub Repository Actually Is

Strip away the website and a repository is just a .git directory containing Git’s object database and a set of refs. Every file’s content is stored as a blob, identified by the SHA-1 hash of its content. A snapshot of a directory (which blobs and sub-trees it contains, by name) is a tree. A commit object points to exactly one tree (the full project snapshot at that moment), one or more parent commits, and metadata (author, committer, message, timestamp). A branch such as main is nothing more than a small file (or packed-ref entry) holding the SHA of the commit it currently points at — it moves forward automatically every time you commit on that branch. HEAD is a pointer to whichever branch you currently have checked out; when HEAD points directly at a commit instead of a branch, you’re in detached HEAD state. When you git push, this is literally all that travels to GitHub: compressed objects plus updated ref pointers. Nothing else about “the repository” in the Git sense exists.

Everything else you see on a GitHub repo page — issues, pull request discussions, wiki pages, Actions run logs, project boards, releases metadata, stars, and settings — lives in GitHub’s own database, entirely outside your .git history. A git clone never downloads any of that; it only gives you the objects and refs. This is why moving a repo’s code to a different host doesn’t bring your issues with it unless you use GitHub’s own migration or API tools.

The file browser on a repo’s Code tab is a direct rendering of a tree object: specifically, the tree reachable from the tip commit of whichever branch is selected in the branch dropdown (the default branch, usually main, when you first land on the page). Switch branches in that dropdown and GitHub is just walking a different ref to a different commit’s tree. The default branch is the one new clones check out automatically and the one pull requests target by default; it can be changed at any time under Settings > Branches.

Files and Folders GitHub Treats Specially

On top of ordinary Git content, GitHub looks for a small set of conventional file names and renders or activates extra behavior around them:

  • README.md — rendered as formatted documentation directly below the file listing on the repo’s home page.
  • LICENSE or LICENSE.md — GitHub detects the license type and shows it as a badge in the About sidebar; without one, the code is technically “all rights reserved” by default.
  • .gitignore — patterns for files Git should never track (build output, dependencies, secrets); GitHub’s repo-creation wizard can generate a language-specific one for you.
  • .gitattributes — per-path settings such as normalized line endings (* text=auto) or telling GitHub’s language-detection (Linguist) to ignore vendored/generated code.
  • CONTRIBUTING.md — shown as a callout when someone opens an issue or pull request.
  • CODEOWNERS (in the repo root, docs/, or .github/) — maps file paths to usernames/teams who are automatically requested as reviewers on matching pull requests.
  • .github/ — a folder GitHub itself reads: .github/workflows/*.yml defines Actions pipelines, .github/ISSUE_TEMPLATE/ holds issue form templates, .github/PULL_REQUEST_TEMPLATE.md pre-fills new PR descriptions, and .github/FUNDING.yml adds a sponsor button.

None of these names are enforced by Git itself — they are conventions GitHub’s web layer looks for. A repository without any of them is still a perfectly valid Git repository; it just loses the extra rendering.

Anatomy of a Typical Repository

A well-structured project repository commonly looks like this once cloned:

my-app/
├── .git/                      # Git's object database and refs (hidden)
├── .github/
│   ├── workflows/
│   │   └── ci.yml             # GitHub Actions pipeline
│   ├── ISSUE_TEMPLATE/
│   │   └── bug_report.md
│   └── PULL_REQUEST_TEMPLATE.md
├── docs/
│   └── architecture.md
├── src/
│   └── index.js
├── .gitattributes
├── .gitignore
├── CODEOWNERS
├── CONTRIBUTING.md
├── LICENSE
└── README.md

Only .git/ is Git internals; everything else is ordinary tracked content that GitHub happens to recognize by name or location.

Syntax

There’s no single “repo structure” command — you assemble it with a few core Git commands plus ordinary file creation:

Command Purpose
git init Create a new, empty .git directory in the current folder.
git clone <url> Copy a remote repository’s full history into a new local directory.
git remote add origin <url> Register GitHub as the remote named origin for this local repo.
git remote -v List configured remotes and their URLs.
git branch -a List local and remote-tracking branches.
git ls-files List every file Git is currently tracking.
git tag List (or create) lightweight/annotated tags, often used for releases.

Examples

Example 1: Creating a New Repository With a Proper Structure

mkdir invoice-tool && cd invoice-tool
git init -b main
echo "node_modules/\n.env" > .gitignore
printf "# Invoice Tool\n\nA small CLI for generating invoices.\n" > README.md
git add README.md .gitignore
git commit -m "chore: initial commit with README and .gitignore"
git remote add origin https://github.com/yourname/invoice-tool.git
git push -u origin main

Output:

Enumerating objects: 4, done.
Writing objects: 100% (4/4), 320 bytes | 320.00 KiB/s, done.
To https://github.com/yourname/invoice-tool.git
 * [new branch]      main -> main
branch 'main' set up to track 'origin/main'.

This creates the repository with main as the default branch from the start (git init -b main), adds a .gitignore before anything sensitive is tracked, and pushes with -u so future git push/git pull calls know which remote branch to talk to.

Example 2: Inspecting an Existing Repository’s Structure

git clone https://github.com/octocat/Hello-World.git
cd Hello-World
ls -la
git remote -v
git branch -a
git log --oneline -5

Output:

total 24
drwxr-xr-x  4 you  staff  128 Aug  3 10:02 .
drwxr-xr-x  3 you  staff   96 Aug  3 10:02 ..
drwxr-xr-x 12 you  staff  384 Aug  3 10:02 .git
-rw-r--r--  1 you  staff   16 Aug  3 10:02 README

origin  https://github.com/octocat/Hello-World.git (fetch)
origin  https://github.com/octocat/Hello-World.git (push)

* main
  remotes/origin/HEAD -> origin/main
  remotes/origin/main

7fd1a60 Merge pull request #6 from Spaceghost/patch-1
762941b Delete CONTRIBUTING.md
da5a221 Merge pull request #4 from ...
...

Right after a clone, .git holds the entire history, the working directory holds only the latest snapshot’s files, and git branch -a shows both your local main and the remote-tracking pointer for GitHub’s main — two separate refs that git pull keeps in sync.

Example 3: Adding GitHub-Specific Structure (Templates and a Workflow)

mkdir -p .github/ISSUE_TEMPLATE .github/workflows
---
name: Bug report
about: Report something that isn't working
title: "[BUG] "
labels: bug
---

**Describe the bug**
A clear description of what went wrong.

**Steps to reproduce**
1.
2.
name: CI
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm test
git add .github
git commit -m "ci: add issue template and CI workflow"
git push

Output:

To https://github.com/yourname/invoice-tool.git
   3af9c21..9b1e047  main -> main

The .github/ISSUE_TEMPLATE/bug_report.md file is ordinary tracked content, but GitHub’s web layer reads its YAML front matter and offers it as a template the next time someone clicks “New issue.” The .github/workflows/ci.yml file is picked up automatically by Actions — no extra registration step — and every future push or pull request against main triggers this job.

How It Works Step by Step

  • You run git commit: Git writes a new tree object (and any changed blobs), a new commit object pointing to that tree and to the previous commit, and moves the current branch ref to the new commit’s SHA.
  • You run git push: Git figures out which objects the remote is missing, packs them into a packfile, uploads it, and asks GitHub to fast-forward (or reject, if it isn’t a fast-forward) the remote’s branch ref to your new commit SHA.
  • GitHub updates its copy of the ref and, because the underlying object graph changed, invalidates its cached file-tree rendering for that branch.
  • If any files under .github/workflows/ define a matching trigger (like push to main), GitHub’s Actions service schedules a runner and checks out that same commit to execute the job.
  • Anyone loading the repo’s Code tab now sees the new tree, because the page is generated from the ref you just moved — not from a separately stored “file structure.”

Common Mistakes

Mistake 1: Committing without a .gitignore in place first. Running git add . in a fresh Node.js project before adding a .gitignore tracks the entire node_modules/ folder into history. Adding a .gitignore afterward does not remove files Git already tracks — you must also untrack them:

echo "node_modules/" >> .gitignore
git rm -r --cached node_modules
git commit -m "chore: stop tracking node_modules"

Mistake 2: Accidentally nesting a repository inside another one. Copying or cloning a project into a subfolder of an existing repo without deleting its own .git directory leaves two repositories overlapping. git status or git add will surface a warning like this instead of tracking the files normally:

warning: adding embedded git repository: vendor/some-lib
hint: You've added another git repository inside your current repository.
hint: Clones of the outer repository will not contain the contents of
hint: the embedded repository...

The fix is either to delete the inner .git folder if you meant to just copy the files in, or to add it properly as a git submodule if you meant to track it as a separate project.

Mistake 3: Committing a real secret. Pasting a live API token or password directly into a config file and committing it means that value is permanently in the object database, retrievable by anyone with read access even after you delete the file in a later commit. Treat any leaked secret as compromised immediately: rotate/revoke it at the source, then use a history-rewriting tool such as git filter-repo if it must be scrubbed, and add the file pattern to .gitignore going forward.

Mistake 4: Expecting git clone to bring issues, PRs, or wiki content. Those live in GitHub’s database, not in the Git object graph, so a clone or a full history export never includes them — only GitHub’s own repository-transfer or migration tools do.

Best Practices

  • Initialize every new repository with a README.md, a LICENSE, and a language-appropriate .gitignore before your first real commit.
  • Use main as the default branch name and set it explicitly with git init -b main to avoid inconsistent defaults across machines.
  • Keep GitHub-specific automation and metadata inside .github/ (workflows, issue templates, PR templates) rather than scattering config files across the repo root.
  • Add a CODEOWNERS file once a project has more than one contributor, so pull requests automatically route to the right reviewers.
  • Commit a .gitattributes with * text=auto so line endings normalize consistently across Windows, macOS, and Linux contributors.
  • Never commit secrets, even temporarily — use environment variables or a secrets manager, and keep .env files in .gitignore from the very first commit.
  • Protect the default branch (Settings > Branches) once collaborators join, so history-rewriting pushes and direct pushes require review.
  • Run git status before committing to catch an accidentally nested repository or an unintended embedded submodule early.

Practice Exercises

  • Create a new local repository for a small script, add a README.md, a LICENSE, and a .gitignore, then push it to a new GitHub repository. Confirm the About sidebar on GitHub correctly shows the detected license.
  • Clone any public repository of your choice and use ls -la plus git ls-files .github to find out whether it defines any Actions workflows, issue templates, or a CODEOWNERS file. List what you find.
  • Add a .github/PULL_REQUEST_TEMPLATE.md file to a repository you control, push it, then open a new pull request on GitHub and confirm the template text pre-fills the description box.

Summary

  • A GitHub repository has two layers: the Git object database (blobs, trees, commits, refs) that travels with git clone/git push, and GitHub’s own web layer (issues, PRs, Actions logs, settings) that does not.
  • The Code tab’s file browser is a live rendering of the tree object at the tip of whichever branch ref is selected.
  • GitHub recognizes and specially renders certain conventional files: README.md, LICENSE, .gitignore, .gitattributes, CONTRIBUTING.md, and CODEOWNERS.
  • The .github/ folder is where GitHub looks for Actions workflows, issue templates, and pull request templates.
  • Forking creates a new server-side repository record on GitHub; cloning copies Git history to your local machine — they are not the same operation.
  • Setting up .gitignore, a license, and .github/ conventions from a repository’s first commit avoids most of the common structural mistakes teams run into later.