A vertical slice to bootstrap the product loop on the canonical zopu-code repo. Primitives (packages/primitives, all tested + lint/type clean): - GitRemoteRuntime: Gitea REST client (createIssue, createBranch, createPullRequest, listIssues, getIssue) as an Effect context service with a fetch-backed transport. Live-verified against puter/zopu-code. - GitLocalRuntime: clone, addWorktree, commit, push, currentBranch, setRemoteUrl over a pluggable Shell (host subprocess now, AgentOS VM exec later). Tested against real temp git repos. - agent-os: Codex support via @agentos-software/codex-cli — codexSoftware bundle (codex+git), makeCodexAgentOsConfig(), codexSessionEnv() for the OpenAI base URL/key. Env: - GITEA_URL/GITEA_TOKEN wired into convex.ts and convex.config.ts. - .env.example documents the self-hosted git section. Agents (packages/agents): - zopu-dev: development agent that creates issues on the canonical repo and starts autonomous work runs. git-remote tools (create/list/branch/PR) wired to GitRemoteRuntime; start_workflow tool enqueues a work run. - flue run zopu-dev verified live (lists/creates issues on real Gitea). Backend (packages/backend): - workflows.startIssueWork mutation: fetches a Gitea issue server-side and admits a queued work run. The Codex VM spawn body is the next step.
117 lines
3.5 KiB
TypeScript
117 lines
3.5 KiB
TypeScript
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
// oxlint-disable-next-line unicorn/import-style -- named import avoids shadowing local `path` vars
|
|
import { join } from "node:path";
|
|
|
|
import { Effect } from "effect";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
addWorktree,
|
|
commit,
|
|
currentBranch,
|
|
nodeShell,
|
|
push,
|
|
} from "./git-local-runtime";
|
|
|
|
const run = <A, E>(eff: Effect.Effect<A, E>) => Effect.runPromise(eff);
|
|
|
|
const execPlain = async (cwd: string, cmd: string) => {
|
|
const res = await run(nodeShell.exec("sh", ["-c", cmd], { cwd }));
|
|
return res.stdout.trim();
|
|
};
|
|
|
|
let root = "";
|
|
let origin = "";
|
|
|
|
beforeEach(async () => {
|
|
root = await mkdtemp(join(tmpdir(), "git-local-"));
|
|
origin = join(root, "origin.git");
|
|
await execPlain(root, `git init --bare -b main ${origin}`);
|
|
const seed = join(root, "seed");
|
|
await execPlain(root, `git clone ${origin} ${seed}`);
|
|
await execPlain(
|
|
seed,
|
|
"git config user.email t@t.t && git config user.name t"
|
|
);
|
|
await writeFile(join(seed, "README.md"), "# hi\n");
|
|
await execPlain(
|
|
seed,
|
|
"git add -A && git commit -m init && git push -u origin main"
|
|
);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await execPlain(root, "chmod -R u+w . 2>/dev/null; true");
|
|
});
|
|
|
|
describe("GitLocalRuntime", () => {
|
|
it("creates a worktree on a new branch", async () => {
|
|
const cloneDir = join(root, "work");
|
|
await run(nodeShell.exec("git", ["clone", origin, cloneDir]));
|
|
const worktree = join(root, "wt-1");
|
|
const result = await run(
|
|
addWorktree(nodeShell, {
|
|
branch: "work/1/fix",
|
|
path: worktree,
|
|
repositoryPath: cloneDir,
|
|
})
|
|
);
|
|
expect(result).toBe(worktree);
|
|
expect(await execPlain(worktree, "git rev-parse --abbrev-ref HEAD")).toBe(
|
|
"work/1/fix"
|
|
);
|
|
});
|
|
|
|
it("commits staged changes and returns the new sha", async () => {
|
|
const cloneDir = join(root, "work2");
|
|
await run(nodeShell.exec("git", ["clone", origin, cloneDir]));
|
|
await run(
|
|
nodeShell.exec(
|
|
"git",
|
|
["worktree", "add", "-b", "work/2", join(root, "wt-2")],
|
|
{ cwd: cloneDir }
|
|
)
|
|
);
|
|
const worktree = join(root, "wt-2");
|
|
await writeFile(join(worktree, "a.txt"), "a");
|
|
const sha = await run(
|
|
commit(nodeShell, { message: "add a", repositoryPath: worktree })
|
|
);
|
|
expect(sha).toMatch(/^[0-9a-f]{7,}/u);
|
|
});
|
|
|
|
it("reads the current branch", async () => {
|
|
const cloneDir = join(root, "work3");
|
|
await run(nodeShell.exec("git", ["clone", origin, cloneDir]));
|
|
expect(await run(currentBranch(nodeShell, cloneDir))).toBe("main");
|
|
});
|
|
|
|
it("pushes a branch to origin", async () => {
|
|
const cloneDir = join(root, "work4");
|
|
await run(nodeShell.exec("git", ["clone", origin, cloneDir]));
|
|
await run(
|
|
nodeShell.exec(
|
|
"git",
|
|
["worktree", "add", "-b", "work/4", join(root, "wt-4")],
|
|
{ cwd: cloneDir }
|
|
)
|
|
);
|
|
const worktree = join(root, "wt-4");
|
|
await writeFile(join(worktree, "b.txt"), "b");
|
|
await run(
|
|
commit(nodeShell, { message: "add b", repositoryPath: worktree })
|
|
);
|
|
await run(push(nodeShell, { refspec: "work/4", repositoryPath: worktree }));
|
|
const branches = await execPlain(origin, "git branch --list");
|
|
expect(branches).toContain("work/4");
|
|
});
|
|
|
|
it("maps a non-repo path to NotARepository", async () => {
|
|
const empty = await mkdtemp(join(tmpdir(), "empty-"));
|
|
await expect(run(currentBranch(nodeShell, empty))).rejects.toMatchObject({
|
|
reason: "NotARepository",
|
|
});
|
|
});
|
|
});
|