Skip to content

Commit 0ebd59d

Browse files
committed
Implements a clean and correct implementation for #85
full commit comparison is now available. Uses the rolodex and a custom revision file system to handle this correctly.
1 parent f202e37 commit 0ebd59d

20 files changed

Lines changed: 1710 additions & 144 deletions

AGENTS.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Public documentation:
1212

1313
`openapi-changes` is a Go CLI for comparing OpenAPI specifications across:
1414

15-
- direct left/right file or URL comparison
15+
- direct left/right file, URL, or git-revision comparison
1616
- local git history for a file in a repository
1717
- GitHub-hosted file history via file URL
1818

@@ -65,6 +65,7 @@ All `cmd/` implementation files use their canonical names (e.g., `cmd/summary.go
6565

6666
- Left/right comparisons are synthetic comparisons, not fake git history.
6767
- Do not emit synthetic commit metadata in left/right machine- or human-facing report output.
68+
- Git revision inputs (`revision:path`) resolve `$ref` siblings from the same revision via `GitRevisionFS`, not from the working tree.
6869

6970
### Failure semantics
7071

@@ -99,7 +100,9 @@ All `cmd/` implementation files use their canonical names (e.g., `cmd/summary.go
99100
- `cmd/console.go` — launches the interactive Bubbletea terminal UI
100101
- `cmd/flatten_report.go` — flattens hierarchical change reports into flat structures with hashed changes and normalized paths
101102
- `cmd/report_common.go` — shared utilities: summary report creation from commits, formatted report file writing
103+
- `cmd/left_right_sources.go` — resolves local, URL, and git-revision inputs into uniform comparison sources with proper document configuration
102104
- `git/read_local.go` — local git history extraction via git commands; commit and file content preparation
105+
- `git/revision_fs.go` — virtual filesystem that reads files from a git revision for `$ref` resolution in revision-scoped comparisons
103106
- `git/github.go` — remote file history fetching from GitHub repos via doctor GitHub service
104107
- `html-report/generator.go` — renders self-contained HTML using embedded templates and syntax-highlighted code
105108
- `model/report.go` — hashed change structures with SHA256 hashes and raw paths for serialization

README.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99

1010
## The world's **_most powerful and complete_** OpenAPI diff tool.
1111

12-
`openapi-changes` lets you inspect what changed in an OpenAPI specification between two files,
13-
across local git history, or directly from a GitHub-hosted file URL.
12+
`openapi-changes` lets you inspect what changed in an OpenAPI specification between two files,
13+
between git revisions of the same file, across local git history, or directly from a GitHub-hosted file URL.
1414

1515
It can render the same semantic change model as:
1616

@@ -128,6 +128,21 @@ A self-contained, offline HTML report with interactive timeline, change explorer
128128

129129
---
130130

131+
## Comparing git revisions
132+
133+
Compare a file at different git revisions without checking out branches:
134+
135+
```bash
136+
openapi-changes summary HEAD~1:openapi.yaml ./openapi.yaml
137+
openapi-changes html-report main:api/openapi.yaml feature-branch:api/openapi.yaml
138+
```
139+
140+
The `revision:path` syntax works with any git ref -- branches, tags, `HEAD~N`, commit SHAs.
141+
The path is relative to the repository root. This works with all commands and supports
142+
multi-file specs with `$ref` references resolved from the same revision.
143+
144+
---
145+
131146
## Documentation
132147

133148
### [Quick Start Guide 🚀](https://pb33f.io/openapi-changes/quickstart/)

cmd/common.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"image/color"
99
"net/url"
1010
"os"
11-
"strings"
1211

1312
"charm.land/lipgloss/v2"
1413
"github.com/pb33f/doctor/terminal"
@@ -209,9 +208,14 @@ func loadCommitsFromArgs(args []string, opts summaryOpts, breakingConfig *whatCh
209208
if len(args) == 1 {
210209
return loadGitHubCommits(args[0], opts, breakingConfig)
211210
}
212-
firstURL, _ := url.Parse(args[0])
213-
if firstURL != nil && strings.HasPrefix(firstURL.Scheme, "http") {
214-
return loadLeftRightCommits(args[0], args[1], opts, breakingConfig)
211+
if isHTTPURL(args[0]) {
212+
return loadLeftRightCommits(args[0], args[1], opts)
213+
}
214+
if _, _, ok := parseGitRef(args[0]); ok {
215+
return loadLeftRightCommits(args[0], args[1], opts)
216+
}
217+
if _, _, ok := parseGitRef(args[1]); ok {
218+
return loadLeftRightCommits(args[0], args[1], opts)
215219
}
216220
f, statErr := os.Stat(args[0])
217221
if statErr != nil {
@@ -220,7 +224,7 @@ func loadCommitsFromArgs(args []string, opts summaryOpts, breakingConfig *whatCh
220224
if f.IsDir() {
221225
return loadGitHistoryCommits(args[0], args[1], opts, breakingConfig)
222226
}
223-
return loadLeftRightCommits(args[0], args[1], opts, breakingConfig)
227+
return loadLeftRightCommits(args[0], args[1], opts)
224228
}
225229

226230
// printCommandUsage prints lipgloss-styled usage for any doctor-based command.
@@ -239,6 +243,7 @@ func printCommandUsage(commandName, description string, palette terminal.Palette
239243
fmt.Println()
240244
fmt.Println("Examples:")
241245
fmt.Printf(" %s\n", cmdStyle.Render("openapi-changes "+commandName+" ./specs/old.yaml ./specs/new.yaml"))
246+
fmt.Printf(" %s\n", cmdStyle.Render("openapi-changes "+commandName+" HEAD~1:openapi.yaml ./openapi.yaml"))
242247
fmt.Printf(" %s\n", cmdStyle.Render("openapi-changes "+commandName+" https://github.com/user/repo/blob/main/openapi.yaml"))
243248
fmt.Printf(" %s\n", cmdStyle.Render("openapi-changes "+commandName+" /path/to/git/repo path/to/openapi.yaml"))
244249
fmt.Println()

cmd/console.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ func GetConsoleCommand() *cobra.Command {
6060
Use: "console",
6161
Short: "Interactive terminal UI for exploring changes",
6262
Long: "Navigate through changes visually in an interactive terminal UI built with Bubbletea, using the doctor changerator engine.",
63-
Example: "openapi-changes console /path/to/git/repo path/to/file/in/repo/openapi.yaml",
63+
Example: "openapi-changes console HEAD~1:openapi.yaml ./openapi.yaml",
6464
RunE: func(cmd *cobra.Command, args []string) error {
6565
input, err := prepareCommandRun(cmd, args, printConsoleUsage)
6666
if err != nil {

cmd/html_report.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
"errors"
1010
"fmt"
1111
"os"
12-
"path/filepath"
1312
"strconv"
1413
"text/template"
1514
"time"
@@ -94,10 +93,11 @@ func generateHTMLReport(commits []*model.Commit, breakingConfig *whatChangedMode
9493
History: history,
9594
}
9695

97-
// For left/right comparisons, include sanitized spec paths (basename only)
96+
// For left/right comparisons, preserve explicit git-ref and URL labels while
97+
// keeping plain local files compact.
9898
if len(args) == 2 && len(items) == 1 {
99-
payload.OriginalPath = filepath.Base(args[0])
100-
payload.ModifiedPath = filepath.Base(args[1])
99+
payload.OriginalPath = displayLabelForHTML(args[0])
100+
payload.ModifiedPath = displayLabelForHTML(args[1])
101101
}
102102

103103
// json.Marshal escapes <, >, & by default — prevents </script> injection.
@@ -135,7 +135,7 @@ func GetHTMLReportCommand() *cobra.Command {
135135
Use: "html-report",
136136
Short: "Generate an interactive HTML report",
137137
Long: "Generate a rich, interactive HTML report. The report is fully self-contained and works offline.",
138-
Example: "openapi-changes html-report /path/to/git/repo path/to/file/in/repo/openapi.yaml",
138+
Example: "openapi-changes html-report HEAD~1:openapi.yaml ./openapi.yaml",
139139
RunE: func(cmd *cobra.Command, args []string) error {
140140
input, err := prepareCommandRun(cmd, args, printHTMLReportUsage)
141141
if err != nil {

cmd/html_report_test.go

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ package cmd
55

66
import (
77
"encoding/json"
8+
"net/http"
9+
"net/http/httptest"
810
"path/filepath"
911
"strings"
1012
"testing"
@@ -20,7 +22,6 @@ func TestGenerateHTMLReport_UnchangedLeftRight(t *testing.T) {
2022
"../sample-specs/petstorev3.json",
2123
"../sample-specs/petstorev3.json",
2224
summaryOpts{noColor: true},
23-
nil,
2425
)
2526
require.NoError(t, err)
2627

@@ -37,7 +38,6 @@ func TestGenerateHTMLReport_LeftRightIncludesSanitizedPaths(t *testing.T) {
3738
"../sample-specs/petstorev3-original.json",
3839
"../sample-specs/petstorev3.json",
3940
summaryOpts{noColor: true},
40-
nil,
4141
)
4242
require.NoError(t, err)
4343

@@ -54,6 +54,54 @@ func TestGenerateHTMLReport_LeftRightIncludesSanitizedPaths(t *testing.T) {
5454
assert.Contains(t, content, "<!DOCTYPE html")
5555
}
5656

57+
func TestGenerateHTMLReport_LeftRightPreservesGitRefPaths(t *testing.T) {
58+
repoDir := createGitSpecRepo(t)
59+
chdirForTest(t, repoDir)
60+
61+
commits, err := loadLeftRightCommits(
62+
"HEAD~1:openapi.yaml",
63+
"HEAD:openapi.yaml",
64+
summaryOpts{noColor: true},
65+
)
66+
require.NoError(t, err)
67+
68+
report, err := generateHTMLReport(commits, nil, true,
69+
"HEAD~1:openapi.yaml",
70+
"HEAD:openapi.yaml",
71+
)
72+
require.NoError(t, err)
73+
require.NotNil(t, report)
74+
75+
content := string(report)
76+
assert.Contains(t, content, `"originalPath":"HEAD~1:openapi.yaml"`)
77+
assert.Contains(t, content, `"modifiedPath":"HEAD:openapi.yaml"`)
78+
}
79+
80+
func TestGenerateHTMLReport_LeftRightPreservesURLPaths(t *testing.T) {
81+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
82+
if r.URL.Path == "/left.yaml" {
83+
_, _ = w.Write([]byte("openapi: 3.0.3\ninfo:\n title: Left\n version: '1.0'\npaths: {}\n"))
84+
return
85+
}
86+
_, _ = w.Write([]byte("openapi: 3.0.3\ninfo:\n title: Right\n version: '1.1'\npaths:\n /pets:\n get:\n responses:\n \"200\":\n description: ok\n"))
87+
}))
88+
defer server.Close()
89+
90+
leftURL := server.URL + "/left.yaml"
91+
rightURL := server.URL + "/right.yaml"
92+
93+
commits, err := loadLeftRightCommits(leftURL, rightURL, summaryOpts{noColor: true})
94+
require.NoError(t, err)
95+
96+
report, err := generateHTMLReport(commits, nil, true, leftURL, rightURL)
97+
require.NoError(t, err)
98+
require.NotNil(t, report)
99+
100+
content := string(report)
101+
assert.Contains(t, content, `"originalPath":"`+leftURL+`"`)
102+
assert.Contains(t, content, `"modifiedPath":"`+rightURL+`"`)
103+
}
104+
57105
func TestBuildHTMLReportItems_AllCommitsFail(t *testing.T) {
58106
items, err := buildHTMLReportItems([]*model.Commit{makeSwagger2Commit(t)}, nil)
59107
require.Error(t, err)
@@ -66,7 +114,6 @@ func TestBuildHTMLReportItems_PartialFailureReturnsPartialResults(t *testing.T)
66114
"../sample-specs/petstorev3-original.json",
67115
"../sample-specs/petstorev3.json",
68116
summaryOpts{noColor: true},
69-
nil,
70117
)
71118
require.NoError(t, err)
72119
require.NotEmpty(t, commits)
@@ -88,7 +135,6 @@ func TestGenerateHTMLReport_PartialFailureReturnsPartialReport(t *testing.T) {
88135
"../sample-specs/petstorev3-original.json",
89136
"../sample-specs/petstorev3.json",
90137
summaryOpts{noColor: true},
91-
nil,
92138
)
93139
require.NoError(t, err)
94140
require.NotEmpty(t, commits)
@@ -136,7 +182,6 @@ func TestBuildHTMLReportItems_PreservesSchemaNodesInDocumentTree(t *testing.T) {
136182
"../sample-specs/petstorev3-original.json",
137183
"../sample-specs/petstorev3.json",
138184
summaryOpts{noColor: true},
139-
nil,
140185
)
141186
require.NoError(t, err)
142187
require.NotEmpty(t, commits)
@@ -179,7 +224,6 @@ func TestBuildHTMLReportItems_SplitsStandardAndChangeExplorerGraphs(t *testing.T
179224
"../sample-specs/petstorev3-original.json",
180225
"../sample-specs/petstorev3.json",
181226
summaryOpts{noColor: true},
182-
nil,
183227
)
184228
require.NoError(t, err)
185229
require.NotEmpty(t, commits)

0 commit comments

Comments
 (0)