From e3b0a44da6effeacda6e72edb04b4ca7e04b7041 Mon Sep 17 00:00:00 2001 From: Shiv Rossi Date: Sun, 26 Jul 2026 03:59:48 -0600 Subject: [PATCH] feat: add line-level creed diff preview --- cmd/gen/diff.go | 27 +++ cmd/gen/handlers.go | 18 ++ cmd/gen/registry.go | 1 + cmd/gen/runtime.go | 11 ++ internal/adapters/localfs/emitter.go | 202 ++++++++++++++++++- internal/adapters/localfs/emitter_test.go | 56 ++++++ internal/codegen/main.go | 16 ++ internal/httpapi/gen/handlers.go | 20 ++ internal/mcp/gen/diff.go | 12 ++ internal/mcp/gen/handlers.go | 32 +++ internal/mcp/gen/tool_specs.go | 1 + internal/ops/gen/operations.go | 10 + internal/ports/emitter.go | 13 ++ internal/service/diff_test.go | 83 ++++++++ internal/service/impl.go | 6 + internal/service/service.go | 2 + internal/usecase/diff.go | 229 ++++++++++++++++++++++ internal/usecase/diff_test.go | 51 +++++ internal/usecase/options.go | 7 + main.go | 7 +- 20 files changed, 802 insertions(+), 2 deletions(-) create mode 100644 cmd/gen/diff.go create mode 100644 internal/mcp/gen/diff.go create mode 100644 internal/service/diff_test.go create mode 100644 internal/usecase/diff.go create mode 100644 internal/usecase/diff_test.go diff --git a/cmd/gen/diff.go b/cmd/gen/diff.go new file mode 100644 index 0000000..d38b44c --- /dev/null +++ b/cmd/gen/diff.go @@ -0,0 +1,27 @@ +// Code generated by creed-codegen; DO NOT EDIT. + +package gen + +import ( + "github.com/spf13/cobra" + + opsgen "github.com/techgodhq/creed/internal/ops/gen" + "github.com/techgodhq/creed/internal/service" +) + +// DiffCommandSpec describes the generated CLI wrapper for service.Service.Diff. +type DiffCommandSpec struct { + Operation opsgen.OperationDescriptor + ParamNames []string +} + +// DiffSpec is metadata extracted from service.Service.Diff. +var DiffSpec = DiffCommandSpec{ + Operation: mustOperation("Diff"), + ParamNames: []string{"ctx", "opts"}, +} + +// NewDiffCommand returns the generated Cobra command wrapper for service.Service.Diff. +func NewDiffCommand(s service.Service) *cobra.Command { + return newGeneratedCommand(s, DiffSpec.Operation, runDiff) +} diff --git a/cmd/gen/handlers.go b/cmd/gen/handlers.go index baa2bba..2ccdedf 100644 --- a/cmd/gen/handlers.go +++ b/cmd/gen/handlers.go @@ -73,6 +73,24 @@ func runValidate(cmd *cobra.Command, s service.Service, args []string) error { return nil } +func runDiff(cmd *cobra.Command, s service.Service, args []string) error { + target, err := stringFlag(cmd, "target") + if err != nil { + return err + } + result, err := s.Diff(cmd.Context(), usecase.DiffOptions{Target: target}) + if err != nil { + return err + } + if diff := result.UnifiedDiff(); diff != "" { + fmt.Fprint(cmd.OutOrStdout(), diff) + } + if result.HasDifferences() { + return diffExitStatus{} + } + return nil +} + func runAddSkill(cmd *cobra.Command, s service.Service, args []string) error { name := positionalInput(args, 0) sourcePath := positionalInput(args, 1) diff --git a/cmd/gen/registry.go b/cmd/gen/registry.go index 61ccc0d..11fc555 100644 --- a/cmd/gen/registry.go +++ b/cmd/gen/registry.go @@ -14,6 +14,7 @@ func Commands(s service.Service) []*cobra.Command { NewInitCommand(s), NewSyncCommand(s), NewValidateCommand(s), + NewDiffCommand(s), NewAddSkillCommand(s), NewRemoveSkillCommand(s), NewListSkillsCommand(s), diff --git a/cmd/gen/runtime.go b/cmd/gen/runtime.go index 282d0d6..ec15afa 100644 --- a/cmd/gen/runtime.go +++ b/cmd/gen/runtime.go @@ -19,6 +19,13 @@ import ( type commandRunner func(*cobra.Command, service.Service, []string) error +// diffExitStatus signals the conventional successful comparison-with-differences +// exit code without printing an error diagnostic. +type diffExitStatus struct{} + +func (diffExitStatus) Error() string { return "differences found" } +func (diffExitStatus) ExitCode() int { return 1 } + func mustOperation(methodName string) opsgen.OperationDescriptor { operation, ok := opsgen.ByMethodName(methodName) if !ok { @@ -36,6 +43,10 @@ func newGeneratedCommand(s service.Service, operation opsgen.OperationDescriptor return runner(cmd, s, args) }, } + if operation.MethodName == "Diff" { + cmd.SilenceErrors = true + cmd.SilenceUsage = true + } for _, input := range operation.Inputs { if input.CLIKind != "flag" { continue diff --git a/internal/adapters/localfs/emitter.go b/internal/adapters/localfs/emitter.go index f7e3b69..6e54fb1 100644 --- a/internal/adapters/localfs/emitter.go +++ b/internal/adapters/localfs/emitter.go @@ -3,16 +3,20 @@ package localfs import ( "bytes" "context" + "encoding/json" "fmt" "os" "path/filepath" + "sort" + "strings" "github.com/techgodhq/creed/internal/domain" "github.com/techgodhq/creed/internal/ports" ) -// Compile-time assertion that Emitter implements ports.TargetEmitter. +// Compile-time assertions for Emitter capabilities. var _ ports.TargetEmitter = (*Emitter)(nil) +var _ ports.OutputInventory = (*Emitter)(nil) // Emitter writes synced files to a local filesystem directory. // It implements ports.TargetEmitter. @@ -39,10 +43,160 @@ func (e *Emitter) Emit(ctx context.Context, target domain.Target, files []ports. result := e.emitFile(f) results = append(results, result) } + for _, result := range results { + if result.Status == ports.EmitStatusError { + return results, nil + } + } + if err := e.writeOwnedPaths(target.Name, files); err != nil { + return results, fmt.Errorf("record managed output: %w", err) + } return results, nil } +// ownershipPath is deliberately kept below .creed/ so stale detection only +// considers files Creed previously emitted, never unrelated user files that +// happen to share a target output directory. +func (e *Emitter) ownershipPath(targetName string) string { + return filepath.Join(e.baseDir, ".creed", ".outputs", targetName+".json") +} + +// ownershipDirectory returns the directory used for Creed's private output +// inventory. Existing components must be directories, never symlinks, so +// ownership metadata written during sync cannot escape the project. +func (e *Emitter) ownershipDirectory(create bool) (string, error) { + current := e.baseDir + for _, part := range []string{".creed", ".outputs"} { + current = filepath.Join(current, part) + info, err := os.Lstat(current) + if os.IsNotExist(err) { + if !create { + return current, nil + } + if err := os.Mkdir(current, 0755); err != nil && !os.IsExist(err) { + return "", err + } + info, err = os.Lstat(current) + } + if err != nil { + return "", err + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return "", fmt.Errorf("ownership directory %q must be a non-symlink directory", current) + } + } + return current, nil +} + +func (e *Emitter) safeOwnershipPath(targetName string, create bool) (string, error) { + directory, err := e.ownershipDirectory(create) + if err != nil { + return "", err + } + return filepath.Join(directory, targetName+".json"), nil +} + +func (e *Emitter) writeOwnedPaths(targetName string, files []ports.EmittedFile) error { + // Retain paths from prior successful syncs. Sync intentionally does not + // delete removed render outputs, so replacing this list would make a stale + // formerly-generated file invisible to `creed diff` after the next sync. + owned, err := e.ownedPaths(domain.Target{Name: targetName}) + if err != nil { + return err + } + paths := make(map[string]struct{}, len(owned)+len(files)) + for _, path := range owned { + paths[path] = struct{}{} + } + for _, file := range files { + path, err := cleanOutputPath(file.Path) + if err != nil { + return err + } + paths[path] = struct{}{} + } + ordered := make([]string, 0, len(paths)) + for path := range paths { + ordered = append(ordered, path) + } + sort.Strings(ordered) + data, err := json.Marshal(ordered) + if err != nil { + return err + } + path, err := e.safeOwnershipPath(targetName, true) + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + +func (e *Emitter) ownedPaths(target domain.Target) ([]string, error) { + ownershipPath, err := e.safeOwnershipPath(target.Name, false) + if err != nil { + return nil, err + } + if info, err := os.Lstat(ownershipPath); err == nil && info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("ownership manifest must not be a symlink") + } else if err != nil && !os.IsNotExist(err) { + return nil, err + } + data, err := os.ReadFile(ownershipPath) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + var rawPaths []string + if err := json.Unmarshal(data, &rawPaths); err != nil { + return nil, fmt.Errorf("decode ownership manifest: %w", err) + } + paths := make([]string, 0, len(rawPaths)) + for _, path := range rawPaths { + clean, err := cleanOutputPath(path) + if err != nil { + return nil, fmt.Errorf("invalid ownership path %q: %w", path, err) + } + paths = append(paths, clean) + } + return paths, nil +} + +func cleanOutputPath(path string) (string, error) { + if path == "" || filepath.IsAbs(path) { + return "", fmt.Errorf("must be a non-empty relative path") + } + clean := filepath.Clean(filepath.FromSlash(path)) + if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("must not escape the project root") + } + return filepath.ToSlash(clean), nil +} + +func (e *Emitter) safeOutputPath(relPath string) (string, error) { + clean, err := cleanOutputPath(relPath) + if err != nil { + return "", err + } + current := e.baseDir + for _, part := range strings.Split(filepath.FromSlash(clean), string(filepath.Separator)) { + current = filepath.Join(current, part) + info, err := os.Lstat(current) + if os.IsNotExist(err) { + return current, nil + } + if err != nil { + return "", err + } + if info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("output path %q traverses a symlink", relPath) + } + } + return current, nil +} + // emitFile writes a single file atomically, returning the result. func (e *Emitter) emitFile(f ports.EmittedFile) ports.EmitResult { fullPath := filepath.Join(e.baseDir, f.Path) @@ -139,6 +293,52 @@ func (e *Emitter) Preview(_ context.Context, _ domain.Target, files []ports.Emit return results, nil } +// ExistingFiles returns current candidate outputs and files recorded by prior +// successful emits. It never recursively adopts arbitrary user files below a +// directory-style output descriptor. +func (e *Emitter) ExistingFiles(ctx context.Context, target domain.Target, candidates []ports.EmittedFile) ([]ports.ExistingFile, error) { + files := []ports.ExistingFile{} + paths := make(map[string]struct{}) + for _, relPath := range target.EmitPaths("") { + if !strings.HasSuffix(relPath, "/") { + paths[relPath] = struct{}{} + } + } + for _, candidate := range candidates { + path, err := cleanOutputPath(candidate.Path) + if err != nil { + return nil, err + } + paths[path] = struct{}{} + } + owned, err := e.ownedPaths(target) + if err != nil { + return nil, err + } + for _, path := range owned { + paths[path] = struct{}{} + } + for relPath := range paths { + if err := ctx.Err(); err != nil { + return nil, err + } + fullPath, err := e.safeOutputPath(relPath) + if err != nil { + return nil, err + } + data, err := os.ReadFile(fullPath) + if os.IsNotExist(err) { + continue + } + if err != nil { + return nil, fmt.Errorf("read %s: %w", relPath, err) + } + files = append(files, ports.ExistingFile{Path: filepath.ToSlash(relPath), Content: data}) + } + sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) + return files, nil +} + // Clean removes all files and directories that the target would emit. // It uses the target's EmitPaths to determine what to remove. func (e *Emitter) Clean(ctx context.Context, target domain.Target) error { diff --git a/internal/adapters/localfs/emitter_test.go b/internal/adapters/localfs/emitter_test.go index c915e93..24bc776 100644 --- a/internal/adapters/localfs/emitter_test.go +++ b/internal/adapters/localfs/emitter_test.go @@ -2,6 +2,7 @@ package localfs import ( "context" + "encoding/json" "os" "path/filepath" "testing" @@ -196,3 +197,58 @@ func TestCleanEmptyDir(t *testing.T) { t.Fatalf("Clean on empty dir error: %v", err) } } + +func TestExistingFilesRejectsEscapingOwnershipPath(t *testing.T) { + baseDir := t.TempDir() + emitter := NewEmitter(baseDir) + path := emitter.ownershipPath("claude") + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + data, err := json.Marshal([]string{"../../outside"}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } + if _, err := emitter.ExistingFiles(context.Background(), testTarget(), nil); err == nil { + t.Fatal("ExistingFiles accepted an escaping ownership path") + } +} + +func TestExistingFilesIncludesCandidateWithoutOwnershipManifest(t *testing.T) { + baseDir := t.TempDir() + emitter := NewEmitter(baseDir) + if err := os.MkdirAll(filepath.Join(baseDir, ".claude", "skills"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(baseDir, ".claude", "skills", "review.md"), []byte("# review\n"), 0644); err != nil { + t.Fatal(err) + } + files, err := emitter.ExistingFiles(context.Background(), testTarget(), []ports.EmittedFile{{Path: ".claude/skills/review.md"}}) + if err != nil { + t.Fatal(err) + } + if len(files) != 1 || files[0].Path != ".claude/skills/review.md" { + t.Fatalf("candidate inventory = %#v, want matching candidate file", files) + } +} + +func TestEmitRejectsSymlinkedOwnershipDirectory(t *testing.T) { + baseDir := t.TempDir() + external := t.TempDir() + if err := os.MkdirAll(filepath.Join(baseDir, ".creed"), 0755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(external, filepath.Join(baseDir, ".creed", ".outputs")); err != nil { + t.Fatal(err) + } + emitter := NewEmitter(baseDir) + if _, err := emitter.Emit(context.Background(), testTarget(), []ports.EmittedFile{{Path: "CLAUDE.md", Content: []byte("# Claude\n")}}); err == nil { + t.Fatal("Emit accepted a symlinked ownership directory") + } + if _, err := os.Stat(filepath.Join(external, "claude.json")); !os.IsNotExist(err) { + t.Fatalf("ownership metadata escaped project: %v", err) + } +} diff --git a/internal/codegen/main.go b/internal/codegen/main.go index 56ded2e..32484af 100644 --- a/internal/codegen/main.go +++ b/internal/codegen/main.go @@ -916,6 +916,11 @@ func cliHandlerFunction(method serviceMethod, inputs []methodParam) (string, err fmt.Fprintf(&b, " for _, target := range result.Targets {\n status := \"disabled\"\n if target.Enabled {\n status = \"enabled\"\n }\n fmt.Fprintf(cmd.OutOrStdout(), \"Target %%s: %%s\\n\", target.Name, status)\n }\n") fmt.Fprintf(&b, " for _, check := range result.Checks {\n if check.Kind == \"error\" {\n fmt.Fprintf(cmd.OutOrStdout(), \"ERROR %%s: %%s\\n\", check.Code, check.Message)\n }\n }\n") fmt.Fprintf(&b, " if result.HasErrors() {\n return fmt.Errorf(\"doctor found issues\")\n }\n fmt.Fprintln(cmd.OutOrStdout(), \"All checks passed\")\n return nil\n}\n\n") + case "Diff": + fmt.Fprintf(&b, " result, err := s.Diff(%s)\n", callArgs) + fmt.Fprintf(&b, " if err != nil {\n return err\n }\n") + fmt.Fprintf(&b, " if diff := result.UnifiedDiff(); diff != \"\" {\n fmt.Fprint(cmd.OutOrStdout(), diff)\n }\n") + fmt.Fprintf(&b, " if result.HasDifferences() {\n return diffExitStatus{}\n }\n return nil\n}\n\n") case "Watch": fmt.Fprintf(&b, "\treturn runWatchCommand(cmd, s, target, quiet, force, debounce)\n}\n\n") case "AddSkill", "AddConfig": @@ -1483,6 +1488,13 @@ import ( type commandRunner func(*cobra.Command, service.Service, []string) error +// diffExitStatus signals the conventional successful comparison-with-differences +// exit code without printing an error diagnostic. +type diffExitStatus struct{} + +func (diffExitStatus) Error() string { return "differences found" } +func (diffExitStatus) ExitCode() int { return 1 } + func mustOperation(methodName string) opsgen.OperationDescriptor { operation, ok := opsgen.ByMethodName(methodName) if !ok { @@ -1500,6 +1512,10 @@ func newGeneratedCommand(s service.Service, operation opsgen.OperationDescriptor return runner(cmd, s, args) }, } + if operation.MethodName == "Diff" { + cmd.SilenceErrors = true + cmd.SilenceUsage = true + } for _, input := range operation.Inputs { if input.CLIKind != "flag" { continue diff --git a/internal/httpapi/gen/handlers.go b/internal/httpapi/gen/handlers.go index da97544..662cc24 100644 --- a/internal/httpapi/gen/handlers.go +++ b/internal/httpapi/gen/handlers.go @@ -28,6 +28,7 @@ func GeneratedOperations(s service.Service) []GeneratedOperation { {Descriptor: mustOperation("Init"), Handler: InitHTTPHandler(s)}, {Descriptor: mustOperation("Sync"), Handler: SyncHTTPHandler(s)}, {Descriptor: mustOperation("Validate"), Handler: ValidateHTTPHandler(s)}, + {Descriptor: mustOperation("Diff"), Handler: DiffHTTPHandler(s)}, {Descriptor: mustOperation("AddSkill"), Handler: AddSkillHTTPHandler(s)}, {Descriptor: mustOperation("RemoveSkill"), Handler: RemoveSkillHTTPHandler(s)}, {Descriptor: mustOperation("ListSkills"), Handler: ListSkillsHTTPHandler(s)}, @@ -99,6 +100,25 @@ func ValidateHTTPHandler(s service.Service) OperationHandler { } } +type diffRequest struct { + Target string `json:"target,omitempty"` +} + +// DiffHTTPHandler returns the generated HTTP handler for service.Service.Diff. +func DiffHTTPHandler(s service.Service) OperationHandler { + return func(ctx context.Context, payload json.RawMessage) (any, error) { + var req diffRequest + if err := decodePayload(payload, &req); err != nil { + return nil, err + } + result, err := s.Diff(ctx, usecase.DiffOptions{Target: req.Target}) + if err != nil { + return nil, err + } + return result, nil + } +} + type addSkillRequest struct { Name string `json:"name"` SourcePath string `json:"source_path,omitempty"` diff --git a/internal/mcp/gen/diff.go b/internal/mcp/gen/diff.go new file mode 100644 index 0000000..d3a3504 --- /dev/null +++ b/internal/mcp/gen/diff.go @@ -0,0 +1,12 @@ +// Code generated by creed-codegen; DO NOT EDIT. + +package gen + +// DiffToolName is the generated MCP tool name for service.Service.Diff. +const DiffToolName = "diff" + +// DiffToolDescription is the generated MCP tool description for service.Service.Diff. +const DiffToolDescription = "Diff previews line-level changes between rendered target output and disk." + +// DiffToolParams are parameter names extracted from service.Service.Diff. +var DiffToolParams = []string{"ctx", "opts"} diff --git a/internal/mcp/gen/handlers.go b/internal/mcp/gen/handlers.go index 95a8046..2a33334 100644 --- a/internal/mcp/gen/handlers.go +++ b/internal/mcp/gen/handlers.go @@ -30,6 +30,7 @@ func GeneratedTools(s service.Service) []GeneratedTool { {Spec: InitToolSpec(), Tool: InitMCPTool(), Handler: InitMCPHandler(s)}, {Spec: SyncToolSpec(), Tool: SyncMCPTool(), Handler: SyncMCPHandler(s)}, {Spec: ValidateToolSpec(), Tool: ValidateMCPTool(), Handler: ValidateMCPHandler(s)}, + {Spec: DiffToolSpec(), Tool: DiffMCPTool(), Handler: DiffMCPHandler(s)}, {Spec: AddSkillToolSpec(), Tool: AddSkillMCPTool(), Handler: AddSkillMCPHandler(s)}, {Spec: RemoveSkillToolSpec(), Tool: RemoveSkillMCPTool(), Handler: RemoveSkillMCPHandler(s)}, {Spec: ListSkillsToolSpec(), Tool: ListSkillsMCPTool(), Handler: ListSkillsMCPHandler(s)}, @@ -138,6 +139,37 @@ func ValidateMCPHandler(s service.Service) ToolHandler { } } +type diffRequest struct { + Target string `json:"target,omitempty"` +} + +// DiffToolSpec returns generated MCP metadata for service.Service.Diff. +func DiffToolSpec() ToolSpec { + return ToolSpec{MethodName: "Diff", Name: DiffToolName, Description: DiffToolDescription, ParamNames: []string{"target"}} +} + +// DiffMCPTool returns the generated MCP tool definition for service.Service.Diff. +func DiffMCPTool() mcplib.Tool { + options := []mcplib.ToolOption{mcplib.WithDescription(DiffToolDescription)} + options = append(options, mcplib.WithString("target")) + return mcplib.NewTool(DiffToolName, options...) +} + +// DiffMCPHandler returns the generated MCP handler for service.Service.Diff. +func DiffMCPHandler(s service.Service) ToolHandler { + return func(ctx context.Context, payload json.RawMessage) (any, error) { + var req diffRequest + if err := decodePayload(payload, &req); err != nil { + return nil, err + } + result, err := s.Diff(ctx, usecase.DiffOptions{Target: req.Target}) + if err != nil { + return nil, err + } + return result, nil + } +} + type addSkillRequest struct { Name string `json:"name"` SourcePath string `json:"source_path,omitempty"` diff --git a/internal/mcp/gen/tool_specs.go b/internal/mcp/gen/tool_specs.go index fde4964..ef2a406 100644 --- a/internal/mcp/gen/tool_specs.go +++ b/internal/mcp/gen/tool_specs.go @@ -15,6 +15,7 @@ var ToolSpecs = []ToolSpec{ {MethodName: "Init", Name: InitToolName, Description: InitToolDescription, ParamNames: InitToolParams}, {MethodName: "Sync", Name: SyncToolName, Description: SyncToolDescription, ParamNames: SyncToolParams}, {MethodName: "Validate", Name: ValidateToolName, Description: ValidateToolDescription, ParamNames: ValidateToolParams}, + {MethodName: "Diff", Name: DiffToolName, Description: DiffToolDescription, ParamNames: DiffToolParams}, {MethodName: "AddSkill", Name: AddSkillToolName, Description: AddSkillToolDescription, ParamNames: AddSkillToolParams}, {MethodName: "RemoveSkill", Name: RemoveSkillToolName, Description: RemoveSkillToolDescription, ParamNames: RemoveSkillToolParams}, {MethodName: "ListSkills", Name: ListSkillsToolName, Description: ListSkillsToolDescription, ParamNames: ListSkillsToolParams}, diff --git a/internal/ops/gen/operations.go b/internal/ops/gen/operations.go index 8080d1d..aa87995 100644 --- a/internal/ops/gen/operations.go +++ b/internal/ops/gen/operations.go @@ -64,6 +64,16 @@ var Operations = []OperationDescriptor{ Inputs: []InputDescriptor{}, Outputs: []OutputDescriptor{{Name: "result1", Type: "ValidationResult"}, {Name: "result2", Type: "error"}}, }, + { + MethodName: "Diff", + OperationName: "diff", + Description: "Diff previews line-level changes between rendered target output and disk.", + CLIName: "diff", + MCPName: "diff", + HTTPRoute: "/v1/operations/diff", + Inputs: []InputDescriptor{{Name: "target", ExternalName: "target", Type: "string", Kind: "primitive", Required: false, CLIKind: "flag", Help: ""}}, + Outputs: []OutputDescriptor{{Name: "result1", Type: "*usecase.DiffResult"}, {Name: "result2", Type: "error"}}, + }, { MethodName: "AddSkill", OperationName: "add_skill", diff --git a/internal/ports/emitter.go b/internal/ports/emitter.go index 31e2aa3..b0ae45c 100644 --- a/internal/ports/emitter.go +++ b/internal/ports/emitter.go @@ -45,3 +45,16 @@ type TargetEmitter interface { // from the output location. Clean(ctx context.Context, target domain.Target) error } + +// ExistingFile is a generated output currently owned by a target. +type ExistingFile struct { + Path string + Content []byte +} + +// OutputInventory is an optional read-only capability used to read candidate +// outputs and identify stale generated output. Candidates must be included even +// if no prior ownership record exists, so pre-existing matching output is clean. +type OutputInventory interface { + ExistingFiles(ctx context.Context, target domain.Target, candidates []EmittedFile) ([]ExistingFile, error) +} diff --git a/internal/service/diff_test.go b/internal/service/diff_test.go new file mode 100644 index 0000000..918e9f4 --- /dev/null +++ b/internal/service/diff_test.go @@ -0,0 +1,83 @@ +package service + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/techgodhq/creed/internal/usecase" +) + +func TestDiffReportsCleanChangedNewAndDeletedOutput(t *testing.T) { + root := t.TempDir() + svc := New(root) + ctx := context.Background() + if err := svc.Init(ctx, "demo"); err != nil { + t.Fatal(err) + } + writeProjectConfig(t, root) + if _, err := svc.Sync(ctx, usecase.SyncOptions{Target: "codex"}); err != nil { + t.Fatal(err) + } + + clean, err := svc.Diff(ctx, usecase.DiffOptions{Target: "codex"}) + if err != nil { + t.Fatal(err) + } + if clean.HasDifferences() || clean.UnifiedDiff() != "" { + t.Fatalf("clean diff = %#v, want no differences", clean) + } + + agents := filepath.Join(root, "AGENTS.md") + if err := os.WriteFile(agents, []byte("changed\n"), 0644); err != nil { + t.Fatal(err) + } + changed, err := svc.Diff(ctx, usecase.DiffOptions{Target: "codex"}) + if err != nil { + t.Fatal(err) + } + if !changed.HasDifferences() || !strings.Contains(changed.UnifiedDiff(), "--- a/AGENTS.md") || !strings.Contains(changed.UnifiedDiff(), "+# Project") { + t.Fatalf("changed diff = %q, want unified AGENTS.md diff", changed.UnifiedDiff()) + } + if !strings.Contains(changed.UnifiedDiff(), "@@ -1,1 +1,") { + t.Fatalf("changed diff uses invalid replacement range: %q", changed.UnifiedDiff()) + } + + if err := os.Remove(agents); err != nil { + t.Fatal(err) + } + created, err := svc.Diff(ctx, usecase.DiffOptions{Target: "codex"}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(created.UnifiedDiff(), "--- /dev/null\n+++ b/AGENTS.md") { + t.Fatalf("created diff = %q", created.UnifiedDiff()) + } + + if _, err := svc.Sync(ctx, usecase.SyncOptions{Target: "cursor"}); err != nil { + t.Fatal(err) + } + if err := svc.RemoveSkill(ctx, "review"); err != nil { + t.Fatal(err) + } + // A normal sync does not delete obsolete outputs; diff must continue to + // recognize the formerly generated path after refreshing the inventory. + if _, err := svc.Sync(ctx, usecase.SyncOptions{Target: "cursor"}); err != nil { + t.Fatal(err) + } + deleted, err := svc.Diff(ctx, usecase.DiffOptions{Target: "cursor"}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(deleted.UnifiedDiff(), "--- a/.cursor/rules/review.md\n+++ /dev/null") { + t.Fatalf("deleted diff = %q", deleted.UnifiedDiff()) + } + if err := os.WriteFile(filepath.Join(root, ".cursor", "rules", "user.md"), []byte("keep\n"), 0644); err != nil { + t.Fatal(err) + } + if strings.Contains(deleted.UnifiedDiff(), "user.md") { + t.Fatalf("diff adopted unrelated user output: %q", deleted.UnifiedDiff()) + } +} diff --git a/internal/service/impl.go b/internal/service/impl.go index d99adcc..5e7c907 100644 --- a/internal/service/impl.go +++ b/internal/service/impl.go @@ -123,6 +123,12 @@ func (s *Implementation) Sync(ctx context.Context, opts usecase.SyncOptions) (*u return engine.Sync(ctx, opts) } +// Diff compares rendered local Creed context with its target outputs. +func (s *Implementation) Diff(ctx context.Context, opts usecase.DiffOptions) (*usecase.DiffResult, error) { + engine := usecase.NewSyncEngine(localfs.NewSource(s.root), localfs.NewEmitter(s.root)) + return engine.Diff(ctx, opts) +} + // AddSkill registers a skill path in the manifest. func (s *Implementation) AddSkill(ctx context.Context, name, sourcePath string) error { if err := ctx.Err(); err != nil { diff --git a/internal/service/service.go b/internal/service/service.go index d61d49b..2fd4187 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -23,6 +23,8 @@ type Service interface { // writing outputs. Validation errors are returned in the result so generated // CLI, MCP, and HTTP callers receive the same structured diagnostics. Validate(ctx context.Context) (ValidationResult, error) + // Diff previews line-level changes between rendered target output and disk. + Diff(ctx context.Context, opts usecase.DiffOptions) (*usecase.DiffResult, error) // AddSkill registers a skill file in the manifest. AddSkill(ctx context.Context, name, sourcePath string) error // RemoveSkill removes a skill registration from the manifest. diff --git a/internal/usecase/diff.go b/internal/usecase/diff.go new file mode 100644 index 0000000..6ee39b5 --- /dev/null +++ b/internal/usecase/diff.go @@ -0,0 +1,229 @@ +package usecase + +import ( + "bytes" + "context" + "fmt" + "sort" + "strings" + + "github.com/techgodhq/creed/internal/domain" + "github.com/techgodhq/creed/internal/ports" +) + +// DiffResult contains stable unified diffs for selected target outputs. +type DiffResult struct { + Targets []TargetDiff +} + +// TargetDiff is the diff for one target. +type TargetDiff struct { + Target string + Files []FileDiff +} + +// FileDiff is a unified diff for one output path. +type FileDiff struct { + Path string + Diff string +} + +// HasDifferences reports whether any selected target output differs. +func (r *DiffResult) HasDifferences() bool { + for _, target := range r.Targets { + if len(target.Files) > 0 { + return true + } + } + return false +} + +// UnifiedDiff joins per-file diffs in stable target and path order. +func (r *DiffResult) UnifiedDiff() string { + var b strings.Builder + for _, target := range r.Targets { + for _, file := range target.Files { + b.WriteString(file.Diff) + } + } + return b.String() +} + +// Diff renders the same candidate files as Sync and compares them with files +// currently owned by each target, including stale files no longer rendered. +func (e *SyncEngine) Diff(ctx context.Context, opts DiffOptions) (*DiffResult, error) { + inventory, ok := e.emitter.(ports.OutputInventory) + if !ok { + return nil, fmt.Errorf("emitter does not support output inventory") + } + manifest, err := e.source.ReadManifest(ctx) + if err != nil { + return nil, fmt.Errorf("read manifest: %w", err) + } + targets, err := resolveTargets(manifest, SyncOptions{Target: opts.Target}) + if err != nil { + return nil, err + } + skills, err := readAllSkills(ctx, e.source, manifest) + if err != nil { + return nil, fmt.Errorf("read skills: %w", err) + } + configs, err := readAllConfigs(ctx, e.source, manifest) + if err != nil { + return nil, fmt.Errorf("read configs: %w", err) + } + result := &DiffResult{Targets: make([]TargetDiff, 0, len(targets))} + for _, config := range targets { + target, err := domain.LookupTarget(config.Name) + if err != nil { + return nil, err + } + target = targetWithOutputDir(target, config.OutputDir) + desired, err := prepareFiles(target, skills, configs) + if err != nil { + return nil, fmt.Errorf("render target %q: %w", config.Name, err) + } + existing, err := inventory.ExistingFiles(ctx, *target, desired) + if err != nil { + return nil, fmt.Errorf("inventory target %q: %w", config.Name, err) + } + want, have := map[string][]byte{}, map[string][]byte{} + for _, file := range desired { + want[file.Path] = file.Content + } + for _, file := range existing { + have[file.Path] = file.Content + } + paths := map[string]bool{} + for path := range want { + paths[path] = true + } + for path := range have { + paths[path] = true + } + ordered := make([]string, 0, len(paths)) + for path := range paths { + ordered = append(ordered, path) + } + sort.Strings(ordered) + targetDiff := TargetDiff{Target: config.Name} + for _, path := range ordered { + old, oldOK := have[path] + new, newOK := want[path] + if oldOK && newOK && bytes.Equal(old, new) { + continue + } + targetDiff.Files = append(targetDiff.Files, FileDiff{ + Path: path, + Diff: unifiedFileDiff(path, old, new, oldOK, newOK), + }) + } + result.Targets = append(result.Targets, targetDiff) + } + return result, nil +} + +func unifiedFileDiff(path string, old, new []byte, hasOld, hasNew bool) string { + oldLabel, newLabel := "a/"+path, "b/"+path + if !hasOld { + oldLabel = "/dev/null" + } + if !hasNew { + newLabel = "/dev/null" + } + oldLines, newLines := splitLines(old), splitLines(new) + if len(oldLines) == 0 && len(newLines) == 0 && hasOld != hasNew { + return emptyFileDiff(path, hasOld) + } + operations := lineOperations(oldLines, newLines) + oldStart, newStart := 0, 0 + if len(oldLines) > 0 { + oldStart = 1 + } + if len(newLines) > 0 { + newStart = 1 + } + var b strings.Builder + // Emit one complete-file hunk. It is deliberately context-rich rather than + // attempting to coalesce sparse LCS edits: a unified hunk must include every + // unchanged line between edits in its declared range to remain applicable. + fmt.Fprintf(&b, "--- %s\n+++ %s\n@@ -%d,%d +%d,%d @@\n", oldLabel, newLabel, oldStart, len(oldLines), newStart, len(newLines)) + for _, operation := range operations { + b.WriteByte(operation.kind) + b.WriteString(operation.line.text) + b.WriteByte('\n') + if !operation.line.hasNewline { + b.WriteString("\\ No newline at end of file\n") + } + } + return b.String() +} + +func emptyFileDiff(path string, deleted bool) string { + if deleted { + return fmt.Sprintf("diff --git a/%[1]s b/%[1]s\ndeleted file mode 100644\nindex e69de29..0000000\n--- a/%[1]s\n+++ /dev/null\n", path) + } + return fmt.Sprintf("diff --git a/%[1]s b/%[1]s\nnew file mode 100644\nindex 0000000..e69de29\n--- /dev/null\n+++ b/%[1]s\n", path) +} + +type diffLine struct { + text string + hasNewline bool +} + +type diffOperation struct { + kind byte + line diffLine + oldLine, newLine int +} + +// lineOperations computes a smallest insert/delete edit script using LCS. +func lineOperations(oldLines, newLines []diffLine) []diffOperation { + dp := make([][]int, len(oldLines)+1) + for i := range dp { + dp[i] = make([]int, len(newLines)+1) + } + for i := len(oldLines) - 1; i >= 0; i-- { + for j := len(newLines) - 1; j >= 0; j-- { + if oldLines[i] == newLines[j] { + dp[i][j] = dp[i+1][j+1] + 1 + } else if dp[i+1][j] >= dp[i][j+1] { + dp[i][j] = dp[i+1][j] + } else { + dp[i][j] = dp[i][j+1] + } + } + } + operations := []diffOperation{} + for i, j := 0, 0; i < len(oldLines) || j < len(newLines); { + switch { + case i < len(oldLines) && j < len(newLines) && oldLines[i] == newLines[j]: + operations = append(operations, diffOperation{kind: ' ', line: oldLines[i], oldLine: i + 1, newLine: j + 1}) + i, j = i+1, j+1 + case j < len(newLines) && (i == len(oldLines) || dp[i][j+1] > dp[i+1][j]): + operations = append(operations, diffOperation{kind: '+', line: newLines[j], oldLine: i, newLine: j + 1}) + j++ + default: + operations = append(operations, diffOperation{kind: '-', line: oldLines[i], oldLine: i + 1, newLine: j}) + i++ + } + } + return operations +} + +func splitLines(content []byte) []diffLine { + if len(content) == 0 { + return nil + } + text := string(content) + parts := strings.Split(text, "\n") + endsWithNewline := strings.HasSuffix(text, "\n") + if endsWithNewline { + parts = parts[:len(parts)-1] + } + lines := make([]diffLine, 0, len(parts)) + for i, part := range parts { + lines = append(lines, diffLine{text: part, hasNewline: i < len(parts)-1 || endsWithNewline}) + } + return lines +} diff --git a/internal/usecase/diff_test.go b/internal/usecase/diff_test.go new file mode 100644 index 0000000..252c6b0 --- /dev/null +++ b/internal/usecase/diff_test.go @@ -0,0 +1,51 @@ +package usecase + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestUnifiedFileDiffIsApplicableForSeparatedEditsAndEOFNewline(t *testing.T) { + separated := unifiedFileDiff("example.txt", []byte("one\ntwo\nthree\n"), []byte("ONE\ntwo\nTHREE\n"), true, true) + if !strings.Contains(separated, " two\n") || !strings.Contains(separated, "@@ -1,3 +1,3 @@") { + t.Fatalf("separated diff lacks required context or ranges: %q", separated) + } + noNewline := unifiedFileDiff("example.txt", []byte("one\n"), []byte("one"), true, true) + if !strings.Contains(noNewline, "\\ No newline at end of file\n") || !strings.Contains(noNewline, "-one\n") || !strings.Contains(noNewline, "+one\n") { + t.Fatalf("EOF newline change is not represented: %q", noNewline) + } +} + +func TestEmptyFileDiffIsApplicable(t *testing.T) { + for _, test := range []struct { + name string + content string + setup func(t *testing.T, root string) + }{ + {name: "create", content: emptyFileDiff("empty.txt", false)}, + {name: "delete", content: emptyFileDiff("empty.txt", true), setup: func(t *testing.T, root string) { + if err := os.WriteFile(filepath.Join(root, "empty.txt"), nil, 0644); err != nil { + t.Fatal(err) + } + }}, + } { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + if test.setup != nil { + test.setup(t, root) + } + patch := filepath.Join(root, "change.patch") + if err := os.WriteFile(patch, []byte(test.content), 0644); err != nil { + t.Fatal(err) + } + cmd := exec.Command("patch", "--dry-run", "-p1", "-i", patch) + cmd.Dir = root + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("patch rejected empty-file diff: %v\n%s", err, output) + } + }) + } +} diff --git a/internal/usecase/options.go b/internal/usecase/options.go index 6d42c63..8ac27fc 100644 --- a/internal/usecase/options.go +++ b/internal/usecase/options.go @@ -21,3 +21,10 @@ type SyncOptions struct { // before emitting so all files are rewritten fresh. Force bool `json:"force,omitempty"` } + +// DiffOptions controls the behavior of a diff operation. +type DiffOptions struct { + // Target filters the diff to one manifest target. When empty, all enabled + // targets are compared; a named disabled target is still permitted. + Target string `json:"target,omitempty"` +} diff --git a/main.go b/main.go index 40d6639..6931fe0 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( + "errors" "fmt" "os" @@ -11,7 +12,11 @@ import ( func main() { if err := cmd.Execute(); err != nil { + var exitCoder interface{ ExitCode() int } + if errors.As(err, &exitCoder) { + os.Exit(exitCoder.ExitCode()) + } fmt.Fprintf(os.Stderr, "creed: %v\n", err) - os.Exit(1) + os.Exit(2) } }