Skip to main content

103 - ML-Ops: GitHub, remotes, protected branches and pull requests

From local repository to remote: clone, push, fetch, pull requests, conflicts, merge and rebase, with a two-person scenario run locally.

What do you think about this article?

103 - ML-Ops: GitHub, remotes, protected branches and pull requests
Machine Learning Engineerfedericocalo.dev

At a Glance

From local repository to remote: clone, push, fetch, pull requests, conflicts, merge and rebase, with a two-person scenario run locally.

GitHub is a service that hosts Git repositories online and builds on top of them the tools for working as a group: backup, discussion, code review, automatic checks. Git is the program on your computer; GitHub is a place where repositories meet. The feature that matters most is called a pull request: a request to merge one of your branches into another, which others can read, comment on and approve before the code goes in. For an ML project it is the moment when someone also looks at the metric threshold and the dataset used, not just at the code style.

The words of collaboration

This lesson uses Git's vocabulary (commit, branch, merge, explained in Git, the time machine of your project) plus a few words about shared work. A remote is another copy of the same repository, on a server: the "shared counter" where colleagues put their work, and the main one is called origin by convention. Clone means downloading a complete copy of a remote repository, with the whole history, and linking it to it. Push sends your commits to the remote, fetch downloads the news without touching your work, pull does a fetch and then integrates.

A fork is a personal copy of someone else's repository on the server, which you can write to; the original repository is usually called upstream. The pull request (PR) is the formal request "merge my branch into yours" and is also the place of discussion and of code review, that is, the critical reading of the code by another person, like the proofreader before printing. A protected branch is a branch on which the platform enforces rules (mandatory review, passing checks, no direct push). CI (continuous integration) is the automatic checks, tests and linters, that run on every change: here it is enough to know that it shows up next to the PR as a green or red mark; the lesson on manual deploy versus CI/CD explains it in depth.

The pull request flow at a glance:

Pull request flow: local branch, push, pull request with review and checks, merge into mainLocal branchfix/auc-thresholdRemotepublished branchPull requestreview + checksmaincode mergedgit push -> open the PR -> approved: mergefixes = new commits on the same branch

The analogy: a newspaper's newsroom

Each journalist writes their piece on their own computer (the local repository). The newspaper has a common server where published pieces live (the remote). Before an article goes to the page it passes through the editor-in-chief, who reads it, marks corrections, and only then approves it. The pull request is that step: what is on your computer is not yet "the newspaper's" until someone else has seen it.

The mechanism: local, remote, and three commands not to confuse

A repository can have one or more remotes, that is, addresses of copies of the same repository. The basic commands are these; they are a pattern to adapt, because the address github.com/user/project is a placeholder (the scenario actually run is further below).

git clone https://github.com/user/project.git      # copies the remote and sets origin
git remote -v                                       # lists the configured remotes
git push -u origin experiment/c-regularization      # sends the branch (and remembers the link)
git fetch origin                                    # DOWNLOADS the news, without touching your files
git pull                                            # fetch + integration into the current branch

The distinction between fetch and pull avoids many messes. fetch only updates your knowledge of what is on the remote, and is always safe. pull in addition tries to merge that news into your work, and can therefore produce conflicts. If you want to look before integrating: git fetch, then git log HEAD..origin/main --oneline.

The pull request flow, step by step

  1. Start up to date: git switch main && git pull.
  2. Open a branch with a descriptive name: git switch -c fix/auc-threshold.
  3. Work in small commits and push: git push -u origin fix/auc-threshold.
  4. Open the pull request from the web interface, or with GitHub's command-line interface: gh pr create --fill.
  5. Review: colleagues comment on specific lines; you reply or fix with new commits on the same branch (the PR updates itself).
  6. Automatic checks: if configured, tests and linters run and show a green or red outcome next to the PR.
  7. Merge when it is approved. Then you delete the branch.

The order here is part of the content: if you open the PR before pushing there is nothing to review, and if you merge before the checks you lose precisely the guarantee the PR exists for. I could not run gh pr create in this work (it needs a GitHub account and an authenticated network), so the command is taken from the GitHub CLI documentation and has not been tried. A good PR description answers three questions: what problem it solves, how I verified it, what the reader should look at most carefully. In an ML project add: which metric changed, on which dataset and with which seed. "Improves the model" without numbers is not information.

