v0.1.5 (#13)
All checks were successful
ci/woodpecker/push/unit Pipeline was successful

Co-authored-by: Tasnim Kabir Sadik <tksadik92@gmail.com>
Reviewed-on: #13
This commit is contained in:
2026-05-22 23:01:46 +00:00
parent 2b10fde45b
commit de72dfe9c8
8 changed files with 221 additions and 82 deletions

View File

@ -1421,10 +1421,19 @@ paths:
- apiKeyAuth: [] - apiKeyAuth: []
- sessionAuth: [] - sessionAuth: []
description: | description: |
Live snapshot: briefly pauses the capsule, writes its VM state + Snapshot a capsule, processed asynchronously. The call returns
memory + flattened rootfs to a new template directory, then resumes immediately with the capsule in the `snapshotting` state, then it
the capsule. The source capsule keeps running after the snapshot; returns to its original state on completion. The capsule must be
the resulting template can be used to create new capsules. `running` or `paused`.
A `running` capsule is snapshotted live: it briefly pauses while its VM
state + memory + flattened rootfs are written to a new template, then
resumes to `running`. A `paused` capsule is snapshotted directly from
its on-disk state without reviving the VM, and stays `paused`.
Because it is async, the response does NOT contain the template. Watch
for the `template.snapshot.create` SSE event (its `outcome` reports
success or failure) or poll `GET /v1/snapshots` to observe completion.
Snapshots are immutable: each call must use a fresh name. Re-using Snapshots are immutable: each call must use a fresh name. Re-using
an existing name returns 409 Conflict. an existing name returns 409 Conflict.
@ -1435,14 +1444,14 @@ paths:
schema: schema:
$ref: "#/components/schemas/CreateSnapshotRequest" $ref: "#/components/schemas/CreateSnapshotRequest"
responses: responses:
"201": "202":
description: Snapshot created description: Snapshot accepted; capsule is now snapshotting
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Template" $ref: "#/components/schemas/Capsule"
"409": "409":
description: Name already exists or capsule not running description: Name already exists, or capsule is not running or paused
content: content:
application/json: application/json:
schema: schema:
@ -2813,7 +2822,7 @@ paths:
schema: schema:
type: array type: array
items: items:
$ref: "#/components/schemas/Template" $ref: "#/components/schemas/AdminTemplate"
/v1/admin/templates/{name}: /v1/admin/templates/{name}:
delete: delete:
@ -2899,6 +2908,26 @@ paths:
"204": "204":
description: Cancelled description: Cancelled
/v1/admin/builds/{id}/stream:
get:
summary: Stream a build's live console (admin, WebSocket)
description: >
WebSocket endpoint. On connect, replays the completed-step history,
then live-tails JSON events (step-start, output, step-end,
build-status, ping) until the build finishes.
operationId: adminStreamBuild
tags: [admin]
security:
- sessionAuth: []
parameters:
- name: id
in: path
required: true
schema: {type: string}
responses:
"101":
description: WebSocket upgrade — streams build console events
/v1/admin/capsules: /v1/admin/capsules:
post: post:
summary: Create a capsule on behalf of any team (admin) summary: Create a capsule on behalf of any team (admin)
@ -2969,6 +2998,10 @@ paths:
summary: Create snapshot from any capsule (admin) summary: Create snapshot from any capsule (admin)
operationId: adminCreateSnapshotFromCapsule operationId: adminCreateSnapshotFromCapsule
tags: [admin] tags: [admin]
description: |
Snapshots a `running` or `paused` capsule into a platform template,
processed asynchronously (see `POST /v1/snapshots`). A running capsule
resumes to `running`; a paused capsule stays `paused`.
security: security:
- sessionAuth: [] - sessionAuth: []
parameters: parameters:
@ -2977,21 +3010,22 @@ paths:
required: true required: true
schema: {type: string} schema: {type: string}
requestBody: requestBody:
required: true required: false
content: content:
application/json: application/json:
schema: schema:
type: object type: object
required: [name]
properties: properties:
name: {type: string} name:
type: string
description: Optional; an auto-generated name is used when omitted.
responses: responses:
"201": "202":
description: Snapshot created description: Snapshot accepted; capsule is now snapshotting
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Template" $ref: "#/components/schemas/Capsule"
/v1/admin/capsules/{id}/exec: /v1/admin/capsules/{id}/exec:
parameters: parameters:
@ -3486,7 +3520,7 @@ components:
properties: properties:
template: template:
type: string type: string
default: minimal default: minimal-ubuntu
vcpus: vcpus:
type: integer type: integer
default: 1 default: 1
@ -3590,7 +3624,7 @@ components:
type: string type: string
status: status:
type: string type: string
enum: [pending, starting, running, pausing, paused, resuming, stopping, hibernated, stopped, missing, error] enum: [pending, starting, running, pausing, paused, snapshotting, resuming, stopping, hibernated, stopped, missing, error]
template: template:
type: string type: string
vcpus: vcpus:
@ -3664,13 +3698,51 @@ components:
type: boolean type: boolean
description: | description: |
True when the template is platform-managed (visible to all teams, True when the template is platform-managed (visible to all teams,
e.g. the built-in `minimal` rootfs). False for team-owned e.g. the built-in `minimal-ubuntu` rootfs). False for team-owned
snapshot templates. snapshot templates.
protected:
type: boolean
description: |
True for built-in system base templates (minimal-ubuntu,
minimal-alpine, minimal-arch, minimal-fedora). Protected templates
cannot be deleted.
metadata: metadata:
type: object type: object
additionalProperties: {type: string} additionalProperties: {type: string}
nullable: true nullable: true
AdminTemplate:
type: object
description: |
Template as returned by the admin templates list. Unlike `Template`
(the team-facing snapshot shape), this includes the owning `team_id`
and omits `platform`/`metadata`.
properties:
name:
type: string
type:
type: string
enum: [base, snapshot]
vcpus:
type: integer
memory_mb:
type: integer
size_bytes:
type: integer
format: int64
team_id:
type: string
description: Owning team ID (formatted, e.g. `team-…`). Platform team for global templates.
created_at:
type: string
format: date-time
protected:
type: boolean
description: |
True for built-in system base templates (minimal-ubuntu,
minimal-alpine, minimal-arch, minimal-fedora). Protected templates
cannot be deleted.
ExecRequest: ExecRequest:
type: object type: object
required: [cmd] required: [cmd]

View File

@ -1,6 +1,6 @@
[project] [project]
name = "wrenn" name = "wrenn"
version = "0.1.4" version = "0.1.5"
description = "Python SDK for Wrenn" description = "Python SDK for Wrenn"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"

View File

@ -27,7 +27,6 @@ from wrenn.models._generated import (
Status1, Status1,
Template, Template,
Type, Type,
Type1,
Type2, Type2,
) )
@ -60,6 +59,5 @@ __all__ = [
"Status1", "Status1",
"Template", "Template",
"Type", "Type",
"Type1",
"Type2", "Type2",
] ]

