0012 — studio remote-git projects (R-GIT-⅖)¶
Status: IMPLEMENTED (2026-06-23). Local-clone remote projects + commit-on-save
migration + push + token auth landed in MR !45; the in-memory backend (R-GIT-5)
landed on GTB v0.22.0 (WorkFS/aferobilly/OpenInMemory) — held *ThreadSafeRepo
per active project, per-project afero.Fs, switch-away release + UI warning, all
verified live (add → switch → save → commit + push, no disk). Clone branch
selection now honoured on both backends (local clone = single-branch; in-memory
already threaded it). SSH auth works through GTB's forge-aware NewRepo
(<forge>.ssh → key/agent) with no keryx code — documented in the
remote-SSH how-to.
Date: 2026-06-22
Depends on: 0011 §2.2 (the local switcher + commit-on-save it extends); 0002 §1.5
(R-GIT-2/3/4/5); GTB pkg/vcs/repo (the git layer — see §2); the project
registry + the mutable active-project model; pkg/notify (push-failure alerts).
1. Goal & scope¶
Let the studio author against a project that lives as a git remote. The headline capability — the one this feature exists for — is in-memory operation: clone, edit, commit, and push entirely in RAM, with no local checkout, for users who don't want to clone locally or lack filesystem permissions. A local clone is offered as an equally-valid alternative. Either way the reels are authored against the remote and pushed back. Credentials come from config/env (forge-aware, via GTB), never committed; a push failure surfaces in the UI and, in CI, alerts.
In scope: remote project entries with a storage-backend choice (in-memory | local clone), clone, push-on-commit, the per-project filesystem, auth, and push status/alerts. Out of scope: changing the posting flow; auto-rebase on a diverged remote (surfaced, not resolved).
2. The git layer is GTB pkg/vcs/repo (not a bespoke wrapper)¶
GTB already provides exactly this, so keryx consumes it rather than hand-rolling:
- Two storage backends —
repo.LocalRepo(on disk) andrepo.InMemoryRepo(go-gitmemfs, no disk).NewRepo(p, …)thenOpen(type, …)/Clone(uri, target, …)returns a go-git*Repository+*Worktree. - Clone / Checkout / Commit / Push / tree inspection, behind the narrow
RepoLikerole interfaces (Committer,Brancher,Authenticator, …). - Forge-aware auth —
NewReporeads credentials from the tool's forge config subtree (<forge>.auth+ the<FORGE>_TOKENenv fallback for tokens,<forge>.sshfor SSH). Token auth needs no keychain — aGITLAB_TOKEN/GITHUB_TOKENenv var works — so this is testable on this dev box, unlike the OAuth keychain work. - Clone options: shallow, single-branch, no-tags, submodules.
Consequence: keryx's own internal/gitrepo (the commit-on-save wrapper) should
migrate onto vcs/repo so there is one git abstraction. The keryx-specific
identity gate is preserved at the keryx layer: vcs/repo.Commit takes a
*git.CommitOptions, so keryx resolves the effective identity and passes
Author, refusing (the gate) when user.name/user.email aren't set — GTB does
not enforce that itself. (Decision §10.1.)
3. The per-project filesystem — the one real new abstraction¶
In-memory backend — design locked (GTB v0.22.0). The GTB adapter shipped as
WorktreeFS(WorkFS()/WithWorkFS) + thepkg/vcs/repo/aferobillyadapter, withOpenInMemory(remote, branch, …)cloning a remote straight into RAM (memfs+memory.NewStorage(), forge auth). keryx consumes it: -internal/gitrepowraps theRepoLikeinterface (both*Repoand*ThreadSafeReposatisfy it) → one wrapper serves local (per-call*Repo) and in-memory (a held*ThreadSafeRepo). New:OpenInMemory(p, remote, branch)+WorkFS(). The identity gate still works (the clone has no local user →fillFromScopesresolves the global identity). - Local is the default/optimal path; in-memory is an explicit opt-in edge case (storage:inmemory) for permission-less / no-local-clone environments — an advanced "no local checkout" toggle in the UI, off by default. (Resolves the old §10.3 "default in-memory" lean — rejected.) - Held repo + per-project FS. An in-memory project holds its*ThreadSafeRepofor the session; reel handlers read/write a per-projectafero.Fs(WorkFS()for in-memory, OsFs for local); config/registry/project handlers keep the OS FS. commit-on-save + push run on the held repo (not a per-call open). - Switch-away releases the RAM copy (decision A) to bound memory; the UI warns about unsaved/unpushed loss before switching away from an in-memory project, and returning re-clones. (auto-push means a saved reel is already safe in the remote.) - Deadlock constraint: never use aWorkFS()handle inside aWithWorkFS/WithTree/WithRepocallback (non-reentrant mutex). keryx keeps writes and commit/push strictly sequential, so this never arises.
The studio's reel/workspace ops are afero-based. Today every project shares one
afero.OsFs. A remote project's reels live in its worktree, which is:
- local clone → an on-disk dir →
afero.OsFs(today's path, unchanged), or - in-memory → the worktree's billy
memfs— not the OS FS.
So the active project gains its own afero.Fs (already foreshadowed by the
switcher's active-project model), and the handlers read/write through
a.proj().fs instead of a shared a.fs. The workspace/reel ops are already
afero-generic, so they work over either FS unchanged once it's threaded through.
The in-memory bridge is a thin afero.Fs over the worktree's billy.Filesystem.
This belongs in GTB, not keryx — vcs/repo already hands every consumer a billy
worktree, so the bridge is a general capability. A feature request is filed for
GTB to add it as a first-class citizen — repo.AferoFS(billy.Filesystem) afero.Fs
plus a WorkFS() (afero.Fs, error) accessor on RepoLike
(go-tool-base/FEATURE-REQUEST-vcs-repo-afero-worktree.md, with a reference
implementation). keryx then just sets the active project's fs = repo.WorkFS() for
in-memory projects — no local adapter to vendor or test. Sequencing dependency:
the in-memory backend lands once GTB ships WorkFS(); if keryx needs it sooner, a
local copy of the proposed adapter is the stopgap, swapped for GTB's once available.
(The AddToFS materialise alternative was rejected — duplicate state + write-back
sync; the bridge keeps one source of truth.)
4. Adding & switching a remote project¶
POST /api/v1/projects accepts, besides {path} (local dir):
{ "remote": "[email protected]:phpboyscout/blog.git", "branch": "main",
"storage": "inmemory" } // or "local"
- inmemory — clone into a GTB
InMemoryRepoheld for the session; the active project'sfsis the billy bridge; no disk touched. - local — clone into a cache dir (
<configdir>/cache/<hash>/); the active project'sfsisOsFsandreelRootpoints into the clone — i.e. it becomes a normal local project (reusing the switcher + commit-on-save wholesale).
The registry entry records remote + branch + storage (+ the cache path for
local). forget drops the entry (and offers to delete a local cache clone); it
never touches the remote. Switching to an in-memory project re-clones (cheap for a
reel-sized repo); switching to a local one reopens the cache.
5. Commit + push (R-GIT-3 remote half)¶
Commit-on-save already commits (it migrates onto vcs/repo, §2). For a remote
project, after a successful commit, push via vcs/repo.Push with the
forge-resolved auth. git.auto_push is config — default on for remote projects,
n/a for local-dir projects. The save response gains a push alongside commit:
{ "commit": {"committed": true, "hash": "ab12cd34"},
"push": {"pushed": true} } // or {"pushed": false, "reason": "…"}
The editor surfaces both ("✓ committed + pushed" / "committed — not pushed:
6. Push-failure alerts (R-GIT-4)¶
Reuse the pkg/notify seam (none/webhook/email) built for auth-refresh: an
unattended/CI push failure fires the configured notifier; the interactive studio's
UI surfacing (§5) is the alert. Config: git.alerts.backend (its own selector so
git + auth alerts can differ).
7. Security¶
- Credentials only from config/env/keychain via GTB (
<forge>.auth,<FORGE>_TOKEN,<forge>.ssh); never in.keryx.yaml, never instudio.yaml(remotes/paths only), never logged. - In-memory leaves nothing on disk — the point for permission-constrained or shared environments. A local cache clone lives under the user config dir.
- Localhost bind still applies.
8. Architecture summary¶
| Concern | Where | Notes |
|---|---|---|
| git ops (clone/commit/push, both backends) | GTB vcs/repo |
replaces internal/gitrepo |
| identity gate (commit author) | keryx — resolve + pass CommitOptions.Author |
preserved from commit-on-save |
| auth (token/ssh, forge-aware) | GTB vcs/repo (config + <FORGE>_TOKEN) |
no keychain needed for tokens |
per-project afero.Fs (OsFs / billy-bridge) |
api.proj().fs + a billy↔afero adapter |
the one new abstraction |
| remote entry (url/branch/storage/cache) | registry.go (projectEntry) |
Remote already reserved |
| add/switch remote project | handlers_projects.go |
storage choice in the body/UI |
| push-on-commit + status + UI | studio Pusher + Editor.svelte |
git.auto_push |
| push-failure alerts | reuse pkg/notify |
git.alerts.backend |
9. Testing¶
- git mechanics (clone/commit/push, both backends) against a local bare repo as a fake remote — no network, no auth — and an in-memory clone of it.
- Token auth + the auth gate: a
<FORGE>_TOKENenv var against the local remote (or a fake) — runnable on this dev box (no keychain dependency). SSH auth is env-gated. - billy↔afero bridge: tested directly (write/read/stat/readdir parity).
- Studio wiring: a fake
Pusherasserts push-after-commit + the save-response shape; a godog scenario clones a local bare "remote" in-memory, edits, saves, and asserts the commit landed in the bare repo after push.
10. Questions for review¶
- Migrate commit-on-save onto GTB
vcs/repo(one git layer; keep the keryx identity gate by passingAuthor), retiringinternal/gitrepo? (Recommend yes — one abstraction, andvcs/repois what gives us in-memory + push + auth.) - ✅ Resolved — the adapter, and it lands in GTB as a first-class
vcs/repocitizen (WorkFS()), not vendored in keryx. Feature request filed (go-tool-base/FEATURE-REQUEST-vcs-repo-afero-worktree.md). keryx consumesrepo.WorkFS(); the in-memory backend sequences on that GTB change (stopgap: a local copy of the proposed adapter). See §3. - Offer both backends, user-chosen per project; default to in-memory for a remote add (the headline use-case), local clone opt-in? Or default local on desktop? (Lean in-memory-default given the stated permission-less intent — confirm.)
- Auth v1 = token (forge
<FORGE>_TOKEN/config), SSH second — both are GTB- provided; which to wire/test first? (Recommend token first — keychain-free, testable now.) auto_pushdefault-on for remote projects (the point of a remote project is that saves propagate)? (Recommend yes.)- Sequencing. Since GTB provides in-memory, this no longer needs to wait on the mobile UI — in-memory is independently valuable (permission-less desktops/CI). Build it now as fast-follow 3, with the mobile UI separate? (Recommend yes.) ```