Files
homeagent-sdk/example/commondev/workdir_remote_test.go
JianFeeeee 81a3efa4df feat(commondev): 主管式多仓库开发流水线 + sidecar 后端抽象(pi/zcode) (#3)
* feat(commondev): 主管式多仓库开发流水线 + sidecar 后端抽象(pi/zcode)

## 是什么

agent 统筹开发流程的插件:登记项目 → 开工程线(每条线一个隔离工作目录 +
一个 sidecar 子进程 + 一对通道)→ 投递任务 → 同会话续投 → 评审/合入。

38 个工具:project_*/branch_*/workdir_*/job_*/review_*/test_*/
endpoint_*/agent_*/pi_*/conflict_check。

## sidecar 后端抽象(本轮核心)

传输层(local/ssh)与后端(pi/zcode/自定义)**正交**,四个自由组合:

  driver.json 描述一个后端:script / cliPath / env / args / llmConfig
  三级查找:插件数据目录 → **随包 rawfile** → 内置 pi 描述
  占位符:{workdir} {dataDir} {pluginDir} {rawfile} {sessionId}

★ 内置 pi 回退**与默认后端解耦**(2026-10-17 修):原实现写
`if cli == defaultAgentCLI { return builtinPiDriver() }`,把"给谁兜底"
和"默认选谁"耦合到同一常量 —— 一旦有人改默认,pi 当场失去兜底,
显式 cli="pi" 开始报「驱动不存在」。现在判断字面 "pi"。

默认后端保持 **pi**(我们本地主用);交付方用 zcode 走两种局部开关:
 · 单线:commondev_branch_open(cli="zcode")
 · 按机器:commondev_endpoint_set(host=..., cli="zcode")

## zcode 接入(三条真机判据,全部要求真改工程文件)

 · TestZcodeFullPipeline(29-34s):随包 driver + 真实 ZCode CLI
 · TestZcodeOverSSH(39s):真 ssh + scp,远端 node 跑 zcode
 · TestZcodeOverSSHWithPerHostEndpoints(68s):两个 host 别名配不同
   cli_path,走端点表而不是全局 settings

★ 会话库必须隔离到工作区(sidecar.mjs 的 ensureWorkspaceSessionStore):
实测 ZCode 的会话库路径**硬编码 homedir()**,`ZCODE_DATA_BASE_DIR` 对它
完全无效(26 个会话全落全局库)。但 `<workdir>/.zcode/config.json` 的
`storage.sessionDbPath` **确实生效**。所以启动前写一份工作区配置,
一举解决:多线不共用历史 / 不污染全局 ~/.zcode / 删线不留孤儿会话。
只在不存在时写 —— 用户手工放的要尊重。

## 其他

 · rawfile:项目根 rawfile/ 默认全收,解到 <插件目录>/rawfile/,绝对路径拒绝
 · 凭证:命名引用 + ${ENV_VAR},独立 0600 文件,绝不进 config.db(0644)
 · secretshell:给不受信 agent 用的安全 shell(ackey + safeshell)
 · 冲突检查 / 评审判据 / 镜像 / 每条线独立 workdir(hash 命名,合并后删除)

判据 30+ 条,含 4 条「判据本身的判据」(防止判据假绿)。

* fix(sdk): 协议通道纪律 + hmapdev 模板升级 + 示例插件 stdout 污染

## 核心缺陷:插件往 stdout 打日志 = 污染 JSON-RPC 协议通道

子进程插件与内核用 stdout 传 NDJSON 帧,stderr 才是日志通道。
脚手架模板(及 4 个示例插件)用 `fmt.Printf` 打日志 ⇒ 内核收到非 JSON 文本:

    [proc] weather 收到非法 JSON 帧(21 字节): invalid character 'i'

**生产实测 1038 条**(huawei_smarthome 720 / weather 113 / rss 103 / calendar 102)。
后果不只是日志噪音:那一行日志被**丢弃**,插件看起来"没启动"。

修法:
- hmapdev 模板 `fmt.Printf` → `log.Printf`(9 处)
- 示例插件 4 个 14 处:ai_image(2) calendar(5) rss(4) weather(3)
  + 补 "log" import
- 内核侧(主仓)同步让报错可定位:内容摘要 + 可能原因 + 修法

判别法:`package main` = 子进程(协议通道,禁用 stdout 打日志);
普通库包 = 进程内插件(编译进内核,写真实终端,**不是**缺陷)——
`internal/plugins/cli` 的 `fmt.Println` 属后者。

## hmapdev

- `cmd_build.go` +136:平台产物精确匹配(`--target` 是**append** 语义,
  原脚本 `ls dist/*.hmap | head -1` 会按字典序抓到 amd64,
  **导致 arm64 包里装的是 x86-64 二进制**,且退出码 0、校验和通过)
- `cmd_init.go` +46:脚手架 stdio 纪律
- `templates/proc_main.go.tmpl`:补齐 mgrStop/mgrStart/mgrIsRuntimeStopped
  三态能力(插件管理器运行态停启)
- `templates/plg.json.tmpl` 删除(合并进 cmd_init)

## 判据

- `scaffold_stdout_test.go`(3 条):扫模板生成的代码不得写 stdout。
  ★ 判据自身先假红过一次:**模板的注释里就写了 `fmt.Printf`** ⇒
  必须 `stripTemplateComments()` 之后才判
- `example_stdout_test.go`(2 条):扫全部 20 个子进程示例插件,
  报错给**文件:行号**;变异咬住并给出准确行号
- `package_examples_test.go`(2 条):**执行真实打包脚本**
  (第一版测的是 hmapdev 而不是脚本 ⇒ 变异不咬)
- `cmd_build` / `cmd_init` 新判据

## 其他

- sdk/settings.go +15(三态能力透传)
- `docs/versions.md` +§示例插件协议通道纪律(含生产 1038 条实测表格)
- `skills/homeagent-plugin-dev/SKILL.md` +§协议通道纪律(正误代码对比)

* fix(examples): 恢复 go.mod 相对路径 + 移除 plg.json 的 sdk 固定(修 CI 红)

CI 上 `Examples (linux/amd64)` 与 `(darwin/arm64)` 双红,只有两个失败:

  ✗ commondev   构建失败
  ✗ editdoc     构建失败
  error: 项目声明 sdk=1.4.0,但本地 SDK 存储里没有这个版本

其余 20 个示例全部成功 —— 说明不是通用问题,而是这两个被改坏了。

## 根因(两个叠加)

**① go.mod 被本机绝对路径污染**

我在上一个提交里用 `git add -A`,把本机 `go build` **自动改写**过的
go.mod 一并提交了:

  require github.com/JianFeeeee/homeagentsdk v1.4.0
  replace github.com/JianFeeeee/homeagentsdk => /root/.homeagent/hmapdev/sdk/v1.4.0

基线(origin/main)是:

  require github.com/JianFeeeee/homeagentsdk v0.0.0
  replace github.com/JianFeeeee/homeagentsdk => ../../

本机那个目录真的存在 ⇒ 本地全绿;CI 没有 ⇒ 必红。

**② plg.json 声明了 `"sdk": "1.4.0"`**

一声明版本,hmapdev 就去**本地 SDK store** 找它,找不到直接拒绝构建。
全仓 20+ 示例**都不声明**,只有我改过的这两个声明了。
示例的正确依赖表达是 go.mod 的 `v0.0.0` + `replace ../../`。

## 修法

两个文件都恢复到与其余示例一致的写法。

## 判据(3 条,都是新加的)

 · `TestExampleGoModHasNoAbsoluteReplace`:任何示例 go.mod 不得含
   `/root/`、`/home/`、`/Users/`、`C:\`
 · `TestExampleGoModUsesRelativeSDKReplace`:replace 必须是相对路径 `../../`
 · `TestExamplePlgJsonDoesNotPinSDKVersion`:plg.json 不得声明 sdk

变异 2/2 咬住(重新引入绝对路径 / 重新声明 sdk,都准确报出文件路径)。

## 本地验证(模拟 CI 干净环境)

用干净 HOME 跑 `package/build-examples.sh`:
 · 修复前:成功 18 / 失败 4(commondev, editdoc, browser, rss)
 · 修复后:成功 20 / 失败 2(browser, rss —— **本机代理不通**,
   `proxy.golang.org` 超时;用本地 modcache 验证这两个可正常构建)

即 22/22 源码都能构建,CI 有网络不受影响。

## 教训

`git add -A` 会把「构建工具自动改写的文件」一起提交。
提交前必须 `git status` 逐个看,尤其 go.mod / go.sum。

---------

Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
2026-10-17 19:44:15 +08:00

401 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
// 远端工作目录准备的判据。
//
// 本次发现的缺口:`prepareWorkdir` **只有本地一套**(本机 clone --mirror +
// clone mirror workdir)。对 runtime=ssh 的分支,远端 pi 的 cwd 指向一个
// **从未被创建**的远端目录 —— 症状是"agent 起来了,但一执行命令就说目录不存在",
// 而 job 状态显示 done(因为本地 clone 成功了)。
//
// 这类"两边各自看起来都成功"的缺口最难靠肉眼发现,必须有判据。
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// filepathBase 是 filepath.Base 的薄包装(判据里读起来更清楚)。
func filepathBase(p string) string { return filepath.Base(p) }
// writeFile 是判据用的小工具。
func writeFile(p, content string) error {
return os.WriteFile(p, []byte(content), 0o644)
}
// readSourceOrSkip 读一个源文件,读不到就跳过。
func readSourceOrSkip(t *testing.T, name string) string {
t.Helper()
b, err := os.ReadFile(name)
if err != nil {
t.Skipf("读不到 %s: %v", name, err)
}
return string(b)
}
// ★★★ 判据:runtime=ssh 时台账里的 Workdir 必须是**远端路径**。
//
// 留本地路径会让 agent_up 把远端 pi 的 cwd 指到本地目录名上。
func TestSSHBranchWorkdirBecomesRemotePath(t *testing.T) {
sshE2EReady(t)
p := newTestPluginForShell(t)
p.ledger = &Ledger{Projects: map[string]*Project{}, Branches: map[string]*Branch{},
Jobs: map[string]*Job{}, Criteria: map[string]*Criteria{}}
remoteRoot := t.TempDir()
p.testSetSetting("ssh_workdir_root", remoteRoot)
// 造一个**远端可就地 clone 的**裸仓库(本机路径对 localhost 的 ssh 同样可见)
localBare := makeBareRepo(t)
p.ledger.Projects["proj"] = &Project{
Name: "proj", RemoteURL: localBare, DefaultBranch: "main",
}
// ★ 直接构造 branch,**不调 handleBranchOpen**:
// 后者会起后台 goroutine 也去准备同一个 mirror,与本次同步准备并发撞车
// (那正是它暴露出的 TOCTOU —— 已在生产代码用 prepMu 修掉,
// 但判据不该依赖"两条路径同时跑"的时序)。
b := &Branch{ID: "br-remote", Name: "feat/remote", ProjectID: "proj",
Runtime: "ssh", SSHHost: "localhost"}
tgt, err := parseSSHTarget(b)
if err != nil {
t.Fatal(err)
}
tgt.WorkdirRoot = remoteRoot
remoteWorkdir := remoteRoot + "/" + dirName("proj", "feat/remote")
if err := p.prepareRemoteWorkdir(tgt, p.ledger.Projects["proj"], "feat/remote", remoteWorkdir); err != nil {
t.Fatalf("★ 远端目录准备失败: %v", err)
}
// ① 远端目录真的存在且是 git 仓库
if !tgt.remotePathExists(remoteWorkdir) {
t.Fatal("★ 远端工作目录不存在")
}
if _, err := tgt.remoteGit(remoteWorkdir, "rev-parse", "--git-dir"); err != nil {
t.Fatalf("★ 远端目录不是 git 仓库: %v", err)
}
// ② 已切到目标分支(不是默认分支)
br, err := tgt.remoteGit(remoteWorkdir, "rev-parse", "--abbrev-ref", "HEAD")
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(br) != "feat/remote" {
t.Errorf("★ 远端目录在错的分支上: %q(应为 feat/remote)", strings.TrimSpace(br))
}
// ③ 共享 mirror 建在远端(不是本地路径)
if !tgt.remotePathExists(remoteRoot + "/.mirrors/proj.git") {
t.Error("★ 远端共享 mirror 未建立")
}
}
// ★★ 判据:**缺 ssh_workdir_root 时响亮报错**(不猜默认值)。
//
// 猜默认会把文件写到远端别的目录里 —— 那是很难回滚的错。
func TestRemoteWorkdirRequiresRootConfig(t *testing.T) {
p := newTestPluginForShell(t)
// settings 的 Get 返回 nil ⇒ 视为未配置
_, err := p.remoteWorkdirRoot("somehost")
if err == nil {
t.Fatal("★ 未配置 ssh_workdir_root 时应报错")
}
if !strings.Contains(err.Error(), "workdir_root") {
t.Errorf("错误应指出缺哪个配置: %v", err)
}
}
// ★★★ 判据:**本地路径与远端路径不能混用**。
//
// prepareWorkdir 用的是本机 git;远端线绝不能走它。
// 这条钉住"两条路径必须分开"这个结构性事实:
// 若 runPrepareWorkdir 忽略 runtime 一律走本地,远端目录永远是空的。
func TestLocalAndRemoteWorkdirPathsAreDistinct(t *testing.T) {
// 远端目录名与本地同构(<hash>-<name>),但**根不同**
local := branchDir("/local/program", "proj", "feat/x")
remote := "/remote/root/" + dirName("proj", "feat/x")
if local == remote {
t.Error("本地与远端目录不应相同")
}
// 基名必须一致(便于人对照),根必须不同
if filepathBase(local) != filepathBase(remote) {
t.Errorf("两端目录基名应一致(可读性): %s vs %s", filepathBase(local), filepathBase(remote))
}
}
// ★★ 判据:远端目录**已存在时复用**(重开分支线不重新克隆)。
func TestRemoteWorkdirReuseIsIdempotent(t *testing.T) {
sshE2EReady(t)
p := newTestPluginForShell(t)
p.ledger = &Ledger{Projects: map[string]*Project{}, Branches: map[string]*Branch{},
Jobs: map[string]*Job{}, Criteria: map[string]*Criteria{}}
root := t.TempDir()
bare := makeBareRepo(t)
pr := &Project{Name: "proj", RemoteURL: bare, DefaultBranch: "main"}
tgt := sshTarget{Host: "localhost", NodePath: "node", WorkdirRoot: root}
wd := root + "/" + dirName("proj", "feat/reuse")
// 第一次:完整准备
if err := p.prepareRemoteWorkdir(tgt, pr, "feat/reuse", wd); err != nil {
t.Fatalf("首次准备失败: %v", err)
}
// 放一个标记文件,第二次准备**不能**把它冲掉
tgt.remoteMkdir(wd)
if _, err := tgt.remoteGit(wd, "rev-parse"); err != nil {
t.Fatal(err)
}
if err := p.prepareRemoteWorkdir(tgt, pr, "feat/reuse", wd); err != nil {
t.Fatalf("复用准备失败: %v", err)
}
// 仍在正确分支
br, _ := tgt.remoteGit(wd, "rev-parse", "--abbrev-ref", "HEAD")
if strings.TrimSpace(br) != "feat/reuse" {
t.Errorf("复用后分支不对: %q", strings.TrimSpace(br))
}
}
// ★ 判据:远端 git 命令的关键要素(BatchMode + GIT_TERMINAL_PROMPT=0)。
//
// 远端拉取若卡在认证提示,会挂到超时而不是报"没凭据"。
func TestRemoteGitIsNonInteractive(t *testing.T) {
src := readSourceOrSkip(t, "workdir_remote.go")
if !strings.Contains(src, "GIT_TERMINAL_PROMPT=0") {
t.Error("★ 远端 git 未禁用交互提示(会挂到超时而非报错)")
}
if !strings.Contains(src, "sshBaseArgs()") {
t.Error("★ 远端 git 未用 sshBaseArgs(缺 BatchMode)")
}
}
// makeBareRepo 造一个可被 clone 的裸仓库(含 main 分支与一次提交)。
func makeBareRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
bare := dir + "/origin.git"
work := dir + "/work"
run := func(d string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = d
cmd.Env = append(cmd.Environ(),
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
}
run(dir, "init", "--bare", "-b", "main", bare)
run(dir, "init", "-b", "main", work)
if err := writeFile(work+"/README.md", "hi\n"); err != nil {
t.Fatal(err)
}
run(work, "add", "-A")
run(work, "commit", "-m", "init")
run(work, "remote", "add", "origin", bare)
run(work, "push", "origin", "main")
return bare
}
// ★★★ 判据:**完整远端闭环** —— 远端建目录 → 远端拉起 pi → 投递一次。
//
// 前面两条分别验了"远端目录准备"与"远端 sidecar 握手",但真实使用是
// 串起来的:目录必须先于 pi 存在(否则 pi 的 cwd 无效),而投递必须落到
// 那个目录里的会话上。这条把三段串起来跑一遍。
func TestSSHFullLoop(t *testing.T) {
sshE2EReady(t)
p := newTestPluginForShell(t)
p.ledger = &Ledger{Projects: map[string]*Project{}, Branches: map[string]*Branch{},
Jobs: map[string]*Job{}, Criteria: map[string]*Criteria{}}
root := t.TempDir()
runtimeRoot := t.TempDir()
p.testSetSetting("ssh_workdir_root", root)
p.testSetSetting("ssh_runtime_root", runtimeRoot)
p.testSetSetting("ssh_node_path", "node")
// 真实 sidecar 放进 driver 目录(供 scp 推到远端)
cliDir := p.driverDir("pi")
if err := os.MkdirAll(cliDir, 0o755); err != nil {
t.Fatal(err)
}
srcBytes, err := os.ReadFile("sidecar.mjs")
if err != nil {
t.Skipf("读不到 sidecar.mjs: %v", err)
}
if err := os.WriteFile(filepath.Join(cliDir, "sidecar.mjs"), srcBytes, 0o644); err != nil {
t.Fatal(err)
}
bare := makeBareRepo(t)
pr := &Project{Name: "proj", RemoteURL: bare, DefaultBranch: "main"}
// ① 远端准备目录
tgt := sshTarget{Host: "localhost", NodePath: "node", WorkdirRoot: root}
wd := root + "/" + dirName("proj", "feat/loop")
if err := p.prepareRemoteWorkdir(tgt, pr, "feat/loop", wd); err != nil {
t.Fatalf("① 远端目录准备失败: %v", err)
}
// ② 远端拉起 sidecar,并确认它在**那个目录**里工作
b := &Branch{ID: "br-loop", Name: "feat/loop", ProjectID: "proj",
Workdir: wd, Runtime: "ssh", SSHHost: "localhost"}
ctx := driverCtx{Workdir: wd, SessionID: "sess-loop", SessionName: "br-loop",
DataDir: filepath.Dir(p.path), PluginDir: cliDir}
drv, _, err := p.loadDriver("pi")
if err != nil {
t.Fatal(err)
}
sc, err := p.startSSHSidecar(b, drv, ctx, "pi")
if err != nil {
t.Fatalf("② 远端 sidecar 启动失败: %v", err)
}
defer sc.Close()
// ③ 投递一次,断言会话未漂移
res, err := sc.call("prompt", map[string]interface{}{
"session_id": "sess-loop", "text": "只回一个词:ok",
})
if err != nil {
t.Fatalf("③ 远端投递失败: %v", err)
}
m, _ := res.(map[string]interface{})
if got, _ := m["session_id"].(string); got != "" && got != "sess-loop" {
t.Errorf("★ 远端会话漂移: %q", got)
}
t.Logf("远端完整闭环成功:目录=%s 会话=%v", wd, m["session_id"])
}
// ★★★ 判据:`runPrepareWorkdir` **必须按 runtime 分派**。
//
// 这条补的是变异暴露的缺口:上面那些判据直接调用 prepareRemoteWorkdir,
// 所以把 runPrepareWorkdir 里的 `if runtime == "ssh"` 去掉(一律走本地)
// 时**它们全绿** —— 而那正是最致命的回退:远端目录永远为空,
// 但 job 状态显示 done(本地 clone 成功了)。
//
// 判据必须钉住**分派本身**,而不是只钉住被分派到的函数。
func TestPrepareWorkdirDispatchesByRuntime(t *testing.T) {
src := readSourceOrSkip(t, "plugin.go")
// 找到 runPrepareWorkdir 函数体
i := strings.Index(src, "func (p *Plugin) runPrepareWorkdir(")
if i < 0 {
t.Fatal("找不到 runPrepareWorkdir")
}
end := strings.Index(src[i:], "\n}\n")
if end < 0 {
t.Fatal("找不到函数结尾")
}
body := src[i : i+end]
// ① 必须有 runtime 参数(否则无从分派)
if !strings.Contains(body, "runtime, sshHost string") {
t.Error("★ runPrepareWorkdir 未接收 runtime 参数")
}
// ② 必须按 runtime 分派到远端实现
//
// ★ 判据要**精确匹配分派语句**,不能只 Contains("runtime == \"ssh\""):
// 实测 `if false && runtime == "ssh"`(把分派关掉)仍然包含那个子串,
// 于是判据照绿。这是"判据通过 ≠ 检查到了目标性质"的又一例。
sshIdx := strings.Index(body, `if runtime == "ssh" {`)
if !strings.Contains(body, `if runtime == "ssh" {`) {
t.Error("★ runPrepareWorkdir 未按 runtime 分派(远端线会走本地 clone,远端目录永远为空)")
}
if strings.Contains(body, `false && runtime == "ssh"`) {
t.Error("★ 分派条件被禁用(存在 `false &&` 前缀)")
}
// 分派必须**真的返回**(进入远端分支后不能落到本地路径)
if sshIdx >= 0 {
seg := body[sshIdx:]
if !strings.Contains(seg, "return") {
t.Error("★ 远端分派分支没有 return(会继续落到本地路径)")
}
}
if !strings.Contains(body, "prepareRemoteWorkdir") {
t.Error("★ runPrepareWorkdir 未调用 prepareRemoteWorkdir")
}
// ③ 本地路径必须在分派之后(否则会被远端分支挡住)
localIdx := strings.Index(body, "p.prepareWorkdir(")
if localIdx >= 0 && sshIdx >= 0 && localIdx < sshIdx {
t.Error("★ 本地 prepareWorkdir 出现在分派之前(远端线会走本地路径)")
}
// ④ 远端线的 Workdir 必须被改写成远端路径
if !strings.Contains(body, "bb.Workdir = remoteWorkdir") {
t.Error("★ 远端线未把台账 Workdir 改成远端路径(agent_up 会用错 cwd)")
}
}
// ★★ 判据:`branch_open` 必须把 runtime/ssh_host 传给后台准备。
//
// 只记进台账但不传给 runPrepareWorkdir 的话,台账对了、目录还是本地建的
// —— "参数记了但没用"是同一类缺口的另一半。
func TestBranchOpenPassesRuntimeToPrepare(t *testing.T) {
src := readSourceOrSkip(t, "plugin.go")
if !strings.Contains(src, "go p.runPrepareWorkdir(jobID, id, pr, branch, workdir, b.Runtime, b.SSHHost)") {
t.Error("★ branch_open 未把 runtime/ssh_host 传给 runPrepareWorkdir")
}
}
// ★★★ 判据:**并发的目录准备不能互相撞车**(真 bug,实测 3/6 复现)。
//
// 现象:
//
// fatal: destination path '.../.mirrors/proj.git' already exists
// and is not an empty directory
//
// 根因是"检查后 clone"这个 TOCTOU:两个 goroutine 同时判定 mirror 不存在,
// 然后都去 clone。**触发场景是真实的** —— branch_open 的目录准备在后台跑,
// 用户看 job 还没 done 就再点一次 workdir_prepare(完全自然的操作)。
//
// 判据直接并发调 prepareRemoteWorkdir N 次,要求**至少一次成功且无致命错误**
// ("已被别人建好"不算失败,那是幂等的一部分)。
func TestConcurrentRemotePrepareDoesNotCollide(t *testing.T) {
sshE2EReady(t)
p := newTestPluginForShell(t)
root := t.TempDir()
bare := makeBareRepo(t)
pr := &Project{Name: "proj", RemoteURL: bare, DefaultBranch: "main"}
tgt := sshTarget{Host: "localhost", NodePath: "node", WorkdirRoot: root}
wd := root + "/" + dirName("proj", "feat/race")
const n = 4
errCh := make(chan error, n)
for i := 0; i < n; i++ {
go func() {
errCh <- p.prepareRemoteWorkdir(tgt, pr, "feat/race", wd)
}()
}
var errs []error
for i := 0; i < n; i++ {
if err := <-errCh; err != nil {
errs = append(errs, err)
}
}
for _, err := range errs {
// "already exists" 是撞车的直接证据,必须为 0
if strings.Contains(err.Error(), "already exists") {
t.Errorf("★ 并发准备撞车: %v", err)
}
}
// 最终必须有一个可用的仓库在正确分支上
if _, err := tgt.remoteGit(wd, "rev-parse", "--abbrev-ref", "HEAD"); err != nil {
t.Fatalf("并发准备后工作目录不可用: %v(错误: %v)", err, errs)
}
br, _ := tgt.remoteGit(wd, "rev-parse", "--abbrev-ref", "HEAD")
if strings.TrimSpace(br) != "feat/race" {
t.Errorf("并发准备后分支不对: %q", strings.TrimSpace(br))
}
}
// ★★ 判据:mirror 就位用 **shell mv**,不是 `git mv`。
//
// `git mv` 只在仓库内工作,用于文件系统搬移会报 "not a git repository"
// (实测踩到:mirror 就位时失败)。
func TestRemoteMirrorPlacedWithShellMv(t *testing.T) {
src := readSourceOrSkip(t, "workdir_remote.go")
if !strings.Contains(src, `remoteShell("mv "`) {
t.Error("★ mirror 就位未用 shell mv(git mv 会报 not a git repository)")
}
if strings.Contains(src, `remoteGit("", "mv"`) {
t.Error("★ 仍在用 git mv 搬移 mirror")
}
}