sid-harness 0.3.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.
- sid_harness/__init__.py +13 -0
- sid_harness/__main__.py +4 -0
- sid_harness/_native.py +1639 -0
- sid_harness/brave_proof.py +246 -0
- sid_harness/bridge.py +851 -0
- sid_harness/cli.py +937 -0
- sid_harness/client.py +161 -0
- sid_harness/companion.py +151 -0
- sid_harness/config.py +68 -0
- sid_harness/context/__init__.py +40 -0
- sid_harness/context/index.py +267 -0
- sid_harness/context/retrieval.py +74 -0
- sid_harness/editor.py +231 -0
- sid_harness/gateway.py +106 -0
- sid_harness/git_write.py +598 -0
- sid_harness/local_workspace.py +421 -0
- sid_harness/loop.py +394 -0
- sid_harness/lsp.py +598 -0
- sid_harness/policy/__init__.py +44 -0
- sid_harness/policy/repo_policy.py +626 -0
- sid_harness/receipt.py +142 -0
- sid_harness/runner.py +468 -0
- sid_harness/selftest.py +149 -0
- sid_harness/session/__init__.py +33 -0
- sid_harness/session/bounds.py +140 -0
- sid_harness/session/diff_gate.py +93 -0
- sid_harness/session/session.py +427 -0
- sid_harness/session/store.py +88 -0
- sid_harness/tools/__init__.py +52 -0
- sid_harness/tools/mcp_client.py +285 -0
- sid_harness/tools/plan.py +138 -0
- sid_harness/tools/registry.py +159 -0
- sid_harness/tools/skills.py +185 -0
- sid_harness/tools/subagents.py +112 -0
- sid_harness/tui.py +96 -0
- sid_harness/workspace.py +107 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/README.md +46 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/README.md +32 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/extension.js +395 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/package.json +120 -0
- sid_harness-0.3.0.dist-info/METADATA +229 -0
- sid_harness-0.3.0.dist-info/RECORD +45 -0
- sid_harness-0.3.0.dist-info/WHEEL +5 -0
- sid_harness-0.3.0.dist-info/entry_points.txt +2 -0
- sid_harness-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""Local workspace driver (SUB-54): the loop edits the user's REAL working tree.
|
|
2
|
+
|
|
3
|
+
SUB-53 (``workspace.py``) loads a read-only, bounded snapshot of ``--dir`` and
|
|
4
|
+
never writes back. This driver adds what SUB-54 requires:
|
|
5
|
+
|
|
6
|
+
- real file read/write against the user's tree (no sandbox, no copy);
|
|
7
|
+
- real ``git`` status/diff — read-only, never commit, never push;
|
|
8
|
+
- worktree awareness (linked worktree vs main checkout);
|
|
9
|
+
- a ``.gitignore`` filter plus the secret / VCS / dependency denylist.
|
|
10
|
+
|
|
11
|
+
The denylist IS the shared ``native_agent._safe_path`` — the harness does not
|
|
12
|
+
carry a second safety model, and the selection walk stays in ``workspace.py``.
|
|
13
|
+
By default the harness never commits and never pushes. The only write path is
|
|
14
|
+
the explicit opt-in ``git_write`` module (SUB-95), which refuses main/master,
|
|
15
|
+
never forces, and gates every step behind its own approval.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import subprocess
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from .gateway import native_agent
|
|
27
|
+
from .workspace import (
|
|
28
|
+
DEFAULT_MAX_FILE_BYTES,
|
|
29
|
+
DEFAULT_MAX_FILES,
|
|
30
|
+
DEFAULT_MAX_WORKSPACE_BYTES,
|
|
31
|
+
WorkspaceError,
|
|
32
|
+
WorkspaceSelection,
|
|
33
|
+
load_workspace,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_GIT_TIMEOUT = 30.0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class LocalWorkspaceError(RuntimeError):
|
|
40
|
+
"""A local workspace action was refused or could not complete."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def safe_rel_path(raw: object) -> str:
|
|
44
|
+
"""Gate a caller path through the SHARED ``native_agent._safe_path``.
|
|
45
|
+
|
|
46
|
+
One safety model: secrets, private keys, VCS, and dependency directories are
|
|
47
|
+
refused here exactly as the server-side loop refuses them.
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
return native_agent._safe_path(raw)
|
|
51
|
+
except native_agent.NativeAgentError as exc:
|
|
52
|
+
raise LocalWorkspaceError(str(exc)) from exc
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# --------------------------------------------------------------------------- #
|
|
56
|
+
# .gitignore — a self-contained subset (no git subprocess, so it is testable
|
|
57
|
+
# and deterministic). Covers comments, blank lines, ``!`` negation, trailing
|
|
58
|
+
# ``/`` directory-only, leading ``/`` anchoring, and ``*`` / ``?`` / ``**``.
|
|
59
|
+
# Nested .gitignore files are a documented v1 boundary: only the root file is
|
|
60
|
+
# read.
|
|
61
|
+
# --------------------------------------------------------------------------- #
|
|
62
|
+
def _glob_to_regex(pattern: str) -> re.Pattern[str]:
|
|
63
|
+
out = ["^"]
|
|
64
|
+
index = 0
|
|
65
|
+
length = len(pattern)
|
|
66
|
+
while index < length:
|
|
67
|
+
if pattern[index : index + 2] == "**":
|
|
68
|
+
if pattern[index + 2 : index + 3] == "/":
|
|
69
|
+
out.append("(?:.*/)?")
|
|
70
|
+
index += 3
|
|
71
|
+
else:
|
|
72
|
+
out.append(".*")
|
|
73
|
+
index += 2
|
|
74
|
+
elif pattern[index] == "*":
|
|
75
|
+
out.append("[^/]*")
|
|
76
|
+
index += 1
|
|
77
|
+
elif pattern[index] == "?":
|
|
78
|
+
out.append("[^/]")
|
|
79
|
+
index += 1
|
|
80
|
+
else:
|
|
81
|
+
out.append(re.escape(pattern[index]))
|
|
82
|
+
index += 1
|
|
83
|
+
out.append("$")
|
|
84
|
+
return re.compile("".join(out))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class _IgnoreRule:
|
|
89
|
+
anchored: bool
|
|
90
|
+
dir_only: bool
|
|
91
|
+
negate: bool
|
|
92
|
+
regex: re.Pattern[str]
|
|
93
|
+
|
|
94
|
+
def matches(self, prefixes: list[str]) -> bool:
|
|
95
|
+
# prefixes are every ancestor path plus the path itself; a dir-only rule
|
|
96
|
+
# may not match the file itself, only a directory prefix.
|
|
97
|
+
targets = prefixes[:-1] if self.dir_only else prefixes
|
|
98
|
+
for target in targets:
|
|
99
|
+
subject = target if self.anchored else target.rsplit("/", 1)[-1]
|
|
100
|
+
if self.regex.match(subject):
|
|
101
|
+
return True
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _parse_rule(line: str) -> _IgnoreRule | None:
|
|
106
|
+
stripped = line.rstrip("\n").rstrip()
|
|
107
|
+
if not stripped or stripped.lstrip().startswith("#"):
|
|
108
|
+
return None
|
|
109
|
+
negate = stripped.startswith("!")
|
|
110
|
+
body = stripped[1:] if negate else stripped
|
|
111
|
+
if not body:
|
|
112
|
+
return None
|
|
113
|
+
dir_only = body.endswith("/")
|
|
114
|
+
core = body.rstrip("/")
|
|
115
|
+
anchored = core.startswith("/") or "/" in core
|
|
116
|
+
core = core.lstrip("/")
|
|
117
|
+
if not core:
|
|
118
|
+
return None
|
|
119
|
+
return _IgnoreRule(
|
|
120
|
+
anchored=anchored,
|
|
121
|
+
dir_only=dir_only,
|
|
122
|
+
negate=negate,
|
|
123
|
+
regex=_glob_to_regex(core),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class GitIgnore:
|
|
128
|
+
"""Root ``.gitignore`` for a local workspace (last matching rule wins)."""
|
|
129
|
+
|
|
130
|
+
def __init__(self, rules: tuple[_IgnoreRule, ...] = ()) -> None:
|
|
131
|
+
self._rules = rules
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def load(cls, root: Path) -> GitIgnore:
|
|
135
|
+
path = Path(root) / ".gitignore"
|
|
136
|
+
rules: list[_IgnoreRule] = []
|
|
137
|
+
if path.is_file():
|
|
138
|
+
try:
|
|
139
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
140
|
+
except OSError:
|
|
141
|
+
text = ""
|
|
142
|
+
for line in text.splitlines():
|
|
143
|
+
rule = _parse_rule(line)
|
|
144
|
+
if rule is not None:
|
|
145
|
+
rules.append(rule)
|
|
146
|
+
return cls(tuple(rules))
|
|
147
|
+
|
|
148
|
+
def ignored(self, rel: str) -> bool:
|
|
149
|
+
rel = rel.replace("\\", "/")
|
|
150
|
+
parts = rel.split("/")
|
|
151
|
+
prefixes = ["/".join(parts[: index + 1]) for index in range(len(parts))]
|
|
152
|
+
ignored = False
|
|
153
|
+
for rule in self._rules:
|
|
154
|
+
if rule.matches(prefixes):
|
|
155
|
+
ignored = not rule.negate
|
|
156
|
+
return ignored
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# --------------------------------------------------------------------------- #
|
|
160
|
+
# git — read-only. status/diff only; there is no commit and no push here.
|
|
161
|
+
# --------------------------------------------------------------------------- #
|
|
162
|
+
@dataclass(frozen=True)
|
|
163
|
+
class GitInfo:
|
|
164
|
+
is_repo: bool
|
|
165
|
+
root: Path | None
|
|
166
|
+
git_dir: Path | None
|
|
167
|
+
common_dir: Path | None
|
|
168
|
+
is_worktree: bool
|
|
169
|
+
branch: str | None
|
|
170
|
+
head: str | None
|
|
171
|
+
|
|
172
|
+
def as_dict(self) -> dict[str, object]:
|
|
173
|
+
return {
|
|
174
|
+
"is_repo": self.is_repo,
|
|
175
|
+
"is_worktree": self.is_worktree,
|
|
176
|
+
"root": str(self.root) if self.root else None,
|
|
177
|
+
"git_dir": str(self.git_dir) if self.git_dir else None,
|
|
178
|
+
"common_dir": str(self.common_dir) if self.common_dir else None,
|
|
179
|
+
"branch": self.branch,
|
|
180
|
+
"head": self.head,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _run_git(root: Path, args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
185
|
+
return subprocess.run(
|
|
186
|
+
["git", "-C", str(root), *args],
|
|
187
|
+
capture_output=True,
|
|
188
|
+
text=True,
|
|
189
|
+
encoding="utf-8",
|
|
190
|
+
errors="replace",
|
|
191
|
+
timeout=_GIT_TIMEOUT,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _resolve_git_path(base: Path, value: str) -> Path | None:
|
|
196
|
+
value = value.strip()
|
|
197
|
+
if not value:
|
|
198
|
+
return None
|
|
199
|
+
candidate = Path(value)
|
|
200
|
+
if not candidate.is_absolute():
|
|
201
|
+
candidate = base / candidate
|
|
202
|
+
try:
|
|
203
|
+
return candidate.resolve()
|
|
204
|
+
except OSError: # pragma: no cover - defensive
|
|
205
|
+
return candidate
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def git_info(root: str | os.PathLike[str]) -> GitInfo:
|
|
209
|
+
"""Worktree-aware, read-only git metadata for ``root`` (never mutates)."""
|
|
210
|
+
base = Path(root).expanduser().resolve()
|
|
211
|
+
probe = _run_git(
|
|
212
|
+
base,
|
|
213
|
+
["rev-parse", "--is-inside-work-tree", "--git-dir", "--git-common-dir", "--show-toplevel"],
|
|
214
|
+
)
|
|
215
|
+
if probe.returncode != 0:
|
|
216
|
+
return GitInfo(False, None, None, None, False, None, None)
|
|
217
|
+
lines = probe.stdout.splitlines()
|
|
218
|
+
if len(lines) < 4 or lines[0].strip() != "true":
|
|
219
|
+
return GitInfo(False, None, None, None, False, None, None)
|
|
220
|
+
git_dir = _resolve_git_path(base, lines[1])
|
|
221
|
+
common_dir = _resolve_git_path(base, lines[2])
|
|
222
|
+
toplevel = _resolve_git_path(base, lines[3])
|
|
223
|
+
is_worktree = bool(git_dir and common_dir and git_dir != common_dir)
|
|
224
|
+
|
|
225
|
+
branch_probe = _run_git(base, ["rev-parse", "--abbrev-ref", "HEAD"])
|
|
226
|
+
branch = branch_probe.stdout.strip() if branch_probe.returncode == 0 else None
|
|
227
|
+
head_probe = _run_git(base, ["rev-parse", "HEAD"])
|
|
228
|
+
head = head_probe.stdout.strip() if head_probe.returncode == 0 else None
|
|
229
|
+
return GitInfo(True, toplevel, git_dir, common_dir, is_worktree, branch or None, head or None)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def git_status(root: str | os.PathLike[str]) -> list[str]:
|
|
233
|
+
"""``git status --porcelain`` (read-only). Empty when ``root`` is not a repo."""
|
|
234
|
+
base = Path(root).expanduser().resolve()
|
|
235
|
+
proc = _run_git(base, ["status", "--porcelain"])
|
|
236
|
+
if proc.returncode != 0:
|
|
237
|
+
return []
|
|
238
|
+
return [line for line in proc.stdout.splitlines() if line.strip()]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def git_diff(root: str | os.PathLike[str], *, staged: bool = False) -> str:
|
|
242
|
+
"""``git diff`` (read-only). Empty when ``root`` is not a repo."""
|
|
243
|
+
base = Path(root).expanduser().resolve()
|
|
244
|
+
args = ["diff"]
|
|
245
|
+
if staged:
|
|
246
|
+
args.append("--cached")
|
|
247
|
+
proc = _run_git(base, args)
|
|
248
|
+
if proc.returncode != 0:
|
|
249
|
+
return ""
|
|
250
|
+
return proc.stdout
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# --------------------------------------------------------------------------- #
|
|
254
|
+
# The driver
|
|
255
|
+
# --------------------------------------------------------------------------- #
|
|
256
|
+
@dataclass
|
|
257
|
+
class LocalWorkspace:
|
|
258
|
+
"""Real-tree driver: read/write on disk, git status/diff, worktree aware."""
|
|
259
|
+
|
|
260
|
+
root: Path
|
|
261
|
+
git: GitInfo
|
|
262
|
+
gitignore: GitIgnore = field(repr=False, default_factory=GitIgnore)
|
|
263
|
+
|
|
264
|
+
@classmethod
|
|
265
|
+
def open(cls, directory: str | os.PathLike[str]) -> LocalWorkspace:
|
|
266
|
+
base = Path(directory).expanduser().resolve()
|
|
267
|
+
if not base.is_dir():
|
|
268
|
+
raise LocalWorkspaceError(f"--dir is not a directory: {base}")
|
|
269
|
+
return cls(root=base, git=git_info(base), gitignore=GitIgnore.load(base))
|
|
270
|
+
|
|
271
|
+
def load(
|
|
272
|
+
self,
|
|
273
|
+
*,
|
|
274
|
+
max_files: int = DEFAULT_MAX_FILES,
|
|
275
|
+
max_bytes: int = DEFAULT_MAX_WORKSPACE_BYTES,
|
|
276
|
+
max_file_bytes: int = DEFAULT_MAX_FILE_BYTES,
|
|
277
|
+
) -> WorkspaceSelection:
|
|
278
|
+
"""Bounded read snapshot; ``.gitignore`` + the shared denylist applied."""
|
|
279
|
+
return load_workspace(
|
|
280
|
+
self.root,
|
|
281
|
+
max_files=max_files,
|
|
282
|
+
max_bytes=max_bytes,
|
|
283
|
+
max_file_bytes=max_file_bytes,
|
|
284
|
+
ignore=lambda rel: self.gitignore.ignored(rel)
|
|
285
|
+
or (
|
|
286
|
+
(rel == ".sid" or rel.startswith(".sid/"))
|
|
287
|
+
and rel != native_agent.PROJECT_CONFIG_PATH
|
|
288
|
+
),
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def _resolve_within_root(self, rel: object) -> tuple[str, Path]:
|
|
292
|
+
safe = safe_rel_path(rel)
|
|
293
|
+
root = self.root.resolve()
|
|
294
|
+
target = (root / safe).resolve()
|
|
295
|
+
if target != root and root not in target.parents:
|
|
296
|
+
raise LocalWorkspaceError("path escapes the workspace root")
|
|
297
|
+
return safe, target
|
|
298
|
+
|
|
299
|
+
def read(self, rel: object) -> str:
|
|
300
|
+
_safe, target = self._resolve_within_root(rel)
|
|
301
|
+
if not target.is_file():
|
|
302
|
+
raise LocalWorkspaceError(f"not a file: {rel}")
|
|
303
|
+
try:
|
|
304
|
+
return target.read_text(encoding="utf-8")
|
|
305
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
306
|
+
raise LocalWorkspaceError(f"cannot read {rel}: {exc}") from exc
|
|
307
|
+
|
|
308
|
+
def write(self, rel: object, content: object) -> dict[str, object]:
|
|
309
|
+
safe, target = self._resolve_within_root(rel)
|
|
310
|
+
if not isinstance(content, str):
|
|
311
|
+
raise LocalWorkspaceError("content must be a string")
|
|
312
|
+
size = len(content.encode("utf-8"))
|
|
313
|
+
if size > DEFAULT_MAX_FILE_BYTES:
|
|
314
|
+
raise LocalWorkspaceError(
|
|
315
|
+
f"file exceeds the {DEFAULT_MAX_FILE_BYTES}-byte limit"
|
|
316
|
+
)
|
|
317
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
318
|
+
tmp = target.with_name(target.name + ".sid-tmp")
|
|
319
|
+
tmp.write_text(content, encoding="utf-8")
|
|
320
|
+
os.replace(tmp, target)
|
|
321
|
+
return {"written": safe, "bytes": size, "path": str(target)}
|
|
322
|
+
|
|
323
|
+
def plan_apply(self, changed: list[str], files: dict[str, str]) -> dict[str, list[str]]:
|
|
324
|
+
"""Classify a loop result without touching disk.
|
|
325
|
+
|
|
326
|
+
``writes`` are changed paths that exist in the final file set;
|
|
327
|
+
``deletions`` are changed paths that disappeared. Nothing is applied
|
|
328
|
+
here — a deletion needs explicit approval before it reaches the disk.
|
|
329
|
+
"""
|
|
330
|
+
unique = set(changed)
|
|
331
|
+
return {
|
|
332
|
+
"writes": sorted(rel for rel in unique if rel in files),
|
|
333
|
+
"deletions": sorted(rel for rel in unique if rel not in files),
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
def apply(
|
|
337
|
+
self,
|
|
338
|
+
changed: list[str],
|
|
339
|
+
files: dict[str, str],
|
|
340
|
+
*,
|
|
341
|
+
approved_deletions: list[str] | tuple[str, ...] | set[str] | None = None,
|
|
342
|
+
) -> list[str]:
|
|
343
|
+
"""Persist the loop's changed files to the real tree.
|
|
344
|
+
|
|
345
|
+
Writes are applied as before. Deletions are NEVER silent: a path in
|
|
346
|
+
``changed`` that is absent from ``files`` is only removed when it is
|
|
347
|
+
named in ``approved_deletions``. Use :meth:`plan_apply` to show the
|
|
348
|
+
caller exactly what would be deleted before approving.
|
|
349
|
+
"""
|
|
350
|
+
plan = self.plan_apply(changed, files)
|
|
351
|
+
approved = set(approved_deletions or ())
|
|
352
|
+
written: list[str] = []
|
|
353
|
+
for rel in plan["writes"]:
|
|
354
|
+
self.write(rel, files[rel])
|
|
355
|
+
written.append(rel)
|
|
356
|
+
for rel in plan["deletions"]:
|
|
357
|
+
if rel in approved:
|
|
358
|
+
self.delete(rel, approved=True)
|
|
359
|
+
return written
|
|
360
|
+
|
|
361
|
+
def delete(self, rel: object, *, approved: bool = False) -> dict[str, object]:
|
|
362
|
+
"""Delete one real file, only behind explicit approval."""
|
|
363
|
+
safe, target = self._resolve_within_root(rel)
|
|
364
|
+
if not approved:
|
|
365
|
+
raise LocalWorkspaceError(
|
|
366
|
+
f"refusing to delete {safe}: deletions require explicit approval"
|
|
367
|
+
)
|
|
368
|
+
if not target.is_file():
|
|
369
|
+
raise LocalWorkspaceError(f"not a file: {rel}")
|
|
370
|
+
try:
|
|
371
|
+
target.unlink()
|
|
372
|
+
except OSError as exc:
|
|
373
|
+
raise LocalWorkspaceError(f"cannot delete {rel}: {exc}") from exc
|
|
374
|
+
return {"deleted": safe, "path": str(target)}
|
|
375
|
+
|
|
376
|
+
def move(
|
|
377
|
+
self,
|
|
378
|
+
source: object,
|
|
379
|
+
destination: object,
|
|
380
|
+
*,
|
|
381
|
+
approved: bool = False,
|
|
382
|
+
) -> dict[str, object]:
|
|
383
|
+
"""Move/rename one real file, only behind explicit approval."""
|
|
384
|
+
safe_source, src = self._resolve_within_root(source)
|
|
385
|
+
safe_destination, dst = self._resolve_within_root(destination)
|
|
386
|
+
if not approved:
|
|
387
|
+
raise LocalWorkspaceError(
|
|
388
|
+
f"refusing to move {safe_source}: moves require explicit approval"
|
|
389
|
+
)
|
|
390
|
+
if not src.is_file():
|
|
391
|
+
raise LocalWorkspaceError(f"not a file: {source}")
|
|
392
|
+
if dst.exists():
|
|
393
|
+
raise LocalWorkspaceError(f"destination already exists: {destination}")
|
|
394
|
+
try:
|
|
395
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
396
|
+
os.replace(src, dst)
|
|
397
|
+
except OSError as exc:
|
|
398
|
+
raise LocalWorkspaceError(f"cannot move {source}: {exc}") from exc
|
|
399
|
+
return {
|
|
400
|
+
"moved": {"from": safe_source, "to": safe_destination},
|
|
401
|
+
"path": str(dst),
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
def status(self) -> list[str]:
|
|
405
|
+
return git_status(self.root) if self.git.is_repo else []
|
|
406
|
+
|
|
407
|
+
def diff(self, *, staged: bool = False) -> str:
|
|
408
|
+
return git_diff(self.root, staged=staged) if self.git.is_repo else ""
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
__all__ = [
|
|
412
|
+
"GitIgnore",
|
|
413
|
+
"GitInfo",
|
|
414
|
+
"LocalWorkspace",
|
|
415
|
+
"LocalWorkspaceError",
|
|
416
|
+
"WorkspaceError",
|
|
417
|
+
"git_diff",
|
|
418
|
+
"git_info",
|
|
419
|
+
"git_status",
|
|
420
|
+
"safe_rel_path",
|
|
421
|
+
]
|