package main import ( "encoding/json" "os" "os/exec" "path/filepath" "strings" "testing" ) func TestCheckDiffAcceptsExactSubtreeAndSegmentGlob(t *testing.T) { repo := newDiffRepo(t, map[string]string{"README.md": "base\n"}) repo.writeTask(t, []string{ repo.taskPath, repo.activePath, "exact.txt", "nested/**", "logs/*.txt", }) writeFixture(t, filepath.Join(repo.root, "exact.txt"), "exact\n") writeFixture(t, filepath.Join(repo.root, "nested", "deep", "value.txt"), "nested\n") writeFixture(t, filepath.Join(repo.root, "logs", "today.txt"), "log\n") if err := repo.check(t); err != nil { t.Fatalf("accepted scope rejected: %v", err) } } func TestCheckDiffRejectsTrackedStagedUnstagedAndUntrackedPaths(t *testing.T) { tests := []struct { name string mutate func(*testing.T, *diffRepo) }{ { name: "tracked unstaged", mutate: func(t *testing.T, repo *diffRepo) { writeFixture(t, filepath.Join(repo.root, "tracked.txt"), "changed\n") }, }, { name: "tracked staged", mutate: func(t *testing.T, repo *diffRepo) { writeFixture(t, filepath.Join(repo.root, "tracked.txt"), "changed\n") repo.git(t, "add", "tracked.txt") }, }, { name: "untracked", mutate: func(t *testing.T, repo *diffRepo) { writeFixture(t, filepath.Join(repo.root, "outside.txt"), "new\n") }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { repo := newDiffRepo(t, map[string]string{"tracked.txt": "base\n"}) repo.writeTask(t, []string{repo.taskPath, repo.activePath}) test.mutate(t, repo) assertDiffError(t, repo.check(t), "outside task") }) } } func TestCheckDiffRejectsDeletionAndBothSidesOfRename(t *testing.T) { t.Run("deletion", func(t *testing.T) { repo := newDiffRepo(t, map[string]string{"remove.txt": "base\n"}) repo.writeTask(t, []string{repo.taskPath, repo.activePath}) if err := os.Remove(filepath.Join(repo.root, "remove.txt")); err != nil { t.Fatal(err) } assertDiffError(t, repo.check(t), "remove.txt") }) t.Run("rename source", func(t *testing.T) { repo := newDiffRepo(t, map[string]string{"source.txt": "base\n"}) repo.writeTask(t, []string{repo.taskPath, repo.activePath, "destination.txt"}) repo.git(t, "mv", "source.txt", "destination.txt") assertDiffError(t, repo.check(t), "source.txt") }) t.Run("rename destination", func(t *testing.T) { repo := newDiffRepo(t, map[string]string{"source.txt": "base\n"}) repo.writeTask(t, []string{repo.taskPath, repo.activePath, "source.txt"}) repo.git(t, "mv", "source.txt", "destination.txt") assertDiffError(t, repo.check(t), "destination.txt") }) } func TestCheckDiffRejectsInvalidActiveTaskState(t *testing.T) { t.Run("missing pointer", func(t *testing.T) { repo := newDiffRepo(t, nil) assertDiffError(t, repo.check(t), "active task pointer") }) t.Run("malformed pointer", func(t *testing.T) { repo := newDiffRepo(t, nil) writeFixture(t, filepath.Join(repo.root, repo.activePath), "{") assertDiffError(t, repo.check(t), "invalid JSON") }) t.Run("mismatched pointer", func(t *testing.T) { repo := newDiffRepo(t, nil) repo.writeTask(t, []string{repo.taskPath, repo.activePath}) active := ActiveTask{TaskFile: repo.taskPath, TaskID: "OTHER", TaskRevision: 1} repo.writeJSON(t, repo.activePath, active) assertDiffError(t, repo.check(t), "does not match") }) t.Run("task outside dev tasks", func(t *testing.T) { repo := newDiffRepo(t, nil) active := ActiveTask{TaskFile: "task.json", TaskID: "TEST-1", TaskRevision: 1} repo.writeJSON(t, repo.activePath, active) assertDiffError(t, repo.check(t), "below dev/tasks") }) } func TestCheckDiffRejectsInvalidBaseRevisions(t *testing.T) { t.Run("missing object or history", func(t *testing.T) { repo := newDiffRepo(t, nil) repo.base = "0000000000000000000000000000000000000000" repo.writeTask(t, []string{repo.taskPath, repo.activePath}) assertDiffError(t, repo.check(t), "does not resolve") }) t.Run("non ancestor", func(t *testing.T) { repo := newDiffRepo(t, nil) repo.git(t, "checkout", "--orphan", "unrelated") if err := os.Remove(filepath.Join(repo.root, "base.txt")); err != nil { t.Fatal(err) } writeFixture(t, filepath.Join(repo.root, "unrelated.txt"), "unrelated\n") repo.git(t, "add", "-A") repo.git(t, "commit", "-m", "unrelated") unrelated := strings.TrimSpace(repo.git(t, "rev-parse", "HEAD")) repo.git(t, "checkout", "master") repo.base = unrelated repo.writeTask(t, []string{repo.taskPath, repo.activePath}) assertDiffError(t, repo.check(t), "not an ancestor") }) } func TestCheckDiffRejectsMalformedScopeAndImplicitPrefix(t *testing.T) { tests := []struct { name string pattern string want string }{ {"parent traversal", "../outside", "clean repository-relative"}, {"backslash", `directory\\file`, "slash-normalized"}, {"unsupported class", "file[0].txt", "only * and ?"}, {"invalid double star", "directory/**/file", "trailing subtree"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { repo := newDiffRepo(t, nil) repo.writeTask(t, []string{repo.taskPath, repo.activePath, test.pattern}) assertDiffError(t, repo.check(t), test.want) }) } t.Run("implicit prefix", func(t *testing.T) { repo := newDiffRepo(t, nil) repo.writeTask(t, []string{repo.taskPath, repo.activePath, "directory"}) writeFixture(t, filepath.Join(repo.root, "directory", "file.txt"), "outside\n") assertDiffError(t, repo.check(t), "directory/file.txt") }) t.Run("segment glob cannot cross slash", func(t *testing.T) { repo := newDiffRepo(t, nil) repo.writeTask(t, []string{repo.taskPath, repo.activePath, "logs/*.txt"}) writeFixture(t, filepath.Join(repo.root, "logs", "nested", "file.txt"), "outside\n") assertDiffError(t, repo.check(t), "logs/nested/file.txt") }) } func TestCheckDiffRequiresTaskAndPointerInScope(t *testing.T) { t.Run("task packet", func(t *testing.T) { repo := newDiffRepo(t, nil) repo.writeTask(t, []string{repo.activePath}) assertDiffError(t, repo.check(t), "allow its own path") }) t.Run("active pointer", func(t *testing.T) { repo := newDiffRepo(t, nil) repo.writeTask(t, []string{repo.taskPath}) assertDiffError(t, repo.check(t), "allow pointer path") }) } func TestCheckDiffCoversMultipleCommitsFromBase(t *testing.T) { repo := newDiffRepo(t, nil) repo.writeTask(t, []string{repo.taskPath, repo.activePath, "first.txt", "second.txt"}) writeFixture(t, filepath.Join(repo.root, "first.txt"), "first\n") repo.git(t, "add", "-A") repo.git(t, "commit", "-m", "first task commit") writeFixture(t, filepath.Join(repo.root, "second.txt"), "second\n") if err := repo.check(t); err != nil { t.Fatalf("multi-commit diff rejected: %v", err) } } func TestTaskSelectionIsExplicit(t *testing.T) { if _, err := parseTaskSelection([]string{"-active", "active.json", "-task", "task.json"}, true); err == nil { t.Fatal("active and task selection accepted together") } if _, err := parseTaskSelection([]string{"-skip"}, true); err == nil { t.Fatal("unknown skip argument accepted") } selection, err := parseTaskSelection(nil, true) if err != nil || selection.activePath != defaultActiveTaskPath { t.Fatalf("default active selection = %+v, %v", selection, err) } } type diffRepo struct { root string base string taskPath string activePath string } func newDiffRepo(t *testing.T, baseFiles map[string]string) *diffRepo { t.Helper() if _, err := exec.LookPath("git"); err != nil { t.Skip("git is required") } repo := &diffRepo{ root: t.TempDir(), taskPath: "dev/tasks/TEST-0001.json", activePath: defaultActiveTaskPath, } repo.git(t, "init", "--initial-branch=master") repo.git(t, "config", "user.email", "devctl-test@example.invalid") repo.git(t, "config", "user.name", "devctl test") writeFixture(t, filepath.Join(repo.root, "base.txt"), "base\n") for name, content := range baseFiles { writeFixture(t, filepath.Join(repo.root, filepath.FromSlash(name)), content) } repo.git(t, "add", "-A") repo.git(t, "commit", "-m", "base") repo.base = strings.TrimSpace(repo.git(t, "rev-parse", "HEAD")) return repo } func (repo *diffRepo) writeTask(t *testing.T, allowed []string) { t.Helper() packet := TaskPacket{ TaskID: "TEST-0001", TaskRevision: 1, BaseRepoRevision: repo.base, RiskTier: "E", Intent: "test exact diff scope", } packet.AllowedChangeScope.Paths = allowed repo.writeJSON(t, repo.taskPath, packet) repo.writeJSON(t, repo.activePath, ActiveTask{TaskFile: repo.taskPath, TaskID: packet.TaskID, TaskRevision: packet.TaskRevision}) } func (repo *diffRepo) writeJSON(t *testing.T, name string, value any) { t.Helper() b, err := json.MarshalIndent(value, "", " ") if err != nil { t.Fatal(err) } writeFixture(t, filepath.Join(repo.root, filepath.FromSlash(name)), string(b)+"\n") } func (repo *diffRepo) check(t *testing.T) error { t.Helper() return checkDiffAt(repo.root, taskSelection{activePath: repo.activePath}) } func (repo *diffRepo) git(t *testing.T, args ...string) string { t.Helper() out, err := runGit(repo.root, args...) if err != nil { t.Fatalf("git %s: %v", strings.Join(args, " "), err) } return string(out) } func assertDiffError(t *testing.T, err error, want string) { t.Helper() if err == nil || !strings.Contains(err.Error(), want) { t.Fatalf("check-diff error = %v, want substring %q", err, want) } }