328 lines
10 KiB
Go
328 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
const defaultActiveTaskPath = "dev/ACTIVE_TASK.json"
|
|
|
|
type ActiveTask struct {
|
|
TaskFile string `json:"task_file"`
|
|
TaskID string `json:"task_id"`
|
|
TaskRevision int `json:"task_revision"`
|
|
}
|
|
|
|
type taskSelection struct {
|
|
activePath string
|
|
taskPath string
|
|
}
|
|
|
|
func checkDiff(args []string) error {
|
|
selection, err := parseTaskSelection(args, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
root, err := gitRepositoryRoot(".")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return checkDiffAt(root, selection)
|
|
}
|
|
|
|
func parseTaskSelection(args []string, defaultActive bool) (taskSelection, error) {
|
|
var selection taskSelection
|
|
for i := 0; i < len(args); i++ {
|
|
switch args[i] {
|
|
case "-active", "-task":
|
|
if i+1 >= len(args) {
|
|
return taskSelection{}, fmt.Errorf("missing value for %s", args[i])
|
|
}
|
|
value := args[i+1]
|
|
i++
|
|
if args[i-1] == "-active" {
|
|
if selection.activePath != "" {
|
|
return taskSelection{}, errors.New("duplicate -active")
|
|
}
|
|
selection.activePath = value
|
|
} else {
|
|
if selection.taskPath != "" {
|
|
return taskSelection{}, errors.New("duplicate -task")
|
|
}
|
|
selection.taskPath = value
|
|
}
|
|
default:
|
|
return taskSelection{}, fmt.Errorf("unknown argument %q", args[i])
|
|
}
|
|
}
|
|
if selection.activePath != "" && selection.taskPath != "" {
|
|
return taskSelection{}, errors.New("-active and -task are mutually exclusive")
|
|
}
|
|
if selection.activePath == "" && selection.taskPath == "" {
|
|
if !defaultActive {
|
|
return taskSelection{}, errors.New("missing -active <json> or -task <json>")
|
|
}
|
|
selection.activePath = defaultActiveTaskPath
|
|
}
|
|
return selection, nil
|
|
}
|
|
|
|
func gitRepositoryRoot(from string) (string, error) {
|
|
out, err := runGit(from, "rev-parse", "--show-toplevel")
|
|
if err != nil {
|
|
return "", fmt.Errorf("locate Git repository: %w", err)
|
|
}
|
|
root := strings.TrimSpace(string(out))
|
|
if root == "" {
|
|
return "", errors.New("Git repository root is empty")
|
|
}
|
|
return filepath.Clean(root), nil
|
|
}
|
|
|
|
func checkDiffAt(root string, selection taskSelection) error {
|
|
taskPacket, taskPath, activePath, err := loadSelectedTask(root, selection)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if taskPacket.TaskID == "" || taskPacket.TaskRevision < 1 || taskPacket.BaseRepoRevision == "" || taskPacket.RiskTier == "" || taskPacket.Intent == "" {
|
|
return errors.New("task packet missing task_id, positive task_revision, base_repo_revision, risk_tier, or intent")
|
|
}
|
|
|
|
patterns, err := compileScopePatterns(taskPacket.AllowedChangeScope.Paths)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !matchesAny(taskPath, patterns) {
|
|
return fmt.Errorf("task packet must allow its own path %s", taskPath)
|
|
}
|
|
if activePath != "" && !matchesAny(activePath, patterns) {
|
|
return fmt.Errorf("active task packet must allow pointer path %s", activePath)
|
|
}
|
|
|
|
base, err := resolveBaseCommit(root, taskPacket.BaseRepoRevision)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
changed, err := changedPaths(root, base)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var outside []string
|
|
for _, changedPath := range changed {
|
|
if !matchesAny(changedPath, patterns) {
|
|
outside = append(outside, changedPath)
|
|
}
|
|
}
|
|
if len(outside) > 0 {
|
|
return fmt.Errorf("%d changed path(s) outside task %s allowed_change_scope: %s", len(outside), taskPacket.TaskID, strings.Join(outside, ", "))
|
|
}
|
|
fmt.Printf("diff scope checks: OK task=%s base=%s paths=%d\n", taskPacket.TaskID, base, len(changed))
|
|
return nil
|
|
}
|
|
|
|
func loadSelectedTask(root string, selection taskSelection) (TaskPacket, string, string, error) {
|
|
if selection.activePath != "" {
|
|
activePath, err := cleanRepositoryPath(selection.activePath)
|
|
if err != nil {
|
|
return TaskPacket{}, "", "", fmt.Errorf("active pointer path: %w", err)
|
|
}
|
|
var active ActiveTask
|
|
if err := decodeJSONFile(filepath.Join(root, filepath.FromSlash(activePath)), &active, true); err != nil {
|
|
return TaskPacket{}, "", "", fmt.Errorf("active task pointer: %w", err)
|
|
}
|
|
if active.TaskID == "" || active.TaskRevision < 1 {
|
|
return TaskPacket{}, "", "", errors.New("active task pointer missing task_id or positive task_revision")
|
|
}
|
|
taskPath, err := cleanTaskPath(active.TaskFile)
|
|
if err != nil {
|
|
return TaskPacket{}, "", "", err
|
|
}
|
|
taskPacket, err := loadTaskPacket(root, taskPath)
|
|
if err != nil {
|
|
return TaskPacket{}, "", "", err
|
|
}
|
|
if taskPacket.TaskID != active.TaskID || taskPacket.TaskRevision != active.TaskRevision {
|
|
return TaskPacket{}, "", "", fmt.Errorf("active pointer %s revision %d does not match task packet %s revision %d", active.TaskID, active.TaskRevision, taskPacket.TaskID, taskPacket.TaskRevision)
|
|
}
|
|
return taskPacket, taskPath, activePath, nil
|
|
}
|
|
|
|
taskPath, err := cleanTaskPath(selection.taskPath)
|
|
if err != nil {
|
|
return TaskPacket{}, "", "", err
|
|
}
|
|
taskPacket, err := loadTaskPacket(root, taskPath)
|
|
return taskPacket, taskPath, "", err
|
|
}
|
|
|
|
func loadTaskPacket(root, taskPath string) (TaskPacket, error) {
|
|
var taskPacket TaskPacket
|
|
if err := decodeJSONFile(filepath.Join(root, filepath.FromSlash(taskPath)), &taskPacket, true); err != nil {
|
|
return TaskPacket{}, fmt.Errorf("task packet: %w", err)
|
|
}
|
|
return taskPacket, nil
|
|
}
|
|
|
|
func decodeJSONFile(filePath string, target any, strict bool) error {
|
|
b, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(b))
|
|
if strict {
|
|
decoder.DisallowUnknownFields()
|
|
}
|
|
if err := decoder.Decode(target); err != nil {
|
|
return fmt.Errorf("invalid JSON: %w", err)
|
|
}
|
|
if err := decoder.Decode(&struct{}{}); errors.Is(err, io.EOF) {
|
|
return nil
|
|
} else if err != nil {
|
|
return fmt.Errorf("invalid trailing JSON: %w", err)
|
|
}
|
|
return errors.New("invalid JSON: multiple values")
|
|
}
|
|
|
|
func cleanTaskPath(value string) (string, error) {
|
|
cleaned, err := cleanRepositoryPath(value)
|
|
if err != nil {
|
|
return "", fmt.Errorf("task_file: %w", err)
|
|
}
|
|
if !strings.HasPrefix(cleaned, "dev/tasks/") || !strings.HasSuffix(cleaned, ".json") {
|
|
return "", fmt.Errorf("task_file must be a JSON file below dev/tasks/: %s", cleaned)
|
|
}
|
|
return cleaned, nil
|
|
}
|
|
|
|
func cleanRepositoryPath(value string) (string, error) {
|
|
if value == "" || strings.Contains(value, "\\") || strings.Contains(value, ":") || strings.HasPrefix(value, "/") {
|
|
return "", fmt.Errorf("path must be non-empty, relative, slash-normalized and volume-free: %q", value)
|
|
}
|
|
cleaned := path.Clean(value)
|
|
if cleaned != value || cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
|
|
return "", fmt.Errorf("path is not a clean repository-relative path: %q", value)
|
|
}
|
|
return cleaned, nil
|
|
}
|
|
|
|
type scopePattern struct {
|
|
raw string
|
|
subtree string
|
|
}
|
|
|
|
func compileScopePatterns(values []string) ([]scopePattern, error) {
|
|
patterns := make([]scopePattern, 0, len(values))
|
|
for _, value := range values {
|
|
cleaned, err := cleanRepositoryPath(value)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid allowed_change_scope path: %w", err)
|
|
}
|
|
if strings.ContainsAny(cleaned, "[]{}") {
|
|
return nil, fmt.Errorf("invalid allowed_change_scope path %q: only * and ? glob characters are supported", value)
|
|
}
|
|
pattern := scopePattern{raw: cleaned}
|
|
if strings.HasSuffix(cleaned, "/**") {
|
|
pattern.subtree = strings.TrimSuffix(cleaned, "/**")
|
|
if pattern.subtree == "" || strings.ContainsAny(pattern.subtree, "*?") {
|
|
return nil, fmt.Errorf("invalid subtree scope pattern %q", value)
|
|
}
|
|
} else if strings.Contains(cleaned, "**") {
|
|
return nil, fmt.Errorf("invalid allowed_change_scope path %q: ** is supported only as a trailing subtree marker", value)
|
|
}
|
|
if _, err := path.Match(pattern.raw, pattern.raw); err != nil {
|
|
return nil, fmt.Errorf("invalid allowed_change_scope path %q: %w", value, err)
|
|
}
|
|
patterns = append(patterns, pattern)
|
|
}
|
|
return patterns, nil
|
|
}
|
|
|
|
func matchesAny(candidate string, patterns []scopePattern) bool {
|
|
for _, pattern := range patterns {
|
|
if pattern.subtree != "" {
|
|
if candidate == pattern.subtree || strings.HasPrefix(candidate, pattern.subtree+"/") {
|
|
return true
|
|
}
|
|
continue
|
|
}
|
|
matched, _ := path.Match(pattern.raw, candidate)
|
|
if matched {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func resolveBaseCommit(root, revision string) (string, error) {
|
|
out, err := runGit(root, "rev-parse", "--verify", revision+"^{commit}")
|
|
if err != nil {
|
|
return "", fmt.Errorf("base_repo_revision %q does not resolve to a commit: %w", revision, err)
|
|
}
|
|
base := strings.TrimSpace(string(out))
|
|
if _, err := runGit(root, "merge-base", "--is-ancestor", base, "HEAD"); err != nil {
|
|
return "", fmt.Errorf("base_repo_revision %s is not an ancestor of HEAD: %w", base, err)
|
|
}
|
|
return base, nil
|
|
}
|
|
|
|
func changedPaths(root, base string) ([]string, error) {
|
|
tracked, err := runGit(root, "diff", "--name-only", "--no-renames", "-z", base, "--")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read tracked diff: %w", err)
|
|
}
|
|
untracked, err := runGit(root, "ls-files", "--others", "--exclude-standard", "-z")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read untracked paths: %w", err)
|
|
}
|
|
unique := make(map[string]struct{})
|
|
for _, raw := range append(splitNUL(tracked), splitNUL(untracked)...) {
|
|
candidate := filepath.ToSlash(raw)
|
|
cleaned, err := cleanRepositoryPath(candidate)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Git returned invalid changed path: %w", err)
|
|
}
|
|
unique[cleaned] = struct{}{}
|
|
}
|
|
result := make([]string, 0, len(unique))
|
|
for candidate := range unique {
|
|
result = append(result, candidate)
|
|
}
|
|
sort.Strings(result)
|
|
return result, nil
|
|
}
|
|
|
|
func splitNUL(value []byte) []string {
|
|
parts := bytes.Split(value, []byte{0})
|
|
result := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
if len(part) > 0 {
|
|
result = append(result, string(part))
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func runGit(root string, args ...string) ([]byte, error) {
|
|
cmd := exec.Command("git", append([]string{"-C", root}, args...)...)
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
message := strings.TrimSpace(stderr.String())
|
|
if message == "" {
|
|
message = err.Error()
|
|
}
|
|
return nil, errors.New(message)
|
|
}
|
|
return out, nil
|
|
}
|