mosscode 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.
- mosscode/__init__.py +10 -0
- mosscode/__main__.py +3 -0
- mosscode/agent.py +486 -0
- mosscode/agents/__init__.py +249 -0
- mosscode/agents/builtin/explore.md +16 -0
- mosscode/agents/builtin/general.md +12 -0
- mosscode/agents/builtin/plan.md +16 -0
- mosscode/agents/runtime.py +685 -0
- mosscode/agents/tasks.py +193 -0
- mosscode/appearance.py +184 -0
- mosscode/builtin_commands.py +1363 -0
- mosscode/cli.py +547 -0
- mosscode/commands.py +254 -0
- mosscode/compaction.py +259 -0
- mosscode/config.py +337 -0
- mosscode/context.py +102 -0
- mosscode/conversation.py +1046 -0
- mosscode/coordinator.py +54 -0
- mosscode/doctor.py +303 -0
- mosscode/hooks/__init__.py +22 -0
- mosscode/hooks/actions.py +151 -0
- mosscode/hooks/conditions.py +170 -0
- mosscode/hooks/config.py +404 -0
- mosscode/hooks/engine.py +401 -0
- mosscode/hooks/events.py +48 -0
- mosscode/launcher.py +177 -0
- mosscode/mcp_client.py +460 -0
- mosscode/mcp_config.py +216 -0
- mosscode/member_ui.py +289 -0
- mosscode/memory.py +291 -0
- mosscode/memory_extract.py +245 -0
- mosscode/messages.py +216 -0
- mosscode/paths.py +31 -0
- mosscode/permissions.py +733 -0
- mosscode/prompts.py +483 -0
- mosscode/providers.py +587 -0
- mosscode/scrollback.py +106 -0
- mosscode/security.py +282 -0
- mosscode/serialization.py +121 -0
- mosscode/session.py +563 -0
- mosscode/setup.py +248 -0
- mosscode/skills/__init__.py +455 -0
- mosscode/skills/builtin/commit/SKILL.md +15 -0
- mosscode/skills/builtin/review/SKILL.md +15 -0
- mosscode/skills/builtin/test/SKILL.md +13 -0
- mosscode/skills/builtin/test/scripts/detect.py +96 -0
- mosscode/skills/builtin/test/tool.json +9 -0
- mosscode/teams/__init__.py +216 -0
- mosscode/teams/backends.py +259 -0
- mosscode/teams/board.py +230 -0
- mosscode/teams/lock.py +173 -0
- mosscode/teams/mailbox.py +172 -0
- mosscode/teams/member.py +427 -0
- mosscode/teams/merge.py +207 -0
- mosscode/teams/pane.py +20 -0
- mosscode/teams/pane_app.py +167 -0
- mosscode/teams/process.py +371 -0
- mosscode/teams/runtime.py +1355 -0
- mosscode/teams/view.py +182 -0
- mosscode/tool_budget.py +86 -0
- mosscode/tools/__init__.py +38 -0
- mosscode/tools/agent.py +127 -0
- mosscode/tools/base.py +384 -0
- mosscode/tools/diffing.py +16 -0
- mosscode/tools/execution.py +99 -0
- mosscode/tools/files.py +287 -0
- mosscode/tools/memory.py +65 -0
- mosscode/tools/plan.py +30 -0
- mosscode/tools/process.py +216 -0
- mosscode/tools/search.py +229 -0
- mosscode/tools/search_worker.py +243 -0
- mosscode/tools/shell.py +121 -0
- mosscode/tools/skill.py +118 -0
- mosscode/tools/team.py +447 -0
- mosscode/tools/windows_job.py +75 -0
- mosscode/tui.py +2325 -0
- mosscode/usage.py +68 -0
- mosscode/windows_inline.py +148 -0
- mosscode/worktree/__init__.py +29 -0
- mosscode/worktree/changes.py +59 -0
- mosscode/worktree/git.py +125 -0
- mosscode/worktree/manager.py +462 -0
- mosscode/worktree/meta.py +82 -0
- mosscode/worktree/names.py +52 -0
- mosscode/worktree/setup.py +119 -0
- mosscode-0.1.0.dist-info/METADATA +92 -0
- mosscode-0.1.0.dist-info/RECORD +91 -0
- mosscode-0.1.0.dist-info/WHEEL +5 -0
- mosscode-0.1.0.dist-info/entry_points.txt +3 -0
- mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
- mosscode-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"""Create, resume, check, delete and clean the worktrees MossCode manages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import stat
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import git
|
|
15
|
+
from .changes import Changes, count_changes
|
|
16
|
+
from .git import WorktreeError
|
|
17
|
+
from .meta import WorktreeMeta, load_meta, process_alive, save_meta
|
|
18
|
+
from .names import EPHEMERAL, branch_of, ephemeral_name, flatten, validate_name
|
|
19
|
+
from .setup import copy_included, copy_local, create_links, is_link, unlink
|
|
20
|
+
|
|
21
|
+
WORKTREES = Path(".mosscode") / "worktrees"
|
|
22
|
+
META = ".meta"
|
|
23
|
+
CLEAN_INTERVAL = 24 * 3600
|
|
24
|
+
DEFAULT_STALE_HOURS = 24.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class WorktreeChanged(WorktreeError):
|
|
28
|
+
def __init__(self, name: str, changes: Changes):
|
|
29
|
+
files = "、".join(changes.files)
|
|
30
|
+
super().__init__(
|
|
31
|
+
f"工作树 {name} 有改动({changes.describe()}{':' + files if files else ''}),未删除;"
|
|
32
|
+
"确认丢弃时加 --discard。",
|
|
33
|
+
"worktree_changed",
|
|
34
|
+
)
|
|
35
|
+
self.changes = changes
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Created:
|
|
40
|
+
meta: WorktreeMeta
|
|
41
|
+
warnings: tuple[str, ...] = ()
|
|
42
|
+
resumed: bool = False
|
|
43
|
+
dirty: bool = False # The main directory had uncommitted changes.
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Finished:
|
|
48
|
+
kept: bool
|
|
49
|
+
changes: Changes
|
|
50
|
+
error: str = "" # No changes, but deleting failed.
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Entry:
|
|
55
|
+
meta: WorktreeMeta | None
|
|
56
|
+
name: str
|
|
57
|
+
status: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class CleanReport:
|
|
62
|
+
removed: list[str] = field(default_factory=list)
|
|
63
|
+
skipped: list[tuple[str, str]] = field(default_factory=list)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _now() -> str:
|
|
67
|
+
return datetime.now().astimezone().isoformat(timespec="seconds")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _writable(function, path, _exc):
|
|
71
|
+
"""Git objects are read-only on Windows."""
|
|
72
|
+
os.chmod(path, stat.S_IWRITE)
|
|
73
|
+
function(path)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class WorktreeManager:
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
top: Path,
|
|
80
|
+
common: Path,
|
|
81
|
+
project: Path | None = None,
|
|
82
|
+
*,
|
|
83
|
+
links=(),
|
|
84
|
+
stale_hours: float = DEFAULT_STALE_HOURS,
|
|
85
|
+
):
|
|
86
|
+
self.top, self.common = top.resolve(), common.resolve()
|
|
87
|
+
self.project = (project or top).resolve()
|
|
88
|
+
self.links = tuple(links)
|
|
89
|
+
self.stale_hours = stale_hours
|
|
90
|
+
self._lock = asyncio.Lock() # Creating and deleting run one at a time.
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def discover(cls, project: Path, **options) -> WorktreeManager:
|
|
94
|
+
top, common = git.find_repo(project)
|
|
95
|
+
return cls(top, common, project, **options)
|
|
96
|
+
|
|
97
|
+
# Paths ---------------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def base(self) -> Path:
|
|
101
|
+
return self.top / WORKTREES
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def meta_dir(self) -> Path:
|
|
105
|
+
return self.base / META
|
|
106
|
+
|
|
107
|
+
def meta_path(self, flat: str) -> Path:
|
|
108
|
+
return self.meta_dir / f"{flat}.json"
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def project_part(self) -> str:
|
|
112
|
+
"""Where the project sits inside the repository ("" at the top level)."""
|
|
113
|
+
try:
|
|
114
|
+
relative = self.project.relative_to(self.top).as_posix()
|
|
115
|
+
except ValueError:
|
|
116
|
+
return ""
|
|
117
|
+
return "" if relative == "." else relative
|
|
118
|
+
|
|
119
|
+
def root_in(self, meta: WorktreeMeta) -> Path:
|
|
120
|
+
part = self.project_part
|
|
121
|
+
return Path(meta.path) / part if part else Path(meta.path)
|
|
122
|
+
|
|
123
|
+
def display(self, path: Path | str) -> str:
|
|
124
|
+
path = Path(path)
|
|
125
|
+
try:
|
|
126
|
+
return path.relative_to(self.project).as_posix()
|
|
127
|
+
except ValueError:
|
|
128
|
+
return str(path)
|
|
129
|
+
|
|
130
|
+
def metas(self) -> list[tuple[Path, WorktreeMeta]]:
|
|
131
|
+
try:
|
|
132
|
+
files = sorted(self.meta_dir.glob("*.json"))
|
|
133
|
+
except OSError:
|
|
134
|
+
return []
|
|
135
|
+
found = [(path, load_meta(path)) for path in files]
|
|
136
|
+
return [(path, meta) for path, meta in found if meta is not None]
|
|
137
|
+
|
|
138
|
+
# Creating ------------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
async def create(self, name: str, *, manual: bool = True, task: str = "") -> Created:
|
|
141
|
+
async with self._lock:
|
|
142
|
+
return await asyncio.to_thread(self._create, name, manual, task)
|
|
143
|
+
|
|
144
|
+
async def ephemeral(self, task: str) -> Created:
|
|
145
|
+
"""A name nobody chose, marked in use by this process."""
|
|
146
|
+
async with self._lock:
|
|
147
|
+
for _ in range(5):
|
|
148
|
+
name = ephemeral_name()
|
|
149
|
+
if not (self.base / name).exists() and not self.meta_path(name).exists():
|
|
150
|
+
return await asyncio.to_thread(self._create, name, False, task)
|
|
151
|
+
raise WorktreeError("无法生成不重复的工作树名称。")
|
|
152
|
+
|
|
153
|
+
def _create(self, name: str, manual: bool, task: str) -> Created:
|
|
154
|
+
problem = validate_name(name)
|
|
155
|
+
if problem:
|
|
156
|
+
raise WorktreeError(f"名称 {name} 不可用:{problem}。", "invalid_name")
|
|
157
|
+
flat = flatten(name)
|
|
158
|
+
path, meta_path = self.base / flat, self.meta_path(flat)
|
|
159
|
+
self._check_case(flat)
|
|
160
|
+
if path.exists():
|
|
161
|
+
return self._resume(flat, path, meta_path)
|
|
162
|
+
leftover = load_meta(meta_path)
|
|
163
|
+
if leftover is not None: # The directory is gone: finish what the record says.
|
|
164
|
+
self._forget(leftover, meta_path)
|
|
165
|
+
git.ensure_excluded(self.common)
|
|
166
|
+
base = git.git_text(["rev-parse", "HEAD"], self.top)
|
|
167
|
+
branch = branch_of(flat)
|
|
168
|
+
if git.run_git(["rev-parse", "--verify", "--quiet", f"refs/heads/{branch}"], self.top).returncode == 0:
|
|
169
|
+
raise WorktreeError(f"分支 {branch} 已存在;换一个名称,或先删除这个分支。", "branch_exists")
|
|
170
|
+
dirty = bool(git.git_text(["status", "--porcelain"], self.top))
|
|
171
|
+
self.base.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
done = git.run_git(["worktree", "add", "-b", branch, "--", str(path), base], self.top)
|
|
173
|
+
if done.returncode != 0:
|
|
174
|
+
self._undo(path, branch)
|
|
175
|
+
raise WorktreeError(f"git worktree add 失败:{(done.stderr or done.stdout).strip()[:500]}")
|
|
176
|
+
in_use = {"pid": os.getpid(), "task": task} if task else None
|
|
177
|
+
meta = WorktreeMeta(name, flat, str(path), branch, base, _now(), manual, in_use=in_use)
|
|
178
|
+
save_meta(meta, meta_path)
|
|
179
|
+
warnings = self._setup(meta, meta_path)
|
|
180
|
+
return Created(meta, tuple(warnings), False, dirty)
|
|
181
|
+
|
|
182
|
+
def _check_case(self, flat: str) -> None:
|
|
183
|
+
"""Windows file names ignore case, so names must too."""
|
|
184
|
+
folded = flat.casefold()
|
|
185
|
+
names = {meta.flat for _, meta in self.metas()}
|
|
186
|
+
try:
|
|
187
|
+
names.update(child.name for child in self.base.iterdir() if child.name != META)
|
|
188
|
+
except OSError:
|
|
189
|
+
pass
|
|
190
|
+
clash = next((other for other in names if other != flat and other.casefold() == folded), None)
|
|
191
|
+
if clash is not None:
|
|
192
|
+
raise WorktreeError(f"名称与已有的工作树 {clash} 只差大小写。", "invalid_name")
|
|
193
|
+
|
|
194
|
+
def _undo(self, path: Path, branch: str) -> None:
|
|
195
|
+
if path.exists() and not is_link(path):
|
|
196
|
+
shutil.rmtree(path, onexc=_writable)
|
|
197
|
+
git.run_git(["worktree", "prune"], self.top)
|
|
198
|
+
git.run_git(["branch", "-D", branch], self.top)
|
|
199
|
+
|
|
200
|
+
def _setup(self, meta: WorktreeMeta, meta_path: Path) -> list[str]:
|
|
201
|
+
"""Links, copies and the setup command in order; the record says when it is complete."""
|
|
202
|
+
warnings: list[str] = []
|
|
203
|
+
path = Path(meta.path)
|
|
204
|
+
|
|
205
|
+
def copied(items) -> None:
|
|
206
|
+
meta.copied.extend(item for item in items if item not in meta.copied)
|
|
207
|
+
|
|
208
|
+
steps = (
|
|
209
|
+
lambda: copied(copy_local(self.top, path, self.project_part, warnings)),
|
|
210
|
+
lambda: meta.links.extend(
|
|
211
|
+
item for item in create_links(self.top, path, self.links, warnings) if item not in meta.links
|
|
212
|
+
),
|
|
213
|
+
lambda: copied(copy_included(self.top, path, meta.links, warnings)),
|
|
214
|
+
)
|
|
215
|
+
for step in steps:
|
|
216
|
+
try:
|
|
217
|
+
step()
|
|
218
|
+
except Exception as exc: # Best effort: a failed step never fails the worktree.
|
|
219
|
+
warnings.append(f"初始化未完成一步:{exc}")
|
|
220
|
+
if (self.top / ".gitmodules").is_file():
|
|
221
|
+
warnings.append("仓库有子模块,工作树中的子模块未初始化。")
|
|
222
|
+
meta.setup_done = True
|
|
223
|
+
save_meta(meta, meta_path)
|
|
224
|
+
return warnings
|
|
225
|
+
|
|
226
|
+
def _resume(self, flat: str, path: Path, meta_path: Path) -> Created:
|
|
227
|
+
"""The filesystem only, no git process."""
|
|
228
|
+
head = git.read_head(path)
|
|
229
|
+
meta = load_meta(meta_path)
|
|
230
|
+
if head is None or meta is None or meta.flat != flat:
|
|
231
|
+
raise WorktreeError(
|
|
232
|
+
f"{self.display(path)} 存在,但不是有效的 MossCode 工作树;"
|
|
233
|
+
f"用 /worktree remove {flat} --discard 清理,或手动处理。",
|
|
234
|
+
"worktree_invalid",
|
|
235
|
+
)
|
|
236
|
+
if meta.removing:
|
|
237
|
+
raise WorktreeError(
|
|
238
|
+
f"工作树 {meta.name} 的删除尚未完成;用 /worktree remove {meta.name} 再试。", "worktree_invalid"
|
|
239
|
+
)
|
|
240
|
+
warnings = []
|
|
241
|
+
if head[1] != f"refs/heads/{meta.branch}":
|
|
242
|
+
warnings.append(f"工作树当前不在分支 {meta.branch} 上。")
|
|
243
|
+
if not meta.setup_done:
|
|
244
|
+
warnings.extend(self._setup(meta, meta_path))
|
|
245
|
+
return Created(meta, tuple(warnings), True, False)
|
|
246
|
+
|
|
247
|
+
# Changes and deleting ------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
def changes(self, meta: WorktreeMeta) -> Changes:
|
|
250
|
+
path = Path(meta.path)
|
|
251
|
+
if path.is_dir():
|
|
252
|
+
return count_changes(path, meta.branch, (*meta.links, *meta.copied))
|
|
253
|
+
# The directory is gone: only the branch can still hold work.
|
|
254
|
+
if git.run_git(["rev-parse", "--verify", "--quiet", f"refs/heads/{meta.branch}"], self.top).returncode != 0:
|
|
255
|
+
return Changes()
|
|
256
|
+
done = git.run_git(
|
|
257
|
+
[
|
|
258
|
+
"rev-list",
|
|
259
|
+
"--count",
|
|
260
|
+
f"refs/heads/{meta.branch}",
|
|
261
|
+
"--not",
|
|
262
|
+
f"--exclude={meta.branch}",
|
|
263
|
+
"--branches",
|
|
264
|
+
"--remotes",
|
|
265
|
+
],
|
|
266
|
+
self.top,
|
|
267
|
+
)
|
|
268
|
+
if done.returncode != 0:
|
|
269
|
+
return Changes(1, 0, (), done.stderr.strip()[:200] or "git rev-list 失败")
|
|
270
|
+
return Changes(0, int(done.stdout.strip() or "0"))
|
|
271
|
+
|
|
272
|
+
async def remove(self, name: str, *, discard: bool = False) -> Changes:
|
|
273
|
+
async with self._lock:
|
|
274
|
+
return await asyncio.to_thread(self._remove, name, discard)
|
|
275
|
+
|
|
276
|
+
def _remove(self, name: str, discard: bool) -> Changes:
|
|
277
|
+
problem = validate_name(name)
|
|
278
|
+
if problem:
|
|
279
|
+
raise WorktreeError(f"名称 {name} 不可用:{problem}。", "invalid_name")
|
|
280
|
+
flat = flatten(name)
|
|
281
|
+
path, meta_path = self.base / flat, self.meta_path(flat)
|
|
282
|
+
meta = load_meta(meta_path)
|
|
283
|
+
if meta is None:
|
|
284
|
+
if path.exists():
|
|
285
|
+
raise WorktreeError(
|
|
286
|
+
f"{self.display(path)} 没有 MossCode 的记录,不会自动删除;请手动处理。", "worktree_invalid"
|
|
287
|
+
)
|
|
288
|
+
raise WorktreeError(f"没有名为 {name} 的工作树。", "unknown_worktree")
|
|
289
|
+
if meta.in_use and process_alive(int(meta.in_use.get("pid", 0))):
|
|
290
|
+
task = meta.in_use.get("task") or "?"
|
|
291
|
+
raise WorktreeError(f"工作树 {name} 正被子 Agent 使用(任务 {task}),结束后再删除。", "worktree_in_use")
|
|
292
|
+
changes = Changes() if meta.removing else self.changes(meta)
|
|
293
|
+
if changes.any and not discard:
|
|
294
|
+
raise WorktreeChanged(name, changes)
|
|
295
|
+
self._delete(meta, meta_path)
|
|
296
|
+
return changes
|
|
297
|
+
|
|
298
|
+
def _forget(self, meta: WorktreeMeta, meta_path: Path) -> None:
|
|
299
|
+
"""A record whose directory is gone: drop it; the branch stays unless it holds nothing."""
|
|
300
|
+
drop = meta.removing or not self.changes(meta).any
|
|
301
|
+
git.run_git(["worktree", "prune"], self.top)
|
|
302
|
+
if drop:
|
|
303
|
+
git.run_git(["branch", "-D", meta.branch], self.top)
|
|
304
|
+
meta_path.unlink(missing_ok=True)
|
|
305
|
+
|
|
306
|
+
def _delete(self, meta: WorktreeMeta, meta_path: Path) -> None:
|
|
307
|
+
"""After the change check passed or the user discarded. Never `git worktree remove`."""
|
|
308
|
+
path = Path(meta.path).resolve()
|
|
309
|
+
if path.parent != self.base.resolve():
|
|
310
|
+
raise WorktreeError(
|
|
311
|
+
f"记录中的路径 {path} 不在 {self.display(self.base)} 中,拒绝删除。", "worktree_invalid"
|
|
312
|
+
)
|
|
313
|
+
meta.removing, meta.in_use = True, None
|
|
314
|
+
save_meta(meta, meta_path) # An interrupted deletion is retried, never re-checked into "changed".
|
|
315
|
+
for relative in meta.links:
|
|
316
|
+
link = path / relative
|
|
317
|
+
if is_link(link):
|
|
318
|
+
try:
|
|
319
|
+
unlink(link)
|
|
320
|
+
except OSError:
|
|
321
|
+
pass # rmtree below does not follow links either.
|
|
322
|
+
if path.exists():
|
|
323
|
+
if is_link(path):
|
|
324
|
+
raise WorktreeError(f"{self.display(path)} 是一个链接,拒绝删除。", "worktree_invalid")
|
|
325
|
+
try:
|
|
326
|
+
shutil.rmtree(path, onexc=_writable)
|
|
327
|
+
except OSError as exc:
|
|
328
|
+
raise WorktreeError(
|
|
329
|
+
f"删除 {self.display(path)} 未完成:{exc.strerror or exc};"
|
|
330
|
+
f"关闭占用它的程序后用 /worktree remove {meta.name} 重试。",
|
|
331
|
+
"remove_failed",
|
|
332
|
+
) from None
|
|
333
|
+
git.run_git(["worktree", "prune"], self.top)
|
|
334
|
+
git.run_git(["branch", "-D", meta.branch], self.top)
|
|
335
|
+
meta_path.unlink(missing_ok=True)
|
|
336
|
+
|
|
337
|
+
# Sub-agents ----------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
async def finish(self, meta: WorktreeMeta) -> Finished:
|
|
340
|
+
"""No changes -> delete; changes -> keep. Always clears in_use."""
|
|
341
|
+
async with self._lock:
|
|
342
|
+
return await asyncio.to_thread(self._finish, meta)
|
|
343
|
+
|
|
344
|
+
def _finish(self, meta: WorktreeMeta) -> Finished:
|
|
345
|
+
meta_path = self.meta_path(meta.flat)
|
|
346
|
+
current = load_meta(meta_path) or meta
|
|
347
|
+
current.in_use = None
|
|
348
|
+
save_meta(current, meta_path)
|
|
349
|
+
changes = self.changes(current)
|
|
350
|
+
if changes.any:
|
|
351
|
+
return Finished(True, changes)
|
|
352
|
+
try:
|
|
353
|
+
self._delete(current, meta_path)
|
|
354
|
+
except WorktreeError as exc:
|
|
355
|
+
return Finished(False, changes, str(exc))
|
|
356
|
+
return Finished(False, changes)
|
|
357
|
+
|
|
358
|
+
async def mark_in_use(self, flat: str, task: str = "", pid: int | None = None) -> None:
|
|
359
|
+
"""A team member's worktree is in use while the member runs; "" clears it. Never deletes.
|
|
360
|
+
|
|
361
|
+
pid: the process running the member (a pane member's own), this one by default."""
|
|
362
|
+
async with self._lock:
|
|
363
|
+
meta_path = self.meta_path(flat)
|
|
364
|
+
meta = load_meta(meta_path)
|
|
365
|
+
if meta is not None:
|
|
366
|
+
meta.in_use = {"pid": pid or os.getpid(), "task": task} if task else None
|
|
367
|
+
await asyncio.to_thread(save_meta, meta, meta_path)
|
|
368
|
+
|
|
369
|
+
def report(self, meta: WorktreeMeta, finished: Finished) -> str:
|
|
370
|
+
"""The lines after a sub-agent's result."""
|
|
371
|
+
if not finished.kept:
|
|
372
|
+
if finished.error:
|
|
373
|
+
return f"[工作树] 无改动,但删除未完成:{finished.error}"
|
|
374
|
+
return "[工作树] 无改动,已删除。"
|
|
375
|
+
where = self.display(meta.path)
|
|
376
|
+
return (
|
|
377
|
+
f"[工作树] 已保留:{where}\n"
|
|
378
|
+
f"分支 {meta.branch},基于 {meta.base[:7]};{finished.changes.describe()}。\n"
|
|
379
|
+
f"合并:git merge {meta.branch};查看:git -C {where} status;删除:/worktree remove {meta.name}"
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
# Listing and cleaning ------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
async def entries(self) -> list[Entry]:
|
|
385
|
+
return await asyncio.to_thread(self._entries)
|
|
386
|
+
|
|
387
|
+
def _entries(self) -> list[Entry]:
|
|
388
|
+
found, known = [], set()
|
|
389
|
+
for _, meta in self.metas():
|
|
390
|
+
known.add(meta.flat)
|
|
391
|
+
found.append(Entry(meta, meta.name, self._status(meta)))
|
|
392
|
+
try:
|
|
393
|
+
others = sorted(child.name for child in self.base.iterdir() if child.is_dir() and child.name != META)
|
|
394
|
+
except OSError:
|
|
395
|
+
others = []
|
|
396
|
+
found.extend(Entry(None, name, "未登记(没有 MossCode 的记录)") for name in others if name not in known)
|
|
397
|
+
return found
|
|
398
|
+
|
|
399
|
+
def _status(self, meta: WorktreeMeta) -> str:
|
|
400
|
+
if meta.removing:
|
|
401
|
+
return "删除未完成"
|
|
402
|
+
if meta.in_use and process_alive(int(meta.in_use.get("pid", 0))):
|
|
403
|
+
return f"使用中(任务 {meta.in_use.get('task') or '?'})"
|
|
404
|
+
if not Path(meta.path).is_dir():
|
|
405
|
+
return "目录不存在"
|
|
406
|
+
changes = self.changes(meta)
|
|
407
|
+
return f"有改动({changes.describe()})" if changes.any else "无改动"
|
|
408
|
+
|
|
409
|
+
async def clean(self, hours: float | None = None) -> CleanReport:
|
|
410
|
+
async with self._lock:
|
|
411
|
+
return await asyncio.to_thread(self._clean, self.stale_hours if hours is None else hours)
|
|
412
|
+
|
|
413
|
+
def _clean(self, hours: float) -> CleanReport:
|
|
414
|
+
"""Only records MossCode made for sub-agents, not in use, old, and without changes."""
|
|
415
|
+
report = CleanReport()
|
|
416
|
+
cutoff = time.time() - hours * 3600
|
|
417
|
+
for meta_path, meta in self.metas():
|
|
418
|
+
if meta.manual:
|
|
419
|
+
report.skipped.append((meta.name, "手动创建"))
|
|
420
|
+
continue
|
|
421
|
+
if not EPHEMERAL.fullmatch(meta.name):
|
|
422
|
+
report.skipped.append((meta.name, "名称不是临时格式"))
|
|
423
|
+
continue
|
|
424
|
+
if meta.in_use and process_alive(int(meta.in_use.get("pid", 0))):
|
|
425
|
+
report.skipped.append((meta.name, "使用中"))
|
|
426
|
+
continue
|
|
427
|
+
try:
|
|
428
|
+
if meta_path.stat().st_mtime > cutoff:
|
|
429
|
+
report.skipped.append((meta.name, "未过期"))
|
|
430
|
+
continue
|
|
431
|
+
except OSError:
|
|
432
|
+
continue
|
|
433
|
+
if not meta.removing:
|
|
434
|
+
changes = self.changes(meta)
|
|
435
|
+
if changes.any:
|
|
436
|
+
report.skipped.append((meta.name, f"有改动({changes.describe()})"))
|
|
437
|
+
continue
|
|
438
|
+
try:
|
|
439
|
+
self._delete(meta, meta_path)
|
|
440
|
+
except WorktreeError as exc:
|
|
441
|
+
report.skipped.append((meta.name, str(exc)))
|
|
442
|
+
continue
|
|
443
|
+
report.removed.append(meta.name)
|
|
444
|
+
try:
|
|
445
|
+
git.run_git(["worktree", "prune"], self.top)
|
|
446
|
+
except WorktreeError:
|
|
447
|
+
pass
|
|
448
|
+
return report
|
|
449
|
+
|
|
450
|
+
async def keep_clean(self, on_report, *, delay: float = 5.0, interval: float = CLEAN_INTERVAL) -> None:
|
|
451
|
+
"""In the background, after start and then every day; nothing when turned off."""
|
|
452
|
+
if self.stale_hours <= 0:
|
|
453
|
+
return
|
|
454
|
+
await asyncio.sleep(delay)
|
|
455
|
+
while True:
|
|
456
|
+
try:
|
|
457
|
+
report = await self.clean()
|
|
458
|
+
except Exception: # A broken repository must not take the session down.
|
|
459
|
+
report = None
|
|
460
|
+
if report is not None and report.removed:
|
|
461
|
+
on_report(report)
|
|
462
|
+
await asyncio.sleep(interval)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""What MossCode records about each worktree it made; the record is what makes it safe to clean."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import asdict, dataclass, field, fields
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class WorktreeMeta:
|
|
13
|
+
name: str # As given, may contain /
|
|
14
|
+
flat: str # Directory name and the branch name after mosscode/
|
|
15
|
+
path: str # Absolute
|
|
16
|
+
branch: str
|
|
17
|
+
base: str # Full SHA the worktree started from
|
|
18
|
+
created: str # ISO time
|
|
19
|
+
manual: bool # /worktree create: never cleaned automatically
|
|
20
|
+
links: list[str] = field(default_factory=list) # Relative to the worktree's top level
|
|
21
|
+
copied: list[str] = field(default_factory=list) # Copied files; not work of the sub-agent
|
|
22
|
+
setup_done: bool = False
|
|
23
|
+
in_use: dict | None = None # {"pid": 1234, "task": "t3"} while a sub-agent works in it
|
|
24
|
+
removing: bool = False # Deletion started and did not finish
|
|
25
|
+
|
|
26
|
+
def to_json(self) -> str:
|
|
27
|
+
return json.dumps(asdict(self), ensure_ascii=False, indent=2)
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_dict(cls, data: dict) -> WorktreeMeta:
|
|
31
|
+
known = {item.name for item in fields(cls)}
|
|
32
|
+
return cls(**{key: value for key, value in data.items() if key in known})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_meta(path: Path) -> WorktreeMeta | None:
|
|
36
|
+
try:
|
|
37
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
38
|
+
return WorktreeMeta.from_dict(data) if isinstance(data, dict) else None
|
|
39
|
+
except (OSError, ValueError, TypeError):
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def save_meta(meta: WorktreeMeta, path: Path) -> None:
|
|
44
|
+
"""Atomic: a reader sees the old record or the new one."""
|
|
45
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
temporary = path.with_name(path.name + ".tmp")
|
|
47
|
+
temporary.write_text(meta.to_json(), encoding="utf-8")
|
|
48
|
+
os.replace(temporary, path)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def process_alive(pid: int) -> bool:
|
|
52
|
+
"""Never os.kill(pid, 0) on Windows: there it terminates the process."""
|
|
53
|
+
if pid <= 0:
|
|
54
|
+
return False
|
|
55
|
+
if os.name == "nt":
|
|
56
|
+
import ctypes
|
|
57
|
+
from ctypes import wintypes
|
|
58
|
+
|
|
59
|
+
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
60
|
+
kernel.OpenProcess.restype = wintypes.HANDLE
|
|
61
|
+
kernel.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
|
|
62
|
+
kernel.GetExitCodeProcess.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD))
|
|
63
|
+
kernel.CloseHandle.argtypes = (wintypes.HANDLE,)
|
|
64
|
+
handle = kernel.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
|
|
65
|
+
if not handle:
|
|
66
|
+
return ctypes.get_last_error() == 5 # Access denied: it exists.
|
|
67
|
+
try:
|
|
68
|
+
code = wintypes.DWORD()
|
|
69
|
+
if not kernel.GetExitCodeProcess(handle, ctypes.byref(code)):
|
|
70
|
+
return True
|
|
71
|
+
return code.value == 259 # STILL_ACTIVE
|
|
72
|
+
finally:
|
|
73
|
+
kernel.CloseHandle(handle)
|
|
74
|
+
try:
|
|
75
|
+
os.kill(pid, 0)
|
|
76
|
+
except ProcessLookupError:
|
|
77
|
+
return False
|
|
78
|
+
except PermissionError:
|
|
79
|
+
return True
|
|
80
|
+
except OSError:
|
|
81
|
+
return False
|
|
82
|
+
return True
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Worktree names. A name may come from a command, so it is checked before it reaches a path or git."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import secrets
|
|
7
|
+
|
|
8
|
+
NAME_LIMIT = 64
|
|
9
|
+
SEGMENT = re.compile(r"[A-Za-z0-9._-]+")
|
|
10
|
+
EPHEMERAL = re.compile(r"agent-[0-9a-f]{8}")
|
|
11
|
+
BRANCH_PREFIX = "mosscode/"
|
|
12
|
+
# Names Windows reserves for devices, with or without an extension.
|
|
13
|
+
RESERVED = frozenset(
|
|
14
|
+
{"con", "prn", "aux", "nul", *(f"com{i}" for i in range(1, 10)), *(f"lpt{i}" for i in range(1, 10))}
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def validate_name(name: str) -> str | None:
|
|
19
|
+
"""None when the name is usable, otherwise the reason."""
|
|
20
|
+
if not name:
|
|
21
|
+
return "名称不能为空"
|
|
22
|
+
if len(name) > NAME_LIMIT:
|
|
23
|
+
return f"名称最长 {NAME_LIMIT} 个字符"
|
|
24
|
+
for segment in name.split("/"):
|
|
25
|
+
if not segment:
|
|
26
|
+
return "名称中不能有空段(// 或开头、结尾的 /)"
|
|
27
|
+
if segment in {".", ".."}:
|
|
28
|
+
return "名称中不能有 . 或 .. 段"
|
|
29
|
+
if ".." in segment: # git refuses it in a branch name.
|
|
30
|
+
return "名称中不能有连续的 .."
|
|
31
|
+
if not SEGMENT.fullmatch(segment):
|
|
32
|
+
return f"段 {segment!r} 只能含字母、数字、.、_ 和 -"
|
|
33
|
+
if segment[0] in ".-":
|
|
34
|
+
return f"段 {segment!r} 不能以 . 或 - 开头"
|
|
35
|
+
if segment.endswith(".") or segment.lower().endswith(".lock"):
|
|
36
|
+
return f"段 {segment!r} 不能以 . 或 .lock 结尾"
|
|
37
|
+
if segment.split(".", 1)[0].lower() in RESERVED:
|
|
38
|
+
return f"段 {segment!r} 是 Windows 保留的设备名"
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def flatten(name: str) -> str:
|
|
43
|
+
"""One directory level and one branch level, so a/b never collides with a branch a (git D/F)."""
|
|
44
|
+
return name.replace("/", "+")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def branch_of(flat: str) -> str:
|
|
48
|
+
return BRANCH_PREFIX + flat
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def ephemeral_name() -> str:
|
|
52
|
+
return f"agent-{secrets.token_hex(4)}"
|