codebee 0.1.0 → 0.1.1

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.
@@ -1,949 +1,953 @@
1
- # -*- coding: utf-8 -*-
2
- """Git 仓库探测与任务版本检出(新建任务的「代码版本」选项)。
3
-
4
- 设计约束(对齐质量闸门原则:显式意图不容静默降级):
5
- - 探测只读:/api/git/info 只跑 rev-parse / status / log,不改仓库任何东西。
6
- - 检出不破坏用户现场:不直接在所选版本上 detached checkout,而是从它创建
7
- 任务分支 tutti/<task-id> 再检出——原分支指针不动,任务产物落在任务分支上。
8
- - 工作区不必干净也能检出:未提交改动先 git stash 原样收起(_attachments/
9
- 的附件除外,那是同一任务刚上传的素材),收尾切回后 apply 还原——开发仓库
10
- 永远有并行改动,硬拒绝等于代码版本隔离不可用。stash 条目按 tutti-stash-*
11
- 标记,还原失败时保留在 stash 列表里可手工找回,绝不静默丢弃。
12
- """
13
- from __future__ import annotations
14
-
15
- import os
16
- import re
17
-
18
- from . import paths as paths_mod
19
- from . import runner
20
-
21
- # 版本引用白名单:分支名/标签/短哈希及 rev^0、rev~2 组合,禁止以 - 开头
22
- # (argv 传参本身不经过 shell,这里主要挡参数注入与 pathspec 歧义)
23
- _REV_RE = re.compile(r"^[A-Za-z0-9_][\w./\u4e00-\u9fff~^\-]*$")
24
-
25
-
26
- def valid_rev(rev):
27
- s = str(rev or "")
28
- return bool(s) and len(s) <= 200 and bool(_REV_RE.match(s))
29
-
30
-
31
- def _git(workdir, *args, timeout=20):
32
- r = runner.run_process(argv=["git", *args], cwd=str(workdir), timeout=timeout)
33
- return r
34
-
35
-
36
- def _attach_entry(line):
37
- """status --porcelain 的一行是否为任务附件目录下的未跟踪项(不算脏改动)。"""
38
- if not line.startswith("?? "):
39
- return False
40
- p = line[3:]
41
- return p.startswith("_attachments/") or p.startswith('"_attachments/')
42
-
43
-
44
- def repo_info(workdir):
45
- """探测目录是否为 git 仓库,返回 UI 下拉所需信息。非仓库返回 {"repo": False}。"""
46
- r = _git(workdir, "rev-parse", "--is-inside-work-tree")
47
- if not r["ok"] or r["stdout"].strip() != "true":
48
- return {"repo": False}
49
- head = _git(workdir, "rev-parse", "--short", "HEAD")
50
- branch = _git(workdir, "rev-parse", "--abbrev-ref", "HEAD")
51
- st = _git(workdir, "status", "--porcelain")
52
- dirty = [ln for ln in (st["stdout"] or "").splitlines()
53
- if ln.strip() and not _attach_entry(ln.strip())] if st["ok"] else []
54
- branches = tags = []
55
- rb = _git(workdir, "for-each-ref", "--format=%(refname:short)", "refs/heads/")
56
- if rb["ok"]:
57
- branches = [x for x in (l.strip() for l in rb["stdout"].splitlines()) if x][:200]
58
- rt = _git(workdir, "tag", "--sort=-creatordate")
59
- if rt["ok"]:
60
- tags = [x for x in (l.strip() for l in rt["stdout"].splitlines()) if x][:200]
61
- recent = []
62
- rl = _git(workdir, "log", "--oneline", "-n", "30", "--format=%h\x01%s")
63
- if rl["ok"]:
64
- for ln in (rl["stdout"] or "").splitlines()[:30]:
65
- h, _, subj = ln.partition("\x01")
66
- if h.strip():
67
- recent.append({"hash": h.strip(), "subject": subj.strip()[:120]})
68
- br = (branch["stdout"] or "").strip() if branch["ok"] else ""
69
- return {
70
- "repo": True,
71
- "branch": br or "(游离 HEAD)",
72
- "head": (head["stdout"] or "").strip() if head["ok"] else "",
73
- "dirty": bool(dirty),
74
- "dirty_count": len(dirty),
75
- "branches": branches,
76
- "tags": tags,
77
- "recent": recent,
78
- }
79
-
80
-
81
- def branch_name(task_id):
82
- return "tutti/" + str(task_id)
83
-
84
-
85
- def _stash_dirty(workdir, label):
86
- """把用户未提交改动(含未跟踪文件)收进 stash,返回 (stash sha, 错误信息)
87
-
88
- _attachments/ 除外:那是本任务刚上传的附件,必须留在工作目录供注入。
89
- pathspec 排除走 argv,不经过 shell。失败返回错误——绝不静默带着脏改动切分支。
90
- """
91
- m = "tutti-stash-%s" % label
92
- r = _git(workdir, "stash", "push", "-u", "-m", m,
93
- "--", ".", ":(exclude)_attachments", timeout=60)
94
- if not r["ok"]:
95
- return "", ("暂存未提交改动失败(请手工提交或清理后重试):%s"
96
- % (r["stderr"] or r["stdout"] or "")[-160:])
97
- top = _git(workdir, "rev-parse", "--quiet", "--verify", "stash@{0}")
98
- if not top["ok"]:
99
- return "", "暂存后找不到 stash 条目,请手工检查 git stash list"
100
- return (top["stdout"] or "").strip(), ""
101
-
102
-
103
- def _restore_stash(workdir, sha, out):
104
- """把检出前收起的用户改动 apply 回工作区,成功才 drop 那一条。
105
-
106
- 用 apply+drop 而非 pop:pop 在应用冲突时也会丢弃条目,用户的未保存
107
- 改动就真丢了。任何失败都把 stash 原样留在列表里,错误写进 out["restore_error"]。
108
- """
109
- a = _git(workdir, "stash", "apply", "--quiet", str(sha), timeout=60)
110
- if not a["ok"]:
111
- out["restore_error"] += (
112
- "还原未提交改动失败(stash %s 已保留,git stash list 可找回;工作区可能有冲突标记):%.160s"
113
- % (str(sha)[:12], (a["stderr"] or a["stdout"] or "")))
114
- return
115
- lst = _git(workdir, "stash", "list", "--format=%H %gd")
116
- target = ""
117
- for ln in (lst["stdout"] or "").splitlines():
118
- h, _, ref = ln.strip().partition(" ")
119
- if h == str(sha) and ref:
120
- target = ref
121
- break
122
- if target:
123
- d = _git(workdir, "stash", "drop", target)
124
- if not d["ok"]:
125
- out["restore_error"] += ("清理已还原的 stash 失败(内容无损,可手工 drop):%s"
126
- % (d["stderr"] or "")[-120:])
127
-
128
-
129
- def prepare_checkout(workdir, rev, task_id):
130
- """执行前检出任务分支。返回 (ok, 错误信息, 信息 dict)
131
-
132
- 成功时 dict:{rev, branch, commit, from_branch, base_commit, stash}——写入 run.git
133
- UI 展示;from_branch/base_commit 是收尾 finalize_run 切回的依据;
134
- stash 非空表示检出前收起了用户未提交改动(收尾时 apply 还原)。
135
- 已有同名任务分支(重试场景)直接切过去继续,不重置——重试语义是续跑,
136
- 把用户可能已提交到任务分支的改动 reset 掉是破坏性的。
137
- """
138
- if not valid_rev(rev):
139
- return False, "非法的版本引用:%s" % str(rev)[:40], None
140
- info = repo_info(workdir)
141
- if not info.get("repo"):
142
- return False, "工作目录不是 git 仓库,无法按所选版本运行", None
143
- if not info.get("head"):
144
- return False, "仓库还没有任何提交,请先提交基线", None
145
- stash_sha = ""
146
- if info.get("dirty"):
147
- # 脏工作区不再硬拒绝(开发仓库永远有并行改动):原样 stash 收起,收尾还原
148
- stash_sha, err = _stash_dirty(workdir, task_id)
149
- if err:
150
- return False, err, None
151
- rv = _git(workdir, "rev-parse", "--verify", "--quiet", rev)
152
- if not rv["ok"]:
153
- if stash_sha:
154
- out = {"restore_error": ""}
155
- _restore_stash(workdir, stash_sha, out)
156
- return False, "仓库中不存在版本「%s」" % rev, None
157
- br = branch_name(task_id)
158
- exists = _git(workdir, "rev-parse", "--verify", "--quiet", "refs/heads/" + br)
159
- if exists["ok"]:
160
- co = _git(workdir, "checkout", "--quiet", br, timeout=60)
161
- else:
162
- co = _git(workdir, "checkout", "-q", "-b", br, rev, timeout=60)
163
- if not co["ok"]:
164
- tail = (co["stderr"] or co["stdout"] or "")[-200:]
165
- if stash_sha:
166
- out = {"restore_error": ""}
167
- _restore_stash(workdir, stash_sha, out)
168
- tail = "检出任务分支失败:%s(已还原你的未提交改动)" % tail \
169
- if not out["restore_error"] else \
170
- "检出任务分支失败:%s;还原未提交改动也失败,git stash list 可找回" % tail
171
- return False, tail, None
172
- head = _git(workdir, "rev-parse", "--short", "HEAD")
173
- return True, "", {
174
- "rev": rev,
175
- "branch": br,
176
- "commit": (head["stdout"] or "").strip() if head["ok"] else "",
177
- "from_branch": info.get("branch") or "",
178
- "base_commit": info.get("head") or "",
179
- "stash": stash_sha,
180
- }
181
-
182
-
183
- # ---------------------------------------------------------------- 变更快照与收尾
184
-
185
- _INSIDE_MAX_FILE_CHARS = 8000 # 单个新文件进 diff 的上限
186
- _INSIDE_MAX_FILE_LINES = 200
187
- _STAT_MAX_LINES = 20000 # +/- 统计的单文件行数封顶(统计不求全文)
188
-
189
-
190
- def _inside(workdir, target):
191
- try:
192
- return os.path.commonpath(
193
- [os.path.abspath(str(workdir)), os.path.abspath(str(target))]) == os.path.abspath(str(workdir))
194
- except ValueError:
195
- return False
196
-
197
-
198
- def _attach_path(path):
199
- """-z 格式的未跟踪路径是否落在任务附件目录下(目录整体未跟踪时带尾斜杠)。"""
200
- p = (path or "").rstrip("/\\")
201
- return p == "_attachments" or p.startswith("_attachments/")
202
-
203
-
204
- def _porcelain_entries(workdir):
205
- """status --porcelain -z → [(xy, path)];-z 不做引号/八进制转义,中文路径原样。
206
- 重命名条目 (XY new\0old) 只取新路径(展示用足够)。失败返回 None。"""
207
- r = _git(workdir, "status", "--porcelain", "-z")
208
- if not r["ok"]:
209
- return None
210
- out = []
211
- fields = r["stdout"].split("\0")
212
- i = 0
213
- while i < len(fields):
214
- f = fields[i]
215
- if len(f) < 4:
216
- i += 1
217
- continue
218
- xy, path = f[:2], f[3:]
219
- if xy[0] in ("R", "C"): # 下一字段是原路径,跳过
220
- i += 1
221
- out.append((xy, path))
222
- i += 1
223
- return out
224
-
225
-
226
- def _numstat(workdir):
227
- """diff HEAD 的行级统计:{path: (add, del)}。+N -M 徽标的数据源。
228
-
229
- --no-renames 关掉改名探测:改名退化为删+增两行,路径是纯单段,解析零特判,
230
- 对统计口径也无损。-z 下每条记录是 "add\\tdel\\tpath\\0"(记录内制表符分隔、
231
- 记录间 NUL 分隔),二进制文件数值为 '-' 0 计。
232
- """
233
- r = _git(workdir, "diff", "HEAD", "--numstat", "--no-renames", "-z")
234
- out = {}
235
- if not r["ok"]:
236
- return out
237
- for rec in (r["stdout"] or "").split("\0"):
238
- if not rec:
239
- continue
240
- parts = rec.split("\t")
241
- if len(parts) < 3:
242
- continue
243
- add_s, del_s, path = parts[0], parts[1], "\t".join(parts[2:])
244
- if not path:
245
- continue
246
- try:
247
- add = int(add_s)
248
- except ValueError:
249
- add = 0
250
- try:
251
- deln = int(del_s)
252
- except ValueError:
253
- deln = 0
254
- out[path] = (add, deln)
255
- return out
256
-
257
-
258
- def _count_lines(fp, cap=50000):
259
- """按块数行,封顶 cap——只为 +/- 统计,不为内容,超大文件不值得全读。"""
260
- n = 0
261
- try:
262
- with open(fp, "rb") as f:
263
- while True:
264
- chunk = f.read(1 << 16)
265
- if not chunk:
266
- break
267
- n += chunk.count(b"\n")
268
- if n >= cap:
269
- return cap
270
- except OSError:
271
- return 0
272
- return n
273
-
274
-
275
- def _status_code(xy):
276
- """porcelain XY → 单字符状态码(前端 GIT_STATUS_LABEL 的键、快照的持久化形态)。
277
- 原始 XY 是两列(如 " M"/"M "/"MM"/"??"),直接透传会让前端查表落空、
278
- 把 "??" 当文案渲染出来。规则:未跟踪 → "?";任一列含 D 取 D(删除最醒目);
279
- 其余取首个非空列(MM/AM 等组合态收拢到首列)。"""
280
- s = (xy or "").strip()
281
- if s == "??":
282
- return "?"
283
- if not s:
284
- return "M"
285
- if "D" in s:
286
- return "D"
287
- return s[0]
288
-
289
-
290
- def collect_changes(workdir, max_diff=30000):
291
- """只读收集工作区变更(不动 index、不碰分支)。
292
-
293
- 返回 {"files": [{"status", "path", "add", "del"}...], "diff": "...",
294
- "add_total", "del_total"}。diff = 已跟踪改动(git diff HEAD)+ 未跟踪新文件
295
- 逐段拼成 diff 形态——新文件不进 git diff,但恰是智能体产物的大头(新章节、
296
- 新模块),缺了评审与人审都瞎一半。add/del 是行级统计(numstat + 新文件计数),
297
- 供检查器面板的 +N -M 徽标;旧调用方不读这两个字段,向后兼容。
298
- 附件目录不算变更;非 git 仓库/命令失败返回空集合,绝不抛错。
299
- """
300
- empty = {"files": [], "diff": "", "add_total": 0, "del_total": 0}
301
- entries = _porcelain_entries(workdir)
302
- if entries is None:
303
- return empty
304
- files, untracked = [], []
305
- for xy, path in entries:
306
- if not path:
307
- continue
308
- if xy == "??" and _attach_path(path):
309
- continue
310
- files.append({"status": _status_code(xy), "path": path})
311
- if xy == "??":
312
- untracked.append(path)
313
- stats = _numstat(workdir)
314
- add_total = del_total = 0
315
- for f in files:
316
- add, deln = stats.get(f["path"], (0, 0))
317
- f["add"], f["del"] = add, deln
318
- add_total += add
319
- del_total += deln
320
- parts, budget = [], max_diff
321
- r = _git(workdir, "diff", "HEAD")
322
- if r["ok"] and r["stdout"]:
323
- take = r["stdout"][:budget]
324
- parts.append(take)
325
- budget -= len(take)
326
- for p in untracked:
327
- if budget <= 0:
328
- break
329
- fp = os.path.abspath(os.path.join(str(workdir), p))
330
- if not os.path.isfile(fp) or not _inside(workdir, fp):
331
- continue
332
- lines_n = _count_lines(fp, cap=_STAT_MAX_LINES)
333
- add_total += lines_n
334
- try:
335
- text = runner.read_text_any_enc(fp)[:_INSIDE_MAX_FILE_CHARS]
336
- except OSError:
337
- continue
338
- lines = text.splitlines()[:_INSIDE_MAX_FILE_LINES]
339
- body = "\n".join("+" + ln for ln in lines)
340
- if len(text) > _INSIDE_MAX_FILE_CHARS or text.count("\n") >= _INSIDE_MAX_FILE_LINES:
341
- body += "\n+…(新文件过长,已截断)"
342
- section = ("diff --git a/%s b/%s\nnew file mode 100644\n"
343
- "--- /dev/null\n+++ b/%s\n@@ -0,0 +1,%d @@\n%s\n"
344
- % (p, p, p, len(lines), body))[:budget]
345
- parts.append(section)
346
- budget -= len(section)
347
- # 未跟踪文件在 numstat 里没有条目,把行数补进该文件的统计
348
- for f in files:
349
- if f["path"] == p:
350
- f["add"] = lines_n
351
- break
352
- return {"files": files[:200], "diff": "".join(parts),
353
- "add_total": add_total, "del_total": del_total}
354
-
355
-
356
- def finalize_run(workdir, gitinfo, message):
357
- """run 结束后收尾:把产物提交到任务分支,再切回原分支/基线提交。
358
-
359
- 语义(对齐 Baton/Codeband 的隔离模型;本轮只做「保存现场」,任务分支
360
- 的合并/丢弃留给后续的人工裁决):
361
- - 产物必须可找回:哪怕 run 失败/取消,已写盘的半成品也提交到
362
- tutti/<task-id>(WIP 语义),绝不留在用户工作区里污染原分支;
363
- - _attachments/ 不提交也不丢:用户上传的共享附件保持未跟踪,
364
- 切分支后仍留在工作目录(未跟踪文件随分支切换保留);
365
- - 任何收尾失败都不抛出:run 终态已定,问题记入 restore_error
366
- UI 呈现,绝不因此覆盖 run 的最终结论。
367
- 返回 {commit, restored, restore_error}(由调用方并入 run.git)。
368
- """
369
- out = {"commit": "", "restored": False, "restore_error": ""}
370
- branch = (gitinfo or {}).get("branch") or ""
371
- if not branch:
372
- out["restore_error"] = "缺少任务分支信息,跳过收尾"
373
- return out
374
- info = repo_info(workdir)
375
- if not info.get("repo"):
376
- out["restore_error"] = "工作目录已不是 git 仓库,产物留在原处未提交"
377
- return out
378
- if (info.get("branch") or "") != branch:
379
- out["restore_error"] = ("工作区已不在任务分支 %s(当前 %s),不自动提交"
380
- % (branch, info.get("branch") or "?"))
381
- return out
382
- # 产物提交(_attachments 除外:保持未跟踪,随分支切换保留在工作区)
383
- if info.get("dirty"):
384
- a = _git(workdir, "add", "-A")
385
- if not a["ok"]:
386
- out["restore_error"] = "git add 失败:%s" % (a["stderr"] or "")[-160:]
387
- return out
388
- staged_att = _git(workdir, "diff", "--cached", "--name-only", "--", "_attachments")
389
- if staged_att["ok"] and (staged_att["stdout"] or "").strip():
390
- rst = _git(workdir, "reset", "-q", "--", "_attachments")
391
- if not rst["ok"]:
392
- out["restore_error"] = "剔除附件暂存失败:%s" % (rst["stderr"] or "")[-160:]
393
- return out
394
- staged = _git(workdir, "diff", "--cached", "--name-only")
395
- if staged["ok"] and (staged["stdout"] or "").strip():
396
- # 一次性身份配置:用户仓库可能没配 user.name/email,缺身份不应让提交失败
397
- c = _git(workdir, "-c", "user.name=Tutti", "-c", "user.email=tutti@orchestra.local",
398
- "commit", "-m", str(message)[:200], "--quiet")
399
- if not c["ok"]:
400
- out["restore_error"] = "提交任务分支失败:%s" % (c["stderr"] or "")[-160:]
401
- return out
402
- h = _git(workdir, "rev-parse", "--short", "HEAD")
403
- out["commit"] = (h["stdout"] or "").strip() if h["ok"] else ""
404
- # 切回原分支;原为游离 HEAD 时回到基线提交(同样游离)
405
- back = (gitinfo or {}).get("from_branch") or ""
406
- base = (gitinfo or {}).get("base_commit") or ""
407
- target = back if (back and back != "(游离 HEAD)" and back != branch) else base
408
- if target:
409
- co = _git(workdir, "checkout", "--quiet", target, timeout=60)
410
- if not co["ok"]:
411
- out["restore_error"] += "切回 %s 失败:%s" % (target, (co["stderr"] or "")[-160:])
412
- return out
413
- out["restored"] = True
414
- # 检出前收起的用户未提交改动,回到原分支后原样还原
415
- stash = (gitinfo or {}).get("stash") or ""
416
- if stash:
417
- _restore_stash(workdir, stash, out)
418
- return out
419
-
420
-
421
- # ---------------------------------------------------------------- 任务分支裁决(人审出口)
422
-
423
- def merge_task_branch(workdir, task):
424
- """把任务分支合并回原分支(人审后的「采纳」出口)。
425
-
426
- 守卫(显式失败,不静默降级):
427
- - 任务正在跑 → 拒绝(分支可能还要追加提交);
428
- - 仓库缺失/分支不存在 → 拒绝;
429
- - 工作区仍在任务分支上 → 拒绝(说明收尾出错,先处理 restore_error);
430
- - 工作区不在原分支(用户切去了别处)→ 拒绝,合并目标必须明确;
431
- - 工作区不干净先 stash 原样收起(附件除外),合并在干净树上进行,
432
- 结束(含冲突回滚)后原样还原——与检出的暂存语义一致。
433
- 合并冲突立即 git merge --abort 回滚,把冲突留给用户手工处理。
434
- 返回 (ok, 错误信息, 信息 dict);合并成功但还原 stash 失败时,
435
- 错误放进 info["restore_error"](stash 已保留,可手工找回)。
436
- """
437
- def refuse(msg):
438
- return False, msg, None
439
-
440
- if (task or {}).get("status") in ("queued", "running"):
441
- return refuse("任务正在运行,等结束再裁决任务分支")
442
- tid = (task or {}).get("id") or ""
443
- wd = str(workdir or "")
444
- info = repo_info(wd)
445
- if not info.get("repo"):
446
- return refuse("工作目录不是 git 仓库")
447
- br = branch_name(tid)
448
- if not _git(wd, "rev-parse", "--verify", "--quiet", "refs/heads/" + br)["ok"]:
449
- return refuse("任务分支 %s 不存在(可能已被合并或丢弃)" % br)
450
- cur = info.get("branch") or ""
451
- if cur == br:
452
- return refuse("工作区仍停在任务分支上(上一轮收尾出错),请先处理 run.git.restore_error")
453
- if cur == "(游离 HEAD)":
454
- return refuse("工作区处于游离 HEAD,无法作为合并目标;请先检出原分支")
455
- # 脏工作区不再拒绝合并:暂存用户的未提交改动,合并在干净树上进行,
456
- # 结束后原样还原(与检出的 stash 语义一致)
457
- stash_sha = ""
458
- if info.get("dirty"):
459
- stash_sha, err = _stash_dirty(wd, "merge-" + tid)
460
- if err:
461
- return refuse(err)
462
- # 合并目标 = 任务分支的创建基线分支(来自 run.git.from_branch);
463
- # 查不到基线绝不猜「当前分支」——用户切到哪就合进哪等于无守卫。
464
- latest = _latest_run_git(tid) or {}
465
- origin = latest.get("from_branch") or ""
466
- if not origin:
467
- if stash_sha:
468
- out = {"restore_error": ""}
469
- _restore_stash(wd, stash_sha, out)
470
- return refuse("无法确定任务分支的基线分支(找不到带检出信息的运行记录),请手工合并")
471
- if cur != origin:
472
- if stash_sha:
473
- out = {"restore_error": ""}
474
- _restore_stash(wd, stash_sha, out)
475
- return refuse("工作区当前在 %s,而任务分支的基线是 %s;请先切回 %s 再合并"
476
- % (cur, origin, origin))
477
- ahead = _git(wd, "rev-list", "--count", "%s..%s" % (origin, br))
478
- n_ahead = int((ahead["stdout"] or "0").strip() or 0) if ahead["ok"] else 0
479
- if n_ahead <= 0:
480
- if stash_sha:
481
- out = {"restore_error": ""}
482
- _restore_stash(wd, stash_sha, out)
483
- return refuse("任务分支没有领先基线的提交(产物为空),无需合并;可直接丢弃")
484
- m = _git(wd, "-c", "user.name=Tutti", "-c", "user.email=tutti@orchestra.local",
485
- "merge", "--no-ff", br, "-m",
486
- "tutti: 合并任务分支 %s(%s)" % (tid, (task or {}).get("title") or "")[:200])
487
- if not m["ok"]:
488
- ab = _git(wd, "merge", "--abort")
489
- tail = (m["stderr"] or m["stdout"] or "")[-200:]
490
- msg = ""
491
- if ab["ok"]:
492
- msg = "合并冲突,已回滚到合并前状态;请手工解决后自行合并:%s" % tail
493
- else:
494
- msg = "合并失败且回滚失败,工作区可能残留冲突标记:%s" % tail
495
- if stash_sha:
496
- out = {"restore_error": ""}
497
- _restore_stash(wd, stash_sha, out)
498
- if out["restore_error"]:
499
- msg += ";另:还原你的未提交改动失败,git stash list 可找回"
500
- return refuse(msg)
501
- h = _git(wd, "rev-parse", "--short", "HEAD")
502
- info_out = {
503
- "merged_into": origin,
504
- "commits": n_ahead,
505
- "commit": (h["stdout"] or "").strip() if h["ok"] else "",
506
- }
507
- if stash_sha:
508
- out = {"restore_error": ""}
509
- _restore_stash(wd, stash_sha, out)
510
- if out["restore_error"]:
511
- info_out["restore_error"] = out["restore_error"]
512
- return True, "", info_out
513
-
514
-
515
- def discard_task_branch(workdir, task):
516
- """丢弃任务分支(人审后的「否决」出口,不可恢复)。
517
-
518
- 守卫同 merge(活跃任务/仓库缺失/分支不存在拒绝);若工作区恰好停在
519
- 任务分支上且干净(收尾出错的遗留现场),先切回基线再删分支。
520
- 返回 (ok, 错误信息)。
521
- """
522
- def refuse(msg):
523
- return False, msg
524
-
525
- if (task or {}).get("status") in ("queued", "running"):
526
- return refuse("任务正在运行,等结束再裁决任务分支")
527
- tid = (task or {}).get("id") or ""
528
- wd = str(workdir or "")
529
- info = repo_info(wd)
530
- if not info.get("repo"):
531
- return refuse("工作目录不是 git 仓库")
532
- br = branch_name(tid)
533
- if not _git(wd, "rev-parse", "--verify", "--quiet", "refs/heads/" + br)["ok"]:
534
- return refuse("任务分支 %s 不存在(可能已被合并或丢弃)" % br)
535
- cur = info.get("branch") or ""
536
- if cur == br:
537
- if info.get("dirty"):
538
- return refuse("工作区停在任务分支上且有未提交改动,请先处理再丢弃")
539
- latest = _latest_run_git(tid) or {}
540
- back = latest.get("from_branch") or ""
541
- base = latest.get("base_commit") or ""
542
- target = back if (back and back != "(游离 HEAD)" and back != br) else base
543
- if not target:
544
- return refuse("工作区停在任务分支上且找不到可切回的基线,请手工切走后再丢弃")
545
- co = _git(wd, "checkout", "--quiet", target, timeout=60)
546
- if not co["ok"]:
547
- return refuse("切回 %s 失败:%s" % (target, (co["stderr"] or "")[-160:]))
548
- d = _git(wd, "branch", "-D", br)
549
- if not d["ok"]:
550
- return refuse("删除任务分支失败:%s" % (d["stderr"] or "")[-160:])
551
- return True, ""
552
-
553
-
554
- def _latest_run_git(task_id):
555
- """该任务最近一次 run 的 git 上下文(from_branch/base_commit 以它为准)。
556
-
557
- 放在 gitmod 而非 store 是为了避免 store→gitmod 反向依赖;读 run.json
558
- 走磁盘(裁决是低频人工操作,无需常驻内存索引)。
559
- """
560
- import json as _json
561
- best, best_id = None, ""
562
- try:
563
- for rj in paths_mod.RUNS_DIR.glob("*/run.json"):
564
- try:
565
- rdata = _json.loads(rj.read_text(encoding="utf-8"))
566
- except Exception:
567
- continue
568
- rid = str(rdata.get("id") or "")
569
- if rdata.get("task_id") == task_id and rdata.get("git") \
570
- and rid > best_id:
571
- best, best_id = rdata.get("git"), rid
572
- except Exception:
573
- return None
574
- return best
575
-
576
-
577
- # ---------------------------------------------------------------- GIT 工作台
578
- # 详情页「版本」页签的完整 Git 操作面:状态聚合(只读)+ 白名单写操作。
579
- # 与隔离链(prepare_checkout/finalize/merge/discard)互不改动;写操作的
580
- # 「任务运行中拒绝」守卫在路由层(那里才拿得到 task.status),这里只管仓库。
581
-
582
- _WB_MAX_FILES = 300 # 单组文件列表上限(超大仓库不拖死轮询)
583
- _WB_DIFF_MAX_CHARS = 200000 # 单文件 diff 文本上限
584
-
585
-
586
- def _safe_relpath(path):
587
- """工作台路径参数白名单:仓库内相对路径。挡绝对路径、.. 上跳、
588
- 以 - 开头(git 会当选项解析);通过返回规范化后的正斜杠路径。"""
589
- s = str(path or "").replace("\\", "/").strip()
590
- if not s or s.startswith("-") or len(s) > 500:
591
- return ""
592
- if s.startswith("/") or re.match(r"^[A-Za-z]:", s):
593
- return ""
594
- parts = [x for x in s.split("/") if x not in ("", ".")]
595
- if any(x == ".." for x in parts):
596
- return ""
597
- return "/".join(parts)
598
-
599
-
600
- def _porcelain_grouped(workdir):
601
- """status --porcelain -z → (staged, unstaged, untracked) 三组 [{status,path}]。
602
-
603
- 与 _porcelain_entries 的区别:按 XY 两列拆「已暂存/未暂存」——工作台的
604
- stage/unstage 语义需要知道改动落在 index 还是工作区。X 列非空非 ? → 有
605
- 暂存改动;Y 列非空有未暂存改动(同一文件可同时进两组,同 git UI 惯例)。
606
- """
607
- staged, unstaged, untracked = [], [], []
608
- entries = _porcelain_entries(workdir)
609
- if entries is None:
610
- return None
611
- for xy, path in entries:
612
- if not path:
613
- continue
614
- if _attach_path(path):
615
- continue # 任务附件目录不算变更(与 collect_changes 同口径)
616
- x, y = xy[0], xy[1]
617
- if xy == "??":
618
- untracked.append({"status": "?", "path": path})
619
- continue
620
- if x not in (" ", "?"):
621
- staged.append({"status": _status_code(x + " "), "path": path})
622
- if y not in (" ", "?"):
623
- unstaged.append({"status": _status_code(" " + y), "path": path})
624
- return staged, unstaged, untracked
625
-
626
-
627
- def workbench_status(workdir):
628
- """只读聚合工作台全貌:分支/HEAD/远程、三组变更文件、ahead/behind、stash。
629
-
630
- 非 git 仓库返回 {"repo": False};任何子命令失败只降级对应字段,绝不抛错。
631
- """
632
- r = _git(workdir, "rev-parse", "--is-inside-work-tree")
633
- if not r["ok"] or r["stdout"].strip() != "true":
634
- return {"repo": False}
635
- out = {"repo": True}
636
- head = _git(workdir, "rev-parse", "--short", "HEAD")
637
- out["head"] = (head["stdout"] or "").strip() if head["ok"] else ""
638
- br = _git(workdir, "rev-parse", "--abbrev-ref", "HEAD")
639
- cur = (br["stdout"] or "").strip() if br["ok"] else ""
640
- out["branch"] = cur or "(游离 HEAD)"
641
- out["detached"] = cur == "HEAD"
642
- groups = _porcelain_grouped(workdir)
643
- if groups is None:
644
- groups = ([], [], [])
645
- staged, unstaged, untracked = groups
646
- out["staged"], out["unstaged"], out["untracked"] = (
647
- staged[:_WB_MAX_FILES], unstaged[:_WB_MAX_FILES], untracked[:_WB_MAX_FILES])
648
- out["truncated"] = any(len(g) > _WB_MAX_FILES for g in groups)
649
- # +/- 行级统计:git diff HEAD(工作区 vs HEAD)已含暂存+未暂存的全貌,
650
- # 同一文件在两组都出现时共用同一份统计(不可两份相加——会重复计数)
651
- stats = {}
652
- rn = _git(workdir, "diff", "HEAD", "--numstat", "--no-renames", "-z")
653
- if rn["ok"]:
654
- for rec in (rn["stdout"] or "").split("\0"):
655
- parts = rec.split("\t")
656
- if len(parts) < 3 or not parts[2]:
657
- continue
658
- path = "\t".join(parts[2:])
659
- try:
660
- a = int(parts[0])
661
- except ValueError:
662
- a = 0
663
- try:
664
- d = int(parts[1])
665
- except ValueError:
666
- d = 0
667
- stats[path] = (a, d)
668
- for g in (staged, unstaged):
669
- for f in g:
670
- a, d = stats.get(f["path"], (0, 0))
671
- f["add"], f["del"] = a, d
672
- for f in untracked:
673
- fp = os.path.abspath(os.path.join(str(workdir), f["path"]))
674
- f["add"] = _count_lines(fp) if os.path.isfile(fp) else 0
675
- f["del"] = 0
676
- # 分支列表:本地 + 远程(origin/* 去前缀展示,checkout 时再映射回)
677
- branches = []
678
- rb = _git(workdir, "for-each-ref", "--format=%(refname:short)", "refs/heads/")
679
- if rb["ok"]:
680
- branches = [x for x in (l.strip() for l in rb["stdout"].splitlines()) if x][:200]
681
- remote_branches = []
682
- rr = _git(workdir, "for-each-ref", "--format=%(refname:short)", "refs/remotes/")
683
- if rr["ok"]:
684
- for x in (l.strip() for l in rr["stdout"].splitlines()):
685
- if x and not x.endswith("/HEAD"):
686
- remote_branches.append(x)
687
- remote_branches = remote_branches[:200]
688
- out["branches"] = branches
689
- out["remote_branches"] = remote_branches
690
- # ahead/behind 与上游:无上游时 upstream 为空,前端据此禁用 pull/push
691
- out["upstream"] = ""
692
- out["ahead"] = out["behind"] = 0
693
- up = _git(workdir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}")
694
- if up["ok"]:
695
- out["upstream"] = (up["stdout"] or "").strip()
696
- ab = _git(workdir, "rev-list", "--left-right", "--count", "HEAD..." + out["upstream"])
697
- if ab["ok"]:
698
- toks = (ab["stdout"] or "").split()
699
- if len(toks) == 2:
700
- out["ahead"], out["behind"] = int(toks[0]), int(toks[1])
701
- # 远程名(默认 origin;多远程时取第一个)
702
- rv = _git(workdir, "remote")
703
- remotes = [x for x in ((rv["stdout"] or "").split()) if x] if rv["ok"] else []
704
- out["remotes"] = remotes
705
- # stash 列表(工作台收起/还原入口)
706
- st = _git(workdir, "stash", "list", "--format=%gd\x01%s")
707
- stashes = []
708
- if st["ok"]:
709
- for ln in (st["stdout"] or "").splitlines():
710
- h, _, subj = ln.partition("\x01")
711
- if h.strip():
712
- stashes.append({"ref": h.strip(), "subject": subj.strip()[:120]})
713
- out["stashes"] = stashes[:50]
714
- # 最近提交(时间线,给「本次修改」一个上下文锚点)
715
- lg = _git(workdir, "log", "-n", "8", "--format=%h\x01%an\x01%ar\x01%s")
716
- recent = []
717
- if lg["ok"]:
718
- for ln in (lg["stdout"] or "").splitlines():
719
- toks = ln.split("\x01")
720
- if len(toks) >= 4 and toks[0].strip():
721
- recent.append({"hash": toks[0], "author": toks[1],
722
- "age": toks[2], "subject": toks[3][:120]})
723
- out["recent"] = recent
724
- return out
725
-
726
-
727
- def file_diff(workdir, path):
728
- """单文件实时 diff 文本。已跟踪 → git diff HEAD -- path(含暂存区改动,
729
- 即 HEAD 到当前工作区全貌);未跟踪 → 读文件拼 new-file diff 形态。
730
- 返回 {"diff": str, "untracked": bool};路径非法/不在仓库内返回 error。"""
731
- rp = _safe_relpath(path)
732
- if not rp:
733
- return {"error": "非法文件路径"}
734
- fp = os.path.abspath(os.path.join(str(workdir), rp))
735
- if not _inside(workdir, fp):
736
- return {"error": "路径越出工作目录"}
737
- tracked = _git(workdir, "ls-files", "--error-unmatch", "--", rp)["ok"]
738
- if tracked:
739
- r = _git(workdir, "diff", "HEAD", "--", rp, timeout=40)
740
- if not r["ok"]:
741
- return {"error": (r["stderr"] or "git diff 失败")[-200:]}
742
- return {"diff": (r["stdout"] or "")[:_WB_DIFF_MAX_CHARS], "untracked": False}
743
- if not os.path.isfile(fp):
744
- return {"diff": "", "untracked": True} # 未跟踪目录(整棵新树):无单文件 diff
745
- try:
746
- text = runner.read_text_any_enc(fp)[:_WB_DIFF_MAX_CHARS]
747
- except OSError as e:
748
- return {"error": "读取失败:%s" % e}
749
- lines = text.splitlines()[:_INSIDE_MAX_FILE_LINES]
750
- body = "\n".join("+" + ln for ln in lines)
751
- if len(text) >= _WB_DIFF_MAX_CHARS or text.count("\n") >= _INSIDE_MAX_FILE_LINES:
752
- body += "\n+…(文件过长,已截断)"
753
- section = ("diff --git a/%s b/%s\nnew file mode 100644\n"
754
- "--- /dev/null\n+++ b/%s\n@@ -0,0 +1,%d @@\n%s\n"
755
- % (rp, rp, rp, len(lines), body))
756
- return {"diff": section, "untracked": True}
757
-
758
-
759
- # 写操作白名单:action 是否需要 confirm(不可恢复类)
760
- _WB_ACTIONS = {"checkout", "fetch", "pull", "push", "stage", "unstage",
761
- "discard", "delete", "commit", "stage_all", "unstage_all",
762
- "stash_push", "stash_pop", "stash_drop"}
763
-
764
-
765
- def _res(r, extra=None):
766
- """run_process 结果 → (ok, err, data):错误取 stderr 尾(超时尾巴一并带上)。"""
767
- if r["ok"]:
768
- return True, "", (extra or {})
769
- tail = ((r["stderr"] or "") + (r["stdout"] or "")).strip()[-300:]
770
- return False, tail or "git 命令失败(exit %s)" % r.get("exit_code"), (extra or {})
771
-
772
-
773
- def workbench_op(workdir, action, params=None):
774
- """工作台写操作统一入口。返回 (ok, 错误信息, 附带信息 dict)
775
-
776
- 守卫:action 白名单;path 走 _safe_relpath;分支/提交信息限长。
777
- 丢弃(discard)与删除未跟踪文件(delete)不可恢复,必须 confirm=true。
778
- stash 系列只认 tutti-stash-* 标记条目——用户自己的 stash 不在工作台露出,
779
- 避免误 pop 撞掉检出链收起的现场。
780
- """
781
- params = params or {}
782
- wd = str(workdir or "")
783
- if action not in _WB_ACTIONS:
784
- return False, "不支持的操作:%s" % action, {}
785
- info = workbench_status(wd)
786
- if not info.get("repo"):
787
- return False, "工作目录不是 git 仓库", {}
788
-
789
- def need_path():
790
- rp = _safe_relpath(params.get("path"))
791
- if not rp:
792
- return ""
793
- if not _inside(wd, os.path.abspath(os.path.join(wd, rp))):
794
- return ""
795
- return rp
796
-
797
- if action == "checkout":
798
- br = str(params.get("branch") or "").strip()
799
- if not br or len(br) > 200 or br.startswith("-"):
800
- return False, "分支名非法", {}
801
- dirty = (info.get("staged") or []) + (info.get("unstaged") or [])
802
- if dirty:
803
- return False, "工作区有未提交改动,切分支可能丢失现场:先提交或收起(stash)", {}
804
- if br in (info.get("branches") or []):
805
- return _res(_git(wd, "checkout", "--quiet", br, timeout=60))
806
- # 远程分支 origin/foo → 建本地跟踪分支 foo;已存在同名本地分支则直接切
807
- if "/" in br and br in (info.get("remote_branches") or []):
808
- name = br.split("/", 1)[1]
809
- r = _git(wd, "checkout", "-b", name, "--track", br, timeout=60)
810
- if not r["ok"]:
811
- r = _git(wd, "checkout", "--quiet", name, timeout=60)
812
- return _res(r)
813
- if not _git(wd, "rev-parse", "--verify", "--quiet", br)["ok"]:
814
- return False, "分支 %s 不存在" % br, {}
815
- return _res(_git(wd, "checkout", "--quiet", br, timeout=60))
816
-
817
- if action == "stage":
818
- rp = need_path()
819
- if not rp:
820
- return False, "非法文件路径", {}
821
- if _attach_path(rp):
822
- return False, "_attachments/ 是任务附件素材,不进版本库,不能暂存", {}
823
- return _res(_git(wd, "add", "--", rp, timeout=60))
824
-
825
- if action == "unstage":
826
- rp = need_path()
827
- if not rp:
828
- return False, "非法文件路径", {}
829
- # 未跟踪 直接撤出 index;已跟踪 → 回退 index 到 HEAD(保留工作区改动)
830
- if _git(wd, "ls-files", "--error-unmatch", "--", rp)["ok"]:
831
- return _res(_git(wd, "reset", "-q", "--", rp, timeout=60))
832
- return _res(_git(wd, "rm", "--cached", "-q", "--", rp, timeout=60))
833
-
834
- if action == "discard":
835
- rp = need_path()
836
- if not rp:
837
- return False, "非法文件路径", {}
838
- if not params.get("confirm"):
839
- return False, "丢弃未提交改动不可恢复,需要 confirm=true 二次确认", {}
840
- if _git(wd, "ls-files", "--error-unmatch", "--", rp)["ok"]:
841
- # checkout HEAD -- path:index 与工作区一起还原到 HEAD(含已暂存改动)
842
- return _res(_git(wd, "checkout", "HEAD", "--", rp, timeout=60))
843
- return _res(_git(wd, "clean", "-qfd", "--", rp, timeout=60))
844
-
845
- if action == "delete":
846
- rp = need_path()
847
- if not rp:
848
- return False, "非法文件路径", {}
849
- if not params.get("confirm"):
850
- return False, "删除未跟踪文件不可恢复,需要 confirm=true 二次确认", {}
851
- if _git(wd, "ls-files", "--error-unmatch", "--", rp)["ok"]:
852
- return False, "该文件已被 git 跟踪,请用丢弃改动还原", {}
853
- return _res(_git(wd, "clean", "-qfd", "--", rp, timeout=60))
854
-
855
- if action in ("stage_all", "unstage_all"):
856
- if action == "stage_all":
857
- r = _git(wd, "add", "-A", timeout=60)
858
- if r["ok"]:
859
- _git(wd, "reset", "-q", "--", "_attachments") # 附件永不进提交
860
- return _res(r)
861
- return _res(_git(wd, "reset", "-q", timeout=60))
862
-
863
- if action == "commit":
864
- msg = str(params.get("message") or "").strip()[:500]
865
- if not msg:
866
- return False, "提交信息不能为空", {}
867
- staged = info.get("staged") or []
868
- att = [f for f in staged if f["path"] == "_attachments"
869
- or f["path"].startswith("_attachments/")]
870
- if att:
871
- _git(wd, "reset", "-q", "--", "_attachments")
872
- staged = [f for f in staged if f not in att]
873
- if not staged:
874
- return False, "暂存区为空:先暂存要提交的文件", {}
875
- # 用户手动提交优先用仓库自己的身份;没配置再回落 CodeBee 身份
876
- cfg = _git(wd, "config", "user.email")
877
- ident = [] if (cfg["stdout"] or "").strip() else [
878
- "-c", "user.name=CodeBee", "-c", "user.email=codebee@orchestra.local"]
879
- r = _git(wd, *ident, "commit", "-m", msg, "--quiet", timeout=60)
880
- ok, err, data = _res(r)
881
- if ok:
882
- h = _git(wd, "rev-parse", "--short", "HEAD")
883
- data = {"commit": (h["stdout"] or "").strip() if h["ok"] else "",
884
- "files": len(staged)}
885
- return ok, err, data
886
-
887
- if action in ("fetch", "pull", "push"):
888
- remotes = info.get("remotes") or []
889
- if not remotes:
890
- return False, "该仓库没有配置远程(git remote),无法%s" % {
891
- "fetch": "抓取", "pull": "拉取", "push": "推送"}[action], {}
892
- remote = str(params.get("remote") or remotes[0]).strip()
893
- if not remote or remote.startswith("-") or len(remote) > 100:
894
- return False, "远程名非法", {}
895
- if action == "fetch":
896
- return _res(_git(wd, "fetch", "--prune", remote, timeout=120))
897
- br = info.get("branch") or ""
898
- if info.get("detached"):
899
- return False, "游离 HEAD 状态不能%s,请先切换到分支" % {
900
- "pull": "拉取", "push": "推送"}[action], {}
901
- if action == "pull":
902
- if info.get("upstream"):
903
- r = _git(wd, "pull", "--rebase=false", timeout=120)
904
- else:
905
- r = _git(wd, "pull", "--rebase=false", remote, br, timeout=120)
906
- ok, err, data = _res(r)
907
- if not ok and ("CONFLICT" in (r["stdout"] or "") + err
908
- or "conflict" in err.lower()):
909
- err += "(存在冲突:请手工解决后提交,或 git merge --abort 回退)"
910
- return ok, err, data
911
- # push:有上游推上游;无上游首推建立跟踪
912
- if info.get("upstream"):
913
- return _res(_git(wd, "push", timeout=120))
914
- return _res(_git(wd, "push", "-u", remote, br, timeout=120))
915
-
916
- if action == "stash_push":
917
- msg = str(params.get("message") or "").strip()[:120]
918
- m = "tutti-stash-%s" % (msg or "workbench")
919
- r = _git(wd, "stash", "push", "-u", "-m", m, "--", ".", ":(exclude)_attachments",
920
- timeout=90)
921
- ok, err, data = _res(r)
922
- if ok and not (info.get("staged") or info.get("unstaged") or info.get("untracked")):
923
- data = {"noop": True} # 干净树:git 返回 ok 但没存东西
924
- return ok, err, data
925
-
926
- if action in ("stash_pop", "stash_drop"):
927
- ref = str(params.get("ref") or "").strip()
928
- if not re.match(r"^stash@\{[0-9]+\}$", ref):
929
- return False, "stash 引用非法", {}
930
- lst = _git(wd, "stash", "list", "--format=%gd\x01%s")
931
- found = None
932
- if lst["ok"]:
933
- for ln in (lst["stdout"] or "").splitlines():
934
- h, _, subj = ln.partition("\x01")
935
- if h.strip() == ref:
936
- found = subj.strip()
937
- break
938
- if found is None:
939
- return False, "stash 条目不存在", {}
940
- # %s 的形态是 "On master: <message>"/"WIP on ...",认标记用包含判断
941
- if "tutti-stash-" not in found:
942
- return False, "工作台只处理编排台自己收起的 stash(tutti-stash-*),你自己的条目请手工处理", {}
943
- if action == "stash_pop":
944
- return _res(_git(wd, "stash", "pop", ref, timeout=90))
945
- if not params.get("confirm"):
946
- return False, "删除 stash 条目不可恢复,需要 confirm=true 二次确认", {}
947
- return _res(_git(wd, "stash", "drop", ref, timeout=60))
948
-
949
- return False, "不支持的操作:%s" % action, {}
1
+ # -*- coding: utf-8 -*-
2
+ """Git 仓库探测与任务版本检出(新建任务的「代码版本」选项)。
3
+
4
+ 设计约束(对齐质量闸门原则:显式意图不容静默降级):
5
+ - 探测只读:/api/git/info 只跑 rev-parse / status / log,不改仓库任何东西。
6
+ - 检出不破坏用户现场:不直接在所选版本上 detached checkout,而是从它创建
7
+ 任务分支 codebee/<task-id> 再检出(老 tutti/<id> 分支仍可合并/丢弃)——原分支指针不动,任务产物落在任务分支上。
8
+ - 工作区不必干净也能检出:未提交改动先 git stash 原样收起(_attachments/
9
+ 的附件除外,那是同一任务刚上传的素材),收尾切回后 apply 还原——开发仓库
10
+ 永远有并行改动,硬拒绝等于代码版本隔离不可用。stash 条目按 codebee-stash-*
11
+ 标记,还原失败时保留在 stash 列表里可手工找回,绝不静默丢弃。
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import re
17
+
18
+ from . import paths as paths_mod
19
+ from . import runner
20
+
21
+ # 版本引用白名单:分支名/标签/短哈希及 rev^0、rev~2 组合,禁止以 - 开头
22
+ # (argv 传参本身不经过 shell,这里主要挡参数注入与 pathspec 歧义)
23
+ _REV_RE = re.compile(r"^[A-Za-z0-9_][\w./\u4e00-\u9fff~^\-]*$")
24
+
25
+
26
+ def valid_rev(rev):
27
+ s = str(rev or "")
28
+ return bool(s) and len(s) <= 200 and bool(_REV_RE.match(s))
29
+
30
+
31
+ def _git(workdir, *args, timeout=20):
32
+ r = runner.run_process(argv=["git", *args], cwd=str(workdir), timeout=timeout)
33
+ return r
34
+
35
+
36
+ def _attach_entry(line):
37
+ """status --porcelain 的一行是否为任务附件目录下的未跟踪项(不算脏改动)。"""
38
+ if not line.startswith("?? "):
39
+ return False
40
+ p = line[3:]
41
+ return p.startswith("_attachments/") or p.startswith('"_attachments/')
42
+
43
+
44
+ def repo_info(workdir):
45
+ """探测目录是否为 git 仓库,返回 UI 下拉所需信息。非仓库返回 {"repo": False}。"""
46
+ r = _git(workdir, "rev-parse", "--is-inside-work-tree")
47
+ if not r["ok"] or r["stdout"].strip() != "true":
48
+ return {"repo": False}
49
+ head = _git(workdir, "rev-parse", "--short", "HEAD")
50
+ branch = _git(workdir, "rev-parse", "--abbrev-ref", "HEAD")
51
+ st = _git(workdir, "status", "--porcelain")
52
+ dirty = [ln for ln in (st["stdout"] or "").splitlines()
53
+ if ln.strip() and not _attach_entry(ln.strip())] if st["ok"] else []
54
+ branches = tags = []
55
+ rb = _git(workdir, "for-each-ref", "--format=%(refname:short)", "refs/heads/")
56
+ if rb["ok"]:
57
+ branches = [x for x in (l.strip() for l in rb["stdout"].splitlines()) if x][:200]
58
+ rt = _git(workdir, "tag", "--sort=-creatordate")
59
+ if rt["ok"]:
60
+ tags = [x for x in (l.strip() for l in rt["stdout"].splitlines()) if x][:200]
61
+ recent = []
62
+ rl = _git(workdir, "log", "--oneline", "-n", "30", "--format=%h\x01%s")
63
+ if rl["ok"]:
64
+ for ln in (rl["stdout"] or "").splitlines()[:30]:
65
+ h, _, subj = ln.partition("\x01")
66
+ if h.strip():
67
+ recent.append({"hash": h.strip(), "subject": subj.strip()[:120]})
68
+ br = (branch["stdout"] or "").strip() if branch["ok"] else ""
69
+ return {
70
+ "repo": True,
71
+ "branch": br or "(游离 HEAD)",
72
+ "head": (head["stdout"] or "").strip() if head["ok"] else "",
73
+ "dirty": bool(dirty),
74
+ "dirty_count": len(dirty),
75
+ "branches": branches,
76
+ "tags": tags,
77
+ "recent": recent,
78
+ }
79
+
80
+
81
+ BRANCH_PREFIX = "codebee/" # 任务分支前缀;老分支 tutti/<id> 在列表里仍被识别(下方 _TASK_BRANCH_RE)
82
+ _TASK_BRANCH_RES = ("codebee/", "tutti/") # 老版本发布的任务分支前缀,只读兼容
83
+
84
+
85
+ def branch_name(task_id):
86
+ return BRANCH_PREFIX + str(task_id)
87
+
88
+
89
+ def _stash_dirty(workdir, label):
90
+ """把用户未提交改动(含未跟踪文件)收进 stash,返回 (stash sha, 错误信息)。
91
+
92
+ _attachments/ 除外:那是本任务刚上传的附件,必须留在工作目录供注入。
93
+ pathspec 排除走 argv,不经过 shell。失败返回错误——绝不静默带着脏改动切分支。
94
+ """
95
+ m = "codebee-stash-%s" % label
96
+ r = _git(workdir, "stash", "push", "-u", "-m", m,
97
+ "--", ".", ":(exclude)_attachments", timeout=60)
98
+ if not r["ok"]:
99
+ return "", ("暂存未提交改动失败(请手工提交或清理后重试):%s"
100
+ % (r["stderr"] or r["stdout"] or "")[-160:])
101
+ top = _git(workdir, "rev-parse", "--quiet", "--verify", "stash@{0}")
102
+ if not top["ok"]:
103
+ return "", "暂存后找不到 stash 条目,请手工检查 git stash list"
104
+ return (top["stdout"] or "").strip(), ""
105
+
106
+
107
+ def _restore_stash(workdir, sha, out):
108
+ """把检出前收起的用户改动 apply 回工作区,成功才 drop 那一条。
109
+
110
+ apply+drop 而非 pop:pop 在应用冲突时也会丢弃条目,用户的未保存
111
+ 改动就真丢了。任何失败都把 stash 原样留在列表里,错误写进 out["restore_error"]
112
+ """
113
+ a = _git(workdir, "stash", "apply", "--quiet", str(sha), timeout=60)
114
+ if not a["ok"]:
115
+ out["restore_error"] += (
116
+ "还原未提交改动失败(stash %s 已保留,git stash list 可找回;工作区可能有冲突标记):%.160s"
117
+ % (str(sha)[:12], (a["stderr"] or a["stdout"] or "")))
118
+ return
119
+ lst = _git(workdir, "stash", "list", "--format=%H %gd")
120
+ target = ""
121
+ for ln in (lst["stdout"] or "").splitlines():
122
+ h, _, ref = ln.strip().partition(" ")
123
+ if h == str(sha) and ref:
124
+ target = ref
125
+ break
126
+ if target:
127
+ d = _git(workdir, "stash", "drop", target)
128
+ if not d["ok"]:
129
+ out["restore_error"] += ("清理已还原的 stash 失败(内容无损,可手工 drop):%s"
130
+ % (d["stderr"] or "")[-120:])
131
+
132
+
133
+ def prepare_checkout(workdir, rev, task_id):
134
+ """执行前检出任务分支。返回 (ok, 错误信息, 信息 dict)。
135
+
136
+ 成功时 dict:{rev, branch, commit, from_branch, base_commit, stash}——写入 run.git
137
+ 供 UI 展示;from_branch/base_commit 是收尾 finalize_run 切回的依据;
138
+ stash 非空表示检出前收起了用户未提交改动(收尾时 apply 还原)。
139
+ 已有同名任务分支(重试场景)直接切过去继续,不重置——重试语义是续跑,
140
+ 把用户可能已提交到任务分支的改动 reset 掉是破坏性的。
141
+ """
142
+ if not valid_rev(rev):
143
+ return False, "非法的版本引用:%s" % str(rev)[:40], None
144
+ info = repo_info(workdir)
145
+ if not info.get("repo"):
146
+ return False, "工作目录不是 git 仓库,无法按所选版本运行", None
147
+ if not info.get("head"):
148
+ return False, "仓库还没有任何提交,请先提交基线", None
149
+ stash_sha = ""
150
+ if info.get("dirty"):
151
+ # 脏工作区不再硬拒绝(开发仓库永远有并行改动):原样 stash 收起,收尾还原
152
+ stash_sha, err = _stash_dirty(workdir, task_id)
153
+ if err:
154
+ return False, err, None
155
+ rv = _git(workdir, "rev-parse", "--verify", "--quiet", rev)
156
+ if not rv["ok"]:
157
+ if stash_sha:
158
+ out = {"restore_error": ""}
159
+ _restore_stash(workdir, stash_sha, out)
160
+ return False, "仓库中不存在版本「%s」" % rev, None
161
+ br = branch_name(task_id)
162
+ exists = _git(workdir, "rev-parse", "--verify", "--quiet", "refs/heads/" + br)
163
+ if exists["ok"]:
164
+ co = _git(workdir, "checkout", "--quiet", br, timeout=60)
165
+ else:
166
+ co = _git(workdir, "checkout", "-q", "-b", br, rev, timeout=60)
167
+ if not co["ok"]:
168
+ tail = (co["stderr"] or co["stdout"] or "")[-200:]
169
+ if stash_sha:
170
+ out = {"restore_error": ""}
171
+ _restore_stash(workdir, stash_sha, out)
172
+ tail = "检出任务分支失败:%s(已还原你的未提交改动)" % tail \
173
+ if not out["restore_error"] else \
174
+ "检出任务分支失败:%s;还原未提交改动也失败,git stash list 可找回" % tail
175
+ return False, tail, None
176
+ head = _git(workdir, "rev-parse", "--short", "HEAD")
177
+ return True, "", {
178
+ "rev": rev,
179
+ "branch": br,
180
+ "commit": (head["stdout"] or "").strip() if head["ok"] else "",
181
+ "from_branch": info.get("branch") or "",
182
+ "base_commit": info.get("head") or "",
183
+ "stash": stash_sha,
184
+ }
185
+
186
+
187
+ # ---------------------------------------------------------------- 变更快照与收尾
188
+
189
+ _INSIDE_MAX_FILE_CHARS = 8000 # 单个新文件进 diff 的上限
190
+ _INSIDE_MAX_FILE_LINES = 200
191
+ _STAT_MAX_LINES = 20000 # +/- 统计的单文件行数封顶(统计不求全文)
192
+
193
+
194
+ def _inside(workdir, target):
195
+ try:
196
+ return os.path.commonpath(
197
+ [os.path.abspath(str(workdir)), os.path.abspath(str(target))]) == os.path.abspath(str(workdir))
198
+ except ValueError:
199
+ return False
200
+
201
+
202
+ def _attach_path(path):
203
+ """-z 格式的未跟踪路径是否落在任务附件目录下(目录整体未跟踪时带尾斜杠)。"""
204
+ p = (path or "").rstrip("/\\")
205
+ return p == "_attachments" or p.startswith("_attachments/")
206
+
207
+
208
+ def _porcelain_entries(workdir):
209
+ """status --porcelain -z → [(xy, path)];-z 不做引号/八进制转义,中文路径原样。
210
+ 重命名条目 (XY new\0old) 只取新路径(展示用足够)。失败返回 None。"""
211
+ r = _git(workdir, "status", "--porcelain", "-z")
212
+ if not r["ok"]:
213
+ return None
214
+ out = []
215
+ fields = r["stdout"].split("\0")
216
+ i = 0
217
+ while i < len(fields):
218
+ f = fields[i]
219
+ if len(f) < 4:
220
+ i += 1
221
+ continue
222
+ xy, path = f[:2], f[3:]
223
+ if xy[0] in ("R", "C"): # 下一字段是原路径,跳过
224
+ i += 1
225
+ out.append((xy, path))
226
+ i += 1
227
+ return out
228
+
229
+
230
+ def _numstat(workdir):
231
+ """diff HEAD 的行级统计:{path: (add, del)}。+N -M 徽标的数据源。
232
+
233
+ --no-renames 关掉改名探测:改名退化为删+增两行,路径是纯单段,解析零特判,
234
+ 对统计口径也无损。-z 下每条记录是 "add\\tdel\\tpath\\0"(记录内制表符分隔、
235
+ 记录间 NUL 分隔),二进制文件数值为 '-' 按 0 计。
236
+ """
237
+ r = _git(workdir, "diff", "HEAD", "--numstat", "--no-renames", "-z")
238
+ out = {}
239
+ if not r["ok"]:
240
+ return out
241
+ for rec in (r["stdout"] or "").split("\0"):
242
+ if not rec:
243
+ continue
244
+ parts = rec.split("\t")
245
+ if len(parts) < 3:
246
+ continue
247
+ add_s, del_s, path = parts[0], parts[1], "\t".join(parts[2:])
248
+ if not path:
249
+ continue
250
+ try:
251
+ add = int(add_s)
252
+ except ValueError:
253
+ add = 0
254
+ try:
255
+ deln = int(del_s)
256
+ except ValueError:
257
+ deln = 0
258
+ out[path] = (add, deln)
259
+ return out
260
+
261
+
262
+ def _count_lines(fp, cap=50000):
263
+ """按块数行,封顶 cap——只为 +/- 统计,不为内容,超大文件不值得全读。"""
264
+ n = 0
265
+ try:
266
+ with open(fp, "rb") as f:
267
+ while True:
268
+ chunk = f.read(1 << 16)
269
+ if not chunk:
270
+ break
271
+ n += chunk.count(b"\n")
272
+ if n >= cap:
273
+ return cap
274
+ except OSError:
275
+ return 0
276
+ return n
277
+
278
+
279
+ def _status_code(xy):
280
+ """porcelain XY 单字符状态码(前端 GIT_STATUS_LABEL 的键、快照的持久化形态)。
281
+ 原始 XY 是两列(如 " M"/"M "/"MM"/"??"),直接透传会让前端查表落空、
282
+ "??" 当文案渲染出来。规则:未跟踪 → "?";任一列含 D 取 D(删除最醒目);
283
+ 其余取首个非空列(MM/AM 等组合态收拢到首列)。"""
284
+ s = (xy or "").strip()
285
+ if s == "??":
286
+ return "?"
287
+ if not s:
288
+ return "M"
289
+ if "D" in s:
290
+ return "D"
291
+ return s[0]
292
+
293
+
294
+ def collect_changes(workdir, max_diff=30000):
295
+ """只读收集工作区变更(不动 index、不碰分支)。
296
+
297
+ 返回 {"files": [{"status", "path", "add", "del"}...], "diff": "...",
298
+ "add_total", "del_total"}。diff = 已跟踪改动(git diff HEAD)+ 未跟踪新文件
299
+ 逐段拼成 diff 形态——新文件不进 git diff,但恰是智能体产物的大头(新章节、
300
+ 新模块),缺了评审与人审都瞎一半。add/del 是行级统计(numstat + 新文件计数),
301
+ 供检查器面板的 +N -M 徽标;旧调用方不读这两个字段,向后兼容。
302
+ 附件目录不算变更;非 git 仓库/命令失败返回空集合,绝不抛错。
303
+ """
304
+ empty = {"files": [], "diff": "", "add_total": 0, "del_total": 0}
305
+ entries = _porcelain_entries(workdir)
306
+ if entries is None:
307
+ return empty
308
+ files, untracked = [], []
309
+ for xy, path in entries:
310
+ if not path:
311
+ continue
312
+ if xy == "??" and _attach_path(path):
313
+ continue
314
+ files.append({"status": _status_code(xy), "path": path})
315
+ if xy == "??":
316
+ untracked.append(path)
317
+ stats = _numstat(workdir)
318
+ add_total = del_total = 0
319
+ for f in files:
320
+ add, deln = stats.get(f["path"], (0, 0))
321
+ f["add"], f["del"] = add, deln
322
+ add_total += add
323
+ del_total += deln
324
+ parts, budget = [], max_diff
325
+ r = _git(workdir, "diff", "HEAD")
326
+ if r["ok"] and r["stdout"]:
327
+ take = r["stdout"][:budget]
328
+ parts.append(take)
329
+ budget -= len(take)
330
+ for p in untracked:
331
+ if budget <= 0:
332
+ break
333
+ fp = os.path.abspath(os.path.join(str(workdir), p))
334
+ if not os.path.isfile(fp) or not _inside(workdir, fp):
335
+ continue
336
+ lines_n = _count_lines(fp, cap=_STAT_MAX_LINES)
337
+ add_total += lines_n
338
+ try:
339
+ text = runner.read_text_any_enc(fp)[:_INSIDE_MAX_FILE_CHARS]
340
+ except OSError:
341
+ continue
342
+ lines = text.splitlines()[:_INSIDE_MAX_FILE_LINES]
343
+ body = "\n".join("+" + ln for ln in lines)
344
+ if len(text) > _INSIDE_MAX_FILE_CHARS or text.count("\n") >= _INSIDE_MAX_FILE_LINES:
345
+ body += "\n+…(新文件过长,已截断)"
346
+ section = ("diff --git a/%s b/%s\nnew file mode 100644\n"
347
+ "--- /dev/null\n+++ b/%s\n@@ -0,0 +1,%d @@\n%s\n"
348
+ % (p, p, p, len(lines), body))[:budget]
349
+ parts.append(section)
350
+ budget -= len(section)
351
+ # 未跟踪文件在 numstat 里没有条目,把行数补进该文件的统计
352
+ for f in files:
353
+ if f["path"] == p:
354
+ f["add"] = lines_n
355
+ break
356
+ return {"files": files[:200], "diff": "".join(parts),
357
+ "add_total": add_total, "del_total": del_total}
358
+
359
+
360
+ def finalize_run(workdir, gitinfo, message):
361
+ """run 结束后收尾:把产物提交到任务分支,再切回原分支/基线提交。
362
+
363
+ 语义(对齐 Baton/Codeband 的隔离模型;本轮只做「保存现场」,任务分支
364
+ 的合并/丢弃留给后续的人工裁决):
365
+ - 产物必须可找回:哪怕 run 失败/取消,已写盘的半成品也提交到
366
+ codebee/<task-id>(WIP 语义),绝不留在用户工作区里污染原分支;
367
+ - _attachments/ 不提交也不丢:用户上传的共享附件保持未跟踪,
368
+ 切分支后仍留在工作目录(未跟踪文件随分支切换保留);
369
+ - 任何收尾失败都不抛出:run 终态已定,问题记入 restore_error
370
+ UI 呈现,绝不因此覆盖 run 的最终结论。
371
+ 返回 {commit, restored, restore_error}(由调用方并入 run.git)。
372
+ """
373
+ out = {"commit": "", "restored": False, "restore_error": ""}
374
+ branch = (gitinfo or {}).get("branch") or ""
375
+ if not branch:
376
+ out["restore_error"] = "缺少任务分支信息,跳过收尾"
377
+ return out
378
+ info = repo_info(workdir)
379
+ if not info.get("repo"):
380
+ out["restore_error"] = "工作目录已不是 git 仓库,产物留在原处未提交"
381
+ return out
382
+ if (info.get("branch") or "") != branch:
383
+ out["restore_error"] = ("工作区已不在任务分支 %s(当前 %s),不自动提交"
384
+ % (branch, info.get("branch") or "?"))
385
+ return out
386
+ # 产物提交(_attachments 除外:保持未跟踪,随分支切换保留在工作区)
387
+ if info.get("dirty"):
388
+ a = _git(workdir, "add", "-A")
389
+ if not a["ok"]:
390
+ out["restore_error"] = "git add 失败:%s" % (a["stderr"] or "")[-160:]
391
+ return out
392
+ staged_att = _git(workdir, "diff", "--cached", "--name-only", "--", "_attachments")
393
+ if staged_att["ok"] and (staged_att["stdout"] or "").strip():
394
+ rst = _git(workdir, "reset", "-q", "--", "_attachments")
395
+ if not rst["ok"]:
396
+ out["restore_error"] = "剔除附件暂存失败:%s" % (rst["stderr"] or "")[-160:]
397
+ return out
398
+ staged = _git(workdir, "diff", "--cached", "--name-only")
399
+ if staged["ok"] and (staged["stdout"] or "").strip():
400
+ # 一次性身份配置:用户仓库可能没配 user.name/email,缺身份不应让提交失败
401
+ c = _git(workdir, "-c", "user.name=CodeBee", "-c", "user.email=codebee@orchestra.local",
402
+ "commit", "-m", str(message)[:200], "--quiet")
403
+ if not c["ok"]:
404
+ out["restore_error"] = "提交任务分支失败:%s" % (c["stderr"] or "")[-160:]
405
+ return out
406
+ h = _git(workdir, "rev-parse", "--short", "HEAD")
407
+ out["commit"] = (h["stdout"] or "").strip() if h["ok"] else ""
408
+ # 切回原分支;原为游离 HEAD 时回到基线提交(同样游离)
409
+ back = (gitinfo or {}).get("from_branch") or ""
410
+ base = (gitinfo or {}).get("base_commit") or ""
411
+ target = back if (back and back != "(游离 HEAD)" and back != branch) else base
412
+ if target:
413
+ co = _git(workdir, "checkout", "--quiet", target, timeout=60)
414
+ if not co["ok"]:
415
+ out["restore_error"] += "切回 %s 失败:%s" % (target, (co["stderr"] or "")[-160:])
416
+ return out
417
+ out["restored"] = True
418
+ # 检出前收起的用户未提交改动,回到原分支后原样还原
419
+ stash = (gitinfo or {}).get("stash") or ""
420
+ if stash:
421
+ _restore_stash(workdir, stash, out)
422
+ return out
423
+
424
+
425
+ # ---------------------------------------------------------------- 任务分支裁决(人审出口)
426
+
427
+ def merge_task_branch(workdir, task):
428
+ """把任务分支合并回原分支(人审后的「采纳」出口)。
429
+
430
+ 守卫(显式失败,不静默降级):
431
+ - 任务正在跑拒绝(分支可能还要追加提交);
432
+ - 仓库缺失/分支不存在 → 拒绝;
433
+ - 工作区仍在任务分支上 拒绝(说明收尾出错,先处理 restore_error);
434
+ - 工作区不在原分支(用户切去了别处)→ 拒绝,合并目标必须明确;
435
+ - 工作区不干净 → 先 stash 原样收起(附件除外),合并在干净树上进行,
436
+ 结束(含冲突回滚)后原样还原——与检出的暂存语义一致。
437
+ 合并冲突 → 立即 git merge --abort 回滚,把冲突留给用户手工处理。
438
+ 返回 (ok, 错误信息, 信息 dict);合并成功但还原 stash 失败时,
439
+ 错误放进 info["restore_error"](stash 已保留,可手工找回)。
440
+ """
441
+ def refuse(msg):
442
+ return False, msg, None
443
+
444
+ if (task or {}).get("status") in ("queued", "running"):
445
+ return refuse("任务正在运行,等结束再裁决任务分支")
446
+ tid = (task or {}).get("id") or ""
447
+ wd = str(workdir or "")
448
+ info = repo_info(wd)
449
+ if not info.get("repo"):
450
+ return refuse("工作目录不是 git 仓库")
451
+ br = branch_name(tid)
452
+ if not _git(wd, "rev-parse", "--verify", "--quiet", "refs/heads/" + br)["ok"]:
453
+ return refuse("任务分支 %s 不存在(可能已被合并或丢弃)" % br)
454
+ cur = info.get("branch") or ""
455
+ if cur == br:
456
+ return refuse("工作区仍停在任务分支上(上一轮收尾出错),请先处理 run.git.restore_error")
457
+ if cur == "(游离 HEAD)":
458
+ return refuse("工作区处于游离 HEAD,无法作为合并目标;请先检出原分支")
459
+ # 脏工作区不再拒绝合并:暂存用户的未提交改动,合并在干净树上进行,
460
+ # 结束后原样还原(与检出的 stash 语义一致)
461
+ stash_sha = ""
462
+ if info.get("dirty"):
463
+ stash_sha, err = _stash_dirty(wd, "merge-" + tid)
464
+ if err:
465
+ return refuse(err)
466
+ # 合并目标 = 任务分支的创建基线分支(来自 run.git.from_branch);
467
+ # 查不到基线绝不猜「当前分支」——用户切到哪就合进哪等于无守卫。
468
+ latest = _latest_run_git(tid) or {}
469
+ origin = latest.get("from_branch") or ""
470
+ if not origin:
471
+ if stash_sha:
472
+ out = {"restore_error": ""}
473
+ _restore_stash(wd, stash_sha, out)
474
+ return refuse("无法确定任务分支的基线分支(找不到带检出信息的运行记录),请手工合并")
475
+ if cur != origin:
476
+ if stash_sha:
477
+ out = {"restore_error": ""}
478
+ _restore_stash(wd, stash_sha, out)
479
+ return refuse("工作区当前在 %s,而任务分支的基线是 %s;请先切回 %s 再合并"
480
+ % (cur, origin, origin))
481
+ ahead = _git(wd, "rev-list", "--count", "%s..%s" % (origin, br))
482
+ n_ahead = int((ahead["stdout"] or "0").strip() or 0) if ahead["ok"] else 0
483
+ if n_ahead <= 0:
484
+ if stash_sha:
485
+ out = {"restore_error": ""}
486
+ _restore_stash(wd, stash_sha, out)
487
+ return refuse("任务分支没有领先基线的提交(产物为空),无需合并;可直接丢弃")
488
+ m = _git(wd, "-c", "user.name=CodeBee", "-c", "user.email=codebee@orchestra.local",
489
+ "merge", "--no-ff", br, "-m",
490
+ "codebee: 合并任务分支 %s(%s)" % (tid, (task or {}).get("title") or "")[:200])
491
+ if not m["ok"]:
492
+ ab = _git(wd, "merge", "--abort")
493
+ tail = (m["stderr"] or m["stdout"] or "")[-200:]
494
+ msg = ""
495
+ if ab["ok"]:
496
+ msg = "合并冲突,已回滚到合并前状态;请手工解决后自行合并:%s" % tail
497
+ else:
498
+ msg = "合并失败且回滚失败,工作区可能残留冲突标记:%s" % tail
499
+ if stash_sha:
500
+ out = {"restore_error": ""}
501
+ _restore_stash(wd, stash_sha, out)
502
+ if out["restore_error"]:
503
+ msg += ";另:还原你的未提交改动失败,git stash list 可找回"
504
+ return refuse(msg)
505
+ h = _git(wd, "rev-parse", "--short", "HEAD")
506
+ info_out = {
507
+ "merged_into": origin,
508
+ "commits": n_ahead,
509
+ "commit": (h["stdout"] or "").strip() if h["ok"] else "",
510
+ }
511
+ if stash_sha:
512
+ out = {"restore_error": ""}
513
+ _restore_stash(wd, stash_sha, out)
514
+ if out["restore_error"]:
515
+ info_out["restore_error"] = out["restore_error"]
516
+ return True, "", info_out
517
+
518
+
519
+ def discard_task_branch(workdir, task):
520
+ """丢弃任务分支(人审后的「否决」出口,不可恢复)。
521
+
522
+ 守卫同 merge(活跃任务/仓库缺失/分支不存在拒绝);若工作区恰好停在
523
+ 任务分支上且干净(收尾出错的遗留现场),先切回基线再删分支。
524
+ 返回 (ok, 错误信息)。
525
+ """
526
+ def refuse(msg):
527
+ return False, msg
528
+
529
+ if (task or {}).get("status") in ("queued", "running"):
530
+ return refuse("任务正在运行,等结束再裁决任务分支")
531
+ tid = (task or {}).get("id") or ""
532
+ wd = str(workdir or "")
533
+ info = repo_info(wd)
534
+ if not info.get("repo"):
535
+ return refuse("工作目录不是 git 仓库")
536
+ br = branch_name(tid)
537
+ if not _git(wd, "rev-parse", "--verify", "--quiet", "refs/heads/" + br)["ok"]:
538
+ return refuse("任务分支 %s 不存在(可能已被合并或丢弃)" % br)
539
+ cur = info.get("branch") or ""
540
+ if cur == br:
541
+ if info.get("dirty"):
542
+ return refuse("工作区停在任务分支上且有未提交改动,请先处理再丢弃")
543
+ latest = _latest_run_git(tid) or {}
544
+ back = latest.get("from_branch") or ""
545
+ base = latest.get("base_commit") or ""
546
+ target = back if (back and back != "(游离 HEAD)" and back != br) else base
547
+ if not target:
548
+ return refuse("工作区停在任务分支上且找不到可切回的基线,请手工切走后再丢弃")
549
+ co = _git(wd, "checkout", "--quiet", target, timeout=60)
550
+ if not co["ok"]:
551
+ return refuse("切回 %s 失败:%s" % (target, (co["stderr"] or "")[-160:]))
552
+ d = _git(wd, "branch", "-D", br)
553
+ if not d["ok"]:
554
+ return refuse("删除任务分支失败:%s" % (d["stderr"] or "")[-160:])
555
+ return True, ""
556
+
557
+
558
+ def _latest_run_git(task_id):
559
+ """该任务最近一次 run 的 git 上下文(from_branch/base_commit 以它为准)。
560
+
561
+ 放在 gitmod 而非 store 是为了避免 store→gitmod 反向依赖;读 run.json
562
+ 走磁盘(裁决是低频人工操作,无需常驻内存索引)。
563
+ """
564
+ import json as _json
565
+ best, best_id = None, ""
566
+ try:
567
+ for rj in paths_mod.RUNS_DIR.glob("*/run.json"):
568
+ try:
569
+ rdata = _json.loads(rj.read_text(encoding="utf-8"))
570
+ except Exception:
571
+ continue
572
+ rid = str(rdata.get("id") or "")
573
+ if rdata.get("task_id") == task_id and rdata.get("git") \
574
+ and rid > best_id:
575
+ best, best_id = rdata.get("git"), rid
576
+ except Exception:
577
+ return None
578
+ return best
579
+
580
+
581
+ # ---------------------------------------------------------------- GIT 工作台
582
+ # 详情页「版本」页签的完整 Git 操作面:状态聚合(只读)+ 白名单写操作。
583
+ # 与隔离链(prepare_checkout/finalize/merge/discard)互不改动;写操作的
584
+ # 「任务运行中拒绝」守卫在路由层(那里才拿得到 task.status),这里只管仓库。
585
+
586
+ _WB_MAX_FILES = 300 # 单组文件列表上限(超大仓库不拖死轮询)
587
+ _WB_DIFF_MAX_CHARS = 200000 # 单文件 diff 文本上限
588
+
589
+
590
+ def _safe_relpath(path):
591
+ """工作台路径参数白名单:仓库内相对路径。挡绝对路径、.. 上跳、
592
+ - 开头(git 会当选项解析);通过返回规范化后的正斜杠路径。"""
593
+ s = str(path or "").replace("\\", "/").strip()
594
+ if not s or s.startswith("-") or len(s) > 500:
595
+ return ""
596
+ if s.startswith("/") or re.match(r"^[A-Za-z]:", s):
597
+ return ""
598
+ parts = [x for x in s.split("/") if x not in ("", ".")]
599
+ if any(x == ".." for x in parts):
600
+ return ""
601
+ return "/".join(parts)
602
+
603
+
604
+ def _porcelain_grouped(workdir):
605
+ """status --porcelain -z (staged, unstaged, untracked) 三组 [{status,path}]。
606
+
607
+ _porcelain_entries 的区别:按 XY 两列拆「已暂存/未暂存」——工作台的
608
+ stage/unstage 语义需要知道改动落在 index 还是工作区。X 列非空非 ? → 有
609
+ 暂存改动;Y 列非空 有未暂存改动(同一文件可同时进两组,同 git UI 惯例)。
610
+ """
611
+ staged, unstaged, untracked = [], [], []
612
+ entries = _porcelain_entries(workdir)
613
+ if entries is None:
614
+ return None
615
+ for xy, path in entries:
616
+ if not path:
617
+ continue
618
+ if _attach_path(path):
619
+ continue # 任务附件目录不算变更(与 collect_changes 同口径)
620
+ x, y = xy[0], xy[1]
621
+ if xy == "??":
622
+ untracked.append({"status": "?", "path": path})
623
+ continue
624
+ if x not in (" ", "?"):
625
+ staged.append({"status": _status_code(x + " "), "path": path})
626
+ if y not in (" ", "?"):
627
+ unstaged.append({"status": _status_code(" " + y), "path": path})
628
+ return staged, unstaged, untracked
629
+
630
+
631
+ def workbench_status(workdir):
632
+ """只读聚合工作台全貌:分支/HEAD/远程、三组变更文件、ahead/behind、stash。
633
+
634
+ git 仓库返回 {"repo": False};任何子命令失败只降级对应字段,绝不抛错。
635
+ """
636
+ r = _git(workdir, "rev-parse", "--is-inside-work-tree")
637
+ if not r["ok"] or r["stdout"].strip() != "true":
638
+ return {"repo": False}
639
+ out = {"repo": True}
640
+ head = _git(workdir, "rev-parse", "--short", "HEAD")
641
+ out["head"] = (head["stdout"] or "").strip() if head["ok"] else ""
642
+ br = _git(workdir, "rev-parse", "--abbrev-ref", "HEAD")
643
+ cur = (br["stdout"] or "").strip() if br["ok"] else ""
644
+ out["branch"] = cur or "(游离 HEAD)"
645
+ out["detached"] = cur == "HEAD"
646
+ groups = _porcelain_grouped(workdir)
647
+ if groups is None:
648
+ groups = ([], [], [])
649
+ staged, unstaged, untracked = groups
650
+ out["staged"], out["unstaged"], out["untracked"] = (
651
+ staged[:_WB_MAX_FILES], unstaged[:_WB_MAX_FILES], untracked[:_WB_MAX_FILES])
652
+ out["truncated"] = any(len(g) > _WB_MAX_FILES for g in groups)
653
+ # +/- 行级统计:git diff HEAD(工作区 vs HEAD)已含暂存+未暂存的全貌,
654
+ # 同一文件在两组都出现时共用同一份统计(不可两份相加——会重复计数)
655
+ stats = {}
656
+ rn = _git(workdir, "diff", "HEAD", "--numstat", "--no-renames", "-z")
657
+ if rn["ok"]:
658
+ for rec in (rn["stdout"] or "").split("\0"):
659
+ parts = rec.split("\t")
660
+ if len(parts) < 3 or not parts[2]:
661
+ continue
662
+ path = "\t".join(parts[2:])
663
+ try:
664
+ a = int(parts[0])
665
+ except ValueError:
666
+ a = 0
667
+ try:
668
+ d = int(parts[1])
669
+ except ValueError:
670
+ d = 0
671
+ stats[path] = (a, d)
672
+ for g in (staged, unstaged):
673
+ for f in g:
674
+ a, d = stats.get(f["path"], (0, 0))
675
+ f["add"], f["del"] = a, d
676
+ for f in untracked:
677
+ fp = os.path.abspath(os.path.join(str(workdir), f["path"]))
678
+ f["add"] = _count_lines(fp) if os.path.isfile(fp) else 0
679
+ f["del"] = 0
680
+ # 分支列表:本地 + 远程(origin/* 去前缀展示,checkout 时再映射回)
681
+ branches = []
682
+ rb = _git(workdir, "for-each-ref", "--format=%(refname:short)", "refs/heads/")
683
+ if rb["ok"]:
684
+ branches = [x for x in (l.strip() for l in rb["stdout"].splitlines()) if x][:200]
685
+ remote_branches = []
686
+ rr = _git(workdir, "for-each-ref", "--format=%(refname:short)", "refs/remotes/")
687
+ if rr["ok"]:
688
+ for x in (l.strip() for l in rr["stdout"].splitlines()):
689
+ if x and not x.endswith("/HEAD"):
690
+ remote_branches.append(x)
691
+ remote_branches = remote_branches[:200]
692
+ out["branches"] = branches
693
+ out["remote_branches"] = remote_branches
694
+ # ahead/behind 与上游:无上游时 upstream 为空,前端据此禁用 pull/push
695
+ out["upstream"] = ""
696
+ out["ahead"] = out["behind"] = 0
697
+ up = _git(workdir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}")
698
+ if up["ok"]:
699
+ out["upstream"] = (up["stdout"] or "").strip()
700
+ ab = _git(workdir, "rev-list", "--left-right", "--count", "HEAD..." + out["upstream"])
701
+ if ab["ok"]:
702
+ toks = (ab["stdout"] or "").split()
703
+ if len(toks) == 2:
704
+ out["ahead"], out["behind"] = int(toks[0]), int(toks[1])
705
+ # 远程名(默认 origin;多远程时取第一个)
706
+ rv = _git(workdir, "remote")
707
+ remotes = [x for x in ((rv["stdout"] or "").split()) if x] if rv["ok"] else []
708
+ out["remotes"] = remotes
709
+ # stash 列表(工作台收起/还原入口)
710
+ st = _git(workdir, "stash", "list", "--format=%gd\x01%s")
711
+ stashes = []
712
+ if st["ok"]:
713
+ for ln in (st["stdout"] or "").splitlines():
714
+ h, _, subj = ln.partition("\x01")
715
+ if h.strip():
716
+ stashes.append({"ref": h.strip(), "subject": subj.strip()[:120]})
717
+ out["stashes"] = stashes[:50]
718
+ # 最近提交(时间线,给「本次修改」一个上下文锚点)
719
+ lg = _git(workdir, "log", "-n", "8", "--format=%h\x01%an\x01%ar\x01%s")
720
+ recent = []
721
+ if lg["ok"]:
722
+ for ln in (lg["stdout"] or "").splitlines():
723
+ toks = ln.split("\x01")
724
+ if len(toks) >= 4 and toks[0].strip():
725
+ recent.append({"hash": toks[0], "author": toks[1],
726
+ "age": toks[2], "subject": toks[3][:120]})
727
+ out["recent"] = recent
728
+ return out
729
+
730
+
731
+ def file_diff(workdir, path):
732
+ """单文件实时 diff 文本。已跟踪 → git diff HEAD -- path(含暂存区改动,
733
+ HEAD 到当前工作区全貌);未跟踪 → 读文件拼 new-file diff 形态。
734
+ 返回 {"diff": str, "untracked": bool};路径非法/不在仓库内返回 error。"""
735
+ rp = _safe_relpath(path)
736
+ if not rp:
737
+ return {"error": "非法文件路径"}
738
+ fp = os.path.abspath(os.path.join(str(workdir), rp))
739
+ if not _inside(workdir, fp):
740
+ return {"error": "路径越出工作目录"}
741
+ tracked = _git(workdir, "ls-files", "--error-unmatch", "--", rp)["ok"]
742
+ if tracked:
743
+ r = _git(workdir, "diff", "HEAD", "--", rp, timeout=40)
744
+ if not r["ok"]:
745
+ return {"error": (r["stderr"] or "git diff 失败")[-200:]}
746
+ return {"diff": (r["stdout"] or "")[:_WB_DIFF_MAX_CHARS], "untracked": False}
747
+ if not os.path.isfile(fp):
748
+ return {"diff": "", "untracked": True} # 未跟踪目录(整棵新树):无单文件 diff
749
+ try:
750
+ text = runner.read_text_any_enc(fp)[:_WB_DIFF_MAX_CHARS]
751
+ except OSError as e:
752
+ return {"error": "读取失败:%s" % e}
753
+ lines = text.splitlines()[:_INSIDE_MAX_FILE_LINES]
754
+ body = "\n".join("+" + ln for ln in lines)
755
+ if len(text) >= _WB_DIFF_MAX_CHARS or text.count("\n") >= _INSIDE_MAX_FILE_LINES:
756
+ body += "\n+…(文件过长,已截断)"
757
+ section = ("diff --git a/%s b/%s\nnew file mode 100644\n"
758
+ "--- /dev/null\n+++ b/%s\n@@ -0,0 +1,%d @@\n%s\n"
759
+ % (rp, rp, rp, len(lines), body))
760
+ return {"diff": section, "untracked": True}
761
+
762
+
763
+ # 写操作白名单:action → 是否需要 confirm(不可恢复类)
764
+ _WB_ACTIONS = {"checkout", "fetch", "pull", "push", "stage", "unstage",
765
+ "discard", "delete", "commit", "stage_all", "unstage_all",
766
+ "stash_push", "stash_pop", "stash_drop"}
767
+
768
+
769
+ def _res(r, extra=None):
770
+ """run_process 结果 (ok, err, data):错误取 stderr 尾(超时尾巴一并带上)。"""
771
+ if r["ok"]:
772
+ return True, "", (extra or {})
773
+ tail = ((r["stderr"] or "") + (r["stdout"] or "")).strip()[-300:]
774
+ return False, tail or "git 命令失败(exit %s)" % r.get("exit_code"), (extra or {})
775
+
776
+
777
+ def workbench_op(workdir, action, params=None):
778
+ """工作台写操作统一入口。返回 (ok, 错误信息, 附带信息 dict)。
779
+
780
+ 守卫:action 白名单;path 走 _safe_relpath;分支/提交信息限长。
781
+ 丢弃(discard)与删除未跟踪文件(delete)不可恢复,必须 confirm=true。
782
+ stash 系列只认 codebee-stash-* 标记条目(老 tutti-stash-* 也认)——用户自己的 stash 不在工作台露出,
783
+ 避免误 pop 撞掉检出链收起的现场。
784
+ """
785
+ params = params or {}
786
+ wd = str(workdir or "")
787
+ if action not in _WB_ACTIONS:
788
+ return False, "不支持的操作:%s" % action, {}
789
+ info = workbench_status(wd)
790
+ if not info.get("repo"):
791
+ return False, "工作目录不是 git 仓库", {}
792
+
793
+ def need_path():
794
+ rp = _safe_relpath(params.get("path"))
795
+ if not rp:
796
+ return ""
797
+ if not _inside(wd, os.path.abspath(os.path.join(wd, rp))):
798
+ return ""
799
+ return rp
800
+
801
+ if action == "checkout":
802
+ br = str(params.get("branch") or "").strip()
803
+ if not br or len(br) > 200 or br.startswith("-"):
804
+ return False, "分支名非法", {}
805
+ dirty = (info.get("staged") or []) + (info.get("unstaged") or [])
806
+ if dirty:
807
+ return False, "工作区有未提交改动,切分支可能丢失现场:先提交或收起(stash)", {}
808
+ if br in (info.get("branches") or []):
809
+ return _res(_git(wd, "checkout", "--quiet", br, timeout=60))
810
+ # 远程分支 origin/foo → 建本地跟踪分支 foo;已存在同名本地分支则直接切
811
+ if "/" in br and br in (info.get("remote_branches") or []):
812
+ name = br.split("/", 1)[1]
813
+ r = _git(wd, "checkout", "-b", name, "--track", br, timeout=60)
814
+ if not r["ok"]:
815
+ r = _git(wd, "checkout", "--quiet", name, timeout=60)
816
+ return _res(r)
817
+ if not _git(wd, "rev-parse", "--verify", "--quiet", br)["ok"]:
818
+ return False, "分支 %s 不存在" % br, {}
819
+ return _res(_git(wd, "checkout", "--quiet", br, timeout=60))
820
+
821
+ if action == "stage":
822
+ rp = need_path()
823
+ if not rp:
824
+ return False, "非法文件路径", {}
825
+ if _attach_path(rp):
826
+ return False, "_attachments/ 是任务附件素材,不进版本库,不能暂存", {}
827
+ return _res(_git(wd, "add", "--", rp, timeout=60))
828
+
829
+ if action == "unstage":
830
+ rp = need_path()
831
+ if not rp:
832
+ return False, "非法文件路径", {}
833
+ # 未跟踪 → 直接撤出 index;已跟踪 → 回退 index 到 HEAD(保留工作区改动)
834
+ if _git(wd, "ls-files", "--error-unmatch", "--", rp)["ok"]:
835
+ return _res(_git(wd, "reset", "-q", "--", rp, timeout=60))
836
+ return _res(_git(wd, "rm", "--cached", "-q", "--", rp, timeout=60))
837
+
838
+ if action == "discard":
839
+ rp = need_path()
840
+ if not rp:
841
+ return False, "非法文件路径", {}
842
+ if not params.get("confirm"):
843
+ return False, "丢弃未提交改动不可恢复,需要 confirm=true 二次确认", {}
844
+ if _git(wd, "ls-files", "--error-unmatch", "--", rp)["ok"]:
845
+ # checkout HEAD -- path:index 与工作区一起还原到 HEAD(含已暂存改动)
846
+ return _res(_git(wd, "checkout", "HEAD", "--", rp, timeout=60))
847
+ return _res(_git(wd, "clean", "-qfd", "--", rp, timeout=60))
848
+
849
+ if action == "delete":
850
+ rp = need_path()
851
+ if not rp:
852
+ return False, "非法文件路径", {}
853
+ if not params.get("confirm"):
854
+ return False, "删除未跟踪文件不可恢复,需要 confirm=true 二次确认", {}
855
+ if _git(wd, "ls-files", "--error-unmatch", "--", rp)["ok"]:
856
+ return False, "该文件已被 git 跟踪,请用丢弃改动还原", {}
857
+ return _res(_git(wd, "clean", "-qfd", "--", rp, timeout=60))
858
+
859
+ if action in ("stage_all", "unstage_all"):
860
+ if action == "stage_all":
861
+ r = _git(wd, "add", "-A", timeout=60)
862
+ if r["ok"]:
863
+ _git(wd, "reset", "-q", "--", "_attachments") # 附件永不进提交
864
+ return _res(r)
865
+ return _res(_git(wd, "reset", "-q", timeout=60))
866
+
867
+ if action == "commit":
868
+ msg = str(params.get("message") or "").strip()[:500]
869
+ if not msg:
870
+ return False, "提交信息不能为空", {}
871
+ staged = info.get("staged") or []
872
+ att = [f for f in staged if f["path"] == "_attachments"
873
+ or f["path"].startswith("_attachments/")]
874
+ if att:
875
+ _git(wd, "reset", "-q", "--", "_attachments")
876
+ staged = [f for f in staged if f not in att]
877
+ if not staged:
878
+ return False, "暂存区为空:先暂存要提交的文件", {}
879
+ # 用户手动提交优先用仓库自己的身份;没配置再回落 CodeBee 身份
880
+ cfg = _git(wd, "config", "user.email")
881
+ ident = [] if (cfg["stdout"] or "").strip() else [
882
+ "-c", "user.name=CodeBee", "-c", "user.email=codebee@orchestra.local"]
883
+ r = _git(wd, *ident, "commit", "-m", msg, "--quiet", timeout=60)
884
+ ok, err, data = _res(r)
885
+ if ok:
886
+ h = _git(wd, "rev-parse", "--short", "HEAD")
887
+ data = {"commit": (h["stdout"] or "").strip() if h["ok"] else "",
888
+ "files": len(staged)}
889
+ return ok, err, data
890
+
891
+ if action in ("fetch", "pull", "push"):
892
+ remotes = info.get("remotes") or []
893
+ if not remotes:
894
+ return False, "该仓库没有配置远程(git remote),无法%s" % {
895
+ "fetch": "抓取", "pull": "拉取", "push": "推送"}[action], {}
896
+ remote = str(params.get("remote") or remotes[0]).strip()
897
+ if not remote or remote.startswith("-") or len(remote) > 100:
898
+ return False, "远程名非法", {}
899
+ if action == "fetch":
900
+ return _res(_git(wd, "fetch", "--prune", remote, timeout=120))
901
+ br = info.get("branch") or ""
902
+ if info.get("detached"):
903
+ return False, "游离 HEAD 状态不能%s,请先切换到分支" % {
904
+ "pull": "拉取", "push": "推送"}[action], {}
905
+ if action == "pull":
906
+ if info.get("upstream"):
907
+ r = _git(wd, "pull", "--rebase=false", timeout=120)
908
+ else:
909
+ r = _git(wd, "pull", "--rebase=false", remote, br, timeout=120)
910
+ ok, err, data = _res(r)
911
+ if not ok and ("CONFLICT" in (r["stdout"] or "") + err
912
+ or "conflict" in err.lower()):
913
+ err += "(存在冲突:请手工解决后提交,或 git merge --abort 回退)"
914
+ return ok, err, data
915
+ # push:有上游推上游;无上游首推建立跟踪
916
+ if info.get("upstream"):
917
+ return _res(_git(wd, "push", timeout=120))
918
+ return _res(_git(wd, "push", "-u", remote, br, timeout=120))
919
+
920
+ if action == "stash_push":
921
+ msg = str(params.get("message") or "").strip()[:120]
922
+ m = "codebee-stash-%s" % (msg or "workbench")
923
+ r = _git(wd, "stash", "push", "-u", "-m", m, "--", ".", ":(exclude)_attachments",
924
+ timeout=90)
925
+ ok, err, data = _res(r)
926
+ if ok and not (info.get("staged") or info.get("unstaged") or info.get("untracked")):
927
+ data = {"noop": True} # 干净树:git 返回 ok 但没存东西
928
+ return ok, err, data
929
+
930
+ if action in ("stash_pop", "stash_drop"):
931
+ ref = str(params.get("ref") or "").strip()
932
+ if not re.match(r"^stash@\{[0-9]+\}$", ref):
933
+ return False, "stash 引用非法", {}
934
+ lst = _git(wd, "stash", "list", "--format=%gd\x01%s")
935
+ found = None
936
+ if lst["ok"]:
937
+ for ln in (lst["stdout"] or "").splitlines():
938
+ h, _, subj = ln.partition("\x01")
939
+ if h.strip() == ref:
940
+ found = subj.strip()
941
+ break
942
+ if found is None:
943
+ return False, "stash 条目不存在", {}
944
+ # %s 的形态是 "On master: <message>"/"WIP on ...",认标记用包含判断
945
+ if "codebee-stash-" not in found:
946
+ return False, "工作台只处理编排台自己收起的 stash(codebee-stash-*),你自己的条目请手工处理", {}
947
+ if action == "stash_pop":
948
+ return _res(_git(wd, "stash", "pop", ref, timeout=90))
949
+ if not params.get("confirm"):
950
+ return False, "删除 stash 条目不可恢复,需要 confirm=true 二次确认", {}
951
+ return _res(_git(wd, "stash", "drop", ref, timeout=60))
952
+
953
+ return False, "不支持的操作:%s" % action, {}