Skip to content

5. Team workflows

Intermediate · 13 min read

Commands are the easy part. What makes a team fast is agreeing on how to use them: where work happens, what a good commit and PR look like, who can merge, and how releases are cut. This page covers the conventions most GenAI teams use, plus two debugging tools every engineer should know: bisect and blame.

5.1 GitHub flow

Most product teams, including most AI teams, use GitHub flow (or its close cousin, trunk-based development):

flowchart LR
    M1((main)) --> B["branch<br/>feature/rerank"]
    B --> C[commits] --> P[pull request<br/>CI + review]
    P --> M2((main)) --> D[deploy]
  1. main is always deployable.
  2. Every change gets a short-lived branch off main (hours to a few days).
  3. Open a pull request early; CI runs tests and evals; a teammate reviews.
  4. Merge to main, delete the branch, deploy.

Unfinished features hide behind a feature flag instead of living on a long branch. Heavier models like Git Flow (separate develop, release/* and hotfix/* branches) suit software shipped in versioned releases (mobile apps, libraries), but they're overkill for a web service deployed many times a day.

Branch names

Pick a pattern and stick to it: type/short-description, optionally with a ticket number.

Prefix For
feature/ New capability: feature/hybrid-search
fix/ Bug fix: fix/empty-context-crash
exp/ Experiments that may never merge: exp/bge-m3-embeddings
chore/, docs/ Dependencies, tooling, documentation

5.2 Commit messages people can use

A commit message is for the person running git log or git blame in six months, probably you. The widely used Conventional Commits format:

feat(retrieval): add BM25 + vector hybrid search

Pure vector search missed exact product codes like "SKU-4471".
Hybrid search with reciprocal rank fusion fixes 14 of the 17 failing
eval questions; latency p95 goes from 310 ms to 360 ms.

Closes #128
Part Rule
Type feat, fix, docs, refactor, test, perf, chore, ci
Subject Imperative mood ("add", not "added"), under ~70 characters, no full stop
Body Why, and the trade-offs. The diff already shows what
Footer Closes #128, BREAKING CHANGE: ...

The typed prefix lets tools generate changelogs and pick the next version number automatically (5.5). Bad: fix stuff, wip, final changes v2.

5.3 Pull requests and code review

As the author:

  • Keep PRs small and focused. Split refactors from behaviour changes.
  • Describe what, why and how to test. For LLM changes, include example inputs and outputs, and eval scores before and after.
  • Review your own diff on GitHub first; you'll catch half the issues.
  • Open as a draft early to get feedback on direction.

As the reviewer:

  • Check correctness, tests and readability before style (leave style to the linter).
  • For GenAI code, also check: prompts and their versioning, what happens on API errors and timeouts, token costs, whether user input can reach the prompt unescaped (injection), and whether an eval covers the change.
  • Phrase comments as suggestions with reasons, and mark nitpicks as nits.

Merge strategies

GitHub offers three buttons:

Strategy Result on main Good for
Create a merge commit All branch commits + a merge commit Keeping the full history of large branches
Squash and merge One commit per PR Most teams: clean main, one PR = one revertable commit
Rebase and merge Branch commits replayed, no merge commit Teams that curate each commit carefully

Squash merging is the most common default: messy "fix typo" commits on the branch don't matter, and reverting a bad change means reverting one commit.

5.4 Protecting main

In Settings → Rules → Rulesets (or the older Branch protection rules), protect main:

  • Require a pull request, with at least one approving review.
  • Require status checks (tests, lint, evals) to pass before merging.
  • Require branches to be up to date before merging.
  • Block force pushes and deletions.
  • Optionally: require signed commits, linear history, or a merge queue for busy repos.

A CODEOWNERS file automatically requests reviews from the right people:

.github/CODEOWNERS
# path                     owners
*                          @asha-rao
/prompts/                  @asha-rao @ravi-kumar     # prompt changes need the prompt owners
/evals/                    @ml-platform-team
/infra/                    @devops-team

5.5 Tags, versions and releases

A tag is a permanent name for a commit, used for releases. Use annotated tags (they store author, date and message) and semantic versioning: MAJOR.MINOR.PATCH, where major means breaking API changes, minor means new features and patch means fixes.

git tag -a v1.0.0 -m "First release"        # tag the current commit (or add a hash at the end)
git tag
git describe --tags                         # where are we relative to the last tag?
git push origin v1.0.0                      # tags aren't pushed by default (or: git push --tags)
gh release create v1.0.0 --generate-notes   # GitHub release with notes built from merged PRs
Output
v1.0.0
v1.0.0-6-g3d2ce86

v1.0.0-6-g3d2ce86 reads as "6 commits after v1.0.0, at commit 3d2ce86": handy as a build version in logs. For an LLM service, tag what you deploy, so "which prompt and code produced this answer?" always has an answer.

5.6 Bisect: which commit broke it?

An eval that passed at v1.0.0 now fails. There are six commits in between. git bisect binary-searches the history for you: mark one bad and one good commit, and it checks out the middle each time.

python t.py                       # the failing check: case-insensitive match should pass
git bisect start
git bisect bad                    # the current commit is broken
git bisect good v1.0.0            # this one worked
Output
FAIL
status: waiting for both good and bad commits
status: waiting for good commit(s), bad commit known
Bisecting: 2 revisions left to test after this (roughly 2 steps)
[eb0035887e765a119d3cf994ea022534f1fcc8d6] Tweak scorer comments (3)

You could test each step by hand and type git bisect good or bad. Better: give it a command that exits 0 for good and non-zero for bad, and let it run:

git bisect run sh -c "python t.py | grep -q ok"
git bisect reset                  # back to where you started
Output
[e454a5ca3312f22a764ef8950fd07f2931d3cac1] Tweak scorer comments (4)
[ccf7edbd767be7043655b62e33448cf90e105393] Speed up scorer
ccf7edbd767be7043655b62e33448cf90e105393 is the first bad commit
commit ccf7edbd767be7043655b62e33448cf90e105393
Author: Asha Rao <asha@example.com>
Date:   Thu Oct 1 10:00:00 2026 +0530

    Speed up scorer

 score.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
bisect found first bad commit

Three test runs to search six commits; with 1,000 commits it would take about ten. This works brilliantly for eval regressions ("accuracy dropped from 0.91 to 0.84 sometime last month") as long as your eval script can return a pass/fail exit code. Small, focused commits make the answer precise.

5.7 Blame: who changed this line, and why?

git blame -L 1,3 score.py
Output
^ca6a33b (Asha Rao 2026-10-01 10:00:00 +0530 1) def score(answer: str, expected: str) -> float:
ccf7edbd (Asha Rao 2026-10-01 10:00:00 +0530 2)     return 1.0 if expected in answer else 0.0
7f17a6b0 (Asha Rao 2026-10-01 10:00:00 +0530 3) # note 1

Each line shows the commit that last changed it. git show ccf7edbd then shows the full change and, with a good message, the reason. Despite the name, use it to find context, not someone to blame. GitHub's blame view and editor extensions like GitLens show the same inline. Add a commit hash to a .git-blame-ignore-revs file to skip bulk formatting commits.

Interview questions

Describe the Git workflow you'd use for an LLM application team.

GitHub flow: a protected, always-deployable main; short-lived feature branches; pull requests with CI running lint, unit tests and an eval suite; at least one review (CODEOWNERS for prompts and evals); squash merges; tags for each deployed version; feature flags for unfinished work instead of long-lived branches.

Squash merge vs merge commit vs rebase merge?

Squash combines a PR into one commit on main: clean history, easy reverts, loses the branch's individual commits. A merge commit keeps all commits plus a merge commit: full history, noisier. Rebase merge replays the commits without a merge commit: linear, but each commit must be good on its own.

What makes a good commit message?

A short imperative subject (often with a Conventional Commits type like feat: or fix:), then a body that explains why the change was made and its trade-offs, and references to issues. The diff shows what changed; the message must explain why.

How would you find which commit caused an evaluation regression?

git bisect: mark the current commit bad and a known-good tag good, then git bisect run with a script that runs the eval and exits non-zero below the threshold. It binary-searches the history and reports the first bad commit in about log2(n) runs.

What protections would you put on the main branch?

Require pull requests with approving reviews (CODEOWNERS for sensitive paths), required passing status checks, up-to-date branches, no force pushes or deletions, and optionally signed commits or a merge queue.

Practice

  • Rewrite your last five commit messages in Conventional Commits format (on a branch, with rebase -i).
  • Add a CODEOWNERS file and a ruleset that requires one review on main in a test repository.
  • Break a test somewhere in the last ten commits of a toy repo and find it with git bisect run.

Next: 6. Git for GenAI projects Back to: Git & GitHub overview · Notes overview