Chapter 1 · Git and GitHub Like a Professional
Working as a Team on GitHub
- Page 2 of 22
- 26 min read
On your own, Git is a time machine. In a team it becomes a coordination system: several people change the same code every day, and the shared copy on GitHub must stay working. AI teams make this harder, not easier: a data scientist tunes a prompt, a backend developer changes the API that serves it, someone else updates the evaluation script, and all three land on main in the same afternoon.
This page plays out that afternoon. Two developers, Nadia and Rafiq, work on one repository through two clones of one shared remote; a third, Mitu, contributes through a fork. Every Git command really runs. The GitHub parts that only exist in a browser (pull requests, reviews, rulesets) are explained step by step, with the real files and gh commands that drive them.
What you will learn
- Remote-tracking branches, and the real difference between
fetch,pull,pull --rebaseandpull --ff-only - What to do when your push is rejected, and how forks and an
upstreamremote work - The full pull-request cycle: draft, description, review, suggestions, feedback, merge method, clean-up
- Issues, labels, milestones and projects; PR templates and
CODEOWNERS - Protecting
mainwith rulesets and required checks, and publishing releases with release notes
Two developers, one remote
From Git's point of view, a GitHub repository is a bare repository (a repository with history but no working folder) that people push to and fetch from. A bare repository on your own disk behaves exactly the same, so we use one as "GitHub" and give each developer their own clone:
~/remotes/sentiment-api.git ("GitHub")
/ \
push / fetch push / fetch
/ \
~/team/nadia (Nadia's laptop) ~/team/rafiq (Rafiq's laptop)Nadia creates the shared repository and her clone:
mkdir -p ~/remotes ~/team
git init --bare -q ~/remotes/sentiment-api.git
cd ~/team
git clone ~/remotes/sentiment-api.git nadiaCloning into 'nadia'...
warning: You appear to have cloned an empty repository.
done.The project is a tiny sentiment scorer, the kind of baseline an AI team keeps next to an LLM classifier to sanity-check it:
cd ~/team/nadia# file: sentiment.py
POSITIVE = {"good", "great", "love", "excellent"}
NEGATIVE = {"bad", "poor", "hate", "terrible"}
def score(text: str) -> int:
"""Positive words minus negative words."""
words = text.lower().split()
return sum(w in POSITIVE for w in words) - sum(w in NEGATIVE for w in words)git add sentiment.py
git commit -q -m "Add word-list sentiment scorer"
git push -u origin mainTo /home/nadia/remotes/sentiment-api.git
* [new branch] main -> main
branch 'main' set up to track 'origin/main'.Rafiq clones the same remote on "his laptop". Because both people share one machine here, he sets his name and email for this clone only (without --global):
cd ~/team
git clone -q ~/remotes/sentiment-api.git rafiq
cd rafiq
git config user.name "Rafiq Islam"
git config user.email "rafiq@example.com"
git log --oneline9bc4968 Add word-list sentiment scorerRemote-tracking branches
Every clone holds three kinds of branch pointers, and most team confusion comes from mixing them up:
| Name | What it is | Who moves it |
|---|---|---|
main | your local branch | you, when you commit, merge or rebase |
origin/main | a remote-tracking branch: your clone's memory of where main was on the remote the last time you talked to it | only fetch, pull and push |
main on the remote | the real shared branch | whoever pushes |
The link between main and origin/main is the upstream (tracking) setting that push -u or clone created. git branch -vv shows it:
git branch -vv
git branch -r* main 9bc4968 [origin/main] Add word-list sentiment scorer
origin/HEAD -> origin/main
origin/maingit remote show origin asks the remote directly and compares:
git remote show origin* remote origin
Fetch URL: /home/nadia/remotes/sentiment-api.git
Push URL: /home/nadia/remotes/sentiment-api.git
HEAD branch: main
Remote branch:
main tracked
Local branch configured for 'git pull':
main merges with remote main
Local ref configured for 'git push':
main pushes to main (up to date)fetch vs pull
Rafiq adds a word and pushes:
sed -i 's/"excellent"}/"excellent", "amazing"}/' sentiment.py
git commit -q -am "Count 'amazing' as positive"
git push -qNadia has not talked to the remote since. Her git status still says she is up to date, because it only compares main with her stored origin/main:
cd ~/team/nadia
git status -sb## main...origin/maingit fetch downloads new commits and moves the remote-tracking branches. It never touches your branches or your files, so it is always safe:
git fetch
git status -sb
git log --oneline main..origin/mainFrom /home/nadia/remotes/sentiment-api
9bc4968..135759b main -> origin/main
## main...origin/main [behind 1]
135759b Count 'amazing' as positivemain..origin/main means "commits on origin/main that are not on main": exactly what you are about to take in. git diff main origin/main shows the change itself. When you are happy, bring it in. Because Nadia has no new commits of her own, a fast-forward is possible:
git merge --ff-only origin/mainUpdating 9bc4968..135759b
Fast-forward
sentiment.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)git pull is just these two steps in one: fetch, then integrate. How it integrates is a choice:
| Command | What it does | When to use it |
|---|---|---|
git fetch | download, move origin/*, change nothing else | any time; before you review, compare or decide |
git pull --ff-only | fetch, then fast-forward only; stops with an error if you have diverged | on main, where you should never have local commits |
git pull --rebase | fetch, then replay your local commits on top of the remote ones | on your own feature branch, to keep a straight history |
git pull (merge) | fetch, then merge, creating a merge commit if you have diverged | when the branch is shared and must not be rewritten |
Since Git 2.27, a plain git pull on diverged branches asks you to choose; set your default once with git config --global pull.rebase true (rebase) or git config --global pull.ff only (refuse anything but fast-forward).
When your push is rejected
Now both developers work at the same time. Rafiq adds a negative word and pushes first:
cd ~/team/rafiq
git pull -q --ff-only
sed -i 's/"terrible"}/"terrible", "awful"}/' sentiment.py
git commit -q -am "Count 'awful' as negative"
git push -qMeanwhile Nadia writes a README and tries to push:
cd ~/team/nadia
echo "# Sentiment API" > README.md
git add README.md
git commit -q -m "Add README"
git pushTo /home/nadia/remotes/sentiment-api.git
! [rejected] main -> main (fetch first)
error: failed to push some refs to '/home/nadia/remotes/sentiment-api.git'
hint: Updates were rejected because the remote contains work that you do not
hint: have locally. This is usually caused by another repository pushing to
hint: the same ref. If you want to integrate the remote changes, use
hint: 'git pull' before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.The remote refuses because accepting her push would throw away Rafiq's commit: her main does not contain it. This is Git protecting the team, not an error to force through. Never answer it with git push --force. Fetch, put your work on top, push again:
git pull -q --rebase
git push -q
git log --oneline --graph* c9dd2d3 Add README
* 0ae0fca Count 'awful' as negative
* 135759b Count 'amazing' as positive
* 9bc4968 Add word-list sentiment scorerThe history is a straight line: Nadia's README commit was replayed after Rafiq's (it got a new hash, since its parent changed). Had both edited the same lines, the rebase would have stopped with a conflict; you resolve it as on the beginner page Branches, Merging and Merge Conflicts, then run git add and git rebase --continue.
Forks and the upstream remote
Nadia and Rafiq can push to the shared repository; they are collaborators. Mitu, a student who wants to fix something in an open-source project, has no write access. On GitHub she clicks Fork: GitHub makes a copy of the repository under her account, which she can push to, and she opens pull requests from her fork back to the original. A fork is simply another bare repository with the same history:
git clone -q --bare ~/remotes/sentiment-api.git ~/remotes/mitu-sentiment-api.git
cd ~/team
git clone -q ~/remotes/mitu-sentiment-api.git mitu
cd mitu
git config user.name "Mitu Das"
git config user.email "mitu@example.com"
git remote add upstream ~/remotes/sentiment-api.git
git remote -vorigin /home/nadia/remotes/mitu-sentiment-api.git (fetch)
origin /home/nadia/remotes/mitu-sentiment-api.git (push)
upstream /home/nadia/remotes/sentiment-api.git (fetch)
upstream /home/nadia/remotes/sentiment-api.git (push)The convention: origin is your fork (you push there), upstream is the original project (you only fetch from it). Forks do not update themselves. When the original moves on, Mitu syncs her fork:
cd ~/team/nadia
echo "Scores short English texts as positive or negative." >> README.md
git commit -q -am "Describe the project in the README"
git push -q
cd ~/team/mitu
git fetch -q upstream
git switch -q main
git merge --ff-only upstream/main
git push -q origin main
git log --oneline -1Updating c9dd2d3..d7da0ba
Fast-forward
README.md | 1 +
1 file changed, 1 insertion(+)
d7da0ba Describe the project in the READMEOn GitHub the Sync fork button or gh repo sync does the same for the fork's default branch. Keep your fork's main identical to upstream and do all your own work on branches; then syncing is always a fast-forward.
The pull-request workflow
Nobody on the team pushes features straight to main. Every change goes through a pull request (PR): a branch plus a page on GitHub where the team discusses the change, sees test results and finally merges it. A good cycle looks like this:
issue #2 -> branch -> draft PR -> ready for review -> review
|
merge (squash) <- approved <- push fixes <- changes requested
|
delete branch, close issue #2, everyone pulls mainA branch and a draft PR
Issue #2 says "scores are hard to read; return a label". Rafiq takes it on a feature branch:
cd ~/team/rafiq
git pull -q --ff-only
git switch -c feature/labelSwitched to a new branch 'feature/label'cat >> sentiment.py <<'EOF'
def label(text: str) -> str:
"""'positive', 'negative' or 'neutral'."""
s = score(text)
if s > 0:
return "positive"
if s < 0:
return "negative"
return "neutral"
EOF
git commit -q -am "Add label() for positive/negative/neutral"
git push -u origin feature/labelTo /home/nadia/remotes/sentiment-api.git
* [new branch] feature/label -> feature/label
branch 'feature/label' set up to track 'origin/feature/label'.On GitHub, a yellow banner offers Compare & pull request. Rafiq picks base main, compare feature/label, writes a description and, because he wants early feedback before it is finished, chooses Create draft pull request from the button's menu. A draft PR cannot be merged, and code owners are not asked to review it yet; Ready for review turns it into a normal PR. The description is where reviewers start, so it answers four questions:
Add label() for positive/negative/neutral
Closes #2
What and why
score() returns a number that API clients have to interpret themselves.
label() maps it to "positive", "negative" or "neutral".
How to test
python -c "from sentiment import label; print(label('great course'))"
Risks
None for existing callers: score() is unchanged.Closes #2 is a closing keyword (close, closes, closed, fix, fixes, fixed, resolve, resolves, resolved): when the PR is merged into the default branch, issue #2 closes itself and links to the PR.
Reviewing: locally, then on GitHub
Nadia reviews. Reading the diff on GitHub is fine for small changes; for anything bigger, check the branch out and run it. A plain git switch with the name of a remote branch creates a local branch that tracks it:
cd ~/team/nadia
git fetch -q
git switch feature/label
git log --oneline main..feature/label
python -c "from sentiment import label; print([label(t) for t in ['great course', 'awful wifi', 'it is ok']])"Switched to a new branch 'feature/label'
branch 'feature/label' set up to track 'origin/feature/label'.
acb6356 Add label() for positive/negative/neutral
['positive', 'negative', 'neutral']In the PR's Files changed tab she leaves comments on lines and then submits a review as one of three kinds:
| Review | Meaning |
|---|---|
| Comment | feedback without a verdict |
| Approve | "merge it" (counts towards required approvals) |
| Request changes | blocks the merge (when approvals are required) until the reviewer approves or the review is dismissed |
For a small fix she does not describe the change in words: she writes a suggestion, a code block with the language suggestion inside her line comment. GitHub shows it as a diff, and Rafiq can apply it with Commit suggestion (or batch several into one commit):
Magic numbers make the thresholds hard to tune. Could we name them?
```suggestion
if s > POSITIVE_THRESHOLD:
```She submits Request changes with the summary "Please name the thresholds so the ML team can tune them".
Resolving feedback
Rafiq answers by pushing more commits to the same branch; the PR updates itself. He replies to each comment and clicks Resolve conversation, then Re-request review. Small, separate commits during review are easier to re-review than one rewritten commit, and squash-merging (below) tidies them up at the end:
cd ~/team/rafiq
sed -i 's/^NEGATIVE = .*/&\nPOSITIVE_THRESHOLD = 0\nNEGATIVE_THRESHOLD = 0/' sentiment.py
sed -i 's/if s > 0:/if s > POSITIVE_THRESHOLD:/; s/if s < 0:/if s < NEGATIVE_THRESHOLD:/' sentiment.py
git commit -q -am "Name the label thresholds"
git push -q
git log --oneline main..feature/labelfd2277b Name the label thresholds
acb6356 Add label() for positive/negative/neutralNadia approves. With approvals and green checks, the PR can be merged.
Merging: three buttons
| Method | History on main | Good for |
|---|---|---|
| Create a merge commit | all branch commits plus a merge commit | keeping every step; long-lived branches |
| Squash and merge | one new commit with all the changes | most feature PRs: one PR = one commit, easy to revert |
| Rebase and merge | the branch commits replayed one by one, no merge commit | PRs whose commits are each clean and meaningful |
A repository admin can disable the methods the team does not use. Squash merging is what GitHub does when Nadia presses Squash and merge; locally it is git merge --squash plus a commit. GitHub adds the PR number to the message. (Squash commit -- not updating HEAD means the changes are staged but not committed yet; the git commit makes the one new commit.)
cd ~/team/nadia
git switch -q main
git pull -q --ff-only
git merge --squash -q feature/label
git commit -q -m "Add label() for positive/negative/neutral (#3)"
git push -q
git log --onelineSquash commit -- not updating HEAD
bce912a Add label() for positive/negative/neutral (#3)
d7da0ba Describe the project in the README
c9dd2d3 Add README
0ae0fca Count 'awful' as negative
135759b Count 'amazing' as positive
9bc4968 Add word-list sentiment scorerAfter the merge GitHub offers Delete branch (or deletes it automatically if the repository setting is on). Deleting a remote branch from the command line:
git push origin --delete feature/labelTo /home/nadia/remotes/sentiment-api.git
- [deleted] feature/labelRafiq's clone still remembers the deleted branch. fetch --prune removes stale remote-tracking branches, and -vv marks his local branch whose upstream is gone. Because the merge was a squash, his branch's commits are not on main, so Git refuses -d; after checking the PR is merged, -D deletes it:
cd ~/team/rafiq
git switch -q main
git pull -q --prune
git branch -vv
git branch -d feature/label feature/label fd2277b [origin/feature/label: gone] Name the label thresholds
* main bce912a [origin/main] Add label() for positive/negative/neutral (#3)
error: the branch 'feature/label' is not fully merged.
If you are sure you want to delete it, run 'git branch -D feature/label'git branch -D feature/labelDeleted branch feature/label (was fd2277b).Team files: PR template and CODEOWNERS
Some files in a repository are instructions for GitHub itself. They live in .github/ and travel with the code, so they are reviewed like code.
A pull-request template
If .github/pull_request_template.md exists, every new PR description starts with its contents. For an AI project it is the cheapest place to make sure nobody forgets the checks that matter:
cd ~/team/nadia
mkdir -p .github
cat > .github/pull_request_template.md <<'EOF'
## What and why
<!-- One or two sentences. Link the issue: "Closes #12". -->
## How to test
- [ ] `python -m pytest` passes
## AI checks
- [ ] No secrets, datasets or model files committed
- [ ] Prompt or model change: evaluation results attached
- [ ] API response shape unchanged, or the change is versioned
EOFSimilar files: .github/ISSUE_TEMPLATE/*.yml (issue forms with required fields, such as "model version" for bug reports), CONTRIBUTING.md (linked when someone opens an issue or PR) and SECURITY.md (how to report a vulnerability privately).
CODEOWNERS
A CODEOWNERS file maps paths to the people or teams responsible for them. When a PR touches a path, its owners are automatically asked to review; with the "require review from Code Owners" rule turned on, the PR cannot merge without one of them approving. GitHub looks for it in .github/, then the repository root, then docs/, on the PR's base branch:
# file: .github/CODEOWNERS
# Pattern, then one or more owners (@user or @org/team). The LAST matching line wins.
* @nadia-rahman
/app/ @nadia-rahman @rafiq-islam
/prompts/ @bitbyte-ai/ml-team
/models/ @bitbyte-ai/ml-team
/evals/ @bitbyte-ai/ml-team @mitu-das
/.github/ @nadia-rahman
/.github/CODEOWNERS @nadia-rahman @rafiq-islam
uv.lockThings to notice:
- Last match wins. A file in
/prompts/matches*and/prompts/; the later line decides, so the ML team owns prompts. Put broad rules first, specific ones after. - A pattern with no owners (
uv.lock) means nobody owns it, so lockfile-only PRs from Dependabot don't ping anyone. - Owners need write access to the repository, and teams must be visible; otherwise the line is silently ignored (GitHub shows an error on the file's page).
- The syntax is close to
.gitignore, but!negation and[a-z]ranges do not work. - Protect the file itself (the last-but-one line): whoever can edit
CODEOWNERScan remove themselves from review.
Release-note categories
GitHub can write release notes from merged PRs. .github/release.yml groups them by label:
# file: .github/release.yml
changelog:
exclude:
labels:
- ignore-for-release
categories:
- title: Breaking changes
labels:
- breaking-change
- title: New features
labels:
- enhancement
- title: Bug fixes
labels:
- bug
- title: Other changes
labels:
- "*"These files go through a PR like everything else. Here Nadia commits them directly, because the protection is not switched on yet:
git add .github
git commit -q -m "Add PR template, CODEOWNERS and release-note categories"
git push -q
git ls-files .github.github/CODEOWNERS
.github/pull_request_template.md
.github/release.ymlProtecting main: branch protection and rulesets
Agreements like "never push to main" only hold if the server enforces them. A bare repository can enforce rules with a server-side hook: a script the remote runs before accepting a push. GitHub does not let you install hooks; it gives you settings that do the same job. To feel what they do, here is a pre-receive hook that refuses direct pushes to main:
cat > ~/remotes/sentiment-api.git/hooks/pre-receive <<'EOF'
#!/bin/sh
# Runs on the server before any ref is updated. A non-zero exit rejects the whole push.
while read old new ref; do
if [ "$ref" = "refs/heads/main" ]; then
echo "main is protected: push a branch and open a pull request" >&2
exit 1
fi
done
EOF
chmod +x ~/remotes/sentiment-api.git/hooks/pre-receive
cd ~/team/rafiq
git pull -q --ff-only
echo "venv/" > .gitignore
git add .gitignore
git commit -q -m "Ignore venv"
git pushremote: main is protected: push a branch and open a pull request
To /home/nadia/remotes/sentiment-api.git
! [remote rejected] main -> main (pre-receive hook declined)
error: failed to push some refs to '/home/nadia/remotes/sentiment-api.git'Rafiq's commit is still in his clone; he moves it to a branch, resets his main to the remote's, and pushes the branch instead, which the hook allows (the message confirms that the recreated main still tracks origin/main):
git switch -q -c chore/gitignore
git branch -f main origin/main
git push -q -u origin chore/gitignore
git branch -vvbranch 'main' set up to track 'origin/main'.
* chore/gitignore ccde8df [origin/chore/gitignore] Ignore venv
main da45ba7 [origin/main] Add PR template, CODEOWNERS and release-note categoriesOn GitHub you get the same protection, and much more, from two features. Both can apply at once, and when they do, the most restrictive rule wins:
| Branch protection rules (classic) | Rulesets (recommended) | |
|---|---|---|
| Where | Settings → Branches | Settings → Rules → Rulesets |
| Targets | one rule per branch pattern | branches or tags, by pattern or "default branch"; organisation-wide rulesets on Team/Enterprise |
| Layering | only one rule applies to a branch | several rulesets stack |
| Turn off without deleting | no | yes: enforcement Active / Disabled (and Evaluate on Enterprise) |
| Exceptions | "do not allow bypassing" checkbox | an explicit bypass list (roles, teams, apps) |
| Who can see them | admins | anyone with read access |
| Plans | free for public repositories; private ones need Pro, Team or Enterprise | free for public repositories; private ones need Pro, Team or Enterprise |
A good starting ruleset for main:
| Rule | Why |
|---|---|
| Restrict deletions | nobody deletes main by accident |
| Block force pushes | published history cannot be rewritten |
| Require a pull request before merging: 1 approval, dismiss stale approvals when new commits are pushed, require review from Code Owners, require conversation resolution | every change is seen by a second person, and an approval covers the final code, not an earlier version |
Require status checks to pass (e.g. test), branch up to date before merging | CI must be green on the code exactly as it will be merged (you build that CI in CI/CD with GitHub Actions) |
| Optional: require linear history, signed commits, a merge queue | a straight history; verified authors; busy repositories where PRs would otherwise race each other |
Rulesets can be exported and imported as JSON (the Import a ruleset button, or the REST API), so a team keeps its standard ruleset in a repository and applies it everywhere. This is the ruleset above in the API's format:
{
"name": "protect-main",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] }
},
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 1,
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": true,
"require_last_push_approval": false,
"required_review_thread_resolution": true
}
},
{
"type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": true,
"required_status_checks": [{ "context": "test" }]
}
}
],
"bypass_actors": []
}An empty bypass list means the rules apply to admins too. That is the point: the most common way
mainbreaks is an admin "just quickly" pushing a fix. If you need an emergency path, add a bypass for one role and review who used it.
Issues, labels, milestones and projects
PRs carry code; issues carry the work that leads to it. GitHub's planning features are light, which is why small AI teams often need nothing else:
| Feature | What it is for | Example in an AI project |
|---|---|---|
| Issue | one bug, task or idea, with discussion | "Model returns neutral for sarcastic reviews" |
| Sub-issues | break a big issue into tracked pieces | "Add Bangla support" → tokenizer, word lists, evaluation set |
| Label | a tag for filtering and release notes | bug, enhancement, data, prompt, good first issue |
| Milestone | a set of issues and PRs with a due date | "v1.0 — public beta" |
| Assignee | who is doing it | one person, so nothing is "everybody's" |
| Project | a board, table or roadmap across issues and PRs (even across repositories), with custom fields | columns Todo / In progress / In review / Done; a field "Eval score" |
The same workflow with the gh CLI
Everything above can be done from the terminal with GitHub's gh CLI (version 2.x). These commands talk to github.com, so they need a real repository and gh auth login first; they are shown here, not run:
gh auth login # browser login; stores a token for git too
gh repo fork bitbyte-ai/sentiment-api --clone # fork, clone, and add the upstream remote
gh label create data --color 0e8a16 --description "Datasets and data pipelines"
gh issue create --title "Return a label, not just a score" --label enhancement --milestone "v1.0"
gh pr create --draft --base main --title "Add label() for positive/negative/neutral" --body-file pr.md
gh pr ready 3 # draft -> ready for review
gh pr checkout 3 # review someone's PR locally (forks too)
gh pr review 3 --request-changes --body "Please name the thresholds."
gh pr review 3 --approve
gh pr checks 3 --watch # wait for CI
gh pr merge 3 --squash --delete-branch
gh api --method POST repos/bitbyte-ai/sentiment-api/rulesets --input ruleset.json
gh release create v1.0.0 --generate-notes # release with notes from merged PRsUnder the hood a PR is just a ref. GitHub publishes every PR's head as refs/pull/<number>/head, so plain Git can fetch it too: git fetch origin pull/3/head:pr-3.
Releases and release notes
A release on GitHub is an annotated tag plus a page with notes and optional files (a wheel, a model card, a Docker image link). The tag part is plain Git. Nadia updates her main and tags it:
cd ~/team/nadia
git pull -q --ff-only
git log --oneline
git tag -a v1.0.0 -m "First public version: score() and label()"
git push origin v1.0.0da45ba7 Add PR template, CODEOWNERS and release-note categories
bce912a Add label() for positive/negative/neutral (#3)
d7da0ba Describe the project in the README
c9dd2d3 Add README
0ae0fca Count 'awful' as negative
135759b Count 'amazing' as positive
9bc4968 Add word-list sentiment scorer
To /home/nadia/remotes/sentiment-api.git
* [new tag] v1.0.0 -> v1.0.0Hold on: the log does not show Rafiq's .gitignore commit. Nobody has merged his PR yet, so it is not in the release, which is exactly right. The hook allowed the tag because it only guards refs/heads/main; on GitHub, a tag ruleset (for example "only maintainers may create v* tags, and nobody may move them") protects releases the same way.
On GitHub, Releases → Draft a new release, choose v1.0.0, press Generate release notes: GitHub lists the PRs merged since the previous release, grouped by the categories in .github/release.yml, and credits new contributors. Without GitHub, the raw material is the log between two tags:
git log --oneline --no-merges v1.0.0 --format="- %s (%an)"- Add PR template, CODEOWNERS and release-note categories (Nadia Rahman)
- Add label() for positive/negative/neutral (#3) (Nadia Rahman)
- Describe the project in the README (Nadia Rahman)
- Add README (Nadia Rahman)
- Count 'awful' as negative (Rafiq Islam)
- Count 'amazing' as positive (Rafiq Islam)
- Add word-list sentiment scorer (Nadia Rahman)Use semantic versions for anything others call: MAJOR.MINOR.PATCH, where a breaking change to your API's request or response bumps MAJOR. For an AI service, say in the notes when the model or prompt changed, even if the code did not: clients see different answers.
Real-world use
- AI teams with mixed roles. CODEOWNERS routes prompt and model changes to the ML team and API changes to backend developers, so the right person reviews each part of a PR that touches both.
- Required checks as quality gates. Teams add an evaluation job next to the unit tests ("accuracy on the golden set must not drop more than 1 point"); making it a required check stops a prompt tweak that quietly breaks answers.
- Open-source AI libraries (Hugging Face Transformers, LangChain, FastAPI itself) run entirely on forks, PRs, labels like
good first issueand generated release notes. Your first contribution follows exactly the fork workflow above. - Coding agents. AI coding assistants open PRs too. Rulesets, required reviews and CODEOWNERS apply to them exactly as to people, which is one more reason to have them.
Common mistakes
- Trusting
git statuswithout fetching. "Up to date" means "up to date with what I last fetched". Rungit fetch(or look at the PR page) before deciding anything. - Answering a rejected push with
--force. It deletes your teammates' commits from the remote. Usegit pull --rebaseand push again; if you really rewrote your own branch, use--force-with-lease. - Working on your fork's
main. Your fork then diverges from upstream and every sync becomes a merge. Keepmainclean, work on branches. - Huge PRs. Reviewers skim 1,000-line diffs and approve them. Keep PRs small (a few hundred lines), one purpose each; open a draft early.
- Protection with holes. Required reviews but no "dismiss stale approvals" lets someone push new code after approval; a CODEOWNERS file nobody protects lets anyone remove the owners. Review your ruleset as you would review code.
Try it yourself
- Easy: In Rafiq's clone, add a commit on
mainwithout pushing, fetch, and print how many commits hismainis ahead of and behindorigin/mainwith one command. (Hint:git rev-list --left-right --count.) - Medium: Mitu fixes a typo on a branch in her fork and pushes it there. As the maintainer, Nadia wants to review it locally without adding Mitu's fork permanently. Fetch the branch straight from the fork's path into a local branch
review/mituand list its commits that are not onmain. - Hard: Extend the server-side hook so that it also rejects force pushes to any branch (a non-fast-forward update), but still allows creating and deleting branches. Prove it with a rewritten branch. (Hint: a new branch has
old= forty zeros; an update is a fast-forward whengit merge-base --is-ancestor "$old" "$new"succeeds.)
Answers
# Easy
cd ~/team/rafiq
git switch -q main
echo "Contributions welcome." >> README.md
git commit -q -am "Invite contributions"
git fetch -q
git rev-list --left-right --count main...origin/main1 0The left number counts commits only on main (ahead), the right one commits only on origin/main (behind). git status -sb shows the same as [ahead 1].
# Medium
cd ~/team/mitu
git switch -q -c fix/readme-typo
sed -i 's/Scores short/Score short/' README.md
git commit -q -am "Fix README wording"
git push -q origin fix/readme-typo
cd ~/team/nadia
git fetch -q ~/remotes/mitu-sentiment-api.git fix/readme-typo:review/mitu
git log --oneline main..review/mitu1e3bafb Fix README wording# Hard
cat > ~/remotes/sentiment-api.git/hooks/pre-receive <<'EOF'
#!/bin/sh
zero=0000000000000000000000000000000000000000
while read old new ref; do
if [ "$ref" = "refs/heads/main" ]; then
echo "main is protected: push a branch and open a pull request" >&2
exit 1
fi
case "$ref" in
refs/heads/*)
if [ "$old" != "$zero" ] && [ "$new" != "$zero" ] && ! git merge-base --is-ancestor "$old" "$new"; then
echo "force push to ${ref#refs/heads/} rejected" >&2
exit 1
fi
;;
esac
done
EOF
cd ~/team/rafiq
git switch -q chore/gitignore
git commit -q --amend -m "Ignore the venv folder"
git push --forceremote: force push to chore/gitignore rejected
To /home/nadia/remotes/sentiment-api.git
! [remote rejected] chore/gitignore -> chore/gitignore (pre-receive hook declined)
error: failed to push some refs to '/home/nadia/remotes/sentiment-api.git'The amended commit is not a descendant of the old one, so the hook refuses it. Creating (old is all zeros) and deleting (new is all zeros) pass. This is what "Block force pushes" in a ruleset does on GitHub.
Summary
origin/mainis your clone's memory of the remote; onlyfetch,pullandpushupdate it. Fetch before you judge.- A rejected push means someone else pushed first:
git pull --rebase, then push. Never force-push shared branches. - Forks are server-side copies:
originis yours,upstreamis the original; keep your fork'smaina fast-forward copy. - Every change goes branch → (draft) PR → review → merge; squash merging gives one commit per PR; delete merged branches and prune.
- Team rules live in files and settings: PR templates,
CODEOWNERS(last match wins), rulesets with required reviews and checks, and releases built from annotated tags.
Next: Git for AI Projects: Data, Models, LFS, DVC and Leaked Secrets deals with what makes AI repositories different: large data and model files, reproducible environments, and what to do when a key ends up in history.