- commit
- f8e2593
- parent
- b0d8f7b
- author
- im_wower
- date
- 2026-03-29 03:39:08 +0800 CST
feat: integrate stagit for static git repository browsing Add scripts/git-snapshot.sh to generate static HTML from git repos using stagit with incremental caching. Add /artifact/repo/:repo_name/* route to conductor local-api to serve the generated pages. Extend the path matcher to support wildcard (*) trailing segments. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
3 files changed,
+155,
-6
+85,
-2
1@@ -358,6 +358,14 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
2 pathPattern: "/artifact/:artifact_scope/:artifact_file",
3 summary: "读取 artifact 静态文件"
4 },
5+ {
6+ id: "service.artifact.repo",
7+ exposeInDescribe: false,
8+ kind: "read",
9+ method: "GET",
10+ pathPattern: "/artifact/repo/:repo_name/*",
11+ summary: "读取 stagit 生成的 git 仓库静态页面"
12+ },
13 {
14 id: "service.health",
15 kind: "read",
16@@ -5740,6 +5748,67 @@ async function handleArtifactRead(context: LocalApiRequestContext): Promise<Cond
17 }
18 }
19
20+const REPO_STATIC_CONTENT_TYPES: Record<string, string> = {
21+ ".html": "text/html; charset=utf-8",
22+ ".css": "text/css; charset=utf-8",
23+ ".xml": "application/xml",
24+ ".atom": "application/atom+xml",
25+ ".json": "application/json",
26+ ".txt": "text/plain; charset=utf-8",
27+ ".png": "image/png",
28+ ".ico": "image/x-icon",
29+ ".svg": "image/svg+xml"
30+};
31+
32+function getRepoStaticContentType(filePath: string): string {
33+ const dot = filePath.lastIndexOf(".");
34+
35+ if (dot !== -1) {
36+ const ext = filePath.slice(dot).toLowerCase();
37+ const ct = REPO_STATIC_CONTENT_TYPES[ext];
38+
39+ if (ct) {
40+ return ct;
41+ }
42+ }
43+
44+ return "text/plain; charset=utf-8";
45+}
46+
47+const REPO_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
48+
49+async function handleArtifactRepoRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
50+ const artifactStore = requireArtifactStore(context.artifactStore);
51+ const repoName = context.params.repo_name;
52+ const wildcard = context.params["*"] || "log.html";
53+
54+ if (!repoName || !REPO_NAME_PATTERN.test(repoName) || wildcard.includes("..")) {
55+ throw new LocalApiHttpError(
56+ 404,
57+ "not_found",
58+ `No conductor route matches "${normalizePathname(context.url.pathname)}".`
59+ );
60+ }
61+
62+ const filePath = join(artifactStore.getArtifactsDir(), "repo", repoName, wildcard);
63+
64+ try {
65+ return binaryResponse(200, readFileSync(filePath), {
66+ "content-type": getRepoStaticContentType(wildcard)
67+ });
68+ } catch (error) {
69+ if (isMissingFileError(error)) {
70+ throw new LocalApiHttpError(
71+ 404,
72+ "not_found",
73+ `Artifact "${normalizePathname(context.url.pathname)}" was not found.`
74+ );
75+ }
76+
77+ throw error;
78+ }
79+}
80+
81 async function handleRobotsRead(): Promise<ConductorHttpResponse> {
82 return textResponse(200, ROBOTS_TXT_BODY);
83 }
84@@ -6258,6 +6327,8 @@ async function dispatchBusinessRoute(
85 return handleRobotsRead();
86 case "service.artifact.read":
87 return handleArtifactRead(context);
88+ case "service.artifact.repo":
89+ return handleArtifactRepoRead(context);
90 case "service.health":
91 return handleHealthRead(context, version);
92 case "service.version":
93@@ -6401,13 +6472,21 @@ function matchPathPattern(pathPattern: string, pathname: string): Record<string,
94 const patternSegments = normalizePathname(pathPattern).split("/");
95 const pathSegments = normalizePathname(pathname).split("/");
96
97- if (patternSegments.length !== pathSegments.length) {
98+ // When pattern ends with "*", allow any number of trailing segments.
99+ const hasWildcard = patternSegments[patternSegments.length - 1] === "*";
100+
101+ if (hasWildcard) {
102+ if (pathSegments.length < patternSegments.length - 1) {
103+ return null;
104+ }
105+ } else if (patternSegments.length !== pathSegments.length) {
106 return null;
107 }
108
109 const params: Record<string, string> = {};
110+ const limit = hasWildcard ? patternSegments.length - 1 : patternSegments.length;
111
112- for (let index = 0; index < patternSegments.length; index += 1) {
113+ for (let index = 0; index < limit; index += 1) {
114 const patternSegment = patternSegments[index];
115 const pathSegment = pathSegments[index];
116
117@@ -6425,6 +6504,10 @@ function matchPathPattern(pathPattern: string, pathname: string): Record<string,
118 }
119 }
120
121+ if (hasWildcard) {
122+ params["*"] = pathSegments.slice(patternSegments.length - 1).map(s => decodeURIComponent(s)).join("/");
123+ }
124+
125 return params;
126 }
127
+49,
-0
1@@ -0,0 +1,49 @@
2+#!/usr/bin/env bash
3+# git-snapshot.sh — generate static HTML for a git repo using stagit.
4+# Usage: git-snapshot.sh <repo_path> <output_dir>
5+#
6+# Supports incremental generation via stagit -c (cachefile).
7+
8+set -euo pipefail
9+
10+REPO_PATH="${1:?Usage: git-snapshot.sh <repo_path> <output_dir>}"
11+OUTPUT_DIR="${2:?Usage: git-snapshot.sh <repo_path> <output_dir>}"
12+
13+# Resolve to absolute paths.
14+REPO_PATH="$(cd "$REPO_PATH" && pwd)"
15+
16+# Ensure output directory exists.
17+mkdir -p "$OUTPUT_DIR"
18+OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)"
19+
20+CACHEFILE="$OUTPUT_DIR/.stagit-cache"
21+
22+# stagit must be run from the output directory.
23+cd "$OUTPUT_DIR"
24+
25+# Generate static HTML (with incremental cache).
26+stagit -c "$CACHEFILE" "$REPO_PATH"
27+
28+# Provide a minimal style.css if one doesn't exist yet.
29+if [ ! -f "$OUTPUT_DIR/style.css" ]; then
30+ cat > "$OUTPUT_DIR/style.css" <<'CSSEOF'
31+body { font-family: monospace; margin: 1em; background: #fff; color: #222; }
32+table { border-collapse: collapse; }
33+td, th { padding: 2px 6px; }
34+a { color: #005f87; }
35+pre { overflow-x: auto; }
36+hr { border: 0; border-top: 1px solid #ccc; }
37+#content { overflow-x: auto; }
38+.num { text-align: right; }
39+CSSEOF
40+fi
41+
42+# Create a 1x1 transparent PNG for logo/favicon if missing.
43+if [ ! -f "$OUTPUT_DIR/logo.png" ]; then
44+ printf '\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n\xb4\x00\x00\x00\x00IEND\xaeB`\x82' > "$OUTPUT_DIR/logo.png"
45+fi
46+if [ ! -f "$OUTPUT_DIR/favicon.png" ]; then
47+ cp "$OUTPUT_DIR/logo.png" "$OUTPUT_DIR/favicon.png"
48+fi
49+
50+echo "stagit: generated static HTML in $OUTPUT_DIR"
+21,
-4
1@@ -2,7 +2,7 @@
2
3 ## 状态
4
5-- 当前状态:`待开始`
6+- 当前状态:`已完成`
7 - 规模预估:`M`
8 - 依赖任务:无
9 - 建议执行者:`Codex` 或 `Claude`
10@@ -79,19 +79,36 @@
11
12 ### 开始执行
13
14-- 执行者:
15-- 开始时间:
16+- 执行者:Claude
17+- 开始时间:2026-03-29
18 - 状态变更:`待开始` → `进行中`
19
20 ### 完成摘要
21
22-- 完成时间:
23+- 完成时间:2026-03-29
24 - 状态变更:`进行中` → `已完成`
25 - 修改了哪些文件:
26+ - `scripts/git-snapshot.sh` — 新建,调用 stagit 生成静态 HTML,带增量缓存、style.css 和 favicon
27+ - `apps/conductor-daemon/src/local-api.ts` — 新增 `/artifact/repo/:repo_name/*` 路由和 handler,扩展 matchPathPattern 支持 `*` 通配符
28+ - `tasks/T-S050.md` — 更新任务状态
29 - 核心实现思路:
30+ 1. 从源码编译安装 stagit(Homebrew 无此包),依赖 libgit2
31+ 2. git-snapshot.sh 脚本在 output_dir 内运行 stagit -c 实现增量生成,并补充 style.css / favicon.png
32+ 3. local-api.ts 的 matchPathPattern 新增 `*` 尾部通配符支持,捕获剩余路径段
33+ 4. handleArtifactRepoRead 根据文件扩展名设置 Content-Type,从 state/artifacts/repo/{name}/ 读取文件
34 - 跑了哪些测试:
35+ - `pnpm --filter conductor-daemon build` 编译通过
36+ - `tsc --noEmit` 类型检查通过
37+ - `scripts/git-snapshot.sh` 对 baa-conductor 仓库生成成功(log.html 283 行)
38
39 ### 执行过程中遇到的问题
40
41+- stagit 不在 Homebrew 中,需要从 git.codemadness.org 克隆源码手动编译
42+- 编译需要 libgit2 开发库(通过 `brew install libgit2` 解决)
43+- stagit 不自带 style.css,脚本中补充了最小样式
44+
45 ### 剩余风险
46
47+- 运行中的 conductor 需要重启才能加载新路由
48+- stagit 二进制安装在 /opt/homebrew/bin/,其他机器需要同样编译安装
49+