289 lines
9.3 KiB
Go
289 lines
9.3 KiB
Go
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)
|
|
}
|
|
}
|