gitd 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gitd-0.7.0/.gitignore ADDED
@@ -0,0 +1,3 @@
1
+ __pycache__/
2
+ dist/
3
+ *.egg-info/
gitd-0.7.0/PKG-INFO ADDED
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.5
2
+ Name: gitd
3
+ Version: 0.7.0
4
+ Summary: Client for gitd: instant, secretless git checkouts for agent sandboxes
5
+ Project-URL: Homepage, https://github.com/dooservice/gitd
6
+ Project-URL: Source, https://github.com/dooservice/gitd/tree/main/sdk/python
7
+ License-Expression: Apache-2.0
8
+ Keywords: ai-agents,git,grpc,sandbox,workspace
9
+ Classifier: Operating System :: POSIX :: Linux
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Software Development :: Version Control :: Git
12
+ Requires-Python: >=3.11
13
+ Requires-Dist: grpcio-status>=1.84.0
14
+ Requires-Dist: grpcio>=1.84.0
15
+ Requires-Dist: protobuf>=7.36.2
16
+ Description-Content-Type: text/markdown
17
+
18
+ # gitd for Python
19
+
20
+ A client for [gitd](https://github.com/dooservice/gitd): instant, secretless git checkouts
21
+ for agent sandboxes. Your agents work in their own copy and publish a branch; they never
22
+ see your token.
23
+
24
+ ```sh
25
+ pip install gitd
26
+ ```
27
+
28
+ ```python
29
+ from gitd import Gitd, GitdError
30
+
31
+ with Gitd() as gitd: # $GITD_SOCKET, then /run/gitd/gitd.sock
32
+ workspace = gitd.prepare("https://github.com/acme/web", "/var/lib/gitd/workspaces/agent-1")
33
+ print(workspace.path, workspace.commit, workspace.strategy)
34
+
35
+ # … the agent works in workspace.path with plain git, and commits …
36
+
37
+ published = gitd.publish(workspace.path, "agents/fix-login")
38
+ gitd.remove(workspace.path)
39
+ ```
40
+
41
+ No call takes a credential: gitd reads and publishes with the ones it keeps.
42
+
43
+ | Method | What it does |
44
+ |---|---|
45
+ | `prepare(url, path, *, branch, commit, submodules, full_history, strategy, on_progress)` | A workspace of a repository at a path under gitd's roots |
46
+ | `update(path, *, branch, submodules, on_progress)` | To the tip of its branch, or of the branch given |
47
+ | `checkout(path, commit, *, submodules, on_progress)` | To a commit, as a rollback does |
48
+ | `describe(path)` | Its repository, commit, branch, uncommitted changes and submodules |
49
+ | `publish(path, branch)` | Its commits pushed to a new branch |
50
+ | `remove(path)` | Deleted; its pool stays |
51
+ | `probe(url)` | Whether it can be read, its default branch and branches |
52
+ | `info()` | Which gitd answers, and how it makes workspaces here and why |
53
+ | `audit(*, since, url, limit)` | Every publish, done or refused, newest first |
54
+
55
+ A refusal raises `GitdError`, with `reason` (`AUTH_REQUIRED`, `NOT_FOUND`, `PATH_REFUSED`,
56
+ `BRANCH_REFUSED`, `BUSY`, …) and `said`, what git said. Decide from the reason, never from
57
+ the message.
58
+
59
+ [`examples/agent.py`](examples/agent.py) is an agent's whole round trip.
60
+
61
+ ## Layout
62
+
63
+ | Path | What it is |
64
+ |---|---|
65
+ | `src/gitd/client.py` | `Gitd`, the client |
66
+ | `src/gitd/models.py` | What gitd answers, as dataclasses |
67
+ | `src/gitd/errors.py` | `GitdError` |
68
+ | `src/gitd/timestamps.py` | Between the contract's timestamps and `datetime` |
69
+ | `src/gitd/connection.py` | The channel to the socket, and how calls are made |
70
+ | `src/gitd/methods.py` | The gitd methods called, by their gRPC path |
71
+ | `src/gitd/messages.py` | The messages of the contract the client uses |
72
+ | `src/gitd/contract.py` | Reads the contract at run time, with protobuf's public API |
73
+ | `src/gitd/contract.json` | The contract, built from [`proto/gitd/v1/gitd.proto`](../../proto/gitd/v1/gitd.proto) by `make generate` |
74
+
75
+ No code is generated: the client reads gitd's contract when it starts.
76
+
77
+ Python 3.11 or later, on Linux. Apache-2.0.
gitd-0.7.0/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # gitd for Python
2
+
3
+ A client for [gitd](https://github.com/dooservice/gitd): instant, secretless git checkouts
4
+ for agent sandboxes. Your agents work in their own copy and publish a branch; they never
5
+ see your token.
6
+
7
+ ```sh
8
+ pip install gitd
9
+ ```
10
+
11
+ ```python
12
+ from gitd import Gitd, GitdError
13
+
14
+ with Gitd() as gitd: # $GITD_SOCKET, then /run/gitd/gitd.sock
15
+ workspace = gitd.prepare("https://github.com/acme/web", "/var/lib/gitd/workspaces/agent-1")
16
+ print(workspace.path, workspace.commit, workspace.strategy)
17
+
18
+ # … the agent works in workspace.path with plain git, and commits …
19
+
20
+ published = gitd.publish(workspace.path, "agents/fix-login")
21
+ gitd.remove(workspace.path)
22
+ ```
23
+
24
+ No call takes a credential: gitd reads and publishes with the ones it keeps.
25
+
26
+ | Method | What it does |
27
+ |---|---|
28
+ | `prepare(url, path, *, branch, commit, submodules, full_history, strategy, on_progress)` | A workspace of a repository at a path under gitd's roots |
29
+ | `update(path, *, branch, submodules, on_progress)` | To the tip of its branch, or of the branch given |
30
+ | `checkout(path, commit, *, submodules, on_progress)` | To a commit, as a rollback does |
31
+ | `describe(path)` | Its repository, commit, branch, uncommitted changes and submodules |
32
+ | `publish(path, branch)` | Its commits pushed to a new branch |
33
+ | `remove(path)` | Deleted; its pool stays |
34
+ | `probe(url)` | Whether it can be read, its default branch and branches |
35
+ | `info()` | Which gitd answers, and how it makes workspaces here and why |
36
+ | `audit(*, since, url, limit)` | Every publish, done or refused, newest first |
37
+
38
+ A refusal raises `GitdError`, with `reason` (`AUTH_REQUIRED`, `NOT_FOUND`, `PATH_REFUSED`,
39
+ `BRANCH_REFUSED`, `BUSY`, …) and `said`, what git said. Decide from the reason, never from
40
+ the message.
41
+
42
+ [`examples/agent.py`](examples/agent.py) is an agent's whole round trip.
43
+
44
+ ## Layout
45
+
46
+ | Path | What it is |
47
+ |---|---|
48
+ | `src/gitd/client.py` | `Gitd`, the client |
49
+ | `src/gitd/models.py` | What gitd answers, as dataclasses |
50
+ | `src/gitd/errors.py` | `GitdError` |
51
+ | `src/gitd/timestamps.py` | Between the contract's timestamps and `datetime` |
52
+ | `src/gitd/connection.py` | The channel to the socket, and how calls are made |
53
+ | `src/gitd/methods.py` | The gitd methods called, by their gRPC path |
54
+ | `src/gitd/messages.py` | The messages of the contract the client uses |
55
+ | `src/gitd/contract.py` | Reads the contract at run time, with protobuf's public API |
56
+ | `src/gitd/contract.json` | The contract, built from [`proto/gitd/v1/gitd.proto`](../../proto/gitd/v1/gitd.proto) by `make generate` |
57
+
58
+ No code is generated: the client reads gitd's contract when it starts.
59
+
60
+ Python 3.11 or later, on Linux. Apache-2.0.
@@ -0,0 +1,81 @@
1
+ """An agent's round trip with gitd:
2
+
3
+ 1. gitd prepares a workspace of the repository, in milliseconds after the first time;
4
+ 2. the agent works in it with plain git, and commits;
5
+ 3. gitd publishes the commit as a new branch, with the token it keeps.
6
+
7
+ The agent never holds a credential.
8
+
9
+ python examples/agent.py https://github.com/your-org/your-repo /var/lib/gitd/workspaces/agent-1
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ from pathlib import Path
18
+
19
+ from gitd import Gitd, GitdError, Progress, Workspace
20
+
21
+ AUTHOR = ["-c", "user.name=agent", "-c", "user.email=agent@example.com"]
22
+ BRANCH_PREFIX = "agents/example-"
23
+ NOTE_FILE = "AGENT.md"
24
+
25
+
26
+ class Agent:
27
+ """Works on one repository in its own workspace, and publishes what it commits."""
28
+
29
+ def __init__(self, gitd: Gitd, url: str, path: str) -> None:
30
+ self.gitd = gitd
31
+ self.url = url
32
+ self.path = path
33
+
34
+ def run(self) -> None:
35
+ workspace = self.prepare()
36
+ try:
37
+ self.commit_a_change(workspace)
38
+ self.publish(workspace)
39
+ finally:
40
+ self.gitd.remove(workspace.path)
41
+
42
+ def prepare(self) -> Workspace:
43
+ workspace = self.gitd.prepare(self.url, self.path, on_progress=self.show_progress)
44
+ print(file=sys.stderr)
45
+ print(f"Prepared {workspace.path} at {workspace.commit[:12]}, by {workspace.strategy.value}.")
46
+ return workspace
47
+
48
+ def commit_a_change(self, workspace: Workspace) -> None:
49
+ Path(workspace.path, NOTE_FILE).write_text("Written by an agent.\n")
50
+ self.git(workspace, "add", NOTE_FILE)
51
+ self.git(workspace, "commit", "--quiet", "--message", "An agent was here")
52
+ print(f"Committed {NOTE_FILE}.")
53
+
54
+ def publish(self, workspace: Workspace) -> None:
55
+ published = self.gitd.publish(workspace.path, f"{BRANCH_PREFIX}{int(time.time())}")
56
+ repository = published.repositories[0]
57
+ print(f"Published {published.branch} to {repository.url}: {', '.join(repository.subjects)}.")
58
+
59
+ def git(self, workspace: Workspace, *arguments: str) -> None:
60
+ subprocess.run(["git", *AUTHOR, *arguments], cwd=workspace.path, check=True)
61
+
62
+ def show_progress(self, progress: Progress) -> None:
63
+ print(f"\r{progress.phase} {progress.percent}%", end="", file=sys.stderr)
64
+
65
+
66
+ def main(arguments: list[str]) -> int:
67
+ if len(arguments) != 2:
68
+ print("usage: python examples/agent.py <repository url> <workspace path>", file=sys.stderr)
69
+ return 2
70
+ url, path = arguments
71
+ with Gitd() as gitd:
72
+ try:
73
+ Agent(gitd, url, path).run()
74
+ except GitdError as error:
75
+ print(f"gitd refused: {error.reason}: {error.said}", file=sys.stderr)
76
+ return 1
77
+ return 0
78
+
79
+
80
+ if __name__ == "__main__":
81
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.32.4"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "gitd"
7
+ version = "0.7.0"
8
+ description = "Client for gitd: instant, secretless git checkouts for agent sandboxes"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.11"
12
+ keywords = ["git", "ai-agents", "sandbox", "workspace", "grpc"]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Operating System :: POSIX :: Linux",
16
+ "Topic :: Software Development :: Version Control :: Git",
17
+ ]
18
+ dependencies = [
19
+ "grpcio>=1.84.0",
20
+ "grpcio-status>=1.84.0",
21
+ "protobuf>=7.36.2",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/dooservice/gitd"
26
+ Source = "https://github.com/dooservice/gitd/tree/main/sdk/python"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/gitd"]
30
+
31
+ [tool.ruff]
32
+ line-length = 110
33
+ [tool.ruff.lint]
34
+ select = ["E", "F", "I", "B", "UP", "N", "SIM"]
@@ -0,0 +1,42 @@
1
+ """Client for gitd: instant, secretless git checkouts for agent sandboxes."""
2
+
3
+ from .client import Gitd
4
+ from .connection import DEFAULT_SOCKET, SOCKET_VARIABLE
5
+ from .errors import GitdError
6
+ from .models import (
7
+ Access,
8
+ AuditEntry,
9
+ Description,
10
+ Info,
11
+ Layers,
12
+ PendingSubmodule,
13
+ Probe,
14
+ Progress,
15
+ Published,
16
+ PublishedRepository,
17
+ Refusal,
18
+ Strategy,
19
+ Submodule,
20
+ Workspace,
21
+ )
22
+
23
+ __all__ = [
24
+ "DEFAULT_SOCKET",
25
+ "SOCKET_VARIABLE",
26
+ "Access",
27
+ "AuditEntry",
28
+ "Description",
29
+ "Gitd",
30
+ "GitdError",
31
+ "Info",
32
+ "Layers",
33
+ "PendingSubmodule",
34
+ "Probe",
35
+ "Progress",
36
+ "Published",
37
+ "PublishedRepository",
38
+ "Refusal",
39
+ "Strategy",
40
+ "Submodule",
41
+ "Workspace",
42
+ ]
@@ -0,0 +1,124 @@
1
+ """The gitd client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Any
7
+
8
+ from . import messages, methods
9
+ from .connection import Connection, OnProgress
10
+ from .models import AuditEntry, Description, Info, Probe, Published, Strategy, Workspace
11
+ from .timestamps import Timestamp
12
+
13
+
14
+ class Gitd:
15
+ """A gitd on its Unix socket. No call takes a credential: gitd reads and publishes with
16
+ the ones it keeps.
17
+
18
+ with Gitd() as gitd:
19
+ workspace = gitd.prepare("https://github.com/acme/web", "/var/lib/gitd/workspaces/agent-1")
20
+ """
21
+
22
+ def __init__(self, socket: str | None = None, *, timeout: float | None = None) -> None:
23
+ self.connection = Connection(socket, timeout)
24
+
25
+ def __enter__(self) -> Gitd:
26
+ return self
27
+
28
+ def __exit__(self, *exception: object) -> None:
29
+ self.close()
30
+
31
+ def close(self) -> None:
32
+ self.connection.close()
33
+
34
+ def info(self) -> Info:
35
+ """Which gitd answers, and how it makes workspaces here and why."""
36
+ return Info.from_message(self.connection.call(methods.INFO, messages.InfoRequest()))
37
+
38
+ def probe(self, url: str) -> Probe:
39
+ """Whether a repository can be read with the credentials gitd keeps, and its branches."""
40
+ return Probe.from_message(self.connection.call(methods.PROBE, messages.ProbeRequest(url=url)))
41
+
42
+ def prepare(
43
+ self,
44
+ url: str,
45
+ path: str,
46
+ *,
47
+ branch: str | None = None,
48
+ commit: str | None = None,
49
+ submodules: bool = False,
50
+ full_history: bool = False,
51
+ strategy: Strategy | None = None,
52
+ on_progress: OnProgress | None = None,
53
+ ) -> Workspace:
54
+ """Makes a workspace of a repository at a path under gitd's roots, at a commit, a
55
+ branch, or the default branch. strategy is a preference: gitd falls back to copy when
56
+ it cannot make one that way here."""
57
+ if commit:
58
+ revision = messages.Revision(commit=commit)
59
+ elif branch:
60
+ revision = messages.Revision(branch=branch)
61
+ else:
62
+ revision = None
63
+ request = messages.PrepareRequest(
64
+ url=url,
65
+ path=path,
66
+ revision=revision,
67
+ with_submodules=submodules,
68
+ full_history=full_history,
69
+ strategy=Strategy.preference(strategy),
70
+ )
71
+ return Workspace.from_message(path, self.connection.follow(methods.PREPARE, request, on_progress))
72
+
73
+ def update(
74
+ self,
75
+ path: str,
76
+ *,
77
+ branch: str | None = None,
78
+ submodules: bool = False,
79
+ on_progress: OnProgress | None = None,
80
+ ) -> Workspace:
81
+ """Brings a workspace to the tip of its branch, or of the branch given."""
82
+ request = messages.UpdateRequest(path=path, branch=branch or "", with_submodules=submodules)
83
+ return Workspace.from_message(path, self.connection.follow(methods.UPDATE, request, on_progress))
84
+
85
+ def checkout(
86
+ self,
87
+ path: str,
88
+ commit: str,
89
+ *,
90
+ submodules: bool = False,
91
+ on_progress: OnProgress | None = None,
92
+ ) -> Workspace:
93
+ """Moves a workspace to a commit, as a rollback does."""
94
+ request = messages.CheckoutRequest(path=path, commit=commit, with_submodules=submodules)
95
+ return Workspace.from_message(path, self.connection.follow(methods.CHECKOUT, request, on_progress))
96
+
97
+ def describe(self, path: str) -> Description:
98
+ """What a workspace is on disk, without the network."""
99
+ response = self.connection.call(methods.DESCRIBE, messages.DescribeRequest(path=path))
100
+ return Description.from_message(response)
101
+
102
+ def publish(self, path: str, branch: str) -> Published:
103
+ """Pushes what was committed in a workspace to a new branch, with the token gitd keeps."""
104
+ response = self.connection.call(methods.PUBLISH, messages.PublishRequest(path=path, branch=branch))
105
+ return Published.from_message(response)
106
+
107
+ def remove(self, path: str) -> None:
108
+ """Deletes a workspace; its pool stays."""
109
+ self.connection.call(methods.REMOVE, messages.RemoveRequest(path=path))
110
+
111
+ def audit(
112
+ self,
113
+ *,
114
+ since: datetime | None = None,
115
+ url: str | None = None,
116
+ limit: int | None = None,
117
+ ) -> list[AuditEntry]:
118
+ """The audit log: every publish, done or refused, newest first; from since on, of
119
+ the repository at url, at most limit of them (100 by default, never more than 1000)."""
120
+ request: Any = messages.AuditRequest(url=url or "", limit=limit or 0)
121
+ if since is not None:
122
+ Timestamp.fill(request.since, since)
123
+ response = self.connection.call(methods.AUDIT, request)
124
+ return [AuditEntry.from_message(entry) for entry in response.entries]
@@ -0,0 +1,71 @@
1
+ """The channel to gitd's Unix socket, and how a call on it is made and read."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ import grpc
10
+ from google.protobuf.message import Message
11
+
12
+ from .errors import GitdError
13
+ from .methods import Method
14
+ from .models import Progress
15
+
16
+ DEFAULT_SOCKET = "/run/gitd/gitd.sock"
17
+ SOCKET_VARIABLE = "GITD_SOCKET"
18
+
19
+ OnProgress = Callable[[Progress], None]
20
+
21
+
22
+ class Connection:
23
+ """A channel to gitd. The socket defaults to $GITD_SOCKET, then /run/gitd/gitd.sock; the
24
+ timeout, in seconds, bounds every call, which gitd otherwise bounds itself."""
25
+
26
+ def __init__(self, socket: str | None, timeout: float | None) -> None:
27
+ self.socket = socket or os.environ.get(SOCKET_VARIABLE) or DEFAULT_SOCKET
28
+ self.timeout = timeout
29
+ self.channel = grpc.insecure_channel(f"unix:{self.socket}")
30
+
31
+ def close(self) -> None:
32
+ self.channel.close()
33
+
34
+ @staticmethod
35
+ def serialize(message: Message) -> bytes:
36
+ return message.SerializeToString()
37
+
38
+ def call(self, method: Method, request: Message) -> Any:
39
+ """Makes a call that answers once."""
40
+ invoke: grpc.UnaryUnaryMultiCallable[Message, Any] = self.channel.unary_unary(
41
+ method.path,
42
+ request_serializer=self.serialize,
43
+ response_deserializer=method.response.FromString,
44
+ )
45
+ try:
46
+ return invoke(request, timeout=self.timeout)
47
+ except grpc.RpcError as error:
48
+ raise GitdError.from_rpc_error(error) from None
49
+
50
+ def follow(
51
+ self,
52
+ method: Method,
53
+ request: Message,
54
+ on_progress: OnProgress | None,
55
+ ) -> Any:
56
+ """Makes a call that streams its progress, and reads it to its result."""
57
+ invoke: grpc.UnaryStreamMultiCallable[Message, Any] = self.channel.unary_stream(
58
+ method.path,
59
+ request_serializer=self.serialize,
60
+ response_deserializer=method.response.FromString,
61
+ )
62
+ try:
63
+ for response in invoke(request, timeout=self.timeout):
64
+ event = response.WhichOneof("event")
65
+ if event == "progress" and on_progress is not None:
66
+ on_progress(Progress.from_message(response.progress))
67
+ elif event == "result":
68
+ return response.result
69
+ except grpc.RpcError as error:
70
+ raise GitdError.from_rpc_error(error) from None
71
+ raise GitdError("", "gitd ended the call without a result")