View File

@ -1,6 +1,6 @@
# generated by datamodel-codegen: # generated by datamodel-codegen:
# filename: openapi.yaml # filename: openapi.yaml
# timestamp: 2026-05-19T08:54:50+00:00 # timestamp: 2026-05-22T19:20:45+00:00
from __future__ import annotations from __future__ import annotations
from pydantic import AwareDatetime, BaseModel, EmailStr, Field from pydantic import AwareDatetime, BaseModel, EmailStr, Field
@ -65,7 +65,7 @@ class APIKeyResponse(BaseModel):
class CreateCapsuleRequest(BaseModel): class CreateCapsuleRequest(BaseModel):
template: str | None = "minimal" template: str | None = "minimal-ubuntu"
vcpus: int | None = 1 vcpus: int | None = 1
memory_mb: int | None = 512 memory_mb: int | None = 512
disk_size_mb: Annotated[ disk_size_mb: Annotated[
@ -148,6 +148,7 @@ class Status(StrEnum):
running = "running" running = "running"
pausing = "pausing" pausing = "pausing"
paused = "paused" paused = "paused"
snapshotting = "snapshotting"
resuming = "resuming" resuming = "resuming"
stopping = "stopping" stopping = "stopping"
hibernated = "hibernated" hibernated = "hibernated"
@ -203,12 +204,46 @@ class Template(BaseModel):
platform: Annotated[ platform: Annotated[
bool | None, bool | None,
Field( Field(
description="True when the template is platform-managed (visible to all teams,\ne.g. the built-in `minimal` rootfs). False for team-owned\nsnapshot templates.\n" description="True when the template is platform-managed (visible to all teams,\ne.g. the built-in `minimal-ubuntu` rootfs). False for team-owned\nsnapshot templates.\n"
),
] = None
protected: Annotated[
bool | None,
Field(
description="True for built-in system base templates (minimal-ubuntu,\nminimal-alpine, minimal-arch, minimal-fedora). Protected templates\ncannot be deleted.\n"
), ),
] = None ] = None
metadata: dict[str, str] | None = None metadata: dict[str, str] | None = None
class AdminTemplate(BaseModel):
"""
Template as returned by the admin templates list. Unlike `Template`
(the team-facing snapshot shape), this includes the owning `team_id`
and omits `platform`/`metadata`.
"""
name: str | None = None
type: Type | None = None
vcpus: int | None = None
memory_mb: int | None = None
size_bytes: int | None = None
team_id: Annotated[
str | None,
Field(
description="Owning team ID (formatted, e.g. `team-…`). Platform team for global templates."
),
] = None
created_at: AwareDatetime | None = None
protected: Annotated[
bool | None,
Field(
description="True for built-in system base templates (minimal-ubuntu,\nminimal-alpine, minimal-arch, minimal-fedora). Protected templates\ncannot be deleted.\n"
),
] = None
class ExecRequest(BaseModel): class ExecRequest(BaseModel):
cmd: str cmd: str
args: list[str] | None = None args: list[str] | None = None
@ -296,7 +331,7 @@ class ListDirRequest(BaseModel):
] = 1 ] = 1
class Type1(StrEnum): class Type2(StrEnum):
file = "file" file = "file"
directory = "directory" directory = "directory"
symlink = "symlink" symlink = "symlink"
@ -305,7 +340,7 @@ class Type1(StrEnum):
class FileEntry(BaseModel): class FileEntry(BaseModel):
name: str | None = None name: str | None = None
path: str | None = None path: str | None = None
type: Type1 | None = None type: Type2 | None = None
size: int | None = None size: int | None = None
mode: int | None = None mode: int | None = None
permissions: Annotated[ permissions: Annotated[
@ -333,7 +368,7 @@ class RemoveRequest(BaseModel):
path: Annotated[str, Field(description="Path to remove inside the capsule")] path: Annotated[str, Field(description="Path to remove inside the capsule")]
class Type2(StrEnum): class Type3(StrEnum):
""" """
Host type. Regular hosts are shared; BYOC hosts belong to a team. Host type. Regular hosts are shared; BYOC hosts belong to a team.
""" """
@ -344,7 +379,7 @@ class Type2(StrEnum):
class CreateHostRequest(BaseModel): class CreateHostRequest(BaseModel):
type: Annotated[ type: Annotated[
Type2, Type3,
Field( Field(
description="Host type. Regular hosts are shared; BYOC hosts belong to a team." description="Host type. Regular hosts are shared; BYOC hosts belong to a team."
), ),
@ -372,7 +407,7 @@ class RegisterHostRequest(BaseModel):
address: Annotated[str, Field(description="Host agent address (ip:port).")] address: Annotated[str, Field(description="Host agent address (ip:port).")]
class Type3(StrEnum): class Type4(StrEnum):
regular = "regular" regular = "regular"
byoc = "byoc" byoc = "byoc"
@ -387,7 +422,7 @@ class Status1(StrEnum):
class Host(BaseModel): class Host(BaseModel):
id: str | None = None id: str | None = None
type: Type3 | None = None type: Type4 | None = None
team_id: str | None = None team_id: str | None = None
provider: str | None = None provider: str | None = None
availability_zone: str | None = None availability_zone: str | None = None
@ -678,14 +713,14 @@ class Resource(BaseModel):
type: str | None = None type: str | None = None
class Type4(StrEnum): class Type5(StrEnum):
user = "user" user = "user"
api_key = "api_key" api_key = "api_key"
system = "system" system = "system"
class Actor(BaseModel): class Actor(BaseModel):
type: Type4 | None = None type: Type5 | None = None
id: str | None = None id: str | None = None
name: str | None = None name: str | None = None

View File

@ -74,32 +74,32 @@ class TestFilesList:
"entries": [ "entries": [
{ {
"name": "main.py", "name": "main.py",
"path": "/home/user/main.py", "path": "/home/wrenn-user/main.py",
"type": "file", "type": "file",
"size": 1024, "size": 1024,
"mode": 33188, "mode": 33188,
"permissions": "-rw-r--r--", "permissions": "-rw-r--r--",
"owner": "root", "owner": "wrenn-user",
"group": "root", "group": "wrenn-user",
"modified_at": 1712899200, "modified_at": 1712899200,
"symlink_target": None, "symlink_target": None,
}, },
{ {
"name": "config", "name": "config",
"path": "/home/user/config", "path": "/home/wrenn-user/config",
"type": "directory", "type": "directory",
"size": 4096, "size": 4096,
"mode": 16877, "mode": 16877,
"permissions": "drwxr-xr-x", "permissions": "drwxr-xr-x",
"owner": "root", "owner": "wrenn-user",
"group": "root", "group": "wrenn-user",
"modified_at": 1712899100, "modified_at": 1712899100,
"symlink_target": None, "symlink_target": None,
}, },
] ]
}, },
) )
entries = cap.files.list("/home/user") entries = cap.files.list("/home/wrenn-user")
assert len(entries) == 2 assert len(entries) == 2
assert isinstance(entries[0], FileEntry) assert isinstance(entries[0], FileEntry)
assert entries[0].name == "main.py" assert entries[0].name == "main.py"
@ -113,7 +113,7 @@ class TestFilesList:
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond( route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/list").respond(
200, json={"entries": []} 200, json={"entries": []}
) )
cap.files.list("/home/user", depth=3) cap.files.list("/home/wrenn-user", depth=3)
body = json.loads(route.calls[0].request.content) body = json.loads(route.calls[0].request.content)
assert body["depth"] == 3 assert body["depth"] == 3
@ -136,19 +136,19 @@ class TestFilesMakeDir:
json={ json={
"entry": { "entry": {
"name": "data", "name": "data",
"path": "/home/user/data", "path": "/home/wrenn-user/data",
"type": "directory", "type": "directory",
"size": 4096, "size": 4096,
"mode": 16877, "mode": 16877,
"permissions": "drwxr-xr-x", "permissions": "drwxr-xr-x",
"owner": "root", "owner": "wrenn-user",
"group": "root", "group": "wrenn-user",
"modified_at": 1712899200, "modified_at": 1712899200,
"symlink_target": None, "symlink_target": None,
} }
}, },
) )
entry = cap.files.make_dir("/home/user/data") entry = cap.files.make_dir("/home/wrenn-user/data")
assert isinstance(entry, FileEntry) assert isinstance(entry, FileEntry)
assert entry.name == "data" assert entry.name == "data"
assert entry.type == "directory" assert entry.type == "directory"
@ -166,20 +166,20 @@ class TestFilesMakeDir:
"entries": [ "entries": [
{ {
"name": "data", "name": "data",
"path": "/home/user/data", "path": "/home/wrenn-user/data",
"type": "directory", "type": "directory",
"size": 4096, "size": 4096,
"mode": 16877, "mode": 16877,
"permissions": "drwxr-xr-x", "permissions": "drwxr-xr-x",
"owner": "root", "owner": "wrenn-user",
"group": "root", "group": "wrenn-user",
"modified_at": 1712899200, "modified_at": 1712899200,
"symlink_target": None, "symlink_target": None,
} }
] ]
}, },
) )
entry = cap.files.make_dir("/home/user/data") entry = cap.files.make_dir("/home/wrenn-user/data")
assert entry.name == "data" assert entry.name == "data"
@ -188,7 +188,7 @@ class TestFilesRemove:
def test_remove_succeeds(self): def test_remove_succeeds(self):
cap = _make_capsule() cap = _make_capsule()
route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/remove").respond(204) route = respx.post(f"{BASE}/v1/capsules/cl-abc/files/remove").respond(204)
cap.files.remove("/home/user/old_data") cap.files.remove("/home/wrenn-user/old_data")
assert route.called assert route.called
@respx.mock @respx.mock
@ -411,7 +411,7 @@ class TestPtySessionSendStart:
cols=120, cols=120,
rows=40, rows=40,
envs={"TERM": "xterm-256color"}, envs={"TERM": "xterm-256color"},
cwd="/home/user", cwd="/home/wrenn-user",
) )
sent = json.loads(ws.send_text.call_args[0][0]) sent = json.loads(ws.send_text.call_args[0][0])
assert sent["cmd"] == "/bin/zsh" assert sent["cmd"] == "/bin/zsh"

