baa-conductor

git clone 

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
M apps/conductor-daemon/src/local-api.ts
+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 
A scripts/git-snapshot.sh
+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"
M tasks/T-S050.md
+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+