agent-session-relay 0.1.0__py3-none-any.whl
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.
- agent_session_relay/__init__.py +3 -0
- agent_session_relay/__main__.py +3 -0
- agent_session_relay/cli.py +211 -0
- agent_session_relay/core/__init__.py +1 -0
- agent_session_relay/core/errors.py +2 -0
- agent_session_relay/core/git.py +345 -0
- agent_session_relay/core/session.py +374 -0
- agent_session_relay/core/storage.py +205 -0
- agent_session_relay/integrations/__init__.py +1 -0
- agent_session_relay/integrations/kiro/__init__.py +1 -0
- agent_session_relay/integrations/kiro/adapter.py +106 -0
- agent_session_relay/integrations/kiro/guard.py +304 -0
- agent_session_relay/integrations/protocol.py +26 -0
- agent_session_relay-0.1.0.dist-info/METADATA +394 -0
- agent_session_relay-0.1.0.dist-info/RECORD +19 -0
- agent_session_relay-0.1.0.dist-info/WHEEL +5 -0
- agent_session_relay-0.1.0.dist-info/entry_points.txt +2 -0
- agent_session_relay-0.1.0.dist-info/licenses/LICENSE +201 -0
- agent_session_relay-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
from .core.errors import RelayError
|
|
9
|
+
from .core.git import Git
|
|
10
|
+
from .core.session import Relay
|
|
11
|
+
from .integrations.kiro.adapter import install, run_hook
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parser() -> argparse.ArgumentParser:
|
|
15
|
+
root = argparse.ArgumentParser(
|
|
16
|
+
prog="relay", description="Agent-Session-Relay: Git staging is human review."
|
|
17
|
+
)
|
|
18
|
+
root.add_argument("--version", action="version", version=f"Agent-Session-Relay {__version__}")
|
|
19
|
+
commands = root.add_subparsers(dest="command", required=True)
|
|
20
|
+
commands.add_parser("start", help="start from a clean Git workspace")
|
|
21
|
+
status = commands.add_parser("status", help="show session and review state")
|
|
22
|
+
status.add_argument("--json", action="store_true", help="output structured state")
|
|
23
|
+
commands.add_parser("suspend", help="save the full review state and return to normal Git")
|
|
24
|
+
resume = commands.add_parser("resume", help="restore a suspended session")
|
|
25
|
+
resume.add_argument("session", nargs="?", help="session ID or unambiguous prefix")
|
|
26
|
+
finish = commands.add_parser(
|
|
27
|
+
"finish", help="create one public result commit from reviewed code"
|
|
28
|
+
)
|
|
29
|
+
finish.add_argument("-m", "--message", help="result commit message")
|
|
30
|
+
commands.add_parser(
|
|
31
|
+
"abort", help="terminate after TWO confirmations, preserving a recovery branch"
|
|
32
|
+
)
|
|
33
|
+
listing = commands.add_parser(
|
|
34
|
+
"list", help="list active and suspended sessions in this worktree"
|
|
35
|
+
)
|
|
36
|
+
listing.add_argument("--json", action="store_true")
|
|
37
|
+
commands.add_parser(
|
|
38
|
+
"recover", help="recover an interrupted Relay operation, preserving current code"
|
|
39
|
+
)
|
|
40
|
+
agent = commands.add_parser("agent", help="agent-facing semantic inspection")
|
|
41
|
+
agent_commands = agent.add_subparsers(dest="agent_command", required=True)
|
|
42
|
+
agent_commands.add_parser("status", help="output agent-readable session state as JSON")
|
|
43
|
+
diff = agent_commands.add_parser("diff", help="inspect review/provenance patches")
|
|
44
|
+
diff.add_argument("kind", choices=("reviewed", "human", "pending"))
|
|
45
|
+
diff.add_argument("--name-only", action="store_true", help="only output involved file names")
|
|
46
|
+
diff.add_argument("-z", "--null", action="store_true", help="NUL delimit --name-only output")
|
|
47
|
+
kiro = commands.add_parser("kiro", help="Kiro integration")
|
|
48
|
+
kiro_commands = kiro.add_subparsers(dest="kiro_command", required=True)
|
|
49
|
+
installer = kiro_commands.add_parser("install", help="install standalone Kiro lifecycle hooks")
|
|
50
|
+
scope = installer.add_mutually_exclusive_group(required=True)
|
|
51
|
+
scope.add_argument("--global", dest="global_scope", action="store_true")
|
|
52
|
+
scope.add_argument("--project", action="store_true")
|
|
53
|
+
installer.add_argument(
|
|
54
|
+
"--force", action="store_true", help="replace customized Relay hook configuration"
|
|
55
|
+
)
|
|
56
|
+
hook = kiro_commands.add_parser("hook", help="adapter entry point called by Kiro")
|
|
57
|
+
hook.add_argument("event", choices=("prompt-submit", "agent-stop", "pre-tool-use"))
|
|
58
|
+
return root
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def describe_origin(origin: dict) -> str:
|
|
62
|
+
return (
|
|
63
|
+
origin["ref"].removeprefix("refs/heads/")
|
|
64
|
+
if origin["ref"]
|
|
65
|
+
else "detached " + origin["commit"][:12]
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def confirm_abort(session_id: str) -> bool:
|
|
70
|
+
print(
|
|
71
|
+
"WARNING 1/2: Aborting ends this Relay session permanently; it cannot be resumed.\n"
|
|
72
|
+
"Relay will delete its intermediate checkpoints, snapshots, and review/provenance state.\n"
|
|
73
|
+
"Your current code will be preserved first in a recovery branch, including staged,\n"
|
|
74
|
+
"unstaged, and non-ignored untracked project files."
|
|
75
|
+
)
|
|
76
|
+
try:
|
|
77
|
+
if input("Type 'abort' to acknowledge this warning: ").strip() != "abort":
|
|
78
|
+
return False
|
|
79
|
+
print(
|
|
80
|
+
"\nWARNING 2/2: This removes the session's ability to resume and its intermediate\n"
|
|
81
|
+
"review history. Current workspace code will be saved in a single recovery commit\n"
|
|
82
|
+
f"at relay/aborted/{session_id} before cleanup; the staging distinction will be lost."
|
|
83
|
+
)
|
|
84
|
+
return (
|
|
85
|
+
input("Type 'preserve and abort' to confirm the cleanup: ").strip()
|
|
86
|
+
== "preserve and abort"
|
|
87
|
+
)
|
|
88
|
+
except EOFError:
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def execute(args, paths: list[str]) -> int:
|
|
93
|
+
if args.command == "kiro":
|
|
94
|
+
if args.kiro_command == "hook":
|
|
95
|
+
return run_hook(args.event)
|
|
96
|
+
path = install(global_scope=args.global_scope, force=args.force)
|
|
97
|
+
print(
|
|
98
|
+
f"Kiro integration installed at {path}\n"
|
|
99
|
+
"Ensure `relay` is on Kiro's PATH and open a new Kiro session.\n"
|
|
100
|
+
"Without an active Relay session, these hooks are silent and inert."
|
|
101
|
+
)
|
|
102
|
+
if args.project:
|
|
103
|
+
print("Commit the project hook file before `relay start`, or install globally instead.")
|
|
104
|
+
return 0
|
|
105
|
+
relay = Relay(Git())
|
|
106
|
+
if args.command == "start":
|
|
107
|
+
session = relay.start()
|
|
108
|
+
print(
|
|
109
|
+
f"Relay session started: {session['id']}\nBase: {session['base_commit']}\n"
|
|
110
|
+
"Review with normal Git staging; send a prompt in Kiro to hand off to the agent."
|
|
111
|
+
)
|
|
112
|
+
elif args.command in ("status", "agent"):
|
|
113
|
+
if args.command == "agent" and args.agent_command == "diff":
|
|
114
|
+
if args.null and not args.name_only:
|
|
115
|
+
raise RelayError("--null requires --name-only.")
|
|
116
|
+
sys.stdout.buffer.write(
|
|
117
|
+
relay.diff(args.kind, paths, name_only=args.name_only, null=args.null)
|
|
118
|
+
)
|
|
119
|
+
else:
|
|
120
|
+
status = relay.status()
|
|
121
|
+
if args.command == "agent" or args.json:
|
|
122
|
+
print(json.dumps(status, indent=2))
|
|
123
|
+
elif not status["active"]:
|
|
124
|
+
print(f"Relay is {status['lifecycle']}.")
|
|
125
|
+
if status["suspended_sessions"]:
|
|
126
|
+
print("Suspended: " + ", ".join(status["suspended_sessions"]))
|
|
127
|
+
print("Use `relay resume` to continue.")
|
|
128
|
+
else:
|
|
129
|
+
print(
|
|
130
|
+
f"Session: {status['session']} "
|
|
131
|
+
f"({status['lifecycle']}, {status['phase']} turn)\n"
|
|
132
|
+
f"Base: {status['base_commit']}\nReviewed: {status['reviewed_checkpoint']}\n"
|
|
133
|
+
f"Staged approvals: {'yes' if status['staged_approvals'] else 'none'}\n"
|
|
134
|
+
"Unstaged / untracked proposals: "
|
|
135
|
+
f"{'yes' if status['unstaged_changes'] else 'none'}\n"
|
|
136
|
+
"Turn provenance: "
|
|
137
|
+
f"{'available' if status['provenance']['available'] else 'none yet'}"
|
|
138
|
+
)
|
|
139
|
+
elif args.command == "list":
|
|
140
|
+
relay.store.assert_ready()
|
|
141
|
+
sessions = list(relay.store.load()["sessions"].values())
|
|
142
|
+
if args.json:
|
|
143
|
+
print(json.dumps(sessions, indent=2))
|
|
144
|
+
elif not sessions:
|
|
145
|
+
print("No Relay sessions in this worktree.")
|
|
146
|
+
else:
|
|
147
|
+
for session in sorted(sessions, key=lambda s: s["id"]):
|
|
148
|
+
print(
|
|
149
|
+
f"{session['id']} {session['state']} base={session['base_commit'][:12]} "
|
|
150
|
+
f"turns={session['turn']}"
|
|
151
|
+
)
|
|
152
|
+
elif args.command == "suspend":
|
|
153
|
+
session, origin = relay.suspend()
|
|
154
|
+
print(
|
|
155
|
+
f"Relay session suspended: {session['id']}\nReturned to {describe_origin(origin)}.\n"
|
|
156
|
+
"Review state is saved. Hooks are now inert. Use `relay resume` to continue."
|
|
157
|
+
)
|
|
158
|
+
elif args.command == "resume":
|
|
159
|
+
session = relay.resume(args.session)
|
|
160
|
+
print(f"Relay session resumed: {session['id']}\nStaged and unstaged review state restored.")
|
|
161
|
+
elif args.command == "finish":
|
|
162
|
+
branch = relay.finish(args.message)
|
|
163
|
+
print(
|
|
164
|
+
f"Relay session finished. Switched to {branch}.\n"
|
|
165
|
+
"One result commit; its only parent is the immutable session base.\n"
|
|
166
|
+
"Use normal Git to rebase, merge, or cherry-pick the result when ready."
|
|
167
|
+
)
|
|
168
|
+
elif args.command == "abort":
|
|
169
|
+
relay.store.assert_ready()
|
|
170
|
+
session = relay.active(relay.store.load())
|
|
171
|
+
if not confirm_abort(session["id"]):
|
|
172
|
+
print("Abort cancelled. Relay session and code were not changed.")
|
|
173
|
+
return 1
|
|
174
|
+
branch, origin = relay.abort(session["id"])
|
|
175
|
+
print(
|
|
176
|
+
"Relay session aborted.\n\nYour workspace at the time of abort was preserved at:\n\n"
|
|
177
|
+
f" {branch}\n\nRelay's intermediate review/provenance state has been removed.\n"
|
|
178
|
+
f"Returned to {describe_origin(origin)}.\n\n"
|
|
179
|
+
"If you are certain you no longer need the recovery snapshot, delete it\n"
|
|
180
|
+
f"later with normal Git, for example:\n\n git branch -D {branch}"
|
|
181
|
+
)
|
|
182
|
+
elif args.command == "recover":
|
|
183
|
+
with relay.store.lock():
|
|
184
|
+
branch = relay.store.recover()
|
|
185
|
+
print(
|
|
186
|
+
f"Previous Relay state restored. Code from before recovery was preserved at {branch}."
|
|
187
|
+
)
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def main(argv: list[str] | None = None) -> int:
|
|
192
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
193
|
+
paths = []
|
|
194
|
+
# Everything after -- is a literal path, even a name such as --name-only.
|
|
195
|
+
if "--" in argv and argv[:2] == ["agent", "diff"]:
|
|
196
|
+
boundary = argv.index("--")
|
|
197
|
+
paths, argv = argv[boundary + 1 :], argv[:boundary]
|
|
198
|
+
args = parser().parse_args(argv)
|
|
199
|
+
try:
|
|
200
|
+
return execute(args, paths)
|
|
201
|
+
except BrokenPipeError:
|
|
202
|
+
return 0
|
|
203
|
+
except (RelayError, OSError) as exc:
|
|
204
|
+
print(f"relay: {exc}", file=sys.stderr)
|
|
205
|
+
return 2 if args.command == "kiro" and args.kiro_command == "hook" else 1
|
|
206
|
+
except KeyboardInterrupt:
|
|
207
|
+
print(
|
|
208
|
+
"\nrelay: interrupted; any incomplete transition can be restored with `relay recover`.",
|
|
209
|
+
file=sys.stderr,
|
|
210
|
+
)
|
|
211
|
+
return 130
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Harness-independent Git, session, and provenance implementation."""
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Git plumbing. Never invokes a shell or edits a user's branch in place."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .errors import RelayError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Git:
|
|
16
|
+
def __init__(self, cwd: Path | str = "."):
|
|
17
|
+
self.cwd = Path(cwd).absolute()
|
|
18
|
+
self.root = self.cwd
|
|
19
|
+
try:
|
|
20
|
+
self.root = Path(self.text("rev-parse", "--show-toplevel"))
|
|
21
|
+
self.git_dir = Path(self.text("rev-parse", "--absolute-git-dir"))
|
|
22
|
+
except RelayError as exc:
|
|
23
|
+
raise RelayError("Run Relay inside a non-bare Git working tree.") from exc
|
|
24
|
+
|
|
25
|
+
def run(
|
|
26
|
+
self, *args: str, data: bytes | None = None, env: dict | None = None, check: bool = True
|
|
27
|
+
) -> subprocess.CompletedProcess:
|
|
28
|
+
process_env = os.environ.copy()
|
|
29
|
+
# A hook's inherited routing must not redirect operations into another index/repository.
|
|
30
|
+
for key in (
|
|
31
|
+
"GIT_DIR",
|
|
32
|
+
"GIT_WORK_TREE",
|
|
33
|
+
"GIT_INDEX_FILE",
|
|
34
|
+
"GIT_COMMON_DIR",
|
|
35
|
+
"GIT_NAMESPACE",
|
|
36
|
+
"GIT_PREFIX",
|
|
37
|
+
"GIT_EXTERNAL_DIFF",
|
|
38
|
+
"GIT_DIFF_OPTS",
|
|
39
|
+
"GIT_LITERAL_PATHSPECS",
|
|
40
|
+
"GIT_GLOB_PATHSPECS",
|
|
41
|
+
"GIT_NOGLOB_PATHSPECS",
|
|
42
|
+
"GIT_ICASE_PATHSPECS",
|
|
43
|
+
):
|
|
44
|
+
process_env.pop(key, None)
|
|
45
|
+
process_env.update(
|
|
46
|
+
{
|
|
47
|
+
"GIT_OPTIONAL_LOCKS": "0",
|
|
48
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
49
|
+
"GIT_PAGER": "cat",
|
|
50
|
+
"LC_ALL": "C",
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
if env:
|
|
54
|
+
process_env.update(env)
|
|
55
|
+
try:
|
|
56
|
+
result = subprocess.run(
|
|
57
|
+
[
|
|
58
|
+
"git",
|
|
59
|
+
"--no-pager",
|
|
60
|
+
"-c",
|
|
61
|
+
"core.hooksPath=" + os.devnull,
|
|
62
|
+
"-c",
|
|
63
|
+
"gc.auto=0",
|
|
64
|
+
"-c",
|
|
65
|
+
"maintenance.auto=false",
|
|
66
|
+
*args,
|
|
67
|
+
],
|
|
68
|
+
cwd=self.root,
|
|
69
|
+
input=data,
|
|
70
|
+
capture_output=True,
|
|
71
|
+
env=process_env,
|
|
72
|
+
check=False,
|
|
73
|
+
)
|
|
74
|
+
except FileNotFoundError as exc:
|
|
75
|
+
raise RelayError("Git is required and must be on PATH.") from exc
|
|
76
|
+
if check and result.returncode:
|
|
77
|
+
detail = result.stderr.decode("utf-8", "replace").strip()
|
|
78
|
+
raise RelayError(f"Git {args[0]} failed: {detail}")
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
def text(self, *args: str, **kwargs) -> str:
|
|
82
|
+
return os.fsdecode(self.run(*args, **kwargs).stdout).removesuffix("\n")
|
|
83
|
+
|
|
84
|
+
def resolve(self, ref: str) -> str:
|
|
85
|
+
return self.text("rev-parse", "--verify", ref)
|
|
86
|
+
|
|
87
|
+
def head(self) -> dict:
|
|
88
|
+
return {
|
|
89
|
+
"commit": self.resolve("HEAD^{commit}"),
|
|
90
|
+
"ref": self.text("symbolic-ref", "-q", "HEAD", check=False) or None,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
def set_head(self, target: dict) -> None:
|
|
94
|
+
if target["ref"]:
|
|
95
|
+
self.run("symbolic-ref", "HEAD", target["ref"])
|
|
96
|
+
else:
|
|
97
|
+
self.run("update-ref", "--no-deref", "HEAD", target["commit"])
|
|
98
|
+
|
|
99
|
+
def index_path(self) -> Path:
|
|
100
|
+
path = Path(self.text("rev-parse", "--git-path", "index"))
|
|
101
|
+
return path if path.is_absolute() else self.root / path
|
|
102
|
+
|
|
103
|
+
def index_bytes(self) -> bytes:
|
|
104
|
+
path = self.index_path()
|
|
105
|
+
return path.read_bytes() if path.exists() else b""
|
|
106
|
+
|
|
107
|
+
def restore_index(self, contents: bytes) -> None:
|
|
108
|
+
path = self.index_path()
|
|
109
|
+
lock = path.with_name(path.name + ".lock")
|
|
110
|
+
try:
|
|
111
|
+
with lock.open("xb") as stream:
|
|
112
|
+
stream.write(contents)
|
|
113
|
+
stream.flush()
|
|
114
|
+
os.fsync(stream.fileno())
|
|
115
|
+
if contents:
|
|
116
|
+
os.replace(lock, path)
|
|
117
|
+
else:
|
|
118
|
+
path.unlink(missing_ok=True)
|
|
119
|
+
lock.unlink()
|
|
120
|
+
except FileExistsError as exc:
|
|
121
|
+
raise RelayError(
|
|
122
|
+
"Git index is locked by another process; retry after it exits."
|
|
123
|
+
) from exc
|
|
124
|
+
|
|
125
|
+
def assert_stable(self) -> None:
|
|
126
|
+
markers = (
|
|
127
|
+
"MERGE_HEAD",
|
|
128
|
+
"CHERRY_PICK_HEAD",
|
|
129
|
+
"REVERT_HEAD",
|
|
130
|
+
"rebase-merge",
|
|
131
|
+
"rebase-apply",
|
|
132
|
+
"sequencer",
|
|
133
|
+
"BISECT_START",
|
|
134
|
+
"index.lock",
|
|
135
|
+
"HEAD.lock",
|
|
136
|
+
)
|
|
137
|
+
for marker in markers:
|
|
138
|
+
path = Path(self.text("rev-parse", "--git-path", marker))
|
|
139
|
+
if not path.is_absolute():
|
|
140
|
+
path = self.root / path
|
|
141
|
+
if path.exists():
|
|
142
|
+
raise RelayError(f"Git operation or lock in progress ({marker}); finish it first.")
|
|
143
|
+
if self.run("ls-files", "--unmerged", "-z").stdout:
|
|
144
|
+
raise RelayError("Resolve the unmerged Git index before using Relay.")
|
|
145
|
+
if self.text("config", "--bool", "core.sparseCheckout", check=False) == "true":
|
|
146
|
+
raise RelayError("Sparse checkouts are not supported; use a full working tree.")
|
|
147
|
+
for entry in self.run("ls-files", "-v", "-z").stdout.split(b"\0"):
|
|
148
|
+
if entry and (entry[:1].islower() or entry[:1] == b"S"):
|
|
149
|
+
raise RelayError(
|
|
150
|
+
"Clear assume-unchanged/skip-worktree index flags before using Relay."
|
|
151
|
+
)
|
|
152
|
+
self.assert_no_gitlinks(self.index_tree())
|
|
153
|
+
|
|
154
|
+
def assert_clean(self) -> None:
|
|
155
|
+
self.assert_stable()
|
|
156
|
+
if self.run(
|
|
157
|
+
"status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=none"
|
|
158
|
+
).stdout:
|
|
159
|
+
raise RelayError(
|
|
160
|
+
"A clean workspace is required: commit, stash, or discard staged, "
|
|
161
|
+
"unstaged, and non-ignored untracked changes first."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def assert_no_gitlinks(self, tree: str) -> None:
|
|
165
|
+
if any(
|
|
166
|
+
entry.startswith(b"160000 ")
|
|
167
|
+
for entry in self.run("ls-tree", "-r", "-z", tree).stdout.split(b"\0")
|
|
168
|
+
):
|
|
169
|
+
raise RelayError(
|
|
170
|
+
"Submodules and embedded repositories cannot be fully snapshotted; "
|
|
171
|
+
"use a working tree without them."
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
@contextmanager
|
|
175
|
+
def temporary_index(self):
|
|
176
|
+
with tempfile.TemporaryDirectory(prefix="relay-index-") as directory:
|
|
177
|
+
path = Path(directory) / "index"
|
|
178
|
+
contents = self.index_bytes()
|
|
179
|
+
if contents:
|
|
180
|
+
path.write_bytes(contents)
|
|
181
|
+
env = {"GIT_INDEX_FILE": str(path)}
|
|
182
|
+
# Expand split indexes so saved index blobs don't depend on sharedindex.* lifetimes.
|
|
183
|
+
self.run("update-index", "--no-split-index", env=env)
|
|
184
|
+
yield path, env
|
|
185
|
+
|
|
186
|
+
def index_tree(self) -> str:
|
|
187
|
+
# write-tree may update the cache-tree extension. Keep even inspection off the real index.
|
|
188
|
+
with self.temporary_index() as (_, env):
|
|
189
|
+
return self.text("write-tree", env=env)
|
|
190
|
+
|
|
191
|
+
def normalized_index(self) -> bytes:
|
|
192
|
+
with self.temporary_index() as (path, _):
|
|
193
|
+
return path.read_bytes()
|
|
194
|
+
|
|
195
|
+
def workspace_tree(self, baseline: str | None = None) -> str:
|
|
196
|
+
"""Snapshot tracked + non-ignored untracked files without changing the staging UI."""
|
|
197
|
+
baseline = baseline or self.resolve("HEAD")
|
|
198
|
+
with self.temporary_index() as (_, env):
|
|
199
|
+
indexed = {
|
|
200
|
+
entry.split(b"\t", 1)[1]
|
|
201
|
+
for entry in self.run("ls-files", "--stage", "-z", env=env).stdout.split(b"\0")
|
|
202
|
+
if entry
|
|
203
|
+
}
|
|
204
|
+
# A staged deletion can leave a now-ignored file on disk. It is still session code.
|
|
205
|
+
for entry in self.run("ls-tree", "-r", "-z", baseline).stdout.split(b"\0"):
|
|
206
|
+
if not entry:
|
|
207
|
+
continue
|
|
208
|
+
meta, name = entry.split(b"\t", 1)
|
|
209
|
+
mode, kind, oid = meta.split()
|
|
210
|
+
if name in indexed or kind != b"blob":
|
|
211
|
+
continue
|
|
212
|
+
path = self.root / os.fsdecode(name)
|
|
213
|
+
try:
|
|
214
|
+
file_mode = path.lstat().st_mode
|
|
215
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
216
|
+
continue
|
|
217
|
+
if stat.S_ISREG(file_mode) or stat.S_ISLNK(file_mode):
|
|
218
|
+
self.run(
|
|
219
|
+
"update-index",
|
|
220
|
+
"--add",
|
|
221
|
+
"--replace",
|
|
222
|
+
"--cacheinfo",
|
|
223
|
+
os.fsdecode(mode),
|
|
224
|
+
os.fsdecode(oid),
|
|
225
|
+
os.fsdecode(name),
|
|
226
|
+
env=env,
|
|
227
|
+
)
|
|
228
|
+
self.run("add", "--all", "--", ".", env=env)
|
|
229
|
+
tree = self.text("write-tree", env=env)
|
|
230
|
+
self.assert_no_gitlinks(tree)
|
|
231
|
+
return tree
|
|
232
|
+
|
|
233
|
+
def tree(self, commit: str) -> str:
|
|
234
|
+
return self.resolve(commit + "^{tree}")
|
|
235
|
+
|
|
236
|
+
def commit(self, tree: str, parent: str, message: str, *, internal: bool = True) -> str:
|
|
237
|
+
env = None
|
|
238
|
+
if internal:
|
|
239
|
+
env = {
|
|
240
|
+
"GIT_AUTHOR_NAME": "Agent-Session-Relay",
|
|
241
|
+
"GIT_AUTHOR_EMAIL": "relay@localhost",
|
|
242
|
+
"GIT_COMMITTER_NAME": "Agent-Session-Relay",
|
|
243
|
+
"GIT_COMMITTER_EMAIL": "relay@localhost",
|
|
244
|
+
}
|
|
245
|
+
return self.text(
|
|
246
|
+
"commit-tree", tree, "-p", parent, data=(message.rstrip() + "\n").encode(), env=env
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def pin(self, ref: str, oid: str, *, create: bool = False) -> None:
|
|
250
|
+
args = (ref, oid, "") if create else (ref, oid)
|
|
251
|
+
self.run("update-ref", *args)
|
|
252
|
+
|
|
253
|
+
def refs(self, prefix: str) -> dict:
|
|
254
|
+
lines = self.text("for-each-ref", "--format=%(refname) %(objectname)", prefix)
|
|
255
|
+
return dict(line.split(" ", 1) for line in lines.splitlines())
|
|
256
|
+
|
|
257
|
+
def delete_refs(self, refs: dict) -> None:
|
|
258
|
+
if refs:
|
|
259
|
+
data = "".join(f"delete {ref} {oid}\n" for ref, oid in refs.items()).encode()
|
|
260
|
+
self.run("update-ref", "--stdin", data=data)
|
|
261
|
+
|
|
262
|
+
def paths(self, tree: str) -> set[bytes]:
|
|
263
|
+
return set(self.run("ls-tree", "-r", "--name-only", "-z", tree).stdout.split(b"\0")) - {b""}
|
|
264
|
+
|
|
265
|
+
def materialize(self, source: str, target: str) -> None:
|
|
266
|
+
"""Replace captured project files, preserving unrelated ignored files."""
|
|
267
|
+
target_paths = self.paths(target)
|
|
268
|
+
source_paths = self.paths(source)
|
|
269
|
+
target_parents = {
|
|
270
|
+
b"/".join(parts[:i])
|
|
271
|
+
for path in target_paths
|
|
272
|
+
for parts in [path.split(b"/")]
|
|
273
|
+
for i in range(1, len(parts))
|
|
274
|
+
}
|
|
275
|
+
ignored = self.run("ls-files", "--others", "--ignored", "--exclude-standard", "-z").stdout
|
|
276
|
+
for name in ignored.split(b"\0"):
|
|
277
|
+
name = name.rstrip(b"/")
|
|
278
|
+
if not name or name in source_paths:
|
|
279
|
+
continue
|
|
280
|
+
parts = name.split(b"/")
|
|
281
|
+
if (
|
|
282
|
+
name in target_paths
|
|
283
|
+
or name in target_parents
|
|
284
|
+
or any(b"/".join(parts[:i]) in target_paths for i in range(1, len(parts)))
|
|
285
|
+
):
|
|
286
|
+
raise RelayError(
|
|
287
|
+
f"An ignored file would be overwritten: {os.fsdecode(name)}. "
|
|
288
|
+
"Move it out of the way and retry."
|
|
289
|
+
)
|
|
290
|
+
saved_index = self.index_bytes()
|
|
291
|
+
try:
|
|
292
|
+
# Make captured new files tracked so read-tree removes them when leaving the session.
|
|
293
|
+
self.run("read-tree", source)
|
|
294
|
+
self.run("update-index", "--refresh")
|
|
295
|
+
self.run("read-tree", "-m", "-u", source, target)
|
|
296
|
+
except BaseException:
|
|
297
|
+
self.restore_index(saved_index)
|
|
298
|
+
raise
|
|
299
|
+
|
|
300
|
+
def available_origin(self, origin: dict) -> dict:
|
|
301
|
+
ref = origin["ref"]
|
|
302
|
+
if ref:
|
|
303
|
+
result = self.run("rev-parse", "--verify", ref + "^{commit}", check=False)
|
|
304
|
+
occupied = False
|
|
305
|
+
records = self.run("worktree", "list", "--porcelain", "-z").stdout.split(b"\0\0")
|
|
306
|
+
for record in records:
|
|
307
|
+
fields = record.split(b"\0")
|
|
308
|
+
if b"branch " + os.fsencode(ref) in fields:
|
|
309
|
+
location = next((f[9:] for f in fields if f.startswith(b"worktree ")), b"")
|
|
310
|
+
if Path(os.fsdecode(location)).resolve() != self.root.resolve():
|
|
311
|
+
occupied = True
|
|
312
|
+
if result.returncode == 0 and not occupied:
|
|
313
|
+
return {"ref": ref, "commit": os.fsdecode(result.stdout).strip()}
|
|
314
|
+
return {"ref": None, "commit": origin["commit"]}
|
|
315
|
+
|
|
316
|
+
def diff(
|
|
317
|
+
self,
|
|
318
|
+
left: str,
|
|
319
|
+
right: str,
|
|
320
|
+
paths: list[str],
|
|
321
|
+
*,
|
|
322
|
+
name_only: bool = False,
|
|
323
|
+
null: bool = False,
|
|
324
|
+
) -> bytes:
|
|
325
|
+
filters = []
|
|
326
|
+
for name in paths:
|
|
327
|
+
path = Path(os.path.abspath(self.cwd / name))
|
|
328
|
+
try:
|
|
329
|
+
filters.append(str(path.relative_to(self.root)))
|
|
330
|
+
except ValueError as exc:
|
|
331
|
+
raise RelayError(f"Diff path is outside the repository: {name}") from exc
|
|
332
|
+
args = [
|
|
333
|
+
"--literal-pathspecs",
|
|
334
|
+
"diff",
|
|
335
|
+
"--no-ext-diff",
|
|
336
|
+
"--no-textconv",
|
|
337
|
+
"--no-color",
|
|
338
|
+
"--no-renames",
|
|
339
|
+
"--src-prefix=a/",
|
|
340
|
+
"--dst-prefix=b/",
|
|
341
|
+
]
|
|
342
|
+
args += ["--name-only"] if name_only else ["--binary", "--full-index"]
|
|
343
|
+
if null:
|
|
344
|
+
args.append("-z")
|
|
345
|
+
return self.run(*args, left, right, "--", *filters).stdout
|