Fork: contributing to a project that is not yours

If you do not have permission to write to the repository (the case of almost every open source project), you make a fork: your own copy of the repository on your account. You work on the copy, and from your fork you open a PR towards the original. The addresses are placeholders, and this block too is a pattern to adapt:

git remote add upstream https://github.com/organization/project.git
git fetch upstream
git switch -c fix/typo-in-documentation upstream/main

The third command starts from upstream/main and not from your main, so the branch is born from the current state of the original project, not from a copy that may have fallen behind. How to contribute to someone else's project, licences included, you see in open source, licences and how to contribute.

Protected branches: making machines enforce the rules

A rule written in a document gets forgotten. A rule enforced by the platform does not. On GitHub you can protect a branch (usually main) with a protection rule in the repository settings: require at least one approval before the merge, require the automatic checks to pass, forbid direct push, forbid rewriting history. It is the way "the tests must pass" stops being an intention. The detail of the options is in the documentation on protected branches; names and positions of the entries may change, so check it instead of trusting this lesson.

Conflicts, without fear

A merge conflict appears when two people have modified the same lines. Git does not decide for you: it marks the file like this.

<<<<<<< HEAD
AUC_MIN = 0.70
=======
AUC_MIN = 0.72
>>>>>>> fix/auc-threshold

The task is to choose (or combine) the two versions, delete the markers, then git add file and git commit. The practical advice is that conflicts become rare if branches live briefly and PRs are small: a branch open for three weeks is a conflict that is piling up.

This site's project works differently, and it is a good example

Here feature branches and pull requests are not used: work and commits go directly on master, with a single developer and a private repository (rule written in CLAUDE.md). It is not in contradiction with the above, it is a trade-off: the pull request exists to make another person look at the work. With one person only, the cost (opening, describing, approving by yourself) exceeds the benefit, and other checks work in its place: tests run before the commit and a clean history with messages in the format type(scope): subject. As soon as a second person joins, the calculation changes.

ContextReasonable flow
Alone, private repository, manual deployDirect commit on main, tests before the commit
Small team, shared codeShort branch + PR + one reviewer + mandatory tests
Open source projectFork + PR towards upstream, following the project's rules

Frequent traps

The first is the push of a secret: if a key ends up on a remote, deleting it in a later commit is not enough, it stays in the history, and the key must be revoked and replaced. The second is large files: the GitHub documentation says the platform blocks files over 100 MiB, and anyway Git is not the tool for datasets (this is discussed in the lesson on Git). The third is git push --force on a shared branch, which deletes other people's work: if you really need it, --force-with-lease refuses the operation if the remote has changed in the meantime. The fourth is giant PRs: nobody really reads them, and the smaller the PR, the better the review.

A real remote, without an account: trying it locally

To understand push, fetch and conflicts you do not need GitHub: a "remote" is just a repository in another folder. With Git 2.53 I created a bare repository (with no working area, as a server does) and two clones, "Anna" and "Bruno", working on the same project. I ran the whole scenario twice, with LC_ALL=C, commit identity and dates fixed: the outputs came out identical, hashes included. On your computer the hashes will be different and the remote's path will be yours (here I replaced it with /path).

git init --bare -b main remote.git        # the "server": history only, no working files
git clone remote.git anna
# Cloning into 'anna'...
# warning: You appear to have cloned an empty repository.

--bare creates a repository meant to receive pushes; the clone of an empty repository warns, correctly, that there is nothing yet.

cd anna
printf 'AUC_MIN = 0.70\n' > cfg.py
git add . && git commit -m "feat: threshold"
git push -u origin main
# To /path/remote.git
#  * [new branch]      main -> main
# branch 'main' set up to track 'origin/main'.

The -u (--set-upstream) linked your local main to origin/main: from now on git push and git pull without arguments know where to go, and git status can tell you whether you are ahead or behind the remote.

