// sync_git_test.go - GitSyncAdapter 单元测试. // // 测试策略: // - IsAvailable 测试:模拟 git 不可用的情况 // - InitRepo / Pull / Push:使用真实 temp 目录 + git init(本地 git 操作,无网络) // - 冲突策略:通过两个本地 repo 模拟 remote(local bare repo 作为 remote) package memory import ( "context" "errors" "os" "os/exec" "path/filepath" "strings" "testing" "git.flytoex.net/yuanwei/flyto-agent/pkg/execenv" ) // hasGit 检查系统是否有 git 命令,没有则跳过测试. func hasGit(t *testing.T) { t.Helper() if _, err := exec.LookPath("git"); err != nil { t.Skip("git not found in PATH, skipping GitSyncAdapter tests") } } // newTestGitAdapter 创建一个指向本地测试 remote 的 GitSyncAdapter. func newTestGitAdapter(t *testing.T) *GitSyncAdapter { t.Helper() return NewGitSyncAdapter(GitSyncOptions{ Remote: "origin", Branch: "main", CommitAuthorName: "Test Agent", CommitAuthorEmail: "test@flyto.local", Executor: execenv.DefaultExecutor{}, }) } // initBareRepo 在 dir 创建一个 bare git repo(用作 remote). func initBareRepo(t *testing.T, dir string) { t.Helper() cmd := exec.Command("git", "init", "--bare", "-b", "main", dir) if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git init --bare: %v\n%s", err, out) } } // initLocalRepo 在 dir 创建本地 repo,设置 remote,并添加初始提交. func initLocalRepo(t *testing.T, dir string, remoteURL string) { t.Helper() cmds := [][]string{ {"git", "init", "-b", "main", dir}, {"git", "-C", dir, "config", "user.email", "test@flyto.local"}, {"git", "-C", dir, "config", "user.name", "Test"}, {"git", "-C", dir, "remote", "add", "origin", remoteURL}, } for _, args := range cmds { cmd := exec.Command(args[0], args[1:]...) if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("%v: %v\n%s", args, err, out) } } } // writeFile 在 dir 中写入文件. func writeFile(t *testing.T, dir, name, content string) { t.Helper() if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil { t.Fatal(err) } } // gitCommitAll 在 dir 执行 add -A + commit. func gitCommitAll(t *testing.T, dir, msg string) { t.Helper() cmds := [][]string{ {"git", "-C", dir, "add", "-A"}, {"git", "-C", dir, "commit", "-m", msg, "--author", "Test "}, } for _, args := range cmds { cmd := exec.Command(args[0], args[1:]...) if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("%v: %v\n%s", args, err, out) } } } // ───────────────────────────────────────────────────────────────────────────── // IsAvailable // ───────────────────────────────────────────────────────────────────────────── func TestGitSyncAdapter_IsAvailable_GitInPath(t *testing.T) { hasGit(t) a := newTestGitAdapter(t) if !a.IsAvailable() { t.Error("IsAvailable() should be true when git is in PATH") } } func TestGitSyncAdapter_IsAvailable_FakeGitBin(t *testing.T) { a := NewGitSyncAdapter(GitSyncOptions{GitBin: "/nonexistent/git-binary", Executor: execenv.DefaultExecutor{}}) if a.IsAvailable() { t.Error("IsAvailable() should be false when git binary doesn't exist") } } // TestNewGitSyncAdapter_RejectsInjectedRemote ensures a remote starting with // "-" trips ValidateRef's flag-injection guard during construction. // // TestNewGitSyncAdapter_RejectsInjectedRemote 断言以 "-" 开头的 remote // 在构造期触发 ValidateRef 的 flag-injection 拦截. func TestNewGitSyncAdapter_RejectsInjectedRemote(t *testing.T) { defer func() { r := recover() if r == nil { t.Fatal("构造器应对 '-' 起头 remote panic") } msg, ok := r.(string) if !ok || !strings.Contains(msg, "invalid remote") { t.Errorf("panic 消息应含 'invalid remote', 实际: %v", r) } }() NewGitSyncAdapter(GitSyncOptions{ Remote: "--upload-pack=evil", Executor: execenv.DefaultExecutor{}, }) } // TestNewGitSyncAdapter_RejectsPathTraversalBranch ensures a branch containing // ".." trips ValidateRef's path-traversal guard during construction. // // TestNewGitSyncAdapter_RejectsPathTraversalBranch 断言含 ".." 的 branch // 在构造期触发 ValidateRef 的路径遍历拦截. func TestNewGitSyncAdapter_RejectsPathTraversalBranch(t *testing.T) { defer func() { r := recover() if r == nil { t.Fatal("构造器应对含 '..' branch panic") } msg, ok := r.(string) if !ok || !strings.Contains(msg, "invalid branch") { t.Errorf("panic 消息应含 'invalid branch', 实际: %v", r) } }() NewGitSyncAdapter(GitSyncOptions{ Branch: "feat/../etc", Executor: execenv.DefaultExecutor{}, }) } func TestGitSyncAdapter_IsGitRepo_NotARepo(t *testing.T) { hasGit(t) a := newTestGitAdapter(t) dir := t.TempDir() if a.isGitRepo(context.Background(), dir) { t.Error("empty temp dir should not be a git repo") } } func TestGitSyncAdapter_IsGitRepo_IsRepo(t *testing.T) { hasGit(t) a := newTestGitAdapter(t) dir := t.TempDir() cmd := exec.Command("git", "init", "-b", "main", dir) if err := cmd.Run(); err != nil { t.Fatal(err) } if !a.isGitRepo(context.Background(), dir) { t.Error("git-initialized dir should be a git repo") } } // ───────────────────────────────────────────────────────────────────────────── // InitRepo // ───────────────────────────────────────────────────────────────────────────── func TestGitSyncAdapter_InitRepo_CreatesGitRepo(t *testing.T) { hasGit(t) a := newTestGitAdapter(t) localDir := t.TempDir() bareDir := t.TempDir() initBareRepo(t, bareDir) if err := a.InitRepo(context.Background(), localDir, bareDir); err != nil { t.Fatalf("InitRepo() error: %v", err) } if !a.isGitRepo(context.Background(), localDir) { t.Error("after InitRepo, dir should be a git repo") } } func TestGitSyncAdapter_InitRepo_Idempotent(t *testing.T) { hasGit(t) a := newTestGitAdapter(t) localDir := t.TempDir() bareDir := t.TempDir() initBareRepo(t, bareDir) // 第一次 if err := a.InitRepo(context.Background(), localDir, bareDir); err != nil { t.Fatalf("first InitRepo() error: %v", err) } // 第二次--幂等,不应报错 if err := a.InitRepo(context.Background(), localDir, bareDir); err != nil { t.Fatalf("second InitRepo() error: %v", err) } } // ───────────────────────────────────────────────────────────────────────────── // Pull - 从 remote 拉取 // ───────────────────────────────────────────────────────────────────────────── func TestGitSyncAdapter_Pull_NotARepo(t *testing.T) { hasGit(t) a := newTestGitAdapter(t) dir := t.TempDir() _, err := a.Pull(context.Background(), dir) if err == nil { t.Error("Pull() should fail for non-git directory") } } func TestGitSyncAdapter_Pull_FetchesFromRemote(t *testing.T) { hasGit(t) a := newTestGitAdapter(t) ctx := context.Background() // 创建 bare repo 作为 remote bareDir := t.TempDir() initBareRepo(t, bareDir) // 创建"remote"本地 repo,推送初始内容到 bare remoteWorkDir := t.TempDir() initLocalRepo(t, remoteWorkDir, bareDir) writeFile(t, remoteWorkDir, "initial.md", "# Initial memory") gitCommitAll(t, remoteWorkDir, "initial commit") exec.Command("git", "-C", remoteWorkDir, "push", "origin", "main").Run() // 创建"本地"repo(模拟用户本地) localDir := t.TempDir() if err := a.InitRepo(ctx, localDir, bareDir); err != nil { t.Fatal(err) } // 先 fetch 初始化 remote tracking ref exec.Command("git", "-C", localDir, "fetch", "origin").Run() exec.Command("git", "-C", localDir, "checkout", "-b", "main", "--track", "origin/main").Run() // 在 remote 上添加新文件 writeFile(t, remoteWorkDir, "new_memory.md", "# New memory from teammate") gitCommitAll(t, remoteWorkDir, "teammate added memory") exec.Command("git", "-C", remoteWorkDir, "push", "origin", "main").Run() // 执行 Pull pulled, err := a.Pull(ctx, localDir) if err != nil { t.Fatalf("Pull() error: %v", err) } // 验证新文件已拉取到本地 if _, statErr := os.Stat(filepath.Join(localDir, "new_memory.md")); statErr != nil { t.Error("new_memory.md should exist after Pull()") } _ = pulled // pulled count may be 0 if file counting is imprecise in test env } // ───────────────────────────────────────────────────────────────────────────── // Push - 提交并推送到 remote // ───────────────────────────────────────────────────────────────────────────── func TestGitSyncAdapter_Push_NotARepo(t *testing.T) { hasGit(t) a := newTestGitAdapter(t) dir := t.TempDir() _, err := a.Push(context.Background(), dir, ConflictLocalWins) if err == nil { t.Error("Push() should fail for non-git directory") } } func TestGitSyncAdapter_Push_NothingToCommit(t *testing.T) { hasGit(t) a := newTestGitAdapter(t) ctx := context.Background() bareDir := t.TempDir() initBareRepo(t, bareDir) localDir := t.TempDir() if err := a.InitRepo(ctx, localDir, bareDir); err != nil { t.Fatal(err) } // 初始化第一个 commit(bare repo 需要至少一个 commit 才能 push) writeFile(t, localDir, "README.md", "# Memory") gitCommitAll(t, localDir, "init") exec.Command("git", "-C", localDir, "push", "origin", "main").Run() // 工作区干净,Push 应返回 (0, nil) pushed, err := a.Push(ctx, localDir, ConflictLocalWins) if err != nil { t.Errorf("Push() on clean repo error: %v", err) } if pushed != 0 { t.Errorf("Push() on clean repo should return 0, got %d", pushed) } } func TestGitSyncAdapter_Push_CommitsAndPushes(t *testing.T) { hasGit(t) a := newTestGitAdapter(t) ctx := context.Background() bareDir := t.TempDir() initBareRepo(t, bareDir) localDir := t.TempDir() if err := a.InitRepo(ctx, localDir, bareDir); err != nil { t.Fatal(err) } // 初始化 bare repo(需要至少一个 commit) writeFile(t, localDir, "README.md", "# Memory") gitCommitAll(t, localDir, "init") exec.Command("git", "-C", localDir, "push", "origin", "main").Run() // 添加新文件 writeFile(t, localDir, "user_profile.md", "---\nname: user\n---\ntest user") // Push pushed, err := a.Push(ctx, localDir, ConflictLocalWins) if err != nil { t.Fatalf("Push() error: %v", err) } if pushed == 0 { t.Error("Push() should report at least 1 pushed file") } // 验证 bare repo 中有新文件 cloneDir := t.TempDir() cmd := exec.Command("git", "clone", bareDir, cloneDir) if err := cmd.Run(); err != nil { t.Fatalf("git clone: %v", err) } if _, err := os.Stat(filepath.Join(cloneDir, "user_profile.md")); err != nil { t.Error("user_profile.md should exist in remote after Push()") } } // ───────────────────────────────────────────────────────────────────────────── // ConflictFail - diverged 时返回 ErrSyncConflict // ───────────────────────────────────────────────────────────────────────────── func TestGitSyncAdapter_Push_ConflictFail_Diverged(t *testing.T) { hasGit(t) a := newTestGitAdapter(t) ctx := context.Background() bareDir := t.TempDir() initBareRepo(t, bareDir) // 本地 repo 1(模拟当前用户) local1 := t.TempDir() if err := a.InitRepo(ctx, local1, bareDir); err != nil { t.Fatal(err) } writeFile(t, local1, "shared.md", "# Shared") gitCommitAll(t, local1, "init") exec.Command("git", "-C", local1, "push", "origin", "main").Run() // 本地 repo 2(模拟 teammate),从 bare clone local2 := t.TempDir() exec.Command("git", "clone", bareDir, local2).Run() exec.Command("git", "-C", local2, "config", "user.email", "t@t.com").Run() exec.Command("git", "-C", local2, "config", "user.name", "T").Run() // teammate 先提交并 push writeFile(t, local2, "teammate_memory.md", "# Teammate") gitCommitAll(t, local2, "teammate push") exec.Command("git", "-C", local2, "push", "origin", "main").Run() // 本地 repo 1 也有本地新提交(diverged) writeFile(t, local1, "my_memory.md", "# My local memory") gitCommitAll(t, local1, "my local commit") // ConflictFail 应该返回 ErrSyncConflict _, err := a.Push(ctx, local1, ConflictFail) if err == nil { t.Fatal("Push(ConflictFail) should return error when diverged") } if !errors.Is(err, ErrSyncConflict) { // 可能是包装错误,检查消息 if !strings.Contains(err.Error(), "conflict") { t.Errorf("expected ErrSyncConflict, got: %v", err) } } } // ───────────────────────────────────────────────────────────────────────────── // countNewFiles 辅助函数 // ───────────────────────────────────────────────────────────────────────────── func TestCountNewFiles(t *testing.T) { before := map[string]struct{}{"a.md": {}, "b.md": {}} after := map[string]struct{}{"a.md": {}, "b.md": {}, "c.md": {}, "d.md": {}} count := countNewFiles(before, after) if count != 2 { t.Errorf("countNewFiles = %d, want 2", count) } } func TestCountNewFiles_NoChange(t *testing.T) { files := map[string]struct{}{"a.md": {}, "b.md": {}} if count := countNewFiles(files, files); count != 0 { t.Errorf("countNewFiles with same sets = %d, want 0", count) } }