xhostddocs
Console ↗
On this page

Recipe: ship without git

What you get

You get the same kind of static site as Recipe: static site. Here commit_files puts the whole site onto the app. commit_files is a tool call with a {path: contents} map. The platform turns that map into one ordinary commit on the app's own repo. There is no shell, no clone and no push.

git push and then deploy is the standard path, and this recipe is not that path. Use this recipe in one case only: git is not available. That case is a runtime with no shell, such as the claude.ai connector, a Custom GPT, or an MCP client that cannot run a command. If you can run git on the machine where you work, obey Recipe: static site instead. Its four steps are the same on every template.

commit_files is not a lesser mode: it writes real commits with your name on them. They go into the same repo that a push goes to, and a later git clone shows them like any other commits. The cost is per edit. A changeset sends the text you give it through the model's context each time, so what you send decides the price. files sends a whole file. edits and patches send only the region that changes, which is what you want for any file that already exists — a one-line fix in a 78 KB file then costs the anchor, not the file. A push still sends less, because it sends a diff and needs no anchor, so git stays the first choice.

The worked example is live at recipe-commit-files-docs.randomimity.app. It is the two files below, and the transcript in this guide deployed them. Git did not run in that transcript, on any machine, at any point.

The files

There are two files at the repo root: the page, and the stylesheet that the page links to. A later changeset names neither file, and the platform then leaves the stylesheet as it is.

index.html

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Shipped without git — xhostd recipe</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<main>
  <h1>Shipped without git.</h1>
  <p>
    Every file on this page arrived through <code>commit_files</code> — a
    single tool call carrying a <code>{path: contents}</code> map, which the
    platform turns into one ordinary commit on the app's own repo.
  </p>
  <p>
    That is the fallback path. When git is available, <code>git push</code>
    is the one to use: it sends only what changed, so the tenth edit costs a
    few lines instead of the whole project.
  </p>
  <p><a href="https://docs.randomimity.com/guides">xhostd recipes</a></p>
</main>
</body>
</html>

style.css

