mirror of
https://github.com/cexll/myclaude.git
synced 2026-02-05 02:30:26 +08:00
feat: add worktree support and refactor do skill to Python
- Add worktree module for git worktree management - Refactor do skill scripts from shell to Python for better maintainability - Add install.py for do skill installation - Update stop-hook to Python implementation - Enhance executor with additional configuration options - Update CLAUDE.md with first-principles thinking guidelines Generated with SWE-Agent.ai Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
This commit is contained in:
@@ -30,6 +30,7 @@ type cliOptions struct {
|
||||
Agent string
|
||||
PromptFile string
|
||||
SkipPermissions bool
|
||||
Worktree bool
|
||||
|
||||
Parallel bool
|
||||
FullOutput bool
|
||||
@@ -136,6 +137,7 @@ func addRootFlags(fs *pflag.FlagSet, opts *cliOptions) {
|
||||
|
||||
fs.BoolVar(&opts.SkipPermissions, "skip-permissions", false, "Skip permissions prompts (also via CODEAGENT_SKIP_PERMISSIONS)")
|
||||
fs.BoolVar(&opts.SkipPermissions, "dangerously-skip-permissions", false, "Alias for --skip-permissions")
|
||||
fs.BoolVar(&opts.Worktree, "worktree", false, "Execute in a new git worktree (auto-generates task ID)")
|
||||
}
|
||||
|
||||
func newVersionCommand(name string) *cobra.Command {
|
||||
@@ -350,6 +352,7 @@ func buildSingleConfig(cmd *cobra.Command, args []string, rawArgv []string, opts
|
||||
MaxParallelWorkers: config.ResolveMaxParallelWorkers(),
|
||||
AllowedTools: resolvedAllowedTools,
|
||||
DisallowedTools: resolvedDisallowedTools,
|
||||
Worktree: opts.Worktree,
|
||||
}
|
||||
|
||||
if args[0] == "resume" {
|
||||
@@ -653,6 +656,7 @@ func runSingleMode(cfg *Config, name string) int {
|
||||
ReasoningEffort: cfg.ReasoningEffort,
|
||||
Agent: cfg.Agent,
|
||||
SkipPermissions: cfg.SkipPermissions,
|
||||
Worktree: cfg.Worktree,
|
||||
AllowedTools: cfg.AllowedTools,
|
||||
DisallowedTools: cfg.DisallowedTools,
|
||||
UseStdin: useStdin,
|
||||
|
||||
@@ -1616,6 +1616,60 @@ do something`
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelParseConfig_Worktree(t *testing.T) {
|
||||
input := `---TASK---
|
||||
id: task-1
|
||||
worktree: true
|
||||
---CONTENT---
|
||||
do something`
|
||||
|
||||
cfg, err := parseParallelConfig([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("parseParallelConfig() unexpected error: %v", err)
|
||||
}
|
||||
if len(cfg.Tasks) != 1 {
|
||||
t.Fatalf("expected 1 task, got %d", len(cfg.Tasks))
|
||||
}
|
||||
task := cfg.Tasks[0]
|
||||
if !task.Worktree {
|
||||
t.Fatalf("Worktree = %v, want true", task.Worktree)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelParseConfig_WorktreeBooleanValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want bool
|
||||
}{
|
||||
{"true", "true", true},
|
||||
{"1", "1", true},
|
||||
{"yes", "yes", true},
|
||||
{"false", "false", false},
|
||||
{"0", "0", false},
|
||||
{"no", "no", false},
|
||||
{"empty", "", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
input := fmt.Sprintf(`---TASK---
|
||||
id: task-1
|
||||
worktree: %s
|
||||
---CONTENT---
|
||||
do something`, tt.value)
|
||||
|
||||
cfg, err := parseParallelConfig([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("parseParallelConfig() unexpected error: %v", err)
|
||||
}
|
||||
if cfg.Tasks[0].Worktree != tt.want {
|
||||
t.Fatalf("Worktree = %v, want %v for value %q", cfg.Tasks[0].Worktree, tt.want, tt.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelParseConfig_EmptySessionID(t *testing.T) {
|
||||
input := `---TASK---
|
||||
id: task-1
|
||||
|
||||
@@ -26,6 +26,7 @@ type Config struct {
|
||||
MaxParallelWorkers int
|
||||
AllowedTools []string
|
||||
DisallowedTools []string
|
||||
Worktree bool // Execute in a new git worktree
|
||||
}
|
||||
|
||||
// EnvFlagEnabled returns true when the environment variable exists and is not
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
ilogger "codeagent-wrapper/internal/logger"
|
||||
parser "codeagent-wrapper/internal/parser"
|
||||
utils "codeagent-wrapper/internal/utils"
|
||||
"codeagent-wrapper/internal/worktree"
|
||||
)
|
||||
|
||||
const postMessageTerminateDelay = 1 * time.Second
|
||||
@@ -49,6 +50,7 @@ var (
|
||||
selectBackendFn = backend.Select
|
||||
commandContext = exec.CommandContext
|
||||
terminateCommandFn = terminateCommand
|
||||
createWorktreeFn = worktree.CreateWorktree
|
||||
)
|
||||
|
||||
var forceKillDelay atomic.Int32
|
||||
@@ -939,6 +941,18 @@ func RunCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
|
||||
cfg.WorkDir = defaultWorkdir
|
||||
}
|
||||
|
||||
// Handle worktree mode: create a new git worktree and update cfg.WorkDir
|
||||
if taskSpec.Worktree {
|
||||
paths, err := createWorktreeFn(cfg.WorkDir)
|
||||
if err != nil {
|
||||
result.ExitCode = 1
|
||||
result.Error = fmt.Sprintf("failed to create worktree: %v", err)
|
||||
return result
|
||||
}
|
||||
cfg.WorkDir = paths.Dir
|
||||
logInfo(fmt.Sprintf("Using worktree: %s (task_id: %s, branch: %s)", paths.Dir, paths.TaskID, paths.Branch))
|
||||
}
|
||||
|
||||
if cfg.Mode == "resume" && strings.TrimSpace(cfg.SessionID) == "" {
|
||||
result.ExitCode = 1
|
||||
result.Error = "resume mode requires non-empty session_id"
|
||||
|
||||
@@ -75,6 +75,12 @@ func ParseParallelConfig(data []byte) (*ParallelConfig, error) {
|
||||
continue
|
||||
}
|
||||
task.SkipPermissions = config.ParseBoolFlag(value, false)
|
||||
case "worktree":
|
||||
if value == "" {
|
||||
task.Worktree = true
|
||||
continue
|
||||
}
|
||||
task.Worktree = config.ParseBoolFlag(value, false)
|
||||
case "dependencies":
|
||||
for _, dep := range strings.Split(value, ",") {
|
||||
dep = strings.TrimSpace(dep)
|
||||
|
||||
@@ -21,6 +21,7 @@ type TaskSpec struct {
|
||||
Agent string `json:"agent,omitempty"`
|
||||
PromptFile string `json:"prompt_file,omitempty"`
|
||||
SkipPermissions bool `json:"skip_permissions,omitempty"`
|
||||
Worktree bool `json:"worktree,omitempty"`
|
||||
AllowedTools []string `json:"allowed_tools,omitempty"`
|
||||
DisallowedTools []string `json:"disallowed_tools,omitempty"`
|
||||
Mode string `json:"-"`
|
||||
|
||||
97
codeagent-wrapper/internal/worktree/worktree.go
Normal file
97
codeagent-wrapper/internal/worktree/worktree.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package worktree
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Paths contains worktree information
|
||||
type Paths struct {
|
||||
Dir string // .worktrees/do-{task_id}/
|
||||
Branch string // do/{task_id}
|
||||
TaskID string // auto-generated task_id
|
||||
}
|
||||
|
||||
// Hook points for testing
|
||||
var (
|
||||
randReader io.Reader = rand.Reader
|
||||
timeNowFunc = time.Now
|
||||
execCommand = exec.Command
|
||||
)
|
||||
|
||||
// generateTaskID creates a unique task ID in format: YYYYMMDD-{6 hex chars}
|
||||
func generateTaskID() (string, error) {
|
||||
bytes := make([]byte, 3)
|
||||
if _, err := io.ReadFull(randReader, bytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
date := timeNowFunc().Format("20060102")
|
||||
return fmt.Sprintf("%s-%s", date, hex.EncodeToString(bytes)), nil
|
||||
}
|
||||
|
||||
// isGitRepo checks if the given directory is inside a git repository
|
||||
func isGitRepo(dir string) bool {
|
||||
cmd := execCommand("git", "-C", dir, "rev-parse", "--is-inside-work-tree")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(string(output)) == "true"
|
||||
}
|
||||
|
||||
// getGitRoot returns the root directory of the git repository
|
||||
func getGitRoot(dir string) (string, error) {
|
||||
cmd := execCommand("git", "-C", dir, "rev-parse", "--show-toplevel")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get git root: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(output)), nil
|
||||
}
|
||||
|
||||
// CreateWorktree creates a new git worktree with auto-generated task_id
|
||||
// Returns Paths containing the worktree directory, branch name, and task_id
|
||||
func CreateWorktree(projectDir string) (*Paths, error) {
|
||||
if projectDir == "" {
|
||||
projectDir = "."
|
||||
}
|
||||
|
||||
// Verify it's a git repository
|
||||
if !isGitRepo(projectDir) {
|
||||
return nil, fmt.Errorf("not a git repository: %s", projectDir)
|
||||
}
|
||||
|
||||
// Get git root for consistent path calculation
|
||||
gitRoot, err := getGitRoot(projectDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Generate task ID
|
||||
taskID, err := generateTaskID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Calculate paths
|
||||
worktreeDir := filepath.Join(gitRoot, ".worktrees", fmt.Sprintf("do-%s", taskID))
|
||||
branchName := fmt.Sprintf("do/%s", taskID)
|
||||
|
||||
// Create worktree with new branch
|
||||
cmd := execCommand("git", "-C", gitRoot, "worktree", "add", "-b", branchName, worktreeDir)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("failed to create worktree: %w\noutput: %s", err, string(output))
|
||||
}
|
||||
|
||||
return &Paths{
|
||||
Dir: worktreeDir,
|
||||
Branch: branchName,
|
||||
TaskID: taskID,
|
||||
}, nil
|
||||
}
|
||||
449
codeagent-wrapper/internal/worktree/worktree_test.go
Normal file
449
codeagent-wrapper/internal/worktree/worktree_test.go
Normal file
@@ -0,0 +1,449 @@
|
||||
package worktree
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func resetHooks() {
|
||||
randReader = rand.Reader
|
||||
timeNowFunc = time.Now
|
||||
execCommand = exec.Command
|
||||
}
|
||||
|
||||
func TestGenerateTaskID(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
taskID, err := generateTaskID()
|
||||
if err != nil {
|
||||
t.Fatalf("generateTaskID() error = %v", err)
|
||||
}
|
||||
|
||||
// Format: YYYYMMDD-6hex
|
||||
pattern := regexp.MustCompile(`^\d{8}-[0-9a-f]{6}$`)
|
||||
if !pattern.MatchString(taskID) {
|
||||
t.Errorf("generateTaskID() = %q, want format YYYYMMDD-xxxxxx", taskID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTaskID_FixedTime(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
// Mock time to a fixed date
|
||||
timeNowFunc = func() time.Time {
|
||||
return time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
taskID, err := generateTaskID()
|
||||
if err != nil {
|
||||
t.Fatalf("generateTaskID() error = %v", err)
|
||||
}
|
||||
|
||||
if !regexp.MustCompile(`^20260203-[0-9a-f]{6}$`).MatchString(taskID) {
|
||||
t.Errorf("generateTaskID() = %q, want prefix 20260203-", taskID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTaskID_RandReaderError(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
// Mock rand reader to return error
|
||||
randReader = &errorReader{err: errors.New("mock rand error")}
|
||||
|
||||
_, err := generateTaskID()
|
||||
if err == nil {
|
||||
t.Fatal("generateTaskID() expected error, got nil")
|
||||
}
|
||||
if !regexp.MustCompile(`failed to generate random bytes`).MatchString(err.Error()) {
|
||||
t.Errorf("error = %q, want 'failed to generate random bytes'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
type errorReader struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *errorReader) Read(p []byte) (n int, err error) {
|
||||
return 0, e.err
|
||||
}
|
||||
|
||||
func TestGenerateTaskID_Uniqueness(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
const count = 100
|
||||
ids := make(map[string]struct{}, count)
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
id, err := generateTaskID()
|
||||
if err != nil {
|
||||
t.Errorf("generateTaskID() error = %v", err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
ids[id] = struct{}{}
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if len(ids) != count {
|
||||
t.Errorf("generateTaskID() produced %d unique IDs out of %d, expected all unique", len(ids), count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWorktree_NotGitRepo(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "worktree-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
_, err = CreateWorktree(tmpDir)
|
||||
if err == nil {
|
||||
t.Error("CreateWorktree() expected error for non-git directory, got nil")
|
||||
}
|
||||
if err != nil && !regexp.MustCompile(`not a git repository`).MatchString(err.Error()) {
|
||||
t.Errorf("CreateWorktree() error = %q, want 'not a git repository'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWorktree_EmptyProjectDir(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
// When projectDir is empty, it should default to "."
|
||||
// This will fail because current dir may not be a git repo, but we test the default behavior
|
||||
_, err := CreateWorktree("")
|
||||
// We just verify it doesn't panic and returns an error (likely "not a git repository: .")
|
||||
if err == nil {
|
||||
// If we happen to be in a git repo, that's fine too
|
||||
return
|
||||
}
|
||||
if !regexp.MustCompile(`not a git repository: \.`).MatchString(err.Error()) {
|
||||
// It might be a git repo and fail later, which is also acceptable
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWorktree_Success(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
// Create temp git repo
|
||||
tmpDir, err := os.MkdirTemp("", "worktree-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Initialize git repo
|
||||
if err := exec.Command("git", "-C", tmpDir, "init").Run(); err != nil {
|
||||
t.Fatalf("failed to init git repo: %v", err)
|
||||
}
|
||||
if err := exec.Command("git", "-C", tmpDir, "config", "user.email", "test@test.com").Run(); err != nil {
|
||||
t.Fatalf("failed to set git email: %v", err)
|
||||
}
|
||||
if err := exec.Command("git", "-C", tmpDir, "config", "user.name", "Test").Run(); err != nil {
|
||||
t.Fatalf("failed to set git name: %v", err)
|
||||
}
|
||||
|
||||
// Create initial commit (required for worktree)
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
|
||||
t.Fatalf("failed to create test file: %v", err)
|
||||
}
|
||||
if err := exec.Command("git", "-C", tmpDir, "add", ".").Run(); err != nil {
|
||||
t.Fatalf("failed to git add: %v", err)
|
||||
}
|
||||
if err := exec.Command("git", "-C", tmpDir, "commit", "-m", "initial").Run(); err != nil {
|
||||
t.Fatalf("failed to git commit: %v", err)
|
||||
}
|
||||
|
||||
// Test CreateWorktree
|
||||
paths, err := CreateWorktree(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorktree() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify task ID format
|
||||
pattern := regexp.MustCompile(`^\d{8}-[0-9a-f]{6}$`)
|
||||
if !pattern.MatchString(paths.TaskID) {
|
||||
t.Errorf("TaskID = %q, want format YYYYMMDD-xxxxxx", paths.TaskID)
|
||||
}
|
||||
|
||||
// Verify branch name
|
||||
expectedBranch := "do/" + paths.TaskID
|
||||
if paths.Branch != expectedBranch {
|
||||
t.Errorf("Branch = %q, want %q", paths.Branch, expectedBranch)
|
||||
}
|
||||
|
||||
// Verify worktree directory exists
|
||||
if _, err := os.Stat(paths.Dir); os.IsNotExist(err) {
|
||||
t.Errorf("worktree directory %q does not exist", paths.Dir)
|
||||
}
|
||||
|
||||
// Verify worktree directory is under .worktrees/
|
||||
expectedDirSuffix := filepath.Join(".worktrees", "do-"+paths.TaskID)
|
||||
if !regexp.MustCompile(regexp.QuoteMeta(expectedDirSuffix) + `$`).MatchString(paths.Dir) {
|
||||
t.Errorf("Dir = %q, want suffix %q", paths.Dir, expectedDirSuffix)
|
||||
}
|
||||
|
||||
// Verify branch exists
|
||||
cmd := exec.Command("git", "-C", tmpDir, "branch", "--list", paths.Branch)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to list branches: %v", err)
|
||||
}
|
||||
if len(output) == 0 {
|
||||
t.Errorf("branch %q was not created", paths.Branch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWorktree_GetGitRootError(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
// Create a temp dir and mock git commands
|
||||
tmpDir, err := os.MkdirTemp("", "worktree-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
callCount := 0
|
||||
execCommand = func(name string, args ...string) *exec.Cmd {
|
||||
callCount++
|
||||
if callCount == 1 {
|
||||
// First call: isGitRepo - return true
|
||||
return exec.Command("echo", "true")
|
||||
}
|
||||
// Second call: getGitRoot - return error
|
||||
return exec.Command("false")
|
||||
}
|
||||
|
||||
_, err = CreateWorktree(tmpDir)
|
||||
if err == nil {
|
||||
t.Fatal("CreateWorktree() expected error, got nil")
|
||||
}
|
||||
if !regexp.MustCompile(`failed to get git root`).MatchString(err.Error()) {
|
||||
t.Errorf("error = %q, want 'failed to get git root'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWorktree_GenerateTaskIDError(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
// Create temp git repo
|
||||
tmpDir, err := os.MkdirTemp("", "worktree-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Initialize git repo with commit
|
||||
if err := exec.Command("git", "-C", tmpDir, "init").Run(); err != nil {
|
||||
t.Fatalf("failed to init git repo: %v", err)
|
||||
}
|
||||
if err := exec.Command("git", "-C", tmpDir, "config", "user.email", "test@test.com").Run(); err != nil {
|
||||
t.Fatalf("failed to set git email: %v", err)
|
||||
}
|
||||
if err := exec.Command("git", "-C", tmpDir, "config", "user.name", "Test").Run(); err != nil {
|
||||
t.Fatalf("failed to set git name: %v", err)
|
||||
}
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
|
||||
t.Fatalf("failed to create test file: %v", err)
|
||||
}
|
||||
if err := exec.Command("git", "-C", tmpDir, "add", ".").Run(); err != nil {
|
||||
t.Fatalf("failed to git add: %v", err)
|
||||
}
|
||||
if err := exec.Command("git", "-C", tmpDir, "commit", "-m", "initial").Run(); err != nil {
|
||||
t.Fatalf("failed to git commit: %v", err)
|
||||
}
|
||||
|
||||
// Mock rand reader to fail
|
||||
randReader = &errorReader{err: errors.New("mock rand error")}
|
||||
|
||||
_, err = CreateWorktree(tmpDir)
|
||||
if err == nil {
|
||||
t.Fatal("CreateWorktree() expected error, got nil")
|
||||
}
|
||||
if !regexp.MustCompile(`failed to generate random bytes`).MatchString(err.Error()) {
|
||||
t.Errorf("error = %q, want 'failed to generate random bytes'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWorktree_WorktreeAddError(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "worktree-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
callCount := 0
|
||||
execCommand = func(name string, args ...string) *exec.Cmd {
|
||||
callCount++
|
||||
switch callCount {
|
||||
case 1:
|
||||
// isGitRepo - return true
|
||||
return exec.Command("echo", "true")
|
||||
case 2:
|
||||
// getGitRoot - return tmpDir
|
||||
return exec.Command("echo", tmpDir)
|
||||
case 3:
|
||||
// worktree add - return error
|
||||
return exec.Command("false")
|
||||
}
|
||||
return exec.Command("false")
|
||||
}
|
||||
|
||||
_, err = CreateWorktree(tmpDir)
|
||||
if err == nil {
|
||||
t.Fatal("CreateWorktree() expected error, got nil")
|
||||
}
|
||||
if !regexp.MustCompile(`failed to create worktree`).MatchString(err.Error()) {
|
||||
t.Errorf("error = %q, want 'failed to create worktree'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsGitRepo(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
// Test non-git directory
|
||||
tmpDir, err := os.MkdirTemp("", "worktree-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
if isGitRepo(tmpDir) {
|
||||
t.Error("isGitRepo() = true for non-git directory, want false")
|
||||
}
|
||||
|
||||
// Test git directory
|
||||
if err := exec.Command("git", "-C", tmpDir, "init").Run(); err != nil {
|
||||
t.Fatalf("failed to init git repo: %v", err)
|
||||
}
|
||||
|
||||
if !isGitRepo(tmpDir) {
|
||||
t.Error("isGitRepo() = false for git directory, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsGitRepo_CommandError(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
// Mock execCommand to return error
|
||||
execCommand = func(name string, args ...string) *exec.Cmd {
|
||||
return exec.Command("false")
|
||||
}
|
||||
|
||||
if isGitRepo("/some/path") {
|
||||
t.Error("isGitRepo() = true when command fails, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsGitRepo_NotTrueOutput(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
// Mock execCommand to return something other than "true"
|
||||
execCommand = func(name string, args ...string) *exec.Cmd {
|
||||
return exec.Command("echo", "false")
|
||||
}
|
||||
|
||||
if isGitRepo("/some/path") {
|
||||
t.Error("isGitRepo() = true when output is 'false', want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGitRoot(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
// Create temp git repo
|
||||
tmpDir, err := os.MkdirTemp("", "worktree-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
if err := exec.Command("git", "-C", tmpDir, "init").Run(); err != nil {
|
||||
t.Fatalf("failed to init git repo: %v", err)
|
||||
}
|
||||
|
||||
root, err := getGitRoot(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("getGitRoot() error = %v", err)
|
||||
}
|
||||
|
||||
// The root should match tmpDir (accounting for symlinks)
|
||||
absRoot, _ := filepath.EvalSymlinks(root)
|
||||
absTmp, _ := filepath.EvalSymlinks(tmpDir)
|
||||
if absRoot != absTmp {
|
||||
t.Errorf("getGitRoot() = %q, want %q", absRoot, absTmp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGitRoot_Error(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
execCommand = func(name string, args ...string) *exec.Cmd {
|
||||
return exec.Command("false")
|
||||
}
|
||||
|
||||
_, err := getGitRoot("/some/path")
|
||||
if err == nil {
|
||||
t.Fatal("getGitRoot() expected error, got nil")
|
||||
}
|
||||
if !regexp.MustCompile(`failed to get git root`).MatchString(err.Error()) {
|
||||
t.Errorf("error = %q, want 'failed to get git root'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Test that rand reader produces expected bytes
|
||||
func TestGenerateTaskID_RandReaderBytes(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
// Mock rand reader to return fixed bytes
|
||||
randReader = &fixedReader{data: []byte{0xab, 0xcd, 0xef}}
|
||||
timeNowFunc = func() time.Time {
|
||||
return time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
taskID, err := generateTaskID()
|
||||
if err != nil {
|
||||
t.Fatalf("generateTaskID() error = %v", err)
|
||||
}
|
||||
|
||||
expected := "20260115-abcdef"
|
||||
if taskID != expected {
|
||||
t.Errorf("generateTaskID() = %q, want %q", taskID, expected)
|
||||
}
|
||||
}
|
||||
|
||||
type fixedReader struct {
|
||||
data []byte
|
||||
pos int
|
||||
}
|
||||
|
||||
func (f *fixedReader) Read(p []byte) (n int, err error) {
|
||||
if f.pos >= len(f.data) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n = copy(p, f.data[f.pos:])
|
||||
f.pos += n
|
||||
return n, nil
|
||||
}
|
||||
Reference in New Issue
Block a user