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
mosscode/usage.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Normalize service-reported counters without inventing missing measurements."""
|
|
2
|
+
|
|
3
|
+
from .messages import Usage, UsageTotals
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def usage_data(value) -> dict:
|
|
7
|
+
if value is None:
|
|
8
|
+
return {}
|
|
9
|
+
return value if isinstance(value, dict) else value.model_dump()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def counter(data: dict, name: str) -> int | None:
|
|
13
|
+
value = data.get(name)
|
|
14
|
+
return value if type(value) is int and value >= 0 else None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_usage(value, protocol: str) -> Usage:
|
|
18
|
+
data = usage_data(value)
|
|
19
|
+
output = counter(data, "output_tokens" if protocol != "openai-chat-completions" else "completion_tokens")
|
|
20
|
+
if protocol == "anthropic-messages":
|
|
21
|
+
uncached = counter(data, "input_tokens")
|
|
22
|
+
read = counter(data, "cache_read_input_tokens")
|
|
23
|
+
write = counter(data, "cache_creation_input_tokens")
|
|
24
|
+
if data.get("cache_read_input_tokens") is None and data.get("cache_creation_input_tokens") is None:
|
|
25
|
+
total = uncached # Older compatible endpoints may report only total input.
|
|
26
|
+
uncached = None # No reliable decomposition of that reported total.
|
|
27
|
+
else:
|
|
28
|
+
total = uncached + read + write if all(v is not None for v in (uncached, read, write)) else None
|
|
29
|
+
return Usage(total, output, read, write, uncached)
|
|
30
|
+
chat = protocol == "openai-chat-completions"
|
|
31
|
+
total = counter(data, "prompt_tokens" if chat else "input_tokens")
|
|
32
|
+
details = data.get("prompt_tokens_details" if chat else "input_tokens_details") or {}
|
|
33
|
+
if not isinstance(details, dict):
|
|
34
|
+
details = {}
|
|
35
|
+
read = counter(details, "cached_tokens")
|
|
36
|
+
write = counter(details, "cache_write_tokens")
|
|
37
|
+
miss = None
|
|
38
|
+
if chat:
|
|
39
|
+
vendor_read = counter(data, "prompt_cache_hit_tokens")
|
|
40
|
+
if read is None:
|
|
41
|
+
read = vendor_read
|
|
42
|
+
miss = counter(data, "prompt_cache_miss_tokens")
|
|
43
|
+
return Usage(total, output, read, write, miss)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def accumulated(value: int, missing: int, requests: int, unknown: str = "未提供") -> str:
|
|
47
|
+
if missing == requests:
|
|
48
|
+
return unknown
|
|
49
|
+
return ("≥" if missing else "") + str(value)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cache_summary(total: UsageTotals) -> str:
|
|
53
|
+
read = accumulated(total.cached_input_tokens, total.missing_cached, total.requests)
|
|
54
|
+
text = f"缓存读 {read}"
|
|
55
|
+
if total.missing_cache_write < total.requests:
|
|
56
|
+
text += " / 写 " + accumulated(total.cache_write_tokens, total.missing_cache_write, total.requests)
|
|
57
|
+
return text
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def request_usage_text(usage: Usage, iteration: int) -> str:
|
|
61
|
+
def show(value):
|
|
62
|
+
return "未提供" if value is None else str(value)
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
f"第 {iteration} 次请求 · Token 入 {show(usage.input_tokens)} / 出 {show(usage.output_tokens)}"
|
|
66
|
+
f" · 缓存读 {show(usage.cached_input_tokens)} / 写 {show(usage.cache_write_tokens)}"
|
|
67
|
+
f" · 未缓存输入 {show(usage.uncached_input_tokens)}"
|
|
68
|
+
)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Use Textual's inline compositor with its existing Windows input driver.
|
|
2
|
+
|
|
3
|
+
Textual 8 does not ship a Windows inline driver. Keep its console input and mode
|
|
4
|
+
restoration, but stay in the main buffer and consume cursor-position reports.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import ctypes
|
|
9
|
+
from ctypes import byref, wintypes
|
|
10
|
+
|
|
11
|
+
from textual import constants, events
|
|
12
|
+
from textual._xterm_parser import XTermParser
|
|
13
|
+
from textual.drivers import win32
|
|
14
|
+
from textual.drivers._writer_thread import WriterThread
|
|
15
|
+
from textual.drivers.windows_driver import WindowsDriver
|
|
16
|
+
|
|
17
|
+
KEY_EVENT, WINDOW_BUFFER_SIZE_EVENT = 0x0001, 0x0004
|
|
18
|
+
VK_RETURN, VK_SHIFT = 0x0D, 0x10
|
|
19
|
+
SHIFT_PRESSED = 0x0010
|
|
20
|
+
CTRL_OR_ALT = 0x000F # Right/left Alt and Ctrl.
|
|
21
|
+
# Kitty keyboard protocol encoding, which Textual's parser reads as "shift+enter".
|
|
22
|
+
SHIFT_ENTER = "\x1b[13;2u"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def physical_shift_down() -> bool:
|
|
26
|
+
return bool(ctypes.windll.user32.GetAsyncKeyState(VK_SHIFT) & 0x8000)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _shift_enter(records, index: int, shift_down) -> bool:
|
|
30
|
+
event = records[index].Event.KeyEvent
|
|
31
|
+
state = event.dwControlKeyState
|
|
32
|
+
if event.wVirtualKeyCode != VK_RETURN:
|
|
33
|
+
# With VT input enabled the console replaces Enter's key-down record by a
|
|
34
|
+
# bare "\r" without key code or modifiers, so Shift+Enter would submit.
|
|
35
|
+
# conhost keeps the modifiers on the key-up record when it follows in the
|
|
36
|
+
# same batch; Windows Terminal leaves them out there too.
|
|
37
|
+
following = records[index + 1] if index + 1 < len(records) else None
|
|
38
|
+
if (
|
|
39
|
+
following is not None
|
|
40
|
+
and following.EventType == KEY_EVENT
|
|
41
|
+
and not following.Event.KeyEvent.bKeyDown
|
|
42
|
+
and following.Event.KeyEvent.wVirtualKeyCode == VK_RETURN
|
|
43
|
+
):
|
|
44
|
+
state = following.Event.KeyEvent.dwControlKeyState
|
|
45
|
+
# A record may omit Shift; the keyboard state read right after the key press
|
|
46
|
+
# covers that, so either source is enough.
|
|
47
|
+
return (bool(state & SHIFT_PRESSED) or shift_down()) and not state & CTRL_OR_ALT
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def key_text(records, shift_down=physical_shift_down) -> str:
|
|
51
|
+
"""Text for the parser from one batch of console input records."""
|
|
52
|
+
keys = []
|
|
53
|
+
for index, record in enumerate(records):
|
|
54
|
+
if record.EventType != KEY_EVENT:
|
|
55
|
+
continue
|
|
56
|
+
event = record.Event.KeyEvent
|
|
57
|
+
if not event.bKeyDown or (event.dwControlKeyState and event.wVirtualKeyCode == 0):
|
|
58
|
+
continue
|
|
59
|
+
key = event.uChar.UnicodeChar
|
|
60
|
+
keys.append(SHIFT_ENTER if key == "\r" and _shift_enter(records, index, shift_down) else key)
|
|
61
|
+
# Textual issue 3178: join surrogate pairs typed as two records.
|
|
62
|
+
return "".join(keys).encode("utf-16", "surrogatepass").decode("utf-16")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class EventMonitor(win32.EventMonitor):
|
|
66
|
+
"""Textual's console input thread with Shift+Enter kept apart from Enter."""
|
|
67
|
+
|
|
68
|
+
def run(self) -> None:
|
|
69
|
+
exit_requested = self.exit_event.is_set
|
|
70
|
+
parser = XTermParser(debug=constants.DEBUG)
|
|
71
|
+
try:
|
|
72
|
+
read_count = wintypes.DWORD(0)
|
|
73
|
+
handle = win32.GetStdHandle(win32.STD_INPUT_HANDLE)
|
|
74
|
+
input_records = (win32.INPUT_RECORD * 1024)()
|
|
75
|
+
while not exit_requested():
|
|
76
|
+
for event in parser.tick():
|
|
77
|
+
self.process_event(event)
|
|
78
|
+
if win32.wait_for_handles([handle], 100) is None:
|
|
79
|
+
continue
|
|
80
|
+
win32.KERNEL32.ReadConsoleInputW(handle, byref(input_records), 1024, byref(read_count))
|
|
81
|
+
records = input_records[: read_count.value]
|
|
82
|
+
text = key_text(records)
|
|
83
|
+
new_size = None
|
|
84
|
+
for record in records:
|
|
85
|
+
if record.EventType == WINDOW_BUFFER_SIZE_EVENT:
|
|
86
|
+
size = record.Event.WindowBufferSizeEvent.dwSize
|
|
87
|
+
new_size = (size.X, size.Y)
|
|
88
|
+
if text:
|
|
89
|
+
for event in parser.feed(text):
|
|
90
|
+
self.process_event(event)
|
|
91
|
+
if new_size is not None:
|
|
92
|
+
self.on_size_change(*new_size)
|
|
93
|
+
except Exception as error:
|
|
94
|
+
self.app.log.error("EVENT MONITOR ERROR", error)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class WindowsInlineDriver(WindowsDriver):
|
|
98
|
+
@property
|
|
99
|
+
def is_inline(self) -> bool:
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
def start_application_mode(self) -> None:
|
|
103
|
+
# Same sequence as WindowsDriver, with the input thread replaced.
|
|
104
|
+
loop = asyncio.get_running_loop()
|
|
105
|
+
self._restore_console = win32.enable_application_mode()
|
|
106
|
+
self._writer_thread = WriterThread(self._file)
|
|
107
|
+
self._writer_thread.start()
|
|
108
|
+
self.write("\x1b[?1049h")
|
|
109
|
+
self._enable_mouse_support()
|
|
110
|
+
self.write("\x1b[?25l")
|
|
111
|
+
self.write("\033[?1004h")
|
|
112
|
+
self.write("\x1b[>1u")
|
|
113
|
+
self.flush()
|
|
114
|
+
self._enable_bracketed_paste()
|
|
115
|
+
self._event_thread = EventMonitor(loop, self._app, self.exit_event, self.process_message)
|
|
116
|
+
self._event_thread.start()
|
|
117
|
+
|
|
118
|
+
def write(self, data: str) -> None:
|
|
119
|
+
# WindowsDriver emits these only at startup / shutdown. Do not enter or
|
|
120
|
+
# leave the alternate screen: inline rendering uses relative cursor moves.
|
|
121
|
+
data = data.replace("\x1b[?1049h", "\r\n" * self._app.INLINE_PADDING)
|
|
122
|
+
# On exit Textual has already placed the cursor after retained output,
|
|
123
|
+
# or at the top of a temporary picker. Clear only below that cursor.
|
|
124
|
+
data = data.replace("\x1b[?1049l", "\x1b[J")
|
|
125
|
+
if data:
|
|
126
|
+
# Textual prints the retained screen directly on inline exit. Keep
|
|
127
|
+
# cursor updates synchronous so queued writes cannot race that print.
|
|
128
|
+
self._file.write(data)
|
|
129
|
+
|
|
130
|
+
def flush(self) -> None:
|
|
131
|
+
self._file.flush()
|
|
132
|
+
|
|
133
|
+
def _enable_mouse_support(self) -> None:
|
|
134
|
+
# Inline transcript uses the terminal's scrollback, even if a caller
|
|
135
|
+
# supplied mouse=True or an interrupted older app left tracking enabled.
|
|
136
|
+
self.write("\x1b[?1000l\x1b[?1003l\x1b[?1015l\x1b[?1006l")
|
|
137
|
+
self.flush()
|
|
138
|
+
|
|
139
|
+
def process_message(self, message) -> None:
|
|
140
|
+
if isinstance(message, events.MouseEvent):
|
|
141
|
+
# This interface is keyboard-operated: mouse clicks cannot execute
|
|
142
|
+
# commands or shift focus away from the prompt. Native wheel input
|
|
143
|
+
# belongs to the terminal; ignore any stale mouse reports.
|
|
144
|
+
return
|
|
145
|
+
elif isinstance(message, events.CursorPosition):
|
|
146
|
+
self.cursor_origin = (message.x, message.y)
|
|
147
|
+
else:
|
|
148
|
+
super().process_message(message)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Git worktrees, so a sub-agent can change files without touching the main directory."""
|
|
2
|
+
|
|
3
|
+
from .changes import Changes, count_changes
|
|
4
|
+
from .git import WorktreeError, find_repo, read_head
|
|
5
|
+
from .manager import CleanReport, Created, Entry, Finished, WorktreeChanged, WorktreeManager
|
|
6
|
+
from .meta import WorktreeMeta, load_meta, process_alive
|
|
7
|
+
from .names import EPHEMERAL, branch_of, ephemeral_name, flatten, validate_name
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Changes",
|
|
11
|
+
"CleanReport",
|
|
12
|
+
"Created",
|
|
13
|
+
"EPHEMERAL",
|
|
14
|
+
"Entry",
|
|
15
|
+
"Finished",
|
|
16
|
+
"WorktreeChanged",
|
|
17
|
+
"WorktreeError",
|
|
18
|
+
"WorktreeManager",
|
|
19
|
+
"WorktreeMeta",
|
|
20
|
+
"branch_of",
|
|
21
|
+
"count_changes",
|
|
22
|
+
"ephemeral_name",
|
|
23
|
+
"find_repo",
|
|
24
|
+
"flatten",
|
|
25
|
+
"load_meta",
|
|
26
|
+
"process_alive",
|
|
27
|
+
"read_head",
|
|
28
|
+
"validate_name",
|
|
29
|
+
]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Does a worktree hold work that deleting it would lose? Any doubt counts as yes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import git
|
|
9
|
+
from .git import WorktreeError
|
|
10
|
+
|
|
11
|
+
FILE_LIMIT = 10
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Changes:
|
|
16
|
+
uncommitted: int = 0
|
|
17
|
+
commits: int = 0
|
|
18
|
+
files: tuple[str, ...] = ()
|
|
19
|
+
error: str = "" # The check failed; uncommitted is then 1.
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def any(self) -> bool:
|
|
23
|
+
return self.uncommitted > 0 or self.commits > 0
|
|
24
|
+
|
|
25
|
+
def describe(self) -> str:
|
|
26
|
+
if self.error:
|
|
27
|
+
return f"无法确认是否有改动({self.error}),按有改动处理"
|
|
28
|
+
parts = []
|
|
29
|
+
if self.uncommitted:
|
|
30
|
+
parts.append(f"未提交 {self.uncommitted} 个文件")
|
|
31
|
+
if self.commits:
|
|
32
|
+
parts.append(f"独有提交 {self.commits} 个")
|
|
33
|
+
return ",".join(parts) or "无改动"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def count_changes(path: Path, branch: str, skip=()) -> Changes:
|
|
37
|
+
"""skip: links and copied files MossCode put there itself."""
|
|
38
|
+
skip = {item.rstrip("/") for item in skip}
|
|
39
|
+
try:
|
|
40
|
+
status = git.run_git(["status", "--porcelain", "--untracked-files=all"], path)
|
|
41
|
+
if status.returncode != 0:
|
|
42
|
+
return Changes(1, 0, (), (status.stderr or "git status 失败").strip()[:200])
|
|
43
|
+
files = []
|
|
44
|
+
for line in status.stdout.splitlines():
|
|
45
|
+
name = line[3:].split(" -> ")[-1].strip().strip('"').rstrip("/")
|
|
46
|
+
# On Windows git walks into a junction, so the files under a link show up one by one.
|
|
47
|
+
if name and not any(name == item or name.startswith(item + "/") for item in skip):
|
|
48
|
+
files.append(name)
|
|
49
|
+
# With --branches the exclude pattern is relative to refs/heads/.
|
|
50
|
+
commits = git.run_git(
|
|
51
|
+
["rev-list", "--count", "HEAD", "--not", f"--exclude={branch}", "--branches", "--remotes"], path
|
|
52
|
+
)
|
|
53
|
+
if commits.returncode != 0:
|
|
54
|
+
return Changes(
|
|
55
|
+
max(len(files), 1), 0, tuple(files[:FILE_LIMIT]), (commits.stderr or "git rev-list 失败").strip()[:200]
|
|
56
|
+
)
|
|
57
|
+
return Changes(len(files), int(commits.stdout.strip() or "0"), tuple(files[:FILE_LIMIT]))
|
|
58
|
+
except (WorktreeError, ValueError) as exc:
|
|
59
|
+
return Changes(1, 0, (), str(exc)[:200])
|
mosscode/worktree/git.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Git plumbing for worktrees: run git without prompts, find the repository, read HEAD without git."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
GIT_TIMEOUT = 120
|
|
11
|
+
EXCLUDE_LINE = "/.mosscode/worktrees/"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WorktreeError(Exception):
|
|
15
|
+
def __init__(self, message: str, code: str = "worktree_error"):
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
self.code = code
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_git(args, cwd: Path, *, timeout: float = GIT_TIMEOUT) -> subprocess.CompletedProcess:
|
|
21
|
+
"""No terminal prompt, no stdin; a missing git is a WorktreeError."""
|
|
22
|
+
executable = shutil.which("git")
|
|
23
|
+
if executable is None:
|
|
24
|
+
raise WorktreeError("找不到 git,无法使用工作树。", "worktree_unavailable")
|
|
25
|
+
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": "", "LC_ALL": "C"}
|
|
26
|
+
try:
|
|
27
|
+
return subprocess.run(
|
|
28
|
+
[executable, "-c", "core.quotepath=false", *args],
|
|
29
|
+
cwd=str(cwd),
|
|
30
|
+
env=env,
|
|
31
|
+
stdin=subprocess.DEVNULL,
|
|
32
|
+
capture_output=True,
|
|
33
|
+
timeout=timeout,
|
|
34
|
+
encoding="utf-8",
|
|
35
|
+
errors="replace",
|
|
36
|
+
)
|
|
37
|
+
except subprocess.TimeoutExpired:
|
|
38
|
+
raise WorktreeError(f"git {args[0]} 超过 {timeout:g} 秒未完成。") from None
|
|
39
|
+
except OSError as exc:
|
|
40
|
+
raise WorktreeError(f"无法运行 git:{exc}", "worktree_unavailable") from None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def git_text(args, cwd: Path) -> str:
|
|
44
|
+
"""stdout of a git command that must succeed."""
|
|
45
|
+
done = run_git(args, cwd)
|
|
46
|
+
if done.returncode != 0:
|
|
47
|
+
raise WorktreeError(f"git {' '.join(args)} 失败:{(done.stderr or done.stdout).strip()[:500]}")
|
|
48
|
+
return done.stdout.strip()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def find_repo(start: Path) -> tuple[Path, Path]:
|
|
52
|
+
"""(top level of the work tree, common git directory)."""
|
|
53
|
+
done = run_git(["rev-parse", "--is-bare-repository", "--show-toplevel", "--git-common-dir"], start)
|
|
54
|
+
lines = done.stdout.strip().splitlines()
|
|
55
|
+
if done.returncode != 0 or len(lines) < 3:
|
|
56
|
+
if lines[:1] == ["true"]:
|
|
57
|
+
raise WorktreeError("当前仓库是裸仓库,无法使用工作树。", "worktree_unavailable")
|
|
58
|
+
raise WorktreeError("项目目录不在 git 仓库中,无法使用工作树。", "worktree_unavailable")
|
|
59
|
+
if lines[0] == "true":
|
|
60
|
+
raise WorktreeError("当前仓库是裸仓库,无法使用工作树。", "worktree_unavailable")
|
|
61
|
+
top = Path(lines[1]).resolve()
|
|
62
|
+
common = Path(lines[2])
|
|
63
|
+
common = (common if common.is_absolute() else start / common).resolve()
|
|
64
|
+
if run_git(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], top).returncode != 0:
|
|
65
|
+
raise WorktreeError("仓库还没有任何提交,无法创建工作树。", "worktree_unavailable")
|
|
66
|
+
return top, common
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def ensure_excluded(common: Path) -> bool:
|
|
70
|
+
"""True when the line was added."""
|
|
71
|
+
path = common / "info" / "exclude"
|
|
72
|
+
try:
|
|
73
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
74
|
+
except FileNotFoundError:
|
|
75
|
+
text = ""
|
|
76
|
+
if any(line.strip() == EXCLUDE_LINE for line in text.splitlines()):
|
|
77
|
+
return False
|
|
78
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
with path.open("a", encoding="utf-8", newline="\n") as stream:
|
|
80
|
+
stream.write(("" if not text or text.endswith("\n") else "\n") + EXCLUDE_LINE + "\n")
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _read(path: Path) -> str | None:
|
|
85
|
+
try:
|
|
86
|
+
return path.read_text(encoding="utf-8", errors="replace").strip()
|
|
87
|
+
except OSError:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _resolve_ref(ref: str, gitdir: Path, common: Path) -> str | None:
|
|
92
|
+
for base in (gitdir, common):
|
|
93
|
+
value = _read(base / ref)
|
|
94
|
+
if value:
|
|
95
|
+
return value
|
|
96
|
+
packed = _read(common / "packed-refs") or ""
|
|
97
|
+
for line in packed.splitlines():
|
|
98
|
+
if line and line[0] not in "#^":
|
|
99
|
+
sha, _, name = line.partition(" ")
|
|
100
|
+
if name.strip() == ref:
|
|
101
|
+
return sha
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def read_head(worktree: Path) -> tuple[str, str] | None:
|
|
106
|
+
"""(commit SHA, ref or "") from .git -> gitdir -> HEAD -> loose ref or packed-refs; no git process."""
|
|
107
|
+
pointer = _read(worktree / ".git")
|
|
108
|
+
if not pointer or not pointer.startswith("gitdir:"):
|
|
109
|
+
return None
|
|
110
|
+
gitdir = Path(pointer.split(":", 1)[1].strip())
|
|
111
|
+
gitdir = gitdir if gitdir.is_absolute() else (worktree / gitdir).resolve()
|
|
112
|
+
head = _read(gitdir / "HEAD")
|
|
113
|
+
if not head:
|
|
114
|
+
return None
|
|
115
|
+
relative = _read(gitdir / "commondir")
|
|
116
|
+
common = (gitdir / relative).resolve() if relative else gitdir
|
|
117
|
+
if head.startswith("ref:"):
|
|
118
|
+
ref = head[4:].strip()
|
|
119
|
+
sha = _resolve_ref(ref, gitdir, common)
|
|
120
|
+
return (sha, ref) if sha and _is_sha(sha) else None
|
|
121
|
+
return (head, "") if _is_sha(head) else None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _is_sha(value: str) -> bool:
|
|
125
|
+
return len(value) in {40, 64} and all(c in "0123456789abcdef" for c in value)
|