git clone remote.git bruno
cd bruno && git remote -v
# origin	/path/remote.git (fetch)
# origin	/path/remote.git (push)
git log --oneline
# 32df0ba feat: threshold

Bruno gets the whole history and git remote -v shows origin with two lines, (fetch) and (push): where it downloads from and where it sends to. On GitHub the address is an https://... URL or git@github.com:...: the path changes, not the mechanism.

Fetch: looking before touching

Anna publishes a change (tune: threshold 0.72). Bruno does not see it until he asks for it:

git fetch origin
#    32df0ba..12274ca  main       -> origin/main
git log HEAD..origin/main --oneline
# 12274ca tune: threshold 0.72
git status -sb
# ## main...origin/main [behind 1]

The fetch updated his local copy of the remote; HEAD..origin/main means "what is in the remote and not in my branch", and the line [behind 1] says "you are one commit behind". Your work has not been touched: that is why fetch is always safe.

The conflict, live

Before updating, Bruno modifies the same line with another value and commits locally (tune: threshold 0.65). Then he runs git pull --no-rebase (the classic merge):

git pull --no-rebase
# Auto-merging cfg.py
# CONFLICT (content): Merge conflict in cfg.py
# Automatic merge failed; fix conflicts and then commit the result.
git status --short
# UU cfg.py
cat cfg.py
# <<<<<<< HEAD
# AUC_MIN = 0.65
# =======
# AUC_MIN = 0.72
# >>>>>>> 12274cab73eff9fd241f7ada2a1ff98dbb0a4f37

Git does not know which of the two thresholds is the right one: it says so. The status shows UU cfg.py, "modified by both" (unmerged). Above the ======= line is your version (HEAD), below the incoming one. Resolving has four moves in order. First you decide which value is right: it is not a technical choice for Git, it is a project choice, and here you need to talk to Anna (why 0.72? why 0.65?), because a threshold chosen at random to close a conflict is a typo in the worst possible place. Then you edit the file, leaving only the final line and deleting the three marker lines. With git add cfg.py you declare "I have resolved it", and with git commit you complete the merge.

If instead you want to go back to the previous point, git merge --abort cancels the attempt and returns you to how you were before the pull. After doing it, git push is rejected:

git merge --abort
git status -sb
# ## main...origin/main [ahead 1, behind 1]
git push
#  ! [rejected]        main -> main (non-fast-forward)
# hint: Updates were rejected because the tip of your current branch is behind
# hint: its remote counterpart. If you want to integrate the remote changes,
# hint: use 'git pull' before pushing again.

