diff --git a/cmd/plugin/main.go b/cmd/plugin/main.go index 69225ef..4307365 100644 --- a/cmd/plugin/main.go +++ b/cmd/plugin/main.go @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2026 The semrel Authors +// This code has been partially generated by an AI. package main @@ -35,8 +36,14 @@ func run(stdout, stderr io.Writer, getenv func(string) string) int { file = "pyproject.toml" } - if getenv("SEMREL_DRY_RUN") == "true" { - _, _ = fmt.Fprintf(stdout, "updater-python: [dry-run] would update %s to version %s\n", file, version) + if strings.EqualFold(getenv("SEMREL_DRY_RUN"), "true") || strings.EqualFold(getenv("SEMREL_PLUGIN_DRY_RUN"), "true") { + diff, err := plugin.NewUpdater().Diff(file, version) + if err != nil { + _, _ = fmt.Fprintln(stderr, "updater-python:", err) + return 1 + } + _, _ = fmt.Fprintf(stderr, "[dry-run] updater-python: would update %s to version %s\n", file, version) + _, _ = io.WriteString(stdout, diff) return 0 } diff --git a/cmd/plugin/main_test.go b/cmd/plugin/main_test.go index d469381..29f252f 100644 --- a/cmd/plugin/main_test.go +++ b/cmd/plugin/main_test.go @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The semrel Authors +// This code has been partially generated by an AI. + package main import ( @@ -44,14 +48,36 @@ func TestRunUpdatesPyproject(t *testing.T) { func TestRunDryRun(t *testing.T) { t.Parallel() - env := map[string]string{"SEMREL_VERSION": "1.1.0", "SEMREL_DRY_RUN": "true"} + file := filepath.Join(t.TempDir(), "pyproject.toml") + original := "[project]\nname = \"demo\"\nversion = \"1.0.0\"\n" + if err := os.WriteFile(file, []byte(original), 0o644); err != nil { + t.Fatal(err) + } + + env := map[string]string{"SEMREL_VERSION": "v1.1.0", "SEMREL_PLUGIN_FILE": file, "SEMREL_DRY_RUN": "true"} var stdout, stderr bytes.Buffer if code := run(&stdout, &stderr, func(key string) string { return env[key] }); code != 0 { - t.Fatalf("run() code = %d", code) + t.Fatalf("run() code = %d stderr = %s", code, stderr.String()) + } + if !strings.HasPrefix(stderr.String(), "plugin_schema_version=1\n") { + t.Fatalf("stderr = %q", stderr.String()) } - if !strings.Contains(stdout.String(), "[dry-run]") { + if !strings.Contains(stderr.String(), "[dry-run] updater-python: would update "+file+" to version 1.1.0") { + t.Fatalf("stderr = %q", stderr.String()) + } + if !strings.Contains(stdout.String(), "--- a/"+file+"\n+++ b/"+file+"\n") { + t.Fatalf("stdout = %q", stdout.String()) + } + if !strings.Contains(stdout.String(), "-version = \"1.0.0\"\n+version = \"1.1.0\"\n") { t.Fatalf("stdout = %q", stdout.String()) } + got, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + if string(got) != original { + t.Fatalf("dry-run modified file: %s", got) + } } func TestRunRequiresVersion(t *testing.T) { diff --git a/internal/plugin/diff.go b/internal/plugin/diff.go new file mode 100644 index 0000000..672bb4b --- /dev/null +++ b/internal/plugin/diff.go @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The semrel Authors +// This code has been fully generated by an AI. + +package plugin + +import ( + "encoding/json" + "fmt" + "strings" +) + +const diffContextLines = 3 + +// DryRunSummaryPrefix is the optional stderr line prefix plugins can emit to +// provide a structured machine-readable summary for `semrel release --dry-run --output json`. +const DryRunSummaryPrefix = "plugin_dry_run_summary=" + +// UnifiedDiff computes a unified diff between before and after for the given +// file path entirely in memory. When before and after are identical, it returns +// an empty string. +func UnifiedDiff(path, before, after string) string { + if before == after { + return "" + } + cleanPath := strings.TrimPrefix(strings.ReplaceAll(path, "\\", "/"), "./") + if cleanPath == "" { + cleanPath = "file" + } + + aLines := splitLines(before) + bLines := splitLines(after) + ops := diffLines(aLines, bLines) + hunks := buildHunks(ops, diffContextLines) + if len(hunks) == 0 { + return "" + } + + var b strings.Builder + fmt.Fprintf(&b, "--- a/%s\n+++ b/%s\n", cleanPath, cleanPath) + for _, h := range hunks { + fmt.Fprintf(&b, "@@ -%d,%d +%d,%d @@\n", h.aStart, h.aCount, h.bStart, h.bCount) + for _, line := range h.lines { + b.WriteString(line) + b.WriteByte('\n') + } + } + return b.String() +} + +// UnifiedDiffBytes computes a unified diff between two byte slices in memory. +func UnifiedDiffBytes(path string, before, after []byte) string { + return UnifiedDiff(path, string(before), string(after)) +} + +// MaskSecret masks a secret string or webhook token for safe dry-run output. +func MaskSecret(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + if len(trimmed) <= 8 { + return "***" + } + return trimmed[:4] + "***" +} + +// FormatDryRunSummary encodes a structured dry-run summary line suitable for +// writing to stderr. +func FormatDryRunSummary(summary map[string]interface{}) string { + if len(summary) == 0 { + return "" + } + data, err := json.Marshal(summary) + if err != nil { + return "" + } + return DryRunSummaryPrefix + string(data) +} + +type editOp struct { + kind byte // ' ', '-', '+' + text string + aIdx int + bIdx int +} + +type diffHunk struct { + aStart int + aCount int + bStart int + bCount int + lines []string +} + +func splitLines(s string) []string { + if s == "" { + return nil + } + normalized := strings.ReplaceAll(s, "\r\n", "\n") + normalized = strings.TrimSuffix(normalized, "\n") + if normalized == "" { + return []string{""} + } + return strings.Split(normalized, "\n") +} + +func diffLines(a, b []string) []editOp { + n, m := len(a), len(b) + dp := make([][]int, n+1) + for i := range dp { + dp[i] = make([]int, m+1) + } + for i := n - 1; i >= 0; i-- { + for j := m - 1; j >= 0; j-- { + if a[i] == b[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] + } + } + } + + ops := make([]editOp, 0, n+m) + i, j := 0, 0 + for i < n && j < m { + if a[i] == b[j] { + ops = append(ops, editOp{kind: ' ', text: a[i], aIdx: i + 1, bIdx: j + 1}) + i++ + j++ + } else if dp[i+1][j] >= dp[i][j+1] { + ops = append(ops, editOp{kind: '-', text: a[i], aIdx: i + 1, bIdx: j + 1}) + i++ + } else { + ops = append(ops, editOp{kind: '+', text: b[j], aIdx: i + 1, bIdx: j + 1}) + j++ + } + } + for i < n { + ops = append(ops, editOp{kind: '-', text: a[i], aIdx: i + 1, bIdx: j + 1}) + i++ + } + for j < m { + ops = append(ops, editOp{kind: '+', text: b[j], aIdx: i + 1, bIdx: j + 1}) + j++ + } + return ops +} + +func buildHunks(ops []editOp, contextLines int) []diffHunk { + type span struct{ start, end int } + var changes []span + for idx, op := range ops { + if op.kind == ' ' { + continue + } + s := idx - contextLines + if s < 0 { + s = 0 + } + e := idx + contextLines + 1 + if e > len(ops) { + e = len(ops) + } + if len(changes) > 0 && s <= changes[len(changes)-1].end { + changes[len(changes)-1].end = e + } else { + changes = append(changes, span{start: s, end: e}) + } + } + + hunks := make([]diffHunk, 0, len(changes)) + for _, ch := range changes { + slice := ops[ch.start:ch.end] + h := diffHunk{ + aStart: 1, + bStart: 1, + lines: make([]string, 0, len(slice)), + } + if len(slice) > 0 { + h.aStart = slice[0].aIdx + h.bStart = slice[0].bIdx + } + for _, op := range slice { + switch op.kind { + case ' ': + h.aCount++ + h.bCount++ + case '-': + h.aCount++ + case '+': + h.bCount++ + } + h.lines = append(h.lines, string(op.kind)+op.text) + } + if h.aCount == 0 { + h.aStart = 0 + } + if h.bCount == 0 { + h.bStart = 0 + } + hunks = append(hunks, h) + } + return hunks +} diff --git a/internal/plugin/updater.go b/internal/plugin/updater.go index 65d2661..cf9dd85 100644 --- a/internal/plugin/updater.go +++ b/internal/plugin/updater.go @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2026 The semrel Authors +// This code has been partially generated by an AI. // Package plugin updates pyproject.toml files in-place. package plugin @@ -22,14 +23,18 @@ func NewUpdater() *Updater { return &Updater{} } -// Update rewrites the version line in [project] or [tool.poetry]. -func (u *Updater) Update(path, version string) error { - data, err := os.ReadFile(path) +// Diff computes a unified diff for updating the version line in [project] or [tool.poetry] without writing to disk. +func (u *Updater) Diff(path, version string) (string, error) { + before, after, err := u.prepare(path, version) if err != nil { - return fmt.Errorf("read %s: %w", path, err) + return "", err } + return UnifiedDiff(path, before, after), nil +} - updated, err := updateContent(string(data), version) +// Update rewrites the version line in [project] or [tool.poetry]. +func (u *Updater) Update(path, version string) error { + _, updated, err := u.prepare(path, version) if err != nil { return err } @@ -40,6 +45,20 @@ func (u *Updater) Update(path, version string) error { return nil } +func (u *Updater) prepare(path, version string) (string, string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", "", fmt.Errorf("read %s: %w", path, err) + } + + before := string(data) + updated, err := updateContent(before, version) + if err != nil { + return "", "", err + } + return before, updated, nil +} + func updateContent(content, version string) (string, error) { scanner := bufio.NewScanner(strings.NewReader(content)) lines := make([]string, 0) diff --git a/internal/plugin/updater_test.go b/internal/plugin/updater_test.go index 4382a11..049fd1c 100644 --- a/internal/plugin/updater_test.go +++ b/internal/plugin/updater_test.go @@ -1,3 +1,7 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The semrel Authors +// This code has been partially generated by an AI. + package plugin import ( @@ -39,6 +43,34 @@ func TestUpdaterUpdateProjectVersion(t *testing.T) { } } +func TestUpdaterDiff(t *testing.T) { + t.Parallel() + + file := filepath.Join(t.TempDir(), "pyproject.toml") + original := "[project]\nname = \"demo\"\nversion = \"1.2.3\"\n" + if err := os.WriteFile(file, []byte(original), 0o644); err != nil { + t.Fatal(err) + } + + diff, err := NewUpdater().Diff(file, "1.3.0") + if err != nil { + t.Fatalf("Diff() error = %v", err) + } + if !strings.Contains(diff, "--- a/"+file+"\n+++ b/"+file+"\n") { + t.Fatalf("diff = %s", diff) + } + if !strings.Contains(diff, "-version = \"1.2.3\"\n+version = \"1.3.0\"\n") { + t.Fatalf("diff = %s", diff) + } + got, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + if string(got) != original { + t.Fatalf("Diff() modified file: %s", got) + } +} + func TestUpdaterUpdatePoetryVersion(t *testing.T) { t.Parallel() diff --git a/schema/v1.json b/schema/v1.json index 713abc4..28b6c34 100644 --- a/schema/v1.json +++ b/schema/v1.json @@ -3,6 +3,7 @@ "$id": "https://registry.semrel.io/schemas/plugins/python/v1.json", "title": "updater-python plugin configuration", "description": "Schema v1 for the updater-python semrel plugin. Configure via SEMREL_* environment variables.", + "x-semrel-dry-run": true, "type": "object", "properties": { "SEMREL_PLUGIN_FILE": {