View File

@ -323,7 +323,7 @@ class TestFiles:
class TestGit: class TestGit:
"""Shared capsule for git operation tests. """Shared capsule for git operation tests.
Initializes a repo at /root (default cwd) since the exec API Initializes a repo at /home/wrenn-user (default cwd) since the exec API
does not support the cwd parameter. does not support the cwd parameter.
""" """
@ -344,14 +344,14 @@ class TestGit:
pass pass
def test_init_created_repo(self): def test_init_created_repo(self):
assert self.capsule.files.exists("/root/.git") assert self.capsule.files.exists("/home/wrenn-user/.git")
def test_status_clean(self): def test_status_clean(self):
status = self.capsule.git.status() status = self.capsule.git.status()
assert status.branch == "main" assert status.branch == "main"
def test_add_and_commit(self): def test_add_and_commit(self):
self.capsule.files.write("/root/hello.txt", "hello git") self.capsule.files.write("/home/wrenn-user/hello.txt", "hello git")
self.capsule.git.add(all=True) self.capsule.git.add(all=True)
result = self.capsule.git.commit("initial commit") result = self.capsule.git.commit("initial commit")
assert result.exit_code == 0 assert result.exit_code == 0
@ -361,14 +361,14 @@ class TestGit:
assert status.is_clean assert status.is_clean
def test_status_with_changes(self): def test_status_with_changes(self):
self.capsule.files.write("/root/dirty.txt", "uncommitted") self.capsule.files.write("/home/wrenn-user/dirty.txt", "uncommitted")
try: try:
status = self.capsule.git.status() status = self.capsule.git.status()
assert not status.is_clean assert not status.is_clean
paths = [f.path for f in status.files] paths = [f.path for f in status.files]
assert "dirty.txt" in paths assert "dirty.txt" in paths
finally: finally:
self.capsule.files.remove("/root/dirty.txt") self.capsule.files.remove("/home/wrenn-user/dirty.txt")
def test_branches(self): def test_branches(self):
branches = self.capsule.git.branches() branches = self.capsule.git.branches()

View File

@ -75,7 +75,7 @@ class TestCommandEnvironment:
def test_default_cwd_is_home(self): def test_default_cwd_is_home(self):
result = self.capsule.commands.run("pwd") result = self.capsule.commands.run("pwd")
assert result.stdout.strip() == "/root" assert result.stdout.strip() == "/home/wrenn-user"
def test_cwd_resolves_relative_paths(self): def test_cwd_resolves_relative_paths(self):
self.capsule.files.make_dir("/tmp/cwd_probe/sub") self.capsule.files.make_dir("/tmp/cwd_probe/sub")
@ -90,7 +90,7 @@ class TestCommandEnvironment:
# Each run is a fresh process — `cd` in one does not affect the next. # Each run is a fresh process — `cd` in one does not affect the next.
self.capsule.commands.run("cd /tmp") self.capsule.commands.run("cd /tmp")
result = self.capsule.commands.run("pwd") result = self.capsule.commands.run("pwd")
assert result.stdout.strip() == "/root" assert result.stdout.strip() == "/home/wrenn-user"
def test_single_env_var(self): def test_single_env_var(self):
result = self.capsule.commands.run("echo $GREETING", envs={"GREETING": "hi"}) result = self.capsule.commands.run("echo $GREETING", envs={"GREETING": "hi"})
@ -115,9 +115,29 @@ class TestCommandEnvironment:
def test_base_environment_present(self): def test_base_environment_present(self):
result = self.capsule.commands.run("echo $HOME; echo $PATH") result = self.capsule.commands.run("echo $HOME; echo $PATH")
lines = result.stdout.strip().splitlines() lines = result.stdout.strip().splitlines()
assert lines[0] == "/root" assert lines[0] == "/home/wrenn-user"
assert "/usr/bin" in lines[1] assert "/usr/bin" in lines[1]
def test_sudo_available(self):
result = self.capsule.commands.run("which sudo")
assert result.exit_code == 0
def test_sudo_runs_without_password(self):
result = self.capsule.commands.run("sudo whoami")
assert result.exit_code == 0
assert result.stdout.strip() == "root"
def test_sudo_can_write_to_protected_path(self):
result = self.capsule.commands.run(
"sudo touch /opt/sudo-test-marker && cat /opt/sudo-test-marker"
)
assert result.exit_code == 0
def test_sudo_can_read_root_owned_file(self):
result = self.capsule.commands.run("sudo cat /etc/shadow | head -1")
assert result.exit_code == 0
assert "root" in result.stdout
# ══════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════
# Long-running commands # Long-running commands
@ -143,7 +163,7 @@ class TestLongRunningCommands:
def test_apt_get_install(self): def test_apt_get_install(self):
result = self.capsule.commands.run( result = self.capsule.commands.run(
"apt-get update -qq && apt-get install -y -qq cowsay", timeout=300 "sudo apt-get update -qq && sudo apt-get install -y -qq cowsay", timeout=300
) )
assert result.exit_code == 0 assert result.exit_code == 0
@ -388,7 +408,9 @@ class TestGitClone:
def setup_class(cls): def setup_class(cls):
_ensure_env() _ensure_env()
cls.capsule = Capsule(wait=True) cls.capsule = Capsule(wait=True)
cls.capsule.git.clone(WRENN_REPO, "/root/wrenn", depth=1, timeout=300) cls.capsule.git.clone(
WRENN_REPO, "/home/wrenn-user/wrenn", depth=1, timeout=300
)
@classmethod @classmethod
def teardown_class(cls): def teardown_class(cls):
@ -398,66 +420,74 @@ class TestGitClone:
pass pass
def test_clone_created_repo(self): def test_clone_created_repo(self):
assert self.capsule.files.exists("/root/wrenn/.git") assert self.capsule.files.exists("/home/wrenn-user/wrenn/.git")
def test_clone_checked_out_files(self): def test_clone_checked_out_files(self):
entries = self.capsule.files.list("/root/wrenn") entries = self.capsule.files.list("/home/wrenn-user/wrenn")
names = [e.name for e in entries] names = [e.name for e in entries]
assert "README.md" in names assert "README.md" in names
def test_status_of_clone_is_clean(self): def test_status_of_clone_is_clean(self):
status = self.capsule.git.status(cwd="/root/wrenn") status = self.capsule.git.status(cwd="/home/wrenn-user/wrenn")
assert status.branch == "main" assert status.branch == "main"
assert status.is_clean assert status.is_clean
def test_branches_lists_main(self): def test_branches_lists_main(self):
branches = self.capsule.git.branches(cwd="/root/wrenn") branches = self.capsule.git.branches(cwd="/home/wrenn-user/wrenn")
names = [b.name for b in branches] names = [b.name for b in branches]
assert "main" in names assert "main" in names
assert any(b.is_current for b in branches) assert any(b.is_current for b in branches)
def test_remote_get_origin(self): def test_remote_get_origin(self):
url = self.capsule.git.remote_get("origin", cwd="/root/wrenn") url = self.capsule.git.remote_get("origin", cwd="/home/wrenn-user/wrenn")
assert url is not None assert url is not None
assert "wrennhq/wrenn" in url assert "wrennhq/wrenn" in url
def test_git_log_has_commit(self): def test_git_log_has_commit(self):
result = self.capsule.commands.run("git log --oneline -1", cwd="/root/wrenn") result = self.capsule.commands.run(
"git log --oneline -1", cwd="/home/wrenn-user/wrenn"
)
assert result.exit_code == 0 assert result.exit_code == 0
assert result.stdout.strip() assert result.stdout.strip()
def test_modify_add_commit(self): def test_modify_add_commit(self):
marker = uuid.uuid4().hex marker = uuid.uuid4().hex
self.capsule.git.configure_user( self.capsule.git.configure_user(
"CI Bot", "ci@example.com", cwd="/root/wrenn", scope="local" "CI Bot", "ci@example.com", cwd="/home/wrenn-user/wrenn", scope="local"
) )
self.capsule.files.write(f"/root/wrenn/sdk_probe_{marker}.txt", marker) self.capsule.files.write(
self.capsule.git.add([f"sdk_probe_{marker}.txt"], cwd="/root/wrenn") f"/home/wrenn-user/wrenn/sdk_probe_{marker}.txt", marker
)
self.capsule.git.add([f"sdk_probe_{marker}.txt"], cwd="/home/wrenn-user/wrenn")
staged = self.capsule.git.status(cwd="/root/wrenn") staged = self.capsule.git.status(cwd="/home/wrenn-user/wrenn")
assert staged.has_staged assert staged.has_staged
result = self.capsule.git.commit("probe commit", cwd="/root/wrenn") result = self.capsule.git.commit("probe commit", cwd="/home/wrenn-user/wrenn")
assert result.exit_code == 0 assert result.exit_code == 0
after = self.capsule.git.status(cwd="/root/wrenn") after = self.capsule.git.status(cwd="/home/wrenn-user/wrenn")
assert after.is_clean assert after.is_clean
assert after.ahead >= 1 assert after.ahead >= 1
def test_create_and_checkout_branch_in_clone(self): def test_create_and_checkout_branch_in_clone(self):
self.capsule.git.create_branch("sdk-feature", cwd="/root/wrenn") self.capsule.git.create_branch("sdk-feature", cwd="/home/wrenn-user/wrenn")
branches = self.capsule.git.branches(cwd="/root/wrenn") branches = self.capsule.git.branches(cwd="/home/wrenn-user/wrenn")
current = [b for b in branches if b.is_current] current = [b for b in branches if b.is_current]
assert current and current[0].name == "sdk-feature" assert current and current[0].name == "sdk-feature"
self.capsule.git.checkout_branch("main", cwd="/root/wrenn") self.capsule.git.checkout_branch("main", cwd="/home/wrenn-user/wrenn")
def test_diff_via_commands(self): def test_diff_via_commands(self):
self.capsule.files.write("/root/wrenn/README.md", "overwritten\n") self.capsule.files.write("/home/wrenn-user/wrenn/README.md", "overwritten\n")
try: try:
result = self.capsule.commands.run("git diff --stat", cwd="/root/wrenn") result = self.capsule.commands.run(
"git diff --stat", cwd="/home/wrenn-user/wrenn"
)
assert "README.md" in result.stdout assert "README.md" in result.stdout
finally: finally:
self.capsule.git.restore(["README.md"], worktree=True, cwd="/root/wrenn") self.capsule.git.restore(
["README.md"], worktree=True, cwd="/home/wrenn-user/wrenn"
)
class TestGitErrors: class TestGitErrors:
@ -481,7 +511,7 @@ class TestGitErrors:
with pytest.raises(GitError): with pytest.raises(GitError):
self.capsule.git.clone( self.capsule.git.clone(
"https://github.com/wrennhq/this-repo-does-not-exist-xyz", "https://github.com/wrennhq/this-repo-does-not-exist-xyz",
"/root/missing", "/home/wrenn-user/missing",
timeout=120, timeout=120,
) )
@ -493,7 +523,11 @@ class TestGitErrors:
def test_clone_with_branch(self): def test_clone_with_branch(self):
self.capsule.git.clone( self.capsule.git.clone(
WRENN_REPO, "/root/wrenn-main", branch="main", depth=1, timeout=300 WRENN_REPO,
"/home/wrenn-user/wrenn-main",
branch="main",
depth=1,
timeout=300,
) )
status = self.capsule.git.status(cwd="/root/wrenn-main") status = self.capsule.git.status(cwd="/home/wrenn-user/wrenn-main")
assert status.branch == "main" assert status.branch == "main"

2
uv.lock generated
View File

@ -1166,7 +1166,7 @@ wheels = [
[[package]] [[package]]
name = "wrenn" name = "wrenn"
version = "0.1.4" version = "0.1.5"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "certifi" }, { name = "certifi" },