ai-code-engineer 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.
- ai_code_engineer/__init__.py +2 -0
- ai_code_engineer/catalog.py +143 -0
- ai_code_engineer/chat.py +181 -0
- ai_code_engineer/cli.py +384 -0
- ai_code_engineer/config.py +405 -0
- ai_code_engineer/engine.py +1282 -0
- ai_code_engineer/errors.py +27 -0
- ai_code_engineer/git_integration.py +443 -0
- ai_code_engineer/gui.py +2646 -0
- ai_code_engineer/host.py +81 -0
- ai_code_engineer/ignore.py +269 -0
- ai_code_engineer/intent.py +222 -0
- ai_code_engineer/labels.py +871 -0
- ai_code_engineer/memory.py +91 -0
- ai_code_engineer/modes.py +156 -0
- ai_code_engineer/overrides.py +540 -0
- ai_code_engineer/planbook.py +192 -0
- ai_code_engineer/providers.py +404 -0
- ai_code_engineer/redaction.py +54 -0
- ai_code_engineer/repair.py +564 -0
- ai_code_engineer/report.py +352 -0
- ai_code_engineer/runner.py +854 -0
- ai_code_engineer/setup.py +386 -0
- ai_code_engineer/symbols.py +1286 -0
- ai_code_engineer/verification.py +218 -0
- ai_code_engineer/webapp/__init__.py +1 -0
- ai_code_engineer/webapp/__main__.py +45 -0
- ai_code_engineer/webapp/contract.py +36 -0
- ai_code_engineer/webapp/controller.py +3556 -0
- ai_code_engineer/webapp/fake.py +1141 -0
- ai_code_engineer/webapp/launch.py +108 -0
- ai_code_engineer/webapp/server.py +349 -0
- ai_code_engineer/webapp/static/app.css +780 -0
- ai_code_engineer/webapp/static/app.js +2118 -0
- ai_code_engineer/webapp/static/boot.js +19 -0
- ai_code_engineer/webapp/static/index.html +89 -0
- ai_code_engineer/webapp/static/tokens.css +173 -0
- ai_code_engineer/workspace.py +385 -0
- ai_code_engineer-0.1.0.dist-info/METADATA +7 -0
- ai_code_engineer-0.1.0.dist-info/RECORD +44 -0
- ai_code_engineer-0.1.0.dist-info/WHEEL +5 -0
- ai_code_engineer-0.1.0.dist-info/entry_points.txt +2 -0
- ai_code_engineer-0.1.0.dist-info/licenses/LICENSE +21 -0
- ai_code_engineer-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
class AgentError(Exception):
|
|
2
|
+
"""An actionable failure that can be displayed without a traceback."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class PolicyError(AgentError):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MissingFileError(PolicyError):
|
|
10
|
+
"""A policy-accessible path does not exist; creation may still be allowed."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProviderError(AgentError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProviderUnavailable(ProviderError):
|
|
18
|
+
"""The provider could not be reached at all — no status, no body, nothing it said.
|
|
19
|
+
|
|
20
|
+
A subclass rather than a new field because the choice it enables is per call site, not per error:
|
|
21
|
+
asking a model list again one second later costs a second, asking a generation again costs a model
|
|
22
|
+
load. A caller that can tell the two apart is the only thing that may retry anything.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Cancelled(AgentError):
|
|
27
|
+
"""A user requested cancellation at a safe planning boundary."""
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""Git awareness that reads, commits, and moves HEAD only when it is asked.
|
|
2
|
+
|
|
3
|
+
A project folder on disk is not trusted code. Three of git's read commands have side
|
|
4
|
+
doors — `status` can start an fsmonitor daemon, any command takes hooks and configuration
|
|
5
|
+
from the folder it runs in, and a command that wants credentials can open a prompt and sit
|
|
6
|
+
there forever — so each call carries the flags that close those doors rather than relying
|
|
7
|
+
on the user's global configuration.
|
|
8
|
+
|
|
9
|
+
The status half reads and nothing else — `rev-parse`, `symbolic-ref`, `status --porcelain`,
|
|
10
|
+
and no network command exists in this file. Every failure path returns the same "no
|
|
11
|
+
information" shape as "this folder is not a repo", because the result feeds a status chip,
|
|
12
|
+
and a chip that cannot render git is a smaller loss than a turn that hangs on a git process
|
|
13
|
+
that never exits.
|
|
14
|
+
|
|
15
|
+
The other half writes exactly four kinds of thing:
|
|
16
|
+
|
|
17
|
+
* `checkpoint` commits the files a proposal just wrote, under its own subject line, and
|
|
18
|
+
refuses to touch anything else.
|
|
19
|
+
* `start_task_branch` creates and switches to `agent/task-…`, on an explicit request only.
|
|
20
|
+
* `switch_branch` returns to a named local branch, and never forces.
|
|
21
|
+
* `restore_paths` puts the proposal's own files back to a hash this program wrote — the one
|
|
22
|
+
call here that discards what is on disk, which is why it takes a hash, a path list, and
|
|
23
|
+
nothing else.
|
|
24
|
+
|
|
25
|
+
Those limits are the point of the file. `git commit -am` would sweep a developer's
|
|
26
|
+
half-finished edits into an agent-authored commit, and `git reset` or `git checkout --force`
|
|
27
|
+
would delete work no proposal ever proposed — so no verb in here can discard a change that
|
|
28
|
+
is not the agent's own. Creating a branch carries the working tree with it, and switching
|
|
29
|
+
refuses rather than overwrites: both are reversible by the user typing the same command back.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
import shutil
|
|
36
|
+
import subprocess
|
|
37
|
+
import threading
|
|
38
|
+
import time
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
from . import runner
|
|
42
|
+
from .redaction import redact
|
|
43
|
+
|
|
44
|
+
# git can be present and still not answer: a huge monorepo takes tens of seconds to
|
|
45
|
+
# stat every file on `status`. It is bounded rather than trusted.
|
|
46
|
+
TIMEOUT = 4.0
|
|
47
|
+
# A snapshot is rebuilt on every streamed event, and each inspection is four git
|
|
48
|
+
# processes — about 0.2 s on Windows. Eight seconds is long enough that the cost is
|
|
49
|
+
# invisible and short enough that a commit made in a terminal shows up on the next
|
|
50
|
+
# exchange; anything the agent itself changes calls forget() instead of waiting.
|
|
51
|
+
CACHE_TTL = 8.0
|
|
52
|
+
# Porcelain is one line per path; a repo with 40 000 dirty files is a count, not a list.
|
|
53
|
+
MAX_PATHS = 500
|
|
54
|
+
# A commit is a different job from a status read: a cold cache and an object write both
|
|
55
|
+
# take longer than the second git needs to answer a question.
|
|
56
|
+
COMMIT_TIMEOUT = 20.0
|
|
57
|
+
|
|
58
|
+
# Global options, in the order git expects them, before the subcommand.
|
|
59
|
+
PREFIX = ["--no-optional-locks", "-c", "core.fsmonitor=false", "-c", "color.ui=false"]
|
|
60
|
+
|
|
61
|
+
# Environment the child gets on top of the scrubbed toolchain env. The scrub itself
|
|
62
|
+
# matters: GIT_DIR, GIT_INDEX_FILE and GIT_WORK_TREE in the parent's environment would
|
|
63
|
+
# point git at some other folder entirely.
|
|
64
|
+
GIT_ENV = {"GIT_TERMINAL_PROMPT": "0", "GIT_OPTIONAL_LOCKS": "0", "GIT_ADVICE": "0",
|
|
65
|
+
"GCM_INTERACTIVE": "never", "GIT_PAGER": "cat"}
|
|
66
|
+
|
|
67
|
+
_EMPTY = {"known": False, "repo": False, "branch": "", "detached": False,
|
|
68
|
+
"head": "", "toplevel": "", "dirty": None, "paths": [], "reason": ""}
|
|
69
|
+
|
|
70
|
+
_lock = threading.Lock()
|
|
71
|
+
_cache: dict[str, tuple[float, dict]] = {}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def git_program() -> str | None:
|
|
75
|
+
"""Absolute path to the git binary, or None when it is not on PATH."""
|
|
76
|
+
return shutil.which("git")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def env() -> dict:
|
|
80
|
+
"""The scrubbed toolchain environment, plus the switches that keep git from blocking.
|
|
81
|
+
|
|
82
|
+
git on Windows reads USERPROFILE but warns when HOME is unset, and the scrub drops
|
|
83
|
+
anything not on the allowlist, so HOME is filled from the same place git would.
|
|
84
|
+
"""
|
|
85
|
+
child = runner.child_env()
|
|
86
|
+
child.setdefault("HOME", child.get("USERPROFILE", ""))
|
|
87
|
+
child.update(GIT_ENV)
|
|
88
|
+
return child
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _done(root: Path, args: list[str], timeout: float) -> dict:
|
|
92
|
+
"""Run one fixed git argv. Never raises: a failure is an empty result plus a reason."""
|
|
93
|
+
program = git_program()
|
|
94
|
+
if program is None:
|
|
95
|
+
return {"ok": False, "out": "", "err": "", "reason": "git is not installed"}
|
|
96
|
+
command = [program, *PREFIX, *args]
|
|
97
|
+
options = {"cwd": str(root), "env": env(), "stdin": subprocess.DEVNULL,
|
|
98
|
+
"stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "shell": False}
|
|
99
|
+
if os.name == "nt":
|
|
100
|
+
options["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
|
|
101
|
+
process = None # the spawn itself may be what fails
|
|
102
|
+
try:
|
|
103
|
+
process = subprocess.Popen(command, **options)
|
|
104
|
+
finished_out, finished_err = process.communicate(timeout=max(0.1, float(timeout)))
|
|
105
|
+
except subprocess.TimeoutExpired:
|
|
106
|
+
# The old `subprocess.run(timeout=…)` killed only the git child, and a credential helper
|
|
107
|
+
# git started survives that. Same tree kill the build runner uses, then reap what is left.
|
|
108
|
+
runner.kill_tree(process)
|
|
109
|
+
try:
|
|
110
|
+
process.communicate(timeout=runner.POST_KILL_GRACE)
|
|
111
|
+
except subprocess.TimeoutExpired:
|
|
112
|
+
pass
|
|
113
|
+
return {"ok": False, "out": "", "err": "", "reason": "git took too long to answer"}
|
|
114
|
+
except (OSError, ValueError, subprocess.SubprocessError) as exc:
|
|
115
|
+
if process is not None:
|
|
116
|
+
runner.kill_tree(process) # a pipe that broke still leaves a child behind
|
|
117
|
+
return {"ok": False, "out": "", "err": "", "reason": str(exc)[:200]}
|
|
118
|
+
out = (finished_out or b"").decode("utf-8", "replace")
|
|
119
|
+
err = (finished_err or b"").decode("utf-8", "replace")
|
|
120
|
+
return {"ok": process.returncode == 0, "out": out, "err": err,
|
|
121
|
+
"code": process.returncode, "reason": ""}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _first_line(text: str) -> str:
|
|
125
|
+
return redact(text.strip().splitlines()[0])[:200] if text.strip() else ""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _porcelain(root: Path) -> dict:
|
|
129
|
+
"""Changed paths, worktree-relative. Untracked files count as dirty: they are the
|
|
130
|
+
ones a checkpoint commit is most likely to be asked to carry."""
|
|
131
|
+
result = _done(root, ["status", "--porcelain"], TIMEOUT)
|
|
132
|
+
if not result["ok"]:
|
|
133
|
+
# None, not 0: a repo whose status timed out is not a clean repo, and the chip
|
|
134
|
+
# would say so.
|
|
135
|
+
return {"dirty": None, "paths": [],
|
|
136
|
+
"reason": result["reason"] or _first_line(result["err"])}
|
|
137
|
+
lines = [line for line in result["out"].splitlines() if line.strip()]
|
|
138
|
+
paths = []
|
|
139
|
+
for line in lines[:MAX_PATHS]:
|
|
140
|
+
# XY <path>, or "!! <path>" when ignored; the payload always starts at column 3.
|
|
141
|
+
tail = line[3:] if len(line) > 3 else ""
|
|
142
|
+
paths.append(tail.split(" -> ")[-1].strip('"'))
|
|
143
|
+
return {"dirty": len(lines), "paths": paths, "reason": ""}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def inspect(root: str | Path) -> dict:
|
|
147
|
+
"""What git says about this folder right now — no cache, no writes."""
|
|
148
|
+
info = dict(_EMPTY)
|
|
149
|
+
try:
|
|
150
|
+
folder = Path(root).expanduser().resolve()
|
|
151
|
+
except (OSError, RuntimeError):
|
|
152
|
+
info["reason"] = "that folder is not on this disk"
|
|
153
|
+
return info
|
|
154
|
+
if git_program() is None:
|
|
155
|
+
info["reason"] = "git is not installed"
|
|
156
|
+
return info
|
|
157
|
+
info["known"] = True
|
|
158
|
+
if not folder.is_dir():
|
|
159
|
+
info["reason"] = "that folder is not on this disk"
|
|
160
|
+
return info
|
|
161
|
+
where = _done(folder, ["rev-parse", "--is-inside-work-tree"], TIMEOUT)
|
|
162
|
+
if not where["ok"] or where["out"].strip() != "true":
|
|
163
|
+
info["reason"] = where["reason"] or _first_line(where["err"]) or "not a git repository"
|
|
164
|
+
return info
|
|
165
|
+
info["repo"] = True
|
|
166
|
+
top = _done(folder, ["rev-parse", "--show-toplevel"], TIMEOUT)
|
|
167
|
+
# git prints forward slashes even on Windows; the value is compared against folder paths.
|
|
168
|
+
info["toplevel"] = str(Path(top["out"].strip())) if top["out"].strip() else ""
|
|
169
|
+
head = _done(folder, ["rev-parse", "--short", "HEAD"], TIMEOUT)
|
|
170
|
+
if head["ok"]:
|
|
171
|
+
info["head"] = head["out"].strip()
|
|
172
|
+
# `branch --show-current` needs git 2.22; this has to work with whatever is installed.
|
|
173
|
+
# symbolic-ref answers nothing on a detached HEAD, which is how "detached" is detected.
|
|
174
|
+
ref = _done(folder, ["symbolic-ref", "--short", "-q", "HEAD"], TIMEOUT)
|
|
175
|
+
if ref["ok"] and ref["out"].strip():
|
|
176
|
+
info["branch"] = ref["out"].strip()
|
|
177
|
+
else:
|
|
178
|
+
info["detached"] = bool(info["head"])
|
|
179
|
+
state = _porcelain(folder)
|
|
180
|
+
info.update({"dirty": state["dirty"], "paths": state["paths"]})
|
|
181
|
+
info["reason"] = state["reason"]
|
|
182
|
+
if not info["branch"] and not info["head"]:
|
|
183
|
+
# A repository with no commits yet: real, and it has nothing to compare against.
|
|
184
|
+
info["reason"] = info["reason"] or "no commits yet"
|
|
185
|
+
return info
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _copy(info: dict) -> dict:
|
|
189
|
+
# A shallow copy would hand the caller the cached path list, and one sort in place
|
|
190
|
+
# would then silently reorder every later chip.
|
|
191
|
+
return {**info, "paths": list(info["paths"])}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def status(root: str | Path | None) -> dict:
|
|
195
|
+
"""Cached inspection of the granted folder. Returns the empty shape for no folder."""
|
|
196
|
+
if not root:
|
|
197
|
+
return _copy(_EMPTY)
|
|
198
|
+
key = str(root)
|
|
199
|
+
now = time.monotonic()
|
|
200
|
+
with _lock:
|
|
201
|
+
hit = _cache.get(key)
|
|
202
|
+
if hit and now - hit[0] < CACHE_TTL:
|
|
203
|
+
return _copy(hit[1])
|
|
204
|
+
info = inspect(root)
|
|
205
|
+
with _lock:
|
|
206
|
+
if len(_cache) > 32:
|
|
207
|
+
_cache.clear()
|
|
208
|
+
_cache[key] = (now, info)
|
|
209
|
+
return _copy(info)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def relative(name: str) -> str:
|
|
213
|
+
"""The path in git's own form, or "" when it does not belong inside this folder.
|
|
214
|
+
|
|
215
|
+
These names come from a proposal the user already approved, but they are about to
|
|
216
|
+
cross a process boundary: an absolute path or a parent traversal handed to `git add`
|
|
217
|
+
would make git act on somewhere the folder never granted.
|
|
218
|
+
"""
|
|
219
|
+
raw = str(name or "").strip().strip('"')
|
|
220
|
+
if not raw or raw.startswith(("/", "\\\\")) or re.match(r"^[a-zA-Z]:", raw):
|
|
221
|
+
return ""
|
|
222
|
+
parts = [piece for piece in raw.replace("\\", "/").split("/") if piece not in ("", ".")]
|
|
223
|
+
if ".." in parts or ".git" in parts:
|
|
224
|
+
return ""
|
|
225
|
+
return "/".join(parts)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def checkpoint(root: str | Path, task: str, session_id: str, paths: list[str]) -> dict:
|
|
229
|
+
"""Commit exactly the files this task wrote, and nothing beside them.
|
|
230
|
+
|
|
231
|
+
Two things separate this from typing `git commit -am` in a terminal:
|
|
232
|
+
|
|
233
|
+
* Only the proposal's own paths are staged, so a developer's half-finished edits stay
|
|
234
|
+
out of an agent-authored commit.
|
|
235
|
+
* `--no-verify` skips the repository's hooks. This runs without anyone at the
|
|
236
|
+
keyboard, and a pre-commit hook is arbitrary code the folder chose to install.
|
|
237
|
+
|
|
238
|
+
Returns {ok, hash, before, reason}. Failure is never fatal: the change is already on
|
|
239
|
+
disk and the in-session rollback still works — a checkpoint is an extra safety net,
|
|
240
|
+
not a step in the write path.
|
|
241
|
+
"""
|
|
242
|
+
result = {"ok": False, "hash": "", "before": "", "reason": ""}
|
|
243
|
+
folder = Path(root)
|
|
244
|
+
states = status(folder)
|
|
245
|
+
if not states["repo"]:
|
|
246
|
+
result["reason"] = states["reason"] or "this folder is not a git repository"
|
|
247
|
+
return result
|
|
248
|
+
was = _done(folder, ["rev-parse", "HEAD"], TIMEOUT)
|
|
249
|
+
result["before"] = was["out"].strip() if was["ok"] else ""
|
|
250
|
+
targets = [item for item in (relative(name) for name in paths) if item]
|
|
251
|
+
if not targets:
|
|
252
|
+
result["reason"] = "the proposal named no file inside this folder"
|
|
253
|
+
return result
|
|
254
|
+
staged = _done(folder, ["add", "--", *targets], COMMIT_TIMEOUT)
|
|
255
|
+
if not staged["ok"]:
|
|
256
|
+
result["reason"] = _first_line(staged["err"]) or "git could not stage those files"
|
|
257
|
+
return result
|
|
258
|
+
subject = ("agent: " + " ".join((task or "change").split())[:120]
|
|
259
|
+
+ " [session-" + str(session_id)[:12] + "]")
|
|
260
|
+
committed = _done(folder, ["commit", "--no-verify", "-m", subject], COMMIT_TIMEOUT)
|
|
261
|
+
if not committed["ok"]:
|
|
262
|
+
result["reason"] = (_first_line(committed["err"]) or _first_line(committed["out"])
|
|
263
|
+
or "git had nothing to commit")
|
|
264
|
+
return result
|
|
265
|
+
after = _done(folder, ["rev-parse", "--short", "HEAD"], TIMEOUT)
|
|
266
|
+
result["hash"] = after["out"].strip() if after["ok"] else ""
|
|
267
|
+
result["ok"] = True
|
|
268
|
+
forget(folder)
|
|
269
|
+
return result
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
TASK_BRANCH_PREFIX = "agent/task-"
|
|
273
|
+
# A branch name is built here rather than taken from anyone's text, so git's refname rules —
|
|
274
|
+
# no spaces, no `..`, no control characters, no leading `-` that git would read as an option —
|
|
275
|
+
# hold by construction instead of by validation.
|
|
276
|
+
_SLUG_EDGE = re.compile(r"[^a-z0-9]+")
|
|
277
|
+
MAX_SLUG = 40
|
|
278
|
+
# Accepted back from the UI for the return trip. The generator above cannot produce `.` or `_`,
|
|
279
|
+
# but a human's own branch uses them, and `agent/task-…` is not the only branch worth returning to.
|
|
280
|
+
BRANCH_NAME = re.compile(r"^[a-z0-9][a-z0-9._/-]{0,63}$")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def task_branch_name(task: str, session_id: str) -> str:
|
|
284
|
+
"""`agent/task-<words from the task>-<session>`.
|
|
285
|
+
|
|
286
|
+
A task written in Arabic slugs to nothing at all, which is why the session id is part of the
|
|
287
|
+
name: the fallback has to stay unique across tasks rather than pile every Arabic request onto
|
|
288
|
+
one branch.
|
|
289
|
+
"""
|
|
290
|
+
slug = _SLUG_EDGE.sub("-", str(task or "").casefold()).strip("-")[:MAX_SLUG].strip("-")
|
|
291
|
+
tail = _SLUG_EDGE.sub("", str(session_id or ""))[:8]
|
|
292
|
+
body = slug + ("-" + tail if tail else "") if slug else tail
|
|
293
|
+
return TASK_BRANCH_PREFIX + (body or "task")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _has_branch(folder: Path, name: str) -> bool:
|
|
297
|
+
"""Whether git has a local branch by this name. Never creates one."""
|
|
298
|
+
check = _done(folder, ["rev-parse", "--verify", "--quiet", "refs/heads/" + name], TIMEOUT)
|
|
299
|
+
return check["ok"] and bool(check["out"].strip())
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _refused(result: dict, states: dict, reason: str = "") -> dict:
|
|
303
|
+
if not states["repo"]:
|
|
304
|
+
result["reason"] = states["reason"] or "this folder is not a git repository"
|
|
305
|
+
else:
|
|
306
|
+
result["reason"] = reason
|
|
307
|
+
return result
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def start_task_branch(root: str | Path, task: str, session_id: str) -> dict:
|
|
311
|
+
"""Create `agent/task-…` and move HEAD onto it, in a folder that is already a repository.
|
|
312
|
+
|
|
313
|
+
`checkout -b` carries the working tree with it, so nothing that is not yet committed is lost,
|
|
314
|
+
and typing `git checkout <from>` puts it back. Two cases still refuse: a detached HEAD, where
|
|
315
|
+
there is no named branch to return to, and an existing branch by that name, where switching
|
|
316
|
+
would silently join someone else's work.
|
|
317
|
+
|
|
318
|
+
Returns {ok, created, branch, from, reason}. `from` is what the caller offers to go back to.
|
|
319
|
+
"""
|
|
320
|
+
result = {"ok": False, "created": False, "branch": "", "from": "", "reason": ""}
|
|
321
|
+
folder = Path(root)
|
|
322
|
+
states = status(folder)
|
|
323
|
+
if not states["repo"]:
|
|
324
|
+
return _refused(result, states)
|
|
325
|
+
if states["detached"] or not states["branch"]:
|
|
326
|
+
return _refused(result, states,
|
|
327
|
+
"the folder is on a detached HEAD, so there is no branch to come back to")
|
|
328
|
+
result["from"] = states["branch"]
|
|
329
|
+
name = task_branch_name(task, session_id)
|
|
330
|
+
if _has_branch(folder, name):
|
|
331
|
+
if name == states["branch"]:
|
|
332
|
+
result.update({"ok": True, "branch": name,
|
|
333
|
+
"reason": "already on " + name})
|
|
334
|
+
return result
|
|
335
|
+
return _refused(result, states, "a branch named " + name + " already exists")
|
|
336
|
+
made = _done(folder, ["checkout", "-b", name], COMMIT_TIMEOUT)
|
|
337
|
+
if not made["ok"]:
|
|
338
|
+
result["from"] = ""
|
|
339
|
+
return _refused(result, states,
|
|
340
|
+
_first_line(made["err"]) or "git could not create that branch")
|
|
341
|
+
result.update({"ok": True, "created": True, "branch": name})
|
|
342
|
+
forget(folder)
|
|
343
|
+
return result
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def switch_branch(root: str | Path, name: str) -> dict:
|
|
347
|
+
"""Move HEAD back to a named local branch, unforced.
|
|
348
|
+
|
|
349
|
+
No `--force` and no `-f`: when the working tree holds edits that would be overwritten, git
|
|
350
|
+
refuses, and that refusal is the answer the user needs. Rewriting history is not on this list.
|
|
351
|
+
"""
|
|
352
|
+
result = {"ok": False, "branch": "", "reason": ""}
|
|
353
|
+
wanted = str(name or "").strip()
|
|
354
|
+
# This program addresses a local branch by its bare name and supplies `refs/heads/` itself, so
|
|
355
|
+
# a refpath coming back from the UI is either a mistake or something trying to pick its own
|
|
356
|
+
# namespace. Neither gets handed to git.
|
|
357
|
+
if (not BRANCH_NAME.match(wanted) or ".." in wanted or ".git" in wanted.split("/")
|
|
358
|
+
or wanted.startswith("refs/")):
|
|
359
|
+
result["reason"] = "that is not a branch name this program will hand to git"
|
|
360
|
+
return result
|
|
361
|
+
folder = Path(root)
|
|
362
|
+
states = status(folder)
|
|
363
|
+
if not states["repo"]:
|
|
364
|
+
return _refused(result, states)
|
|
365
|
+
result["branch"] = wanted
|
|
366
|
+
if wanted == states["branch"]:
|
|
367
|
+
result["ok"] = True
|
|
368
|
+
result["reason"] = "already on " + wanted
|
|
369
|
+
return result
|
|
370
|
+
if not _has_branch(folder, wanted):
|
|
371
|
+
return _refused(result, states, "no local branch named " + wanted)
|
|
372
|
+
moved = _done(folder, ["checkout", wanted], COMMIT_TIMEOUT)
|
|
373
|
+
if not moved["ok"]:
|
|
374
|
+
return _refused(result, states,
|
|
375
|
+
_first_line(moved["err"]) or "git refused to switch branches")
|
|
376
|
+
result["ok"] = True
|
|
377
|
+
forget(folder)
|
|
378
|
+
return result
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# A restore is addressed by hash, never by a word. `HEAD~3` is a claim about history this program
|
|
382
|
+
# cannot see, and a branch name could point somewhere else by the moment the click arrives.
|
|
383
|
+
COMMIT = re.compile(r"^[0-9a-f]{4,40}$")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _has_commit(folder: Path, commit: str) -> bool:
|
|
387
|
+
check = _done(folder, ["rev-parse", "--verify", "--quiet", commit + "^{commit}"], TIMEOUT)
|
|
388
|
+
return check["ok"] and bool(check["out"].strip())
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def restore_paths(root: str | Path, commit: str, paths: list[str]) -> dict:
|
|
392
|
+
"""Put the named files back to how they were in `commit`, and touch nothing else.
|
|
393
|
+
|
|
394
|
+
This is the only call in the file that discards what is on disk, so three limits are part of its
|
|
395
|
+
shape rather than left to the caller: the commit must be a hash this program wrote, the paths come
|
|
396
|
+
from a proposal the user already approved, and each file is restored on its own so a path git does
|
|
397
|
+
not recognise cannot silently widen the blast radius. No `reset`, no `checkout .`, no `clean`, no
|
|
398
|
+
`--force` — those are the commands that damage work nobody proposed.
|
|
399
|
+
|
|
400
|
+
Returns {ok, restored, skipped, reason}.
|
|
401
|
+
"""
|
|
402
|
+
result: dict = {"ok": False, "restored": [], "skipped": [], "reason": ""}
|
|
403
|
+
wanted = str(commit or "").strip()
|
|
404
|
+
if not COMMIT.match(wanted):
|
|
405
|
+
result["reason"] = "that is not a commit this program will restore from"
|
|
406
|
+
return result
|
|
407
|
+
folder = Path(root)
|
|
408
|
+
states = status(folder)
|
|
409
|
+
if not states["repo"]:
|
|
410
|
+
return _refused(result, states)
|
|
411
|
+
seen, targets = set(), []
|
|
412
|
+
for name in paths or []:
|
|
413
|
+
one = relative(name)
|
|
414
|
+
if one and one not in seen:
|
|
415
|
+
seen.add(one)
|
|
416
|
+
targets.append(one)
|
|
417
|
+
if not targets:
|
|
418
|
+
result["reason"] = "the request named no file inside this folder"
|
|
419
|
+
return result
|
|
420
|
+
if not _has_commit(folder, wanted):
|
|
421
|
+
return _refused(result, states, "git has no commit " + wanted + " in this folder")
|
|
422
|
+
first_refusal = ""
|
|
423
|
+
for one in targets:
|
|
424
|
+
moved = _done(folder, ["checkout", wanted, "--", one], COMMIT_TIMEOUT)
|
|
425
|
+
if moved["ok"]:
|
|
426
|
+
result["restored"].append(one)
|
|
427
|
+
else:
|
|
428
|
+
result["skipped"].append(one)
|
|
429
|
+
first_refusal = first_refusal or (_first_line(moved["err"]) or "git would not restore it")
|
|
430
|
+
result["ok"] = bool(result["restored"])
|
|
431
|
+
if not result["ok"]:
|
|
432
|
+
result["reason"] = first_refusal
|
|
433
|
+
forget(folder)
|
|
434
|
+
return result
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def forget(root: str | Path | None = None) -> None:
|
|
438
|
+
"""Drop the cached answer, for after something changed the files."""
|
|
439
|
+
with _lock:
|
|
440
|
+
if root is None:
|
|
441
|
+
_cache.clear()
|
|
442
|
+
return
|
|
443
|
+
_cache.pop(str(root), None)
|