The remote has a commit (Anna's) that Bruno's branch does not have: if Git accepted the push, it would delete Anna's work. The rejection is a protection, not an error to get around with --force. The rule: if a push is rejected, do a fetch, look at what is missing, integrate, resolve the conflicts, then try again. Here Bruno repeats the pull, chooses 0.70 (a value decided with Anna, for the example), and closes the merge:

git pull --no-rebase        # the conflict again; I resolve cfg.py by hand
git add cfg.py
git commit --no-edit
# [main f79edb8] Merge branch 'main' of /path/remote
git log --oneline --graph
# *   f79edb8 Merge branch 'main' of /path/remote
# |\
# | * 12274ca tune: threshold 0.72
# * | fc05ad5 tune: threshold 0.65
# |/
# * 32df0ba feat: threshold
git push
#    12274ca..f79edb8  main -> main

The graph shows the two lines joining in the merge commit, and the push is now accepted because the remote can advance from 12274ca to f79edb8 without losing anything.

Access to GitHub: credentials without secrets lying around

To push to GitHub you have to prove who you are, and the common ways are two. With SSH you generate a key pair (ssh-keygen -t ed25519): the public part you upload to GitHub (in the account settings), the private one stays on your computer, protected by a passphrase, and the remote's address becomes git@github.com:user/project.git; it is like a lock (public, you give it to everyone) and a key (private, yours alone). With HTTPS and token, instead of a password you use a personal access token, a string with limited permissions and an expiry that you create from the settings; never paste it into a project file (see environment variables and secrets) nor in commit messages. In both cases the credential is a secret: if you publish it by mistake, deleting it from the repository is not enough, it must be revoked from the GitHub panel and replaced. How SSH works on GitHub is in the official documentation; I did not try authentication to GitHub in this work.

How to write a pull request that can be read in two minutes

A useful template, adapted to machine learning, that you can copy into the description of every PR. The numbers in the example are those measured in the lesson on how to test a model.

## What changes
Raises the minimum AUC threshold from 0.60 to 0.70 in the model's regression test.

## Why
A model with only the tenure feature has AUC 0.643 and passed the check at 0.60; the minimum
observed for the healthy model over 10 split seeds is 0.737, so 0.70 separates them.

## How I verified it
- uv run pytest -q   -> 6 passed
- Deliberately broke the model (one feature fewer): the test turns red.

## What to look at carefully
The choice of the margin (the "thresholds" section of the test).

Structure: what (one sentence), why (with the numbers), verification (commands and results the reviewer can repeat), point of attention (where you want them to look hardest). The reader understands what they have to decide without reading the whole diff. A PR without these four things forces the reviewer to guess them.

Who reviews what, in an ML project

Reviewing an ML project looks at four different things. The code: clarity, tests present, no secrets, no large files. The numbers: is the metric cited measured on an evaluation set separate from training? With which seed? Does the improvement exceed the variability between splits? The data: does the change alter the input data or its preparation, and what happens to missing values? The impact: does it change the format of the model's output, and therefore what whoever consumes it expects? The first two questions are the ones an ordinary software reviewer does not think of asking, and that is why it pays to write them into the description template.

The why: four ways in which work in pairs breaks

Collaborating on the same code looks like an organizational problem ("who does what"), but it is first of all a technical one: two versions of the same set of files born from a common point and diverging. Errors follow four patterns, and each GitHub feature corrects one. The first is silent overwriting: two people edit the same file and the second to save deletes the first's work; Git prevents it (the rejected push seen above, the conflict flagged), but only if the work goes through Git, while a file on a shared disk or sent by mail goes back to the worst case. The second is the unreviewed change: an error (a wrong threshold, a data column misread) enters the code because the only person who looked at it is the one who wrote it, and the pull request introduces a second pair of eyes before the merge, when fixing costs a comment and not an incident.

The third pattern is the long-lived branch: the longer a branch lives in isolation, the more it diverges from main, and the more painful it is to merge back, because conflicts grow and, worse, assumptions change without anyone noticing; the cure is organizational, not technical, and is called short branches, merged often. The fourth is the regression after integration: Anna's code works, Bruno's code works, the sum does not, and only an automatic check run on the result of the merge (the CI) sees it before release.

The three merge strategies, and what they do to history

When a pull request is approved, GitHub usually offers three ways to merge it (documentation on pull requests). The choice changes the shape of the history, hence what git log and git bisect will show you in the future.

StrategyWhat it producesProsCons
Merge commitA merge commit with two parents; the branch's commits stay as they wereHistory faithful to what happened; no commit rewrittenGraph-shaped history, noisier; the branch's "wip" commits stay
Squash and mergeAll the branch's commits become a single commit on mainLinear, clean history; one commit = one featureThe detail of the steps is lost; bisect works at a coarser grain
Rebase and mergeThe branch's commits are rewritten one after another on top of main (new hashes)Linear history with the detail of the commitsHashes change: whoever had the branch must realign

A reasonable choice for an ML project with small, well-written commits is rebase and merge or merge commit, because bisect works better with small, self-contained commits. Squash is convenient when the branch's commits are messy (many "wip") and the PR is a single logical unit.

The rebase, up close

The rebase deserves a demonstration, because it is the command beginners fear the most and the one that best clarifies how history works. In the test repository, Anna published a new commit (feat: anna extra) while Bruno had one of his own, not yet published (docs: bruno notes, hash 8254c2e). Bruno updates with git pull --rebase:

git pull --rebase
#    f79edb8..cb03462  main       -> origin/main
# Rebasing (1/1)Successfully rebased and updated refs/heads/main.
git log --oneline --graph
# * 9146f2b docs: bruno notes
# * cb03462 feat: anna extra
# *   f79edb8 Merge branch 'main' of /path/remote
# |\
# | * 12274ca tune: threshold 0.72
# * | fc05ad5 tune: threshold 0.65
# |/
# * 32df0ba feat: threshold

Instead of creating a merge commit, Git took Bruno's commit and put it back on top of Anna's, as if Bruno had started working after her. After the merge commit, the history continues in a straight line. Bruno's commit has a new hash: it was 8254c2e, now it is 9146f2b, because its parent changed, and the old one no longer exists in his branch. It is the reason for the golden rule: never rebase commits already published and used by others, because changing their hash forces everyone to realign. For local commits, not yet shared, it is safe and produces a more readable history. If the rebase meets a conflict, it stops and reports it: you resolve it as described (edit, git add) and carry on with git rebase --continue, or stop with git rebase --abort; the syntax is in git-rebase, and I did not try the case of a conflict during the rebase.

Configuring a protected branch, with judgement

The options available in the settings of a GitHub repository almost always cover these checks (names and positions may vary):

  • Require a pull request before merging: prevents direct push to main.
  • Require a number of approvals (one is enough for a small team). If the author is also the approver, the check makes no sense: the platform can forbid it.
  • Require the automatic checks to pass, choosing which ones (for example the test job).
  • Require the branch to be up to date with main before the merge: tests are run on the real result, not on a state already superseded.
  • Forbid force push and deletion of the protected branch.
  • CODEOWNERS: a file that indicates who must approve changes to certain folders (for example, a data expert for data/). The platform automatically asks for their review, as described in about code owners.

The first four entries act at the moment of the merge, the last two on history and responsibilities; all have a cost, because they slow every PR. The general criterion is to introduce a rule when it prevents an error that has already cost you: starting with two rules (PR required, green tests) is more sustainable than starting with six and ending up getting around them.

Giving and receiving a review

Review is the most valuable and most wasted moment of a PR. As a reviewer you read the description first and then the diff: if the description is not enough to understand what is being asked, the first comment is on that. Distinguish blocking comments ("this test verifies nothing: it must be fixed") from suggestions ("I would prefer a clearer name, not mandatory"), because a reviewer who makes everything blocking slows everyone down. Explain the why, not just the what: "here the threshold is below the observed variability" teaches, "remove" does not. When the change is delicate, run the code: gh pr checkout NUMBER brings the PR's branch onto your computer (command from the documentation, not tried here) and you can re-run the tests. Look also at what is missing, a test, a documentation update, a migration, because the diff shows what is there and absence is seen only by rethinking the change.

As an author, re-read your own diff in the interface first: you see errors you do not see in the editor, because you look at it as another person will. Keep the PR small, because one of fifty lines gets a real review and one of five hundred gets a "looks ok" (an observation from experience, not a measurement). Reply to every comment, even just with "done", and if you disagree say so with an argument, not with silence. And do not take comments on the code as judgements on you: the usefulness of review depends on frankness.

Short flow or long flow: two organizational models

Two philosophies coexist. In trunk-based development everyone integrates often (even several times a day) into a main branch, with very short branches or none; it is what this project does, with a single developer committing on master. In models like GitFlow there are long-lived branches (develop, release/*, hotfix/*) with promotion rules between one and the other, suited to software with separately released versions. For a typical ML project (one model, one service, a small team) trunk-based with short PRs is almost always enough and much less tiring: long-lived branches multiply conflicts exactly where data and parameters change often. The exception is long research experiments: there an isolated branch is accepted, provided you realign it with main regularly (git merge main inside the branch, so as not to accumulate divergence).

Issues as the project's memory

An issue is a card: an error, a request, a question, with a discussion. Its value lies in the link with the code: writing Fixes #12 in a PR's description makes issue 12 close automatically when the PR is merged into the repository's default branch, leaving the trace "this change solved that problem" (the rule is in the documentation on linking PRs and issues). For a model it is a cheap form of documentation: "why is the threshold 0.65?" has an answer by searching for the issue that discussed it. You do not need complex project management tools to start: a list of well-written issues and a label for the status are enough.

A week in a small team (illustrative scenario)

To fix the flow, here is a hypothetical week of three people on the churn project (it is a scenario built to explain, not a chronicle):

  1. Monday. One person opens an issue: "The model underestimates customers with no complaints". Another takes it on and creates the branch fix/customers-without-complaints.
  2. Tuesday. Three small commits: a test that reproduces the problem (red), the fix, an update of the threshold. Push and opening of the PR with the description template seen above.
  3. Wednesday. The CI shows red: a determinism test fails. The reviewer finds the reason (a missing seed in a new function); the author adds it with a new commit; the PR updates itself.
  4. Thursday. Approval, merge, the branch is deleted. An issue closes automatically.
  5. Friday. The third member, who was working on another branch, does git pull --rebase on main to realign and carries on.

None of this requires sophisticated tools: it is the combination of short branches, small PRs, automatic tests and clear descriptions. The order of the days is only illustrative, but the sequence is not: the red test precedes the fix, the CI precedes the approval, the merge closes the issue.

Try it yourself: four guided exercises

The exercises are done locally, without a GitHub account. Each exercise says what to do, what you should see (the results here are those of my run with Git 2.53) and what it teaches you.

Exercise 1: a remote in a folder

Create a --bare repository and two clones (anna and bruno) as in the lesson. In anna create cfg.py with AUC_MIN = 0.70, commit and run git push -u origin main. Then git clone again as bruno. You should see: in the push, [new branch] main -> main; in bruno, git log --oneline with Anna's commit. What you learn: that a remote is just another repository, and that origin is its local name.

Exercise 2: being "behind" without noticing

In anna change the value to 0.72, commit and push. In bruno run git fetch and then git status -sb. You should see ## main...origin/main [behind 1]. Then try git log HEAD..origin/main --oneline. What you learn: the difference between knowing that the remote is ahead (fetch) and integrating (pull), and that status tells you how many commits you are ahead or behind.

Exercise 3: provoking and resolving a conflict

Before updating, in bruno change the same line to 0.65 and commit. Then git pull --no-rebase. You should see CONFLICT (content): Merge conflict in cfg.py and, with git status --short, UU cfg.py. Open the file: there are the markers <<<<<<< HEAD, ======= and >>>>>>>. Choose a value, delete the markers, then git add cfg.py and git commit. Check with git log --oneline --graph that a merge commit exists. What you learn: that a conflict is not an error but a question Git cannot answer; and that the answer is a project decision, not a syntax one. Variant: repeat with git merge --abort instead of resolving, and observe that the repository goes back to how it was.

Exercise 4: the same situation with rebase

Restore the two clones to the same point. Have anna make a commit on a new file (extra.cfg) and push; in bruno commit a different file (note.cfg) without publishing it. Then, in bruno: git pull --rebase. You should see Successfully rebased and updated refs/heads/main and a linear graph, with Bruno's commit on top of Anna's and a different hash from before (with git log --oneline before and after). What you learn: that rebase rewrites your local commits; and that is why you do not do it on commits already published.

If you do only these four exercises, you have already met all the situations that appear in real teamwork: update, lag, conflict and linear history.

What you can do now

You can tell Git, which is local, from GitHub, which is a remote with collaboration tools on top, and fetch, which looks, from pull, which looks and integrates. You can write a pull request that answers what changes, why and how you verified it, with metric, dataset and seed when the model is involved. You can recognize and resolve a conflict, understand why a push is rejected and choose between merge, squash and rebase. And you know that the right flow depends on how many people work on the project, not on a fashion. Next lesson: open source, licences and how to contribute.

The same series also includes ML-Ops: from notebook to service, ML-Ops: container registries, tags, digests and versions that do not change under your feet and ML-Ops: deployment, where the model runs and how to choose.

Sources

The scenario with the bare repository, Anna and Bruno was run twice with Git 2.53.0 (LC_ALL=C, dates and identity fixed) and the outputs, hashes included, are identical; the remote's path is replaced by /path. Not run: the commands towards GitHub (git clone https://github.com/..., gh pr create, gh pr checkout), the fork with upstream and the conflict during a rebase. The names Anna and Bruno and the scenario of the week are invented.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects