Git Workflow

Your site repository is an ordinary git repository with two unusual properties: you are not allowed to run git commit or git push in it, and you are not its only author. Both fall out of one design goal — the CMS and you writing to the same history without stepping on each other. Once you know how each half behaves, the rest of git works exactly as you expect.

The two branches, restated for git

staging is the working branch. It builds the preview site, and it is the only branch the CLI will commit on or push from. main is the live branch: it builds the public site, and nothing you type promotes to it.

Between them sits one thing that surprises people the first time they open the repository in GitLab: a standing open merge request, staging → main, titled feat: staging to main. The CMS creates it once and then reuses it forever — it is never closed, and a new one is only ever opened if none exists. A perpetually open merge request with hundreds of commits on it is the normal, healthy state of this repository, not a leftover somebody forgot to merge.

You may create local branches for your own scratch work. Nothing stops you. But you have to be back on staging before you commit, because both leed site commit and leed site push refuse any other branch with:

You are only allowed on specific branches. Change back to staging!

Why the CLI owns commit and push

leed site init installs three husky hooks — pre-commit, pre-push and commit-msg — into .husky/. Each of the first two runs the same guard, which asks git one question: was this invoked by Leed?

The CLI answers it by running git with a config flag set for that invocation only — git -c leed.invoked=true …. The hook reads git config --get leed.invoked, and if the answer is not true, it refuses:

$ git commit -m "quick fix"

	You cannot 'commit' directly, please use:

		leed site commit -m "update details"

husky - pre-commit script failed (code 18)

git push gets the same treatment, pointing at leed site push. The exit code is 18 in both cases.

There is one hole in this that is worth knowing about rather than discovering: .husky/ is gitignored. A colleague who clones the repository by hand instead of running leed site init gets no hooks at all and therefore no protection. That is also why init re-installs them every time it runs, and why running leed site init again is the fix when the guards stop firing.

The commit message the CLI writes

leed site commit -m "…" does not write your message as the commit subject. It writes a structured envelope with your message inside it:

$ git log --oneline -1
9c1f4ab @leed/site-management=>{"message":"tighten the footer spacing","userId":"b93t","version":"4.105.0"}

Three fields, all required:

FieldWhat it isWhy it is there
messageExactly the text you passed to -mThe human-readable half — this is what you would have written as a commit subject
userIdYour Leed user idThe CMS reads it back to attribute the deployment to you, even though the build runs on a server with no session
versionThe version of the CLI that made the commitTraceability when a build behaves differently from the one before it

The commit-msg hook parses that envelope and rejects anything that does not carry all three, which is the second reason a hand-written git commit cannot land: even with leed.invoked somehow set, the message would not parse.

It looks alarming in git log and it is not. Everything after the => is one JSON object on one line; your own message is right there at the front of it.

The commits Leed writes

The other half of your history is written by the CMS, on your behalf, whenever somebody publishes something. Every one of them carries a trailing attribution line appended by the commit helper:

committed by: {"companyId":"ecbd78","userId":"b93t","role":"Administrator"}
commit 9c1f4ab2e7d3
Author: Ada Lovelace <ada@example.com>
Date:   Tue Sep 1 14:02:11 2026 +0000

    @leed/site-management=>{"message":"tighten the footer spacing","userId":"b93t","version":"4.105.0"}

One line, on staging, made by leed site commit. The author is whatever git config user.name and user.email say — the CLI refuses to commit if they are unset, because the attribution is read back later.

Publications

Page publications use a deliberately non-conventional subject so your history stays filterable on one word:

Publication: [preview] 2026-09-01T14:07:39.812Z (3 pages)
Publication: [public] 2026-09-01T15:20:03.118Z (12 pages)
Publication: [public] 2026-09-01T15:41:55.002Z (9 pages) — menu folder moves (Leed Docs)

[preview] commits land on staging; [public] commits land on main. The optional — <cause> suffix appears when something other than a plain page publication produced the commit — a menu save that moved pages is the case you are most likely to see, and the suffix names the menu responsible, which is what tells you why a page’s URL changed.

The body carries one line per page: create, update, move or delete, the page id, and who did it.

Settings publications

Publishing settings from the Deployments screen produces a single commit naming everything in the batch, using the internal reason keys rather than the screen labels:

feat: publish labels, page_types, menus

companyId: ecbd78
submittedBy: b93t

These land on main, not staging — labels, page types, team members, autolinks, search indexes, forms, menus, dynamic CTAs, company settings and logo/favicon publishes are all main-branch writes, and reach staging on the next rebase. Only templates, managed_files, an admin rebuild and a CLI push are staging writes.

Layouts-workspace saves

Saving a file in the CMS Layouts workspace commits it to staging with the subject Templates updated, rebases the standing merge request, and opens a deployment tagged Templates. It is a commit like any other and it starts a build. The Layouts workspace is the CMS-side view of the same files.

Brand assets

Uploading a logo or favicon in Settings writes two commits, and here is the oddity worth knowing:

feat: delete old logo /static/images/logo-2026-07-11T15-12-07-709Z.png
feat: add logo logo-2026-09-01T09-14-22-004Z.png

These are committed straight to main, not to staging — as is the company settings file that records the new path. They reach staging on the next rebase rather than flowing the usual preview-first direction. It does not trigger a build either way, so the new logo appears on the live site at the next deployment.

Managed-file refreshes

