DEV Community

Cover image for How does osFoundry push only one branch when Codex CLI asks for a GitHub pull request?
BBT
BBT

Posted on

How does osFoundry push only one branch when Codex CLI asks for a GitHub pull request?

This is the tool Codex CLI calls when it wants a change set on GitHub or GitLab, as osFoundry's MCP server lists it to any client, signed in or not:

{"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"]}]}
Enter fullscreen mode Exit fullscreen mode

Look at what isn't there. No branch name, no title, no target branch, no draft flag, no token. The client names a change set and two facts it observed about it, and everything else comes from the owner's side when the owner approves. "Push only its dedicated branch" is the promise this post takes apart. It also covers the tool that usually comes first, propose_gitnest_file_import, and what its 200 KB budget means for real files.

No codex binary is installed on this Mac, so the client side is a Python script, run here (Apple M1, Python 3.12 via uv, git 2.39.5). Network numbers were measured from Japan.

$ 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
Enter fullscreen mode Exit fullscreen mode

(Required-field lists trimmed from the output above. Tokens are cl100k_base, so approximate for Codex's own tokenizer.) The four tools cost under 1,000 tokens of the list's 12,662. A Codex config that names them in enabled_tools keeps the other 55 out of the prompt.

Why are both publication tools "open world" and the import isn't?

openWorldHint tells the client whether a tool reaches beyond a closed system. The file import writes into a GitNest repository that only the project can see. A pull request, even a native one, is a thing other people get notified about and act on. Codex uses annotations like these to decide when to stop and ask, so the server's hints are the first line of the approval chain, before osFoundry's own card.

What can a file import carry?

The schema answers most of it. Twelve cases against the live inputSchemas, validated with 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
Enter fullscreen mode Exit fullscreen mode

A file entry is path plus content and nothing else. No mode bits, so a script lands non-executable. No deletes, because a delete would need an entry without content. Every entry adds or replaces a file with its full text, which is also exactly what the owner reads on the approval card.

One limit the schema can't express is the 200 KB total from the description. maxLength counts characters. The budget counts UTF-8 bytes:

JA file: 100,000 chars passes maxLength; UTF-8 = 300,000 bytes
Enter fullscreen mode Exit fullscreen mode

A Japanese file at the per-file character cap is already one and a half times the whole commit's budget. A client that checks only the schema will build calls the server has to refuse. Count bytes on the client.

How many approval cards does a real import become?

Part 3 packs real directories into import calls under 20 files and 200 KB, first-fit by size, and counts the proposals. Each proposal is one card the owner has to open:

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
Enter fullscreen mode Exit fullscreen mode

The corpora are osFoundry's own frontend sources and this blog's Japanese drafts, read straight off disk. Small source files hit the 20-file cap first. Big components and non-Latin text hit the byte budget first. Reading "KB" as 1,000 or 1,024 bytes changed nothing for these three sets.

The other thing to notice is the commit count. Four proposals means four commits on the branch, approved one by one, any of which the owner may decline. If the files only make sense together, say so in each commit message, or keep the import small enough for one card.

What does "push only its dedicated branch" mean in git?

osFoundry's server-side provider push isn't public. The desktop app's own push, used by GitNest's Push Change Set Branch button, is: gitnest_push_change_set_branch in gitnest_desktop.rs. It takes the values a publication preflight returned and does this, in order:

  1. Reads origin's fetch URL and push URL and refuses if they point at different repositories, or not at the provider, owner and repo the preflight named.
  2. Requires the branch to be exactly osfoundry/change-set-<id>, with <id> a UUID.
  3. Requires the marker to be exactly GitNest-Change-Set: <id>.
  4. Requires the composition OID to be 4 to 64 hex characters, then git cat-file -e <oid>^{commit}.
  5. Runs git update-ref refs/heads/<branch> <oid> <old>, where <old> is what the ref held when it was read, or empty if it didn't exist.
  6. Pushes one refspec: git push origin refs/heads/<branch>:refs/heads/<branch>.

The script rebuilds that against a bare repository standing in for GitHub. The clone has two things the owner has not pushed: a local branch wip/secret-pricing and an extra commit on release/2.4. The composed commit is built without a worktree (a throwaway 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
Enter fullscreen mode Exit fullscreen mode

That second line is the whole reason for the explicit refspec. A convenient push from the owner's clone would have published an unreviewed hotfix and a private branch along with the change set. The explicit refspec moves one ref, whose name is derived from the change set ID, so it can't collide with anyone's branch.

What gets refused?

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
Enter fullscreen mode Exit fullscreen mode

The "HEAD" case looks pedantic, but it isn't. A symbolic name resolves to whatever is checked out at push time, which is not necessarily what was composed and reviewed. A hex OID can only mean one commit. The stale update-ref is git's own compare-and-swap. If the local branch moved between read and write, the write fails instead of silently replacing it. The split URL check closes a quieter hole: git remote set-url --push can point pushes somewhere other than where fetches come from, and the code requires both to name the same repository.

What does the approval recheck add?

The git steps protect the push. The two expected_ fields protect the decision. Part 5 reconstructs the recheck from the tool descriptions ("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']
Enter fullscreen mode Exit fullscreen mode

Codex read version 3 and commit 73e59f27bc4f…, and the owner's card shows both. If someone recomposes the change set in the meantime, approving the card would publish something nobody looked at, so the proposal fails. The version alone isn't enough, because two compositions of the same version could differ. The OID alone isn't enough either, because it says nothing about which edit of the change set the owner meant. Pinning both costs the client two fields.

What does it cost?

20 runs, local bare remote: compose median 114.2 ms, checks+update-ref+push median 140.0 ms (max 430.0)
Enter fullscreen mode Exit fullscreen mode

The six checks and the push add about 140 ms on a local bare remote. Against a real provider, the network round trip dominates. The whole script, 5 live tools/list calls and 21 publications included, took 11.7 s wall-clock and 3.5 s of user CPU.

If you wire this up yourself

  • Let the agent name the change set and what it observed (version and commit), never the branch, title, target or token. Derive the branch name from the change set ID.
  • Push with one explicit refspec. Never --all, --mirror or a bare git push from a clone a person also uses.
  • Write the local ref with an expected old value, and require fetch and push URLs to name the same repository.
  • Accept only hex OIDs for anything that will be published. Resolve symbolic names before the review, not after.
  • State the import budget in bytes and check it on the client. Character limits in the schema won't catch non-Latin text.
  • Mark tools that reach other people with openWorldHint: true, so the client's own approval prompt fires before yours.

How change sets, checks and publication sit together in a repository is on the GitNest page, and the protocol side is in the MCP glossary entry.

Top comments (0)