:root { color-scheme: light dark; }
body {
  margin: 0; min-height: 100vh;
  display: grid; place-items: center;
  font: 16px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  background: #0b0d12; color: #e6e9ef;
}
main { max-width: 34rem; padding: 2rem; }
h1 { font-size: 1.75rem; margin: 0 0 .5rem; letter-spacing: -.02em; }
p { margin: 0 0 1rem; color: #9aa4b8; }
code {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: .9em; background: #171b24; padding: .15em .4em; border-radius: 4px;
}
a { color: #7aa2f7; }

The static template serves index.html from the repo root at /. It serves style.css at /style.css, which is the path in the page's <link>. The template serves each committed file at its equivalent path, and it does nothing else.

The deploy

This path keeps two acts separate, exactly as git does. commit_files stores your code, but it does not deploy your code. deploy is its own explicit call, and it takes the sha from the changeset. This transcript shows that separation: it makes three commits, and it deploys two of them.

Do these five steps in order.

1. Create the app. This call makes the prod channel, its hostname and its git repo. You get a repo even if you never push to it.

create_app(name="recipe-commit-files", template="static")
→ {"id": "b882c472-2a7b-414a-994b-d9c7c3bcdcee",
   "name": "recipe-commit-files",
   "template": "static",
   "repo_url": "https://git.randomimity.com/docs/recipe-commit-files.git",
   "channels": [{"id": "17a905c3-9365-4c1e-be98-7823f83a2647",
                 "name": "prod",
                 "hostname": "recipe-commit-files-docs.randomimity.app",
                 "current_sha": null}], ...}

Keep two ids: the app's id, and the id of the new prod channel. Every later call needs one or both. This recipe has no credential step. commit_files uses the same authenticated tool session as every other call. You thus make no token, store no token and paste no token. That is the one advantage of the fallback path over the standard path.

2. Commit the whole site in one changeset. The files map is {path: contents}. A string value writes the file at that path, and it replaces the file if the file exists.

commit_files(app_name="recipe-commit-files",
             message="the whole site, in one changeset",
             files={"index.html": "<!doctype html>\n<html lang=\"en\">\n...",
                    "style.css": ":root { color-scheme: light dark; }\n..."})
→ {"sha": "0bf2770b0ab04b119bc4138faf2d9b1a270ab095"}

The transcript above shortens the two values. In the real call each value is the full text of its file from the section above, with all the newlines. That is the cost: both files went through the model's context in full, for one commit.

list_files reads the branch back. Call it after every changeset until this path is familiar to you:

list_files(app_name="recipe-commit-files")
→ {"ref": "master", "sha": "0bf2770b0ab04b119bc4138faf2d9b1a270ab095",
   "files": [{"path": "index.html", "kind": "blob", "size": 815},
             {"path": "style.css", "kind": "blob", "size": 563}]}

3. Deploy that commit.

deploy(app_name="recipe-commit-files",
       channel="prod",
       sha="0bf2770b0ab04b119bc4138faf2d9b1a270ab095")
→ {"deploy_id": "df8750a5-ada3-45fe-b3b1-1543ec14873d",
   "channel_id": "17a905c3-9365-4c1e-be98-7823f83a2647",
   "status": "queued"}

Use sha on this path, not ref. commit_files returns the exact commit that it wrote, so you deploy that commit. ref="master" is also valid, and it resolves to the branch's current head. sha wins if you give both. deploy returns as soon as it queues the deploy, and the work runs asynchronously. Follow it with get_deploy_log: the first line of its reply states the outcome.

4. Change one file, and only that file. The platform does not change a path that the map does not name. This changeset names one new path:

commit_files(app_name="recipe-commit-files",
             message="add robots.txt",
             files={"robots.txt": "User-agent: *\nDisallow:\n"})
→ {"sha": "9664363b9339eec27087c819f49c14c977799134"}
list_files(app_name="recipe-commit-files")
→ {"ref": "master", "sha": "9664363b9339eec27087c819f49c14c977799134",
   "files": [{"path": "index.html", "kind": "blob", "size": 815},
             {"path": "robots.txt", "kind": "blob", "size": 24},
             {"path": "style.css", "kind": "blob", "size": 563}]}

That list is the reason for this step. The changeset named robots.txt and no other path. index.html and style.css did not change, and they keep the same sizes as after commit 1: 815 and 563 bytes. A changeset is a sparse patch, not a full picture of the tree. The platform applies it on top of the branch's current head. If you omit a file, the platform leaves that file as it is. It never deletes the file. Send only the paths that change.

5. Delete it again. A null value removes the path.

commit_files(app_name="recipe-commit-files",
             message="drop robots.txt again",
             files={"robots.txt": null})
→ {"sha": "c16fdc6adcc7b64b72603a674c05aef8ca708972"}
list_files(app_name="recipe-commit-files")
→ {"ref": "master", "sha": "c16fdc6adcc7b64b72603a674c05aef8ca708972",
   "files": [{"path": "index.html", "kind": "blob", "size": 815},
             {"path": "style.css", "kind": "blob", "size": 563}]}

A null value is the only way to delete a path through a changeset. That is why an absent path means "leave the file as it is". The two actions need different forms, and a map holds one value per path. Then comes the second deploy, of c16fdc6a:

deploy(app_name="recipe-commit-files",
       channel="prod",
       sha="c16fdc6adcc7b64b72603a674c05aef8ca708972")
→ {"deploy_id": "2acbf6ad-91b6-40ce-9e43-c3d1840ffb3b",
   "channel_id": "17a905c3-9365-4c1e-be98-7823f83a2647",
   "status": "queued"}

No deploy ever carried robots.txt. The file existed in one commit only, in the repo and nowhere else. The site served commit 1 for that whole period. The next deploy came after the file was gone. That result is not a fault. It shows you that a commit is not a deploy. A git push with no deploy after it gives the same result.

Change a file without resending it

files sends whole content, which is what you want for a new file. For a file that already exists, send the region that changes instead. Two fields do that, and one changeset takes any mix of files, edits, and patches. A path belongs to exactly one of them.

edits replaces an anchored region:

commit_files(app_name="recipe-commit-files",
             message="widen the card",
             edits={"style.css": [
               {"old_string": "  max-width: 40rem;",
                "new_string": "  max-width: 52rem;"}
             ]})

old_string must appear exactly once in the file. If it appears zero times or more than once, the whole changeset fails and nothing is written. The error tells you the count. Add the lines around it until the anchor is unique, or set "replace_all": true when every occurrence is meant to change.

patches takes hunks, which suit several scattered changes to one file:

commit_files(app_name="recipe-commit-files",
             message="bump the version banner",
             patches={"index.html": "@@ <footer>\n-  <p>v1.4</p>\n+  <p>v1.5</p>\n"})

A hunk header is @@, or @@ anchor where the anchor is a line or a unique substring copied from the file. Put the anchor on a line the hunk covers, or on the line just above it: matching starts there and runs to the end of the file. Body lines start with a space for context, - to remove, or + to add. There are no line numbers and no line counts, so a miscounted header cannot happen. Each hunk needs at least one context or - line, because those lines are what locate it.

Both fields match byte for byte. Whitespace, indentation, and line endings all count. Copy anchors from read_file output rather than retyping them, and the match is far more likely to land the first time.

Verify it

get_deploy_log(app_name="recipe-commit-files",
               channel="prod",
               deploy_id="df8750a5-ada3-45fe-b3b1-1543ec14873d")

That is the first deploy. The reply starts with a status header, then the log. The transcript shows short ids. It omits the nginx entrypoint lines and the routine per-channel lines for the network, the blob route and the channel row:

deploy df8750a5 — success (sha 0bf2770b0ab0)
started: 2026-07-31T23:20:42Z   finished: 2026-07-31T23:20:44Z

[2026-07-31T23:20:42+00:00] deploy begin id=df8750a5-... channel=17a905c3-... sha=0bf2770b...
[2026-07-31T23:20:42+00:00] git_sync sha=0bf2770b0ab04b119bc4138faf2d9b1a270ab095
[2026-07-31T23:20:42+00:00] git_sync ok: synced app=b882c472-... channel=17a905c3-... sha=0bf2770b...
[2026-07-31T23:20:42+00:00] start_container template=static
[2026-07-31T23:20:43+00:00] start_container ok: container=07e5fbe4b902...
[2026-07-31T23:20:43+00:00] health_check container=07e5fbe4b902... port=80 timeout=10.0s
[2026-07-31T23:20:43+00:00] health_check ok
[2026-07-31T23:20:43+00:00] [container] 2026/07/31 23:20:43 [notice] 1#1: nginx/1.31.3
[2026-07-31T23:20:43+00:00] [container] 2026/07/31 23:20:43 [notice] 1#1: start worker processes
[2026-07-31T23:20:43+00:00] [container] 10.77.1.5 - - [31/Jul/2026:23:20:43 +0000] "GET / HTTP/1.1" 200 815 "-" "Python-urllib/3.13" "-"
[2026-07-31T23:20:43+00:00] caddy ensure_route hostname=recipe-commit-files-docs.randomimity.app upstream=10.77.1.5:32050
[2026-07-31T23:20:43+00:00] caddy ensure_route ok
[2026-07-31T23:20:43+00:00] pinned deployed sha 0bf2770b0ab04b119bc4138faf2d9b1a270ab095
[2026-07-31T23:20:44+00:00] deploy success

The first line states the outcome. status is one of queued, running, success, or failed. Read the status from that header, not from the log text. Poll get_deploy_log while the status is queued or running. success means the deploy is done, and on failed the reason is in the log tail.

git_sync is the first step, and it tells you the main fact. The deploy fetches a commit from a git repo, exactly as it does after a push. At that point nothing separates this deploy from a deploy after a push. The deploy cannot see how the commit came into the repo.

The log holds no [build] line. This is the static template. It builds nothing, it gives the committed files to nginx, and the whole log covers two seconds. health_check ... port=80 timeout=10.0s is the static template's window. nginx already runs, so the probe waits for no process of yours. The probe itself is the "GET / HTTP/1.1" 200 815 line, from nginx's own access log. Recipe: static site reads a static deploy log in full.

The second deploy's log is the same log with two more lines. The first deploy does not print them, because it replaces no container:

[2026-07-31T23:26:36+00:00] caddy ensure_route ok
[2026-07-31T23:26:36+00:00] stop_and_remove old container=07e5fbe4b902...
[2026-07-31T23:26:36+00:00] stop_and_remove ok

07e5fbe4 is the container from the first deploy. xhostd retires it only after the new container passes its health check, and after the hostname points at the new container. A failed redeploy thus keeps the previous version live.

Then the proof, against the live demo:

$ curl -sS -o /dev/null -w '%{http_code} %{content_type} %{size_download}\n' https://recipe-commit-files-docs.randomimity.app/
200 text/html 815

$ curl -sS -o /dev/null -w '%{http_code} %{content_type} %{size_download}\n' https://recipe-commit-files-docs.randomimity.app/style.css
200 text/css 563

815 and 563 are the byte counts of the two files in this guide. Both bodies are byte-identical to those files. The number 815 is also in the health probe's access line above, because nginx serves the same file there.

A later git clone of https://git.randomimity.com/docs/recipe-commit-files.git shows the three commits above, in order. Its working tree is byte-identical to the two files here. That is the last fact about commit_files: it makes ordinary commits, on the same repo that a push uses. On a machine with git, you clone that repo and continue your work.

When it goes wrong

The app is connected to GitHub

commit_files is refused outright, with

this app is GitHub-connected; xhostd mirrors this repo read-only. Push to GitHub instead — changes sync to xhostd automatically.

There is no way around this refusal. On a connected app, GitHub is the source, and the xhostd repo is a read-only mirror of GitHub. Send the code to GitHub, and xhostd syncs it from there. This guide describes the message and does not capture it. The demo app has no GitHub source, and a connection to GitHub would stay in place. sync_git is not the answer either. It refreshes that mirror, and it does not put code onto an app.

The commit succeeded but the site did not change

commit_files returned a sha and did no more. It stores code, but it does not deploy code. Call deploy with that sha. A push to git with no deploy gives the same result, so this failure occurs on both paths. If you do not know which commit is live, call get_app for the channel's current_sha. Call list_files for the content of the repo. If the two disagree, the repo is ahead and a deploy is absent. A non-null pending_deploy on the channel means the deploy is in flight and current_sha has not caught up yet — poll get_deploy_log rather than starting another deploy.

You send the whole file on every edit

It works. The platform applies every changeset on top of the branch head, so a file that you send again with no change does no damage. But it costs you that text every time. Two rules keep the cost down. Name only the paths that change, because the platform leaves every other path as it is. Then reach for edits or patches instead of files on a file that already exists. If git is available on the machine where you work, stop with commit_files. Recipe: static site gives the four steps, and a push sends the diff, not the file.

The file you need to commit is not text

The values in files are strings, and the platform writes them into the repo as UTF-8. There is no binary form and no base64 alternative. An image, a font, a favicon or a zip file cannot go through commit_files. A static site with such a file needs git, or a URL to a file on another host. This guide describes this limit and does not capture it, because the worked example is two text files. The platform also checks each path. A changeset path must be relative, and it must contain no . or .. segment.

Enter a topic, task, or tool name.

Public documentation only · Search stays in your browser.