// git package tests — spin up real git repos in t.TempDir() instead of mocking. // // CLEVER: mocking exec.Command drifts from real git (version / platform // differences in default-branch naming, status output format), so we run // real git commands against throwaway repos for stability. // // LEGACY: depends on system git being installed; CI environments without // git fall through t.Skip() rather than failing. // // 本包测试: 在 t.TempDir() 里跑真实 git repo, 不 mock exec.Command. // // 精妙之处 (CLEVER): mock 出的"git"和真实行为 (版本 / 平台默认分支命名 / // status 输出格式) 容易漂移, 跑真 git 命令反而稳. // // 历史包袱 (LEGACY): 测试依赖系统 git; CI 无 git 时所有用 helper 的测试 // 走 t.Skip() 而不是失败. package git import ( "os/exec" "path/filepath" "runtime" "strings" "testing" ) // === ValidateRef (pure string, no external deps) === func TestValidateRef_PathTraversal(t *testing.T) { tests := []string{ "../master", "main/../etc", "feat/..", } for _, ref := range tests { err := ValidateRef(ref) if err == nil { t.Errorf("ValidateRef(%q) 应拒绝路径遍历", ref) } if !strings.Contains(err.Error(), "path traversal") { t.Errorf("错误信息应提到 path traversal: %v", err) } } } func TestValidateRef_FlagInjection(t *testing.T) { tests := []string{ "--upload-pack=evil", "-output", } for _, ref := range tests { err := ValidateRef(ref) if err == nil { t.Errorf("ValidateRef(%q) 应拒绝以 - 开头的参数", ref) } if !strings.Contains(err.Error(), "flag injection") { t.Errorf("错误信息应提到 flag injection: %v", err) } } } func TestValidateRef_Valid(t *testing.T) { valid := []string{ "main", "master", "feature/auth", "release-1.0", "v1.2.3", "abc1234567", "feat_x", } for _, ref := range valid { if err := ValidateRef(ref); err != nil { t.Errorf("ValidateRef(%q) 应通过, 错误: %v", ref, err) } } } // === fixture: init git repo in t.TempDir() === // initRepo initializes a git repo in dir, configures user, and commits // one file. Returns ok=false when the caller should t.Skip (git missing). // // initRepo 在 dir 初始化 git repo, 配 user, 提交一份文件. ok=false 时 // 调用方应 Skip (git 不可用). func initRepo(t *testing.T, dir string) bool { t.Helper() if _, err := exec.LookPath("git"); err != nil { t.Skip("git 命令不可用, 跳过") return false } cmds := [][]string{ {"git", "init", "--initial-branch=main"}, {"git", "config", "user.email", "test@example.com"}, {"git", "config", "user.name", "Test"}, {"git", "config", "commit.gpgsign", "false"}, } for _, args := range cmds { cmd := exec.Command(args[0], args[1:]...) cmd.Dir = dir if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git fixture 失败 [%v]: %v\n%s", args, err, out) } } if err := writeFile(filepath.Join(dir, "README.md"), "hello"); err != nil { t.Fatalf("写 README 失败: %v", err) } for _, args := range [][]string{ {"git", "add", "README.md"}, {"git", "commit", "-m", "init"}, } { cmd := exec.Command(args[0], args[1:]...) cmd.Dir = dir if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git commit 失败: %v\n%s", err, out) } } return true } func writeFile(path, content string) error { cmd := exec.Command("sh", "-c", "cat > "+escapeShellPath(path)) cmd.Stdin = strings.NewReader(content) return cmd.Run() } // LEGACY: writeFile uses shell because Windows paths with spaces bite; // we sidestep by only running these tests on unix-like systems. // // 历史包袱 (LEGACY): writeFile 走 shell 是因 Windows 路径有空格坑, // 直接绕开 — 这些测试仅在 unix-like 系统上跑. func escapeShellPath(p string) string { return "'" + strings.ReplaceAll(p, "'", "'\\''") + "'" } func skipOnWindows(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("git 测试目前仅在 unix-like 系统上跑") } } // === DetectRepo === func TestDetectRepo_NotInRepo(t *testing.T) { skipOnWindows(t) dir := t.TempDir() if DetectRepo(dir) { t.Errorf("空目录不应是 git repo") } } func TestDetectRepo_InRepo(t *testing.T) { skipOnWindows(t) dir := t.TempDir() if !initRepo(t, dir) { return } if !DetectRepo(dir) { t.Errorf("已 init 的目录应是 git repo") } } // === GetBranch === func TestGetBranch_Main(t *testing.T) { skipOnWindows(t) dir := t.TempDir() if !initRepo(t, dir) { return } branch := GetBranch(dir) if branch != "main" { t.Errorf("Branch = %q, want main", branch) } } func TestGetBranch_NotRepo(t *testing.T) { skipOnWindows(t) dir := t.TempDir() branch := GetBranch(dir) if branch != "" { t.Errorf("非 repo Branch 应为空, 实际: %q", branch) } } // === GetStatus === func TestGetStatus_Clean(t *testing.T) { skipOnWindows(t) dir := t.TempDir() if !initRepo(t, dir) { return } status := GetStatus(dir) if status != "" { t.Errorf("clean repo status 应为空, 实际: %q", status) } } func TestGetStatus_Dirty(t *testing.T) { skipOnWindows(t) dir := t.TempDir() if !initRepo(t, dir) { return } if err := writeFile(filepath.Join(dir, "README.md"), "modified"); err != nil { t.Fatal(err) } status := GetStatus(dir) if status == "" { t.Error("修改后 status 不应为空") } if !strings.Contains(status, "README.md") { t.Errorf("status 应含 README.md, 实际: %q", status) } } // === GetInfo (single-call) === func TestGetInfo_NotRepo(t *testing.T) { skipOnWindows(t) dir := t.TempDir() info := GetInfo(dir) if info.IsRepo { t.Error("非 repo IsRepo 应为 false") } if info.Branch != "" { t.Errorf("非 repo Branch 应为空, 实际: %q", info.Branch) } if info.Status != "" { t.Errorf("非 repo Status 应为空, 实际: %q", info.Status) } } func TestGetInfo_CleanRepo(t *testing.T) { skipOnWindows(t) dir := t.TempDir() if !initRepo(t, dir) { return } info := GetInfo(dir) if !info.IsRepo { t.Error("已 init 应 IsRepo=true") } if info.Branch != "main" { t.Errorf("Branch = %q", info.Branch) } if info.Status != "" { t.Errorf("clean repo Status 应为空, 实际: %q", info.Status) } } func TestGetInfo_DirtyRepo(t *testing.T) { skipOnWindows(t) dir := t.TempDir() if !initRepo(t, dir) { return } if err := writeFile(filepath.Join(dir, "README.md"), "changed"); err != nil { t.Fatal(err) } info := GetInfo(dir) if info.Status == "" { t.Error("修改后 Status 不应为空") } if !strings.Contains(info.Status, "README.md") { t.Errorf("Status 应含 README.md, 实际: %q", info.Status) } } // === GetDiff === func TestGetDiff_NoChanges(t *testing.T) { skipOnWindows(t) dir := t.TempDir() if !initRepo(t, dir) { return } diff := GetDiff(dir, "") if diff != "" { t.Errorf("clean repo diff 应为空, 实际: %q", diff) } } func TestGetDiff_WithChanges(t *testing.T) { skipOnWindows(t) dir := t.TempDir() if !initRepo(t, dir) { return } if err := writeFile(filepath.Join(dir, "README.md"), "changed"); err != nil { t.Fatal(err) } diff := GetDiff(dir, "") if diff == "" { t.Error("修改后 diff 不应为空") } if !strings.Contains(diff, "README") { t.Errorf("diff 应含 README, 实际: %q", diff) } } func TestGetDiff_RejectsBadRef(t *testing.T) { skipOnWindows(t) dir := t.TempDir() if !initRepo(t, dir) { return } diff := GetDiff(dir, "../etc/passwd") if diff != "" { t.Errorf("路径遍历 ref 应被拦截, 实际: %q", diff) } diff = GetDiff(dir, "--output=evil") if diff != "" { t.Errorf("flag injection ref 应被拦截, 实际: %q", diff) } } func TestGetDiff_NotRepo(t *testing.T) { skipOnWindows(t) dir := t.TempDir() diff := GetDiff(dir, "") if diff != "" { t.Errorf("非 repo diff 应为空, 实际: %q", diff) } } // === GetLog === func TestGetLog_HasCommits(t *testing.T) { skipOnWindows(t) dir := t.TempDir() if !initRepo(t, dir) { return } log := GetLog(dir, 5) if log == "" { t.Error("初始化后应有至少一条 commit") } if !strings.Contains(log, "init") { t.Errorf("log 应含 'init' commit message, 实际: %q", log) } } func TestGetLog_DefaultLimit(t *testing.T) { skipOnWindows(t) dir := t.TempDir() if !initRepo(t, dir) { return } log := GetLog(dir, 0) if log == "" { t.Error("n=0 应使用默认值返回 log") } } func TestGetLog_NegativeN(t *testing.T) { skipOnWindows(t) dir := t.TempDir() if !initRepo(t, dir) { return } log := GetLog(dir, -1) if log == "" { t.Error("负数 n 应回退到默认值") } } func TestGetLog_NotRepo(t *testing.T) { skipOnWindows(t) dir := t.TempDir() log := GetLog(dir, 5) if log != "" { t.Errorf("非 repo log 应为空, 实际: %q", log) } }