Files
best-practices/git-source-control.md
Paul O'Reilly 7e348f5ee3 distill: 48 cross-project best-practices from 2026-07 reflection sweep
Promotions from reflecting 21 projects' session logs (incl. agent-runtimes
122-log drain). Adds coverage across networking (eBPF VIP/VPN SNAT/VLAN
bridge/forward-auth preflight/ingress TLS), kubernetes (CSI hotplug/PodSecurity
debug/self-managed GitOps/runtime annotations), CI (dispatch tokens/runner
death/base image), git (CI-rebase/shallow reset/PR governance), python (async
session pool/httpx redirects/logging), TDD (AsyncMock/xfail lifecycle),
api-integration (SDK parse/token-scope 404/schema probing), plus docker,
scripting, debugging, security-architecture, secrets, react, octopus.

State: .distill-state.json refreshed with current HEADs + 5 newly-tracked projects.
2026-07-02 15:57:42 +12:00

10 KiB

Git & Source Control

Commit Practices

  • Use meaningful commit messages; prefer small, focused commits over large batches
  • Never commit secrets in plaintext — use SOPS + age or equivalent encryption
  • Enable pre-commit hooks where appropriate (secret detection, linting, formatting)
  • Commit each implementation phase separately. For multi-phase milestones, commit at each phase boundary with tests passing. Each commit should be self-contained and independently describable. Phase-by-phase commits surface issues early, keep history bisectable, and make later review and reflection far easier than one large end-of-milestone commit.

Repo Initialization

  • Rename default branch immediately after git init. git init still creates master on many systems despite main being the modern default. Run git branch -m master main (or configure init.defaultBranch = main globally) before first push — otherwise git push -u origin main fails with src refspec main does not match any. Bake this into any project-bootstrap script.

Pre-Commit Hooks

  • Block local_secrets/ and similar directories from being committed
  • Auto-encrypt files matching .sops.yaml rules that aren't yet encrypted
  • Enable with git config core.hooksPath .githooks
  • Consider secret detection, linting, and formatting hooks
  • Copy hook scripts into .git/hooks/; don't symlink. A symlinked hook resolves to the working-tree file, which changes on every branch switch — hooks then run stale (or wrong-branch) logic. Use an install-hooks.sh that copies the source script into .git/hooks/, and re-run it whenever the source changes.
  • Document the sync-then-commit sequence when hooks require an up-to-date main. If a pre-commit hook requires local main to match origin/main, the commit silently blocks when local main is even one commit behind. Standard sequence: git checkout main && git pull --ff-only && git checkout <branch> && git rebase main.

GitOps Workflow

  • All infrastructure changes should be tracked in Git
  • No manual changes without corresponding GitOps manifests — anything applied manually (e.g., kubectl apply, helm install) should immediately get a corresponding tracked manifest
  • For ArgoCD-managed clusters: edit in Git, push, sync — never edit live resources directly

Separate Data Repos from Code Repos for GitOps Controllers

When a GitOps controller watches a git repo for declarative state (zone files, policy documents, config blobs), keep that data in a repo separate from the controller's app code. Mixing the two means every data change triggers CI builds (container rebuilds, test runs) for no reason, and pollutes the code repo's history with non-code churn.

Separation yields:

  • Pure data commits with no CI noise
  • Cleaner webhook targeting per-repo
  • Independent access control for data editors vs code maintainers
  • Simpler rollback semantics — revert a data change without touching the controller image

Remote Conventions

  • SSH workflows preferred over HTTPS for Git remotes
  • Use SSH config host aliases for multi-user setups (e.g., gitea.example.com-<user>)
  • Remote URL format: git@<host-alias>:<org>/<repo>.git
  • Optionally push-mirror to GitHub for public visibility
  • Prefer internal hostnames for self-hosted Git remotes on the local network. SSH push to a self-hosted Git server through a public/VPS hostname can return kex_exchange_identification: read: Connection reset by peer, especially during heavy agent activity — VPS routes often have connection limits or rate limiting that fail silently under load. On the local network, use the internal hostname in SSH aliases for heavy push workflows; reserve the public hostname for external access or HTTPS. Test with ssh -T <alias> immediately after configuring.

Cross-User Pushes

  • Prefer SSH aliases over temp-URL swaps. When a repo is owned by user A but your default SSH key authenticates as user B, configure a dedicated SSH host alias (Host gitea.example.com-userA with matching private key) and use git@gitea.example.com-userA:org/repo.git. Avoid temp-URL-swap (embedding an HTTPS token in the remote URL, pushing, then resetting) — it's clunky, prone to shell-quoting errors, and leaks tokens into reflog/history.
  • API-created repos need the SSH user as a collaborator. If a repo is created via API token (user A) but pushes use an SSH alias authenticating as user B, user B has no access by default. Add the SSH-authenticating user as admin collaborator via API before the first push. Applies to Gitea, GitHub, and any platform where API auth and SSH auth use different identities.

Access and Clone Gotchas

  • Org repos require explicit collaborator grants. Don't assume organizational membership implies write access — verify permissions before setting up automation or CI/CD.
  • Shallow clones break push operations. git clone --depth 1 is fine for read-only CI jobs, but pipelines that push artifacts, tags, or mirror to other remotes need full clones.
  • Reset shallow clones against HEAD, not origin/main. A single-branch shallow clone (--depth N) creates no remote-tracking refs for other branches, so origin/main does not exist unless main is the branch being cloned. Init/reset logic that does git reset --hard origin/main fails on any other branch. Reset against HEAD instead — it is branch-agnostic and works regardless of which branch was shallow-cloned.

Cross-Remote Hygiene for Multi-Remote Projects

When a project has divergent remotes (e.g., local Gitea with granular commits + GitHub with squash-merged PRs), histories diverge and naive merges explode.

  • Never git merge <remote>/<branch> --allow-unrelated-histories — it produces 50+ mass conflicts across unrelated files.
  • Import specific files instead: git checkout <remote>/<branch> -- <specific-files> brings those paths in as a normal local change.
  • Always diff after a bulk checkout. git checkout from a remote silently overwrites locally-modified files with older remote versions with zero warning. Run git status and review each touched file before staging; restore with git checkout HEAD -- <file> if an unwanted overwrite happened.
  • After an upstream PR squash-merge, reset — don't rebase — local branches to main. Rebase against a squashed history leaves phantom commits; a clean git reset --hard origin/main on the local branch is correct.

Version Management

  • Use the latest stable version of dependencies unless pinned for a reason
  • Verify versions from live sources (helm search repo, upstream docs, package registries) — don't rely on memory
  • Document the reason in a comment if a version is intentionally pinned below latest
  • Check compatibility matrices before upgrading (e.g., Talos ↔ Kubernetes, framework ↔ runtime)

Placeholder Conventions in Template-Heavy Repos

When files contain multiple templating syntaxes (Go templates {{ .var }}, CI variables ${{ }}, Helm {{ }}, etc.), use a distinct placeholder convention for your own substitutions that can't be confused with any templating language:

  • Double-underscore: __CUSTOMER_NAME__, __DOMAIN__
  • All-caps curly brace (no spaces): {{CUSTOMER_NAME}} (distinct from Go's {{ .Title }} with spaces and dots)

Choose one convention per repo and document it.

Git Worktrees for Parallel Agent Work

When running parallel agents or tasks that modify the same repo:

  • Give each task its own branch and worktree (git worktree add .worktrees/<task-id> -b <branch>)
  • Tasks with no dependencies branch from HEAD; tasks with one dependency branch from that dependency's branch
  • Tasks with multiple dependencies get an octopus merge base branch
  • Worktrees share the .git object store — fast creation, minimal disk usage
  • Keep containers detached (docker run -d, not --rm) so logs survive for inspection after exit

Rebase Before Manual Commits to a CI-Auto-Bumped Branch/Deploy Repo

When CI auto-commits back to the same branch you push to — [skip ci] dependency-sync commits, deploy manifests with an image newTag bumped by a build job, ArgoCD chore: deploy commits — your push races those bots. A push made just after your fetch is rejected as non-fast-forward (! [rejected] ... (fetch first)), and on a deploy repo the running image can silently lag origin/main by many builds with no obvious error.

  • Standard sequence: git pull --rebase origin <branch> && git push origin <branch>. Rebase (not merge) keeps history linear against the bot commits.
  • Expect conflicts in bot-managed files (dependency manifests, version pins, image tags). Resolve by taking the higher/newer value.
  • This race also fires immediately after you trigger a dependency-sync that CI auto-commits — pull-rebase before pushing your own follow-up.

Gitea: Use workflow_dispatch, Not repository_dispatch, for API-Triggered Builds

On Gitea (observed 1.25.x), POST /api/v1/repos/<org>/<repo>/dispatches (the repository_dispatch trigger) returns 404 and never fires the workflow. Trigger builds instead via POST /api/v1/repos/<org>/<repo>/actions/workflows/<file>.yaml/dispatches with body {"ref":"main","inputs":{...}} (the workflow_dispatch trigger).

Also don't point a Gitea webhook at the /dispatches endpoint to chain builds: Gitea webhooks send the full push-event body (not {"event_type": ...}, which the endpoint ignores) and carry no auth header (so the call 401s). Chain builds from an in-CI dispatch step instead.

PR Governance on Protected Branches

  • Don't bypass a required_approvals gate to unblock automation. On a branch protected with required_approvals: N, do not self-approve a PR through a second bot account/token to let automation merge. That approval gate is a deliberate production-publish guardrail set by the repo owner; bypassing it defeats its intent. Leave the PR open for a human to approve. (This is distinct from a merge-whitelist misconfiguration, which is a config bug to fix — an approval gate is intentional.)
  • Promote a targeted change with a feature branch, never by merging the whole staging branch. When a protected main requires approvals and you need to publish one targeted change, do not push/merge the entire integration branch (e.g. staging → main) — that promotes all accumulated changes at once. Push a feature branch containing only the targeted change and open a PR against main. Keeps the diff reviewable and prevents accidental bulk promotion.