picoclaw/pkg/git/worktree_test.go
dj-oyu 45693bc4ed fix: resolve govet shadow warnings in merge conflict test
Rename err variables to avoid shadowing the outer CreateWorktree error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 06:11:12 +09:00

299 lines
7.9 KiB
Go

package git
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
func TestSanitizeBranchName(t *testing.T) {
tests := []struct {
input string
want string
}{
{"Add auth module", "plan/add-auth-module"},
{"", "plan/worktree"},
{" spaces ", "plan/spaces"},
{"UPPER-case_Mix", "plan/upper-case-mix"},
{"a/b/c", "plan/a-b-c"},
{
"very long task name that exceeds the forty character limit for safety",
"plan/very-long-task-name-that-exceeds-the-for",
},
{"---leading-trailing---", "plan/leading-trailing"},
{"special!@#$%chars", "plan/special-chars"},
}
for _, tt := range tests {
got := SanitizeBranchName(tt.input)
if got != tt.want {
t.Errorf("SanitizeBranchName(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestBranchBaseName(t *testing.T) {
tests := []struct {
input string
want string
}{
{"plan/add-auth", "plan-add-auth"},
{"heartbeat/20260224", "heartbeat-20260224"},
{"main", "main"},
{"", "worktree"},
}
for _, tt := range tests {
got := BranchBaseName(tt.input)
if got != tt.want {
t.Errorf("BranchBaseName(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
// initTestRepo creates a temporary git repo with an initial commit.
func initTestRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
cmds := [][]string{
{"git", "init"},
{"git", "config", "user.email", "test@test.com"},
{"git", "config", "user.name", "Test"},
}
for _, args := range cmds {
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git init: %s: %v", out, err)
}
}
// Create initial commit
f := filepath.Join(dir, "README.md")
os.WriteFile(f, []byte("# Test\n"), 0o644)
add := exec.Command("git", "add", "-A")
add.Dir = dir
add.Run()
commit := exec.Command("git", "commit", "-m", "initial")
commit.Dir = dir
commit.Run()
return dir
}
func TestFindRepoRoot(t *testing.T) {
dir := initTestRepo(t)
root := FindRepoRoot(dir)
if root == "" {
t.Fatal("FindRepoRoot returned empty for valid repo")
}
// Non-repo should return ""
tmpDir := t.TempDir()
if got := FindRepoRoot(tmpDir); got != "" {
t.Errorf("FindRepoRoot(non-repo) = %q, want empty", got)
}
}
func TestCurrentBranch(t *testing.T) {
dir := initTestRepo(t)
branch := CurrentBranch(dir)
// Should be "main" or "master" depending on git config
if branch == "" {
t.Fatal("CurrentBranch returned empty for valid repo")
}
}
func TestCreateWorktreeAndDispose(t *testing.T) {
dir := initTestRepo(t)
wtPath := filepath.Join(dir, ".picoclaw", "worktrees", "test-wt")
wt, err := CreateWorktree(dir, wtPath, "plan/test-feature")
if err != nil {
t.Fatalf("CreateWorktree: %v", err)
}
if wt.Path != wtPath {
t.Errorf("Path = %q, want %q", wt.Path, wtPath)
}
if wt.Branch != "plan/test-feature" {
t.Errorf("Branch = %q, want %q", wt.Branch, "plan/test-feature")
}
// Verify worktree exists
if _, err := os.Stat(wtPath); os.IsNotExist(err) {
t.Fatal("worktree dir was not created")
}
// SafeDispose with no changes — should delete branch
result := SafeDispose(dir, wt)
if result.AutoCommitted {
t.Error("AutoCommitted should be false with no changes")
}
if result.CommitsAhead != 0 {
t.Errorf("CommitsAhead = %d, want 0", result.CommitsAhead)
}
if !result.BranchDeleted {
t.Error("BranchDeleted should be true when no unique commits")
}
}
func TestCreateWorktreeWithChangesAndDispose(t *testing.T) {
dir := initTestRepo(t)
wtPath := filepath.Join(dir, ".picoclaw", "worktrees", "test-changes")
wt, err := CreateWorktree(dir, wtPath, "plan/with-changes")
if err != nil {
t.Fatalf("CreateWorktree: %v", err)
}
// Make a change in the worktree
os.WriteFile(filepath.Join(wtPath, "new-file.txt"), []byte("hello"), 0o644)
if !HasUncommittedChanges(wtPath) {
t.Fatal("HasUncommittedChanges should be true after adding file")
}
// SafeDispose should auto-commit
result := SafeDispose(dir, wt)
if !result.AutoCommitted {
t.Error("AutoCommitted should be true")
}
if result.CommitsAhead != 1 {
t.Errorf("CommitsAhead = %d, want 1", result.CommitsAhead)
}
if result.BranchDeleted {
t.Error("BranchDeleted should be false when branch has commits")
}
}
func TestHasUncommittedChanges(t *testing.T) {
dir := initTestRepo(t)
if HasUncommittedChanges(dir) {
t.Fatal("clean repo should have no uncommitted changes")
}
os.WriteFile(filepath.Join(dir, "test.txt"), []byte("data"), 0o644)
if !HasUncommittedChanges(dir) {
t.Fatal("should detect uncommitted changes after adding file")
}
}
func TestCommitsAhead(t *testing.T) {
dir := initTestRepo(t)
base := CurrentBranch(dir)
// Create a branch with a commit
exec.Command("git", "checkout", "-b", "test-ahead").Run()
branchCmd := exec.Command("git", "checkout", "-b", "test-ahead")
branchCmd.Dir = dir
branchCmd.Run()
os.WriteFile(filepath.Join(dir, "extra.txt"), []byte("data"), 0o644)
AutoCommit(dir, "extra commit")
n := CommitsAhead(dir, base, "test-ahead")
if n != 1 {
t.Errorf("CommitsAhead = %d, want 1", n)
}
}
func TestMergeWorktreeBranch_Success(t *testing.T) {
dir := initTestRepo(t)
baseBranch := CurrentBranch(dir)
wtPath := filepath.Join(dir, ".picoclaw", "worktrees", "merge-ok")
wt, err := CreateWorktree(dir, wtPath, "plan/merge-ok")
if err != nil {
t.Fatalf("CreateWorktree: %v", err)
}
// Make a change in the worktree and commit
os.WriteFile(filepath.Join(wtPath, "merged-file.txt"), []byte("hello from worktree"), 0o644)
if err := AutoCommit(wtPath, "add merged-file"); err != nil {
t.Fatalf("AutoCommit: %v", err)
}
// Merge into base branch
mr := MergeWorktreeBranch(dir, wt)
if !mr.Merged {
t.Fatal("expected Merged=true")
}
if mr.Conflict {
t.Fatal("expected Conflict=false")
}
if mr.Branch != "plan/merge-ok" {
t.Errorf("Branch = %q, want %q", mr.Branch, "plan/merge-ok")
}
// Verify the file exists on the base branch
checkoutCmd := exec.Command("git", "checkout", baseBranch)
checkoutCmd.Dir = dir
checkoutCmd.Run()
if _, err := os.Stat(filepath.Join(dir, "merged-file.txt")); os.IsNotExist(err) {
t.Fatal("merged-file.txt should exist on base branch after merge")
}
}
func TestMergeWorktreeBranch_Conflict(t *testing.T) {
dir := initTestRepo(t)
wtPath := filepath.Join(dir, ".picoclaw", "worktrees", "merge-conflict")
wt, createErr := CreateWorktree(dir, wtPath, "plan/merge-conflict")
if createErr != nil {
t.Fatalf("CreateWorktree: %v", createErr)
}
// Make a change on the base branch
os.WriteFile(filepath.Join(dir, "conflict.txt"), []byte("base content"), 0o644)
if err := AutoCommit(dir, "add conflict.txt on base"); err != nil {
t.Fatalf("AutoCommit base: %v", err)
}
// Make a conflicting change in the worktree
os.WriteFile(filepath.Join(wtPath, "conflict.txt"), []byte("worktree content"), 0o644)
if err := AutoCommit(wtPath, "add conflict.txt on worktree"); err != nil {
t.Fatalf("AutoCommit worktree: %v", err)
}
// Attempt merge — should conflict
mr := MergeWorktreeBranch(dir, wt)
if mr.Merged {
t.Fatal("expected Merged=false on conflict")
}
if !mr.Conflict {
t.Fatal("expected Conflict=true")
}
// Verify base branch file is unchanged (merge was aborted)
content, readErr := os.ReadFile(filepath.Join(dir, "conflict.txt"))
if readErr != nil {
t.Fatalf("ReadFile: %v", readErr)
}
if string(content) != "base content" {
t.Errorf("conflict.txt = %q, want %q (merge should have been aborted)", string(content), "base content")
}
}
func TestPruneOrphaned(t *testing.T) {
dir := initTestRepo(t)
// Use a separate temp dir for worktrees (outside the repo) to avoid
// git rev-parse finding the parent repo's .git.
worktreesDir := filepath.Join(t.TempDir(), "worktrees")
os.MkdirAll(worktreesDir, 0o755)
// Create a fake dir that's not a worktree
orphanDir := filepath.Join(worktreesDir, "orphan")
os.MkdirAll(orphanDir, 0o755)
PruneOrphaned(dir, worktreesDir)
if _, err := os.Stat(orphanDir); !os.IsNotExist(err) {
t.Error("orphaned dir should have been removed")
}
}