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:
| Field | What it is | Why it is there |
|---|---|---|
message | Exactly the text you passed to -m | The human-readable half — this is what you would have written as a commit subject |
userId | Your Leed user id | The CMS reads it back to attribute the deployment to you, even though the build runs on a server with no session |
version | The version of the CLI that made the commit | Traceability 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"}- A commit you wrote
- A commit Leed wrote
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.
commit 3fa87c11b904
Author: Gitlab Manager <gitlab-manager@leed.ai>
Date: Tue Sep 1 14:07:40 2026 +0000
Publication: [preview] 2026-09-01T14:07:39.812Z (3 pages)
update - pageId: 7337f126-8e27-403a-a471-e084e56966d2, modifiedBy: b93t
create - pageId: ade8dbb3-b12d-41a9-ae13-117d6b097c77, modifiedBy: b93t
move - pageId: 272f29dd-e260-439c-a2ac-f0db83d7767b, modifiedBy: b93t
committed by: {"companyId":"ecbd78","userId":"b93t","role":"Administrator"}A subject line naming the site, the ISO timestamp and the page count, then one audit line per page touched, then the attribution. The author is the platform’s git identity; the person who published is in the audit lines and in the committed by: trailer.
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: b93tThese 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.pngThese 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 shape | Written by | Branch | What triggered it |
|---|---|---|---|
@leed/site-management=>{…} | You | staging | leed site commit |
Publication: [preview] <ISO> (N pages) | CMS | staging | Publishing to preview |
Publication: [public] <ISO> (N pages) | CMS | main | Publishing live |
Publication: [public] … — menu folder moves (…) | CMS | main | A menu save that moved pages |
feat: publish labels, page_types, menus | CMS | main | A settings publish from the Deployments screen |
feat: publish company | CMS | main | Company settings, or a logo/favicon upload |
Templates updated | CMS | staging | A Layouts-workspace save |
feat: add logo … / feat: delete old favicon … | CMS | main | A brand upload in Settings |
Templates for New PageType - <slug> | CMS | staging | Creating a page type |
updating managed configuration files | Leed | staging | A 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 runs | If it fails |
|---|---|---|
| 1 | Git identity check — user.name and user.email must be set | Refused; the attribution is read back later, so an anonymous commit is not acceptable |
| 2 | Branch check | “You are only allowed on specific branches. Change back to staging!” |
| 3 | Anything to push? git rev-list --count origin/staging..HEAD | Not a failure: “No changes have been committed. Nothing to push.”, and the command exits 0 |
| 4 | Credentials re-validated against the server | Refused; your token may have been revoked or rotated since it was stored |
| 5 | git fetch origin staging | “Could not fetch from origin:” followed by git’s own words |
| 6 | git rebase origin/staging, but only when the remote has moved | “Remote has conflicting changes that can’t be auto-rebased.” — see below |
| 7 | git push origin staging --no-verify | “An error occurred while pushing:” followed by git’s own words |
| 8 | Re-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.