Das ist das Tool, das Codex CLI aufruft, wenn es ein Change Set nach GitHub oder GitLab bringen will, so wie der MCP-Server von osFoundry es jedem Client auflistet, ob angemeldet oder nicht:
{"name": "propose_gitnest_provider_publication",
"description": "Ask the repository owner to review a composed GitNest change set, push only its dedicated branch using their linked Git provider account, and open a draft pull request. Approval rechecks permissions, risk policy, required checks, and target commit.",
"inputSchema": {"properties": {
"repository_id": {"type": "string"},
"change_set_id": {"type": "string"},
"expected_change_set_version": {"type": "integer", "minimum": 1},
"expected_composition_oid": {"type": "string"},
"idempotency_key": {"type": "string", "minLength": 8, "maxLength": 128}},
"required": ["repository_id", "change_set_id", "expected_change_set_version",
"expected_composition_oid", "idempotency_key"],
"additionalProperties": false},
"annotations": {"readOnlyHint": false, "openWorldHint": true, "destructiveHint": true},
"securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}]}
Interessant ist, was fehlt. Kein Branch-Name, kein Titel, kein Ziel-Branch, kein Draft-Flag, kein Token. Der Client nennt ein Change Set und zwei Fakten, die er darüber beobachtet hat. Alles andere kommt von der Seite des Owners, wenn er freigibt. "Push only its dedicated branch" ist das Versprechen, das dieser Post auseinandernimmt. Außerdem geht es um das Tool, das normalerweise vorher kommt, propose_gitnest_file_import, und darum, was sein Budget von 200 KB für echte Dateien bedeutet.
Auf diesem Mac ist kein codex-Binary installiert, also übernimmt ein Python-Skript die Client-Seite, ausgeführt hier (Apple M1, Python 3.12 über uv, git 2.39.5). Die Netzwerkwerte wurden aus Japan gemessen.
$ uv run --with tiktoken --with jsonschema python3 publish_sim.py
== Part 1: live tools/list (no token) ==
tools/list x5 median 505.7 ms; 59 tools, 13 GitNest
read_gitnest_repository osfoundry:read ro=True openWorld=False destructive=False
propose_gitnest_file_import osfoundry:write ro=False openWorld=False destructive=True
propose_gitnest_native_publication osfoundry:write ro=False openWorld=True destructive=True
propose_gitnest_provider_publication osfoundry:write ro=False openWorld=True destructive=True
these four = 989 of 12662 tokens
(Die Listen der Pflichtfelder sind in der Ausgabe oben gekürzt. Die Tokens stammen aus cl100k_base und sind für den eigenen Tokenizer von Codex daher nur ein Näherungswert.) Die vier Tools kosten weniger als 1,000 der 12,662 Tokens der Liste. Eine Codex-Konfiguration, die sie in enabled_tools einträgt, hält die übrigen 55 aus dem Prompt heraus.
openWorldHint sagt dem Client, ob ein Tool über ein geschlossenes System hinausgreift. Der Datei-Import schreibt in ein GitNest-Repository, das nur das Projekt sieht. Ein Pull Request, auch ein nativer, ist etwas, über das andere Leute benachrichtigt werden und auf das sie reagieren. Codex nutzt solche Annotations, um zu entscheiden, wann es anhält und nachfragt. Die Hints des Servers sind also das erste Glied der Freigabekette, noch vor der eigenen Karte von osFoundry.
Das meiste beantwortet das Schema. Zwölf Fälle gegen die echten inputSchema s, validiert mit jsonschema (Draft 2020-12):
file_import 20 files ok
file_import 21 files REJECT maxItems
file_import one file 100,000 chars ok
file_import one file 100,001 chars REJECT maxLength
file_import file with mode '100755' REJECT additionalProperties
file_import delete: {'path','delete':true} REJECT required
file_import idempotency_key 7 chars REJECT minLength
native_publication full pin ok
native_publication no expected_composition_oid REJECT required
provider_publication expected_change_set_version 0 REJECT minimum
provider_publication extra 'draft': false REJECT additionalProperties
provider_publication extra 'target_branch' REJECT additionalProperties
Ein Datei-Eintrag besteht aus path und content, sonst nichts. Keine Mode-Bits, ein Skript landet also nicht ausführbar. Keine Löschungen, denn dafür bräuchte es einen Eintrag ohne Inhalt. Jeder Eintrag legt eine Datei mit ihrem vollständigen Text an oder ersetzt sie, und genau das liest der Owner auch auf der Freigabekarte.
Ein Limit kann das Schema nicht ausdrücken: die 200 KB insgesamt aus der Beschreibung. maxLength zählt Zeichen. Das Budget zählt UTF-8-Bytes:
JA file: 100,000 chars passes maxLength; UTF-8 = 300,000 bytes
Eine japanische Datei an der Zeichengrenze pro Datei ist bereits anderthalbmal so groß wie das Budget des ganzen Commits. Ein Client, der nur gegen das Schema prüft, baut Aufrufe, die der Server ablehnen muss. Also die Bytes auf dem Client zählen.
Teil 3 des Skripts packt echte Verzeichnisse per First-Fit nach Größe in Import-Aufrufe unter 20 Dateien und 200 KB und zählt die Proposals. Jedes Proposal ist eine Karte, die der Owner öffnen muss:
GitNest web lib (.ts) 25 files 126,756 chars 127,210 bytes (1.00 B/char) | 2 proposals
per proposal (files, KB): 20/115, 5/12
GitNest web components (.tsx) 22 files 274,206 chars 274,819 bytes (1.00 B/char) | 2 proposals
per proposal (files, KB): 10/200, 12/75
Team 2 JA articles (.md) 55 files 359,213 chars 662,285 bytes (1.84 B/char) | 4 proposals
per proposal (files, KB): 13/200, 16/196, 18/194, 8/73
Die Korpora sind die Frontend-Quellen von osFoundry selbst und die japanischen Entwürfe dieses Blogs, direkt von der Platte gelesen. Kleine Quelldateien stoßen zuerst an die Grenze von 20 Dateien. Große Komponenten und nicht-lateinischer Text stoßen zuerst an das Byte-Budget. Ob man "KB" als 1,000 oder 1,024 Bytes liest, änderte bei diesen drei Sets nichts.
Das andere, worauf man achten sollte, ist die Zahl der Commits. Vier Proposals heißen vier Commits auf dem Branch, einzeln freigegeben, und der Owner kann jeden davon ablehnen. Wenn die Dateien nur zusammen Sinn ergeben, sollte das in jeder Commit-Message stehen, oder der Import bleibt klein genug für eine einzige Karte.
Der serverseitige Provider-Push von osFoundry ist nicht öffentlich. Der Push der Desktop-App, den der Button Push Change Set Branch in GitNest verwendet, schon: gitnest_push_change_set_branch in gitnest_desktop.rs. Er nimmt die Werte, die ein Publication-Preflight zurückgegeben hat, und macht in dieser Reihenfolge Folgendes:
origin und bricht ab, wenn sie auf verschiedene Repositories zeigen oder nicht auf den Provider, Owner und das Repo, die der Preflight genannt hat.osfoundry/change-set-<id> heißt, mit <id> als UUID.GitNest-Change-Set: <id> lautet.git cat-file -e <oid>^{commit} aus.git update-ref refs/heads/<branch> <oid> <old> aus, wobei <old> der Wert ist, den die Ref beim Lesen hatte, oder leer, falls sie nicht existierte.git push origin refs/heads/<branch>:refs/heads/<branch>.
Das Skript baut das gegen ein Bare-Repository nach, das GitHub vertritt. Der Clone enthält zwei Dinge, die der Owner nicht gepusht hat: einen lokalen Branch wip/secret-pricing und einen zusätzlichen Commit auf release/2.4. Der komponierte Commit entsteht ohne Worktree (ein Wegwerf-Index, hash-object, update-index --cacheinfo, write-tree, commit-tree):
remote refs before: 3 (dana/refactor-cart, main, release/2.4)
`git push --all` would change 2 remote refs: ['release/2.4', 'wip/secret-pricing']
explicit refspec push changed 1 ref: osfoundry/change-set-5b0c2e8e-3f6a-4d2b-9a71-0c4e9d1f7a23 -> 73e59f27bc4f
untouched: True; marker in pushed commit: True
Die zweite Zeile ist der ganze Grund für die explizite Refspec. Ein bequemer Push aus dem Clone des Owners hätte zusammen mit dem Change Set einen ungeprüften Hotfix und einen privaten Branch veröffentlicht. Die explizite Refspec bewegt genau eine Ref, deren Name aus der Change-Set-ID abgeleitet ist, und kann daher mit niemandes Branch kollidieren.
branch name 'codex/ci' instead of contract refused: publication branch does not match the preflight contract
marker for another change set refused: publication marker does not match the preflight contract
OID 'HEAD' instead of hex refused: composition OID must be a hex commit id
hex OID that is not in the clone refused: fatal: Not a valid object name deadbeefdeadbeefdeadbeefdeadbeefdeadbeef^{commit}
update-ref with a stale expected old value exit 128: fatal: update_ref failed for ref 'refs/heads/osfoundry/change-set-5b0c2e8e-3f6a-
push URL differs from fetch URL refused: origin fetch and push remotes point at different repositories
Der Fall "HEAD" wirkt pedantisch, ist es aber nicht. Ein symbolischer Name löst sich zu dem auf, was zum Zeitpunkt des Pushs ausgecheckt ist, und das muss nicht das sein, was komponiert und geprüft wurde. Eine Hex-OID kann nur einen Commit meinen. Das veraltete update-ref ist das Compare-and-Swap von git selbst: Hat sich der lokale Branch zwischen Lesen und Schreiben bewegt, schlägt das Schreiben fehl, statt ihn stillschweigend zu ersetzen. Die Prüfung auf getrennte URLs schließt eine unauffälligere Lücke: git remote set-url --push kann Pushes an einen anderen Ort schicken als den, von dem gefetcht wird, und der Code verlangt, dass beide auf dasselbe Repository zeigen.
Die git-Schritte schützen den Push. Die beiden expected_-Felder schützen die Entscheidung. Teil 5 des Skripts rekonstruiert die erneute Prüfung aus den Tool-Beschreibungen ("Approval rechecks permissions, risk policy, required checks, and target commit"):
as proposed publish
recomposed to v4 before the click refused: change set is v4, card says v3
lint re-ran and failed refused: required checks not passed: ['lint']
Codex hat Version 3 und Commit 73e59f27bc4f… gelesen, und die Karte des Owners zeigt beides. Komponiert jemand das Change Set in der Zwischenzeit neu, würde die Freigabe der Karte etwas veröffentlichen, das niemand angesehen hat, also schlägt das Proposal fehl. Die Version allein reicht nicht, weil zwei Compositions derselben Version sich unterscheiden können. Die OID allein reicht auch nicht, weil sie nichts darüber sagt, welchen Stand des Change Sets der Owner gemeint hat. Beides festzunageln kostet den Client zwei Felder.
20 runs, local bare remote: compose median 114.2 ms, checks+update-ref+push median 140.0 ms (max 430.0)
Die sechs Prüfungen und der Push kosten gegen ein lokales Bare-Remote etwa 140 ms. Bei einem echten Provider dominiert der Netzwerk-Roundtrip. Das ganze Skript, inklusive 5 echter tools/list-Aufrufe und 21 Publications, brauchte 11.7 s Wall-Clock-Zeit und 3.5 s User-CPU.
--all, --mirror oder ein nacktes git push aus einem Clone, den auch ein Mensch benutzt.openWorldHint: true markieren, damit schon der Freigabedialog des Clients anspringt, bevor der eigene kommt.
Wie Change Sets, Checks und Publication in einem Repository zusammenspielen, steht auf der GitNest-Seite, die Protokollseite im Glossareintrag zu MCP.