When Leed ships a platform change that touches the files it maintains for you — .ci/build.sh, README.md, .gitignore — it re-plants them on staging with the subject updating managed configuration files. This deliberately creates no deployment and starts no build; the files are refreshed and the next build you run picks them up.

Re-planting never touches anything under src/** or tailwind/**, so your own work is not in scope. The full inventory of what Leed writes into your repository sorts every one of these by which wave puts it there.

Every commit format in one table

Message shapeWritten byBranchWhat triggered it
@leed/site-management=>{…}Youstagingleed site commit
Publication: [preview] <ISO> (N pages)CMSstagingPublishing to preview
Publication: [public] <ISO> (N pages)CMSmainPublishing live
Publication: [public] … — menu folder moves (…)CMSmainA menu save that moved pages
feat: publish labels, page_types, menusCMSmainA settings publish from the Deployments screen
feat: publish companyCMSmainCompany settings, or a logo/favicon upload
Templates updatedCMSstagingA Layouts-workspace save
feat: add logo … / feat: delete old favicon …CMSmainA brand upload in Settings
Templates for New PageType - <slug>CMSstagingCreating a page type
updating managed configuration filesLeedstagingA platform release

Push is a rebase, never a merge

leed site push runs eight steps in a fixed order, and the order matters: the cheap refusals come before the expensive ones, so a push that was never going to work fails before it touches the network.

#What runsIf it fails
1Git identity check — user.name and user.email must be setRefused; the attribution is read back later, so an anonymous commit is not acceptable
2Branch check“You are only allowed on specific branches. Change back to staging!”
3Anything to push? git rev-list --count origin/staging..HEADNot a failure: “No changes have been committed. Nothing to push.”, and the command exits 0
4Credentials re-validated against the serverRefused; your token may have been revoked or rotated since it was stored
5git fetch origin staging“Could not fetch from origin:” followed by git’s own words
6git rebase origin/staging, but only when the remote has moved“Remote has conflicting changes that can’t be auto-rebased.” — see below
7git push origin staging --no-verify“An error occurred while pushing:” followed by git’s own words
8Re-point the tracked commit id at the post-rebase HEAD, then notify the CMS“The CMS was not notified:” — your commits are on the remote with no deployment behind them

Two of those deserve a sentence each.

Step 7 passes --no-verify deliberately. The push has already been through every gate the CLI owns; re-running the pre-push hook at this point would only ask the same question again.

Step 8 is not decoration. A rebase rewrites your commits, so the sha recorded when you ran leed site commit no longer exists. Re-pointing it before telling the CMS is what stops a build being launched against an orphaned commit that Cloudflare cannot check out.

gitGraph
    commit id: "CMS - Publication public"
    branch staging
    checkout staging
    commit id: "you - footer spacing"
    commit id: "CMS - Publication preview" type: HIGHLIGHT
    commit id: "you - docs sidebar width"
    commit id: "CMS - Templates updated" type: HIGHLIGHT
    checkout main
    merge staging tag: "promote"
    commit id: "CMS - add logo" type: HIGHLIGHT

Read that left to right: two of your commits on staging with a CMS publication landing between them, a Layouts-workspace save after them, then a promotion merging the standing merge request into main, then a brand upload committed straight to main. One history, two authors, and the interleaving is the normal case rather than a collision.

When the rebase cannot resolve

If your changes and the CMS’s touch the same lines, the rebase fails. The CLI aborts it — leaving your working tree exactly as it was, not half-rebased — and prints:

	Remote has conflicting changes that can't be auto-rebased.
	Run `git pull --rebase` in the content repo, resolve conflicts, then retry `leed site push`.

In practice this is rare, because you and the CMS mostly write to different files: you own templates and CSS, the CMS owns src/_data/ and published page content. The overlap is small by design, and which paths belong to whom is exactly what keeps it small.

What promoting does to the repository

Promotion is a CMS action. There is no leed site promote, and no CLI command touches main.

Pressing promote merges the standing staging → main merge request — creating one first if somebody deleted it — and then opens a deployment on main carrying the reason staging_to_main. That deployment is what builds and activates the live site.

Two refusals you may hit before the merge is attempted, both returned as a 409 with the reason in the message: “Preview site’s latest deployment is in an error state. Resolve and retry.” and “Preview site is still building. Wait for it to finish before promoting.” Promotion also carries its own permission, so not everyone who can push can promote — the details are with promoting preview to live.

Working safely alongside the CMS

Four habits, each with its reason.

Pull before you start. The CMS may have published a dozen times while you were away, and every one of those is a commit on staging. Starting from a stale local tip guarantees the rebase at step 6 has work to do.

Keep changes small. Every push rebases your work onto whatever arrived first. A three-commit change replays cleanly; a forty-commit change that has been sitting unpushed for a week is where conflicts come from.

Never rename or delete a CMS-written file to tidy up. It will come back on the next publish, your deletion will conflict with its recreation, and in the meantime the site will be missing whatever it held. If a generated file looks wrong, republish the settings screen behind it.

Get back on staging before you commit. A local experiment branch is fine; a local experiment branch you forgot you were on produces a refusal at step 2 and a confusing five minutes.

Everything each step of the CLI prints, and the exit code it sets, is cataloged with CLI troubleshooting and exit codes; the commands themselves — their flags, their order and the mandatory -m — are on validate, commit and push. Once the push lands, what it sets in motion picks up where this page stops, and which branch builds which site explains why it all lands on preview first. The token revalidation at step 4 is the reason an expired session stops you here rather than at the remote — see CLI authentication.

ESC