# forge-git — close the loop from your repo, on ANY VCS `forge-git` is Forge's self-serve loop-back: **one HTTP POST per push**, and the systems whose files you touched get their `last_commit` stamp plus an Activity row — while mapped files that were *deleted* auto-flag **drift**, so a system can't sit there saying "implemented" after its code is gone. No agent, no GitHub App, nothing to install. Anything that can run a script or fire a webhook works: GitHub, GitLab, Bitbucket, **Plastic SCM / Unity Version Control**, **Perforce Helix Core**, self-hosted git, any CI. --- ## The endpoint - **URL** — `https://.supabase.co/functions/v1/forge-git` Your project's real URL, with the project id already filled in, is on the web app: **Connect → "Close the loop from your repo"**. - **Auth** — your normal forge key, the same one from *Connect a coding agent*, sent as `Authorization: Bearer ` (or the `X-Forge-Key` header, or GitLab's **Secret token** field). It needs the `propose` scope. An **account** key must also send `project_id` in the body; a project-scoped key doesn't need to. - **Body** — the flat shape is the easiest thing to build from a script: ```jsonc { "sha": "abc1234", "message": "fix shop pricing", "files_changed": ["src/shop.ts"], "files_removed": [], "by": "karn", // optional — defaults to the key's member "project_id": "" // ACCOUNT keys only } ``` GitLab's native push payload (`ref` plus `commits[]` with `added` / `modified` / `removed`) is accepted as-is, with no transform. - **Response** — `{ ok: true, stamped: N, drifted: N, systems: ["Shop", …] }`. - **Unmapped files answer `stamped: 0` quietly.** Firing on every push is safe and expected; you don't need to filter. - **Rate limit** — 60 pushes/min per key. ### The one thing that silently does nothing `files_changed` paths must look like the paths Forge stored for the system — **repo-relative, forward slashes**, e.g. `src/shop.ts`. A VCS that hands you `/src/shop.ts` or `//depot/game/src/shop.ts` will stamp **nothing** and still answer `ok: true`. Every recipe below normalises the paths; if you write your own, check this first when `stamped` comes back `0`. --- ## Probe first — two minutes, saves an hour Each recipe builds the JSON out of whatever your VCS hands the script, and the exact input differs between server versions. Before wiring the POST, install the trigger as a **logger** and look at the real thing: ```bash #!/bin/sh # throwaway probe — logs what the trigger actually receives, then gets out of the way { echo "--- $(date)"; env | grep -E '^(PLASTIC|P4)_'; echo "--- stdin:"; cat; } \ >> /tmp/forge-probe.log exit 0 ``` Then make **one** test commit that *both* edits a file *and* deletes another, and read `/tmp/forge-probe.log`. That tells you the status/action codes your server uses for changed vs deleted — the only part that varies — before you depend on them. --- ## Recipes ### GitLab — zero transform Project → **Settings → Webhooks → Add new webhook**: - **URL** — the endpoint above - **Secret token** — paste your forge key - **Trigger** — ✓ Push events → *Save* GitLab's payload works as-is, and pushes to non-default branches are skipped automatically (`ref` is compared against `project.default_branch`). ### Any CI — GitHub Actions, Bitbucket Pipelines, Jenkins, Unity Cloud Build Needs `git`, `jq`, `curl`, and a checkout at least 2 commits deep: ```bash R='HEAD~1 HEAD' jq -nc \ --arg sha "$(git rev-parse --short HEAD)" \ --arg msg "$(git log -1 --pretty=%s)" \ --arg pid "$FORGE_PROJECT_ID" \ --argjson changed "$(git diff --name-only --diff-filter=ACMR $R | jq -R . | jq -sc .)" \ --argjson removed "$(git diff --name-only --diff-filter=D $R | jq -R . | jq -sc .)" \ '{sha:$sha,message:$msg,files_changed:$changed,files_removed:$removed,project_id:$pid}' \ | curl -s "$FORGE_GIT_URL" \ -H "authorization: Bearer $FORGE_KEY" \ -H "content-type: application/json" -d @- ``` Keep the key in your CI's secret store, never in the workflow file. ### Plain git, no CI — the easy way The same one-command installer that arms Plastic (see the Plastic section) **also handles git** — run it inside a git working copy and it writes a `.git/hooks/pre-push` hook instead of registering a trigger: ```bash bash tools/forge/install.sh ``` It detects `git` vs Plastic on its own, asks for your key once, and is idempotent. Client-side hooks aren't shared by `clone`, so each dev runs it once (or ship the hook via `core.hooksPath`). Prefer server-side and no per-machine step? Use the CI recipe above — one committed workflow file covers the whole team. ### Plastic SCM / Unity Version Control **Pick your path first — this decides everything below.** **The quick answer: look at your repository spec.** If it ends in `@unity` — e.g. `your-project@unity` — you are on a **Unity-hosted cloud organization**. There is no server of yours to install a script on, so `after-checkin` is out and **Path B** is your path. Skip the test below. Otherwise, test it: ```bash cm trigger list after-checkin ``` - **It lists (even an empty list)** → you can reach the server's trigger table. Use **Path A**: one trigger, installed once, covers the whole team. - **It errors or is refused** → use **Path B**: a client-side trigger, installed per machine. **Expect a sign-in prompt on your first `cm` command in a new terminal**, before any output about triggers: ``` Select the system you want to use to sign in to: @unity 0 - Unity ID 1 - Email Select your system [0-1] ``` That is normal and says nothing about triggers either way — answer it, then run the command again. Don't read it as a failure, and don't put a command that can prompt like this inside a trigger script: a trigger has no terminal to answer it on, so it would hang or die silently. Both paths end at the same place. Path B is longer only because it installs on each laptop. --- #### Path A — server-side `after-checkin` (you administer the server) It hands your script the changed items on **stdin**, one per line: ``` CH "/" DIR#br:/main/scm001;changeset:61@@rep:doom3src@@repserver:HERMES:8087 CH "/search/search.h" FILE#br:/main/scm001;changeset:61@@rep:doom3src@@repserver:HERMES:8087 ``` plus these environment variables: `PLASTIC_CHANGESET` (e.g. `cs:23@@br:/main@@rep:default@@repserver:DARKTOWER:8084`, semicolon-separated if the checkin spanned repositories), `PLASTIC_COMMENT`, `PLASTIC_USER`, `PLASTIC_SERVER`, `PLASTIC_CLIENTMACHINE`. `PLASTIC_CHANGESET` is set for **both** `after-checkin` and `after-clientcheckin`. The reference documents it twice — once per section — and each copy reads "only available in the `after-checkin`/`after-clientcheckin` trigger", which means *not in the matching `before-` trigger*, **not** "not on the client side". Read it the other way and you'll go build a workspace-head lookup you don't need (see the fallback below). You do **not** need `cm log` — stdin already lists the items. ```sh #!/bin/sh # after-checkin → Forge loop-back. Needs jq + curl on the SERVER. : "${FORGE_KEY:?set FORGE_KEY in the server's environment}" URL="https://.supabase.co/functions/v1/forge-git" PID="" IN=$(cat) # stdin is readable once — keep it # status "path" FILE#… → repo-relative path. DIR entries are dropped. pick() { # $1 = ERE of status codes to keep printf '%s\n' "$IN" | awk -v st="$1" ' $0 ~ ("^(" st ") ") && /" FILE#/ { p = substr($0, index($0, "\"") + 1) p = substr(p, 1, index(p, "\" FILE#") - 1) sub(/^\//, "", p) print p }' | jq -R . | jq -sc . } CS=$(printf '%s' "$PLASTIC_CHANGESET" | sed -n 's/^[[:space:]]*cs:\([0-9]*\).*/\1/p') jq -nc \ --arg sha "cs:${CS:-?}" \ --arg msg "$PLASTIC_COMMENT" \ --arg by "$PLASTIC_USER" \ --arg pid "$PID" \ --argjson changed "$(pick 'CH|AD')" \ --argjson removed "$(pick 'RM|DE')" \ '{sha:$sha,message:$msg,by:$by,files_changed:$changed,files_removed:$removed,project_id:$pid}' \ | curl -s "$URL" -H "authorization: Bearer $FORGE_KEY" \ -H "content-type: application/json" -d @- exit 0 # never let the loop-back fail a checkin ``` Register it on the server (confirm the argument order with `cm trigger mk --help` on your version — see the [`cm trigger` reference](https://docs.unity.com/en-us/unity-version-control/uvcs-cli/trigger)): ```bash cm trigger mk after-checkin "Forge loop-back" "/opt/forge/after-checkin.sh" --server=myserver:8084 cm trigger ls after-checkin --server=myserver:8084 ``` Three things worth knowing before you start: - **Confirm your delete status code with the probe.** The `CH|AD` / `RM|DE` split above is the common case. If your server spells deletions differently, they simply land in neither list — you lose drift-on-delete, you don't get false drift. That's the safe direction to be wrong in, but check anyway. - **Paths are repo-root-relative** once the leading `/` is stripped. If your Unity project lives in a *subdirectory* of the repo, or the workspace root isn't the repo root, prepend that prefix in `pick()` so the paths match what Forge stored. - **On a Windows Plastic server**, the same logic goes in a `.bat` or PowerShell script — read stdin from `$input`. `jq`/`curl` are still the easiest way to build the body. --- #### Path B — client-side `after-clientcheckin` (cloud org, or no server access) Runs on each developer's machine, like a git hook. Needs `jq` + `curl` (`brew install jq` on macOS) and `cm` on `PATH`. **Two things it gives you, and one it doesn't.** `PLASTIC_CHANGESET` **is** set for `after-clientcheckin` — take the changeset from there. What you must **not** do is parse this trigger's stdin: it receives "the list of items specified by the user on the checkin operation", and the reference **does not specify the line format** for the client-side variant (only the server-side `after-checkin` format is documented). Ask the changeset for its items instead. And do **not** look the changeset up from the workspace head — that is both extra work and a **race**: between your checkin finishing and the script asking, another dev's update can move the head and you stamp the wrong changeset. ##### The easy way: one command per person Don't make artists and designers read this page. **One person sets it up once**, commits two files to the repo, and everyone else runs a single command: ```bash bash tools/forge/install.sh ``` That asks for their key (paste once, not echoed), detects the VCS, wires the right hook, and prints the one test to run. It is safe to re-run and it changes nothing tracked in the repo. On **Plastic** it registers an `after-clientcheckin` trigger; if that is **refused** (a cloud org may block it), the installer prints the server's own error and points you at the CI route. On **git** it writes a `.git/hooks/pre-push` hook instead. Commit these two files as `tools/forge/install.sh` and `tools/forge/forge-loopback.sh` (the Plastic script from just below). The installer: ```bash #!/bin/bash # install.sh — arm the Forge loop-back on THIS machine. Run once: bash tools/forge/install.sh # Detects git (pre-push hook) or Plastic / Unity VCS (after-clientcheckin trigger). set -u URL="https://.supabase.co/functions/v1/forge-git" PID="" NAME="Forge loopback" DIR=$(cd "$(dirname "$0")" && pwd) # absolute, resolved on THIS machine say() { printf '%s\n' "$*"; } die() { printf '\n✗ %s\n' "$*" >&2; exit 1; } command -v curl >/dev/null || die "'curl' not found." command -v jq >/dev/null || { command -v brew >/dev/null && brew install jq; } \ || die "install jq first: brew install jq" mkdir -p "$HOME/.forge" && chmod 700 "$HOME/.forge" KEY_FILE="$HOME/.forge/forge_key" if [ -s "$KEY_FILE" ]; then say "✓ key already saved" else say "Paste YOUR OWN key (forgeengine.app → Connect a coding agent, 'propose' scope)." printf 'Key (not shown as you paste): ' read -rs KEY; printf '\n' case "${KEY:-}" in forge_sk_*) ;; *) die "that doesn't look like a forge key." ;; esac printf '%s\n' "$KEY" > "$KEY_FILE"; chmod 600 "$KEY_FILE"; unset KEY say "✓ key saved, readable only by you" fi if git rev-parse --git-dir >/dev/null 2>&1; then HOOK="$(git rev-parse --git-dir)/hooks/pre-push"; mkdir -p "$(dirname "$HOOK")" cat > "$HOOK" </dev/null 2>&1 && R="@{u} HEAD" jq -nc --arg sha "\$(git rev-parse --short HEAD)" --arg msg "\$(git log -1 --pretty=%s)" \\ --arg pid "$PID" \\ --argjson changed "\$(git diff --name-only --diff-filter=ACMR \$R | jq -R . | jq -sc .)" \\ --argjson removed "\$(git diff --name-only --diff-filter=D \$R | jq -R . | jq -sc .)" \\ '{sha:\$sha,message:\$msg,files_changed:\$changed,files_removed:\$removed,project_id:\$pid}' \\ | curl -s --max-time 10 "$URL" -H "authorization: Bearer \$FORGE_KEY" \\ -H "content-type: application/json" -d @- >> "\$LOG" 2>&1 exit 0 HOOKEOF chmod +x "$HOOK"; say "✓ git pre-push hook installed" TEST="git commit something small, then: git push" elif command -v cm >/dev/null && cm workspace info >/dev/null 2>&1; then SCRIPT="$DIR/forge-loopback.sh" [ -f "$SCRIPT" ] || die "can't find $SCRIPT next to this installer." chmod +x "$SCRIPT" if cm trigger list after-clientcheckin 2>/dev/null | grep -qF "$NAME"; then say "✓ trigger already registered" elif cm trigger create after-clientcheckin "$NAME" "/bin/bash $SCRIPT" 2>/tmp/forge-trig.err; then say "✓ after-clientcheckin trigger registered" else say ""; say "Registering the trigger was refused. The server said:" sed 's/^/ /' /tmp/forge-trig.err >&2 say ""; say "A cloud-hosted org may block client triggers — use the CI route instead." exit 1 fi TEST="check in one small change from your client" else die "couldn't detect git or Plastic here. Run this from inside your working copy." fi cat <>"$LOG") [ -n "$ITEMS" ] || exit 0 # cm said nothing — don't POST an empty push pick() { # $1 = ERE of status letters to keep printf '%s\n' "$ITEMS" | awk -v st="$1" ' $0 ~ ("^(" st ") ") { p = substr($0, index($0, " ") + 1) sub(/^\//, "", p) # → repo-relative if (p != "" && p !~ /\/$/) print p }' | jq -R . | jq -sc . } jq -nc \ --arg sha "cs:$CS" \ --arg msg "$PLASTIC_COMMENT" \ --arg by "$PLASTIC_USER" \ --arg pid "$PID" \ --argjson changed "$(pick 'A|C|M')" \ --argjson removed "$(pick 'D')" \ '{sha:$sha,message:$msg,by:$by,files_changed:$changed,files_removed:$removed,project_id:$pid}' \ | curl -s --max-time 10 "$URL" \ -H "authorization: Bearer $FORGE_KEY" \ -H "content-type: application/json" -d @- >> "$LOG" 2>&1 printf '\n' >> "$LOG" exit 0 # a loop-back must NEVER fail a checkin ``` **B2 · Each developer, on their own machine — three commands.** Everyone needs their own key (`propose` scope, from *Connect a coding agent*) so Activity attributes checkins to the right person: ```bash mkdir -p ~/.forge && chmod 700 ~/.forge && printf '%s\n' 'forge_sk_PASTE_YOURS' > ~/.forge/forge_key && chmod 600 ~/.forge/forge_key ``` ```bash chmod +x "$(cm workspace info --format='{wkpath}' 2>/dev/null || pwd)/tools/forge/forge-loopback.sh" ``` ```bash cm trigger create after-clientcheckin "Forge loopback" "/bin/bash /ABSOLUTE/PATH/TO/tools/forge/forge-loopback.sh" ``` Use a **literal absolute path** in that last command. A variable like `$PLASTIC_WKPATH` may or may not be expanded for you depending on how your client invokes the command, and a path that fails to expand looks exactly like a trigger that never fired. Argument order for `cm trigger create` has also varied between versions — check `cm trigger create --help` rather than trusting this line, including as printed here. Confirm with: ```bash cm trigger list after-clientcheckin ``` **B3 · Test with a REAL throwaway checkin — nothing else counts.** Edit a comment in a file you have already mapped to a system, check it in normally, then: ```bash tail -n 5 ~/.forge/loopback.log ``` ``` {"ok":true,"stamped":1,"drifted":0,"systems":["Monster AI System"]} ``` Calling the script yourself with a hand-fed environment proves the script and **nothing else**. Only a real checkin exercises whether the trigger fires, whether the registered path resolves, and whether the environment the trigger runs in has your key and `cm` on `PATH`. A teammate who skips B2 is simply opted out — nothing breaks for them. ##### When it isn't green | What you see | Cause | | --- | --- | | log file absent or empty | the trigger never fired — re-check `cm trigger list` | | `No such file or directory` | the registered path didn't resolve — use a literal absolute path | | `cm: command not found` | triggers don't inherit your shell's `PATH` — call `cm` by full path in the script | | log has the changeset but no JSON reply | `cm log` produced nothing — usually `cm` wanting an interactive sign-in, which a trigger cannot answer. Run any `cm` command in a terminal once to authenticate, then check in again | | `"stamped":0` | the paths don't match what Forge stored — leading `/`, backslashes, or a missing sub-directory prefix (see *the one thing that silently does nothing*) | | `401` / `403` | the key lacks the `propose` scope | | `402` | the org is frozen — billing, not the loop-back | **Or skip triggers entirely:** fire the same POST from CI (Unity Cloud Build, or anything with a workspace). Slower to notice a checkin, identical result, nothing installed on anyone's laptop. **A moved item (`M`) counts as changed here**, so the path it moved *from* is not reported as removed and won't flag drift. Wrong in the safe direction — no false drift — but worth knowing. ### Perforce Helix Core `change-commit` fires after the changelist is committed. A trigger table line is four fields — `name type path "command"` — and `%change%` carries the changelist number: ``` forge-loopback change-commit //depot/... "/opt/forge/p4-forge.sh %change%" ``` ```sh #!/bin/sh # change-commit → Forge loop-back. Needs p4 + jq + curl. : "${FORGE_KEY:?set FORGE_KEY in the trigger environment}" URL="https://.supabase.co/functions/v1/forge-git" PID="" CH="$1" # Trigger scripts inherit almost no environment — set these explicitly. export P4PORT="${P4PORT:-perforce:1666}" P4USER="${P4USER:-svc_forge}" \ P4TICKETS="${P4TICKETS:-/opt/forge/.p4tickets}" DESC=$(p4 describe -s "$CH") # "... //depot/game/src/x.cpp#4 edit" → src/x.cpp files() { # $1 = ERE of actions to keep printf '%s\n' "$DESC" | awk -v act="$1" ' /^\.\.\. \/\// { line = substr($0, 5) p = substr(line, 1, index(line, "#") - 1) # path keeps its spaces if ($NF ~ ("^(" act ")$")) { # action is the LAST field sub(/^\/\/[^\/]+\/[^\/]+\//, "", p); print p } }' | jq -R . | jq -sc . } jq -nc \ --arg sha "cl:$CH" \ --arg msg "$(p4 -F %Description% change -o "$CH" 2>/dev/null | head -1)" \ --arg pid "$PID" \ --argjson changed "$(files 'add|edit|integrate|branch|move/add')" \ --argjson removed "$(files 'delete|move/delete|purge')" \ '{sha:$sha,message:$msg,files_changed:$changed,files_removed:$removed,project_id:$pid}' \ | curl -s "$URL" -H "authorization: Bearer $FORGE_KEY" \ -H "content-type: application/json" -d @- exit 0 ``` - **Tune the depot-prefix strip.** `sub(/^\/\/[^\/]+\/[^\/]+\//, ...)` drops two levels (`//depot/game/`). Count the levels between your depot root and your repo root and match them, or the paths won't line up. - `p4 -F %Description%` needs a reasonably modern `p4`. On an older one, drop the message or parse it out of `$DESC`. --- ## Behavior notes - **Final state per push wins.** Modified then deleted = drift; deleted then re-added = changed. One push, one verdict per file. - **At most 8 systems are stamped per push** — a sweeping refactor stamps the 8 it touched most, not everything. - **Stamps merge into the existing build record.** Status, the file map, and recorded decisions all survive; only the commit stamp and drift flags are written. - **Drift notes are attributed.** A deleted mapped file records `auto: deleted in push `, by `forge-git` — so it's obvious it was the loop-back and not a person. - **A failing loop-back must never fail a commit.** Every script above ends `exit 0`. Keep it that way: Forge being unreachable is not a reason for your team to be unable to check in. Questions, or a VCS not covered here: