Skip to content

Commit e05b60e

Browse files
akoclaude
andcommitted
feat: add --base flag to diff-local for comparing arbitrary revisions
diff-local now supports --base to compare two git revisions instead of only working tree vs ref. Useful for branch comparisons and feeding diffs into LLMs for code review. Addresses #130. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 197f4ca commit e05b60e

File tree

6 files changed

+86
-16
lines changed

6 files changed

+86
-16
lines changed

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ go build -o bin/mxcli ./cmd/mxcli
334334
| **Testing** | `mxcli test tests/ -p app.mpr` | `.test.mdl` / `.test.md` files, requires Docker |
335335
| **Diff** | `mxcli diff -p app.mpr changes.mdl` | Compare script against project state |
336336
| **Diff local** | `mxcli diff-local -p app.mpr --ref HEAD` | Git diff for MPR v2 projects |
337+
| **Diff revisions** | `mxcli diff-local -p app.mpr --base main --ref feature` | Compare two arbitrary git revisions |
337338
| **OQL** | `mxcli oql -p app.mpr "SELECT ..."` | Query running Mendix runtime |
338339
| **Widgets** | `SHOW WIDGETS`, `UPDATE WIDGETS SET ...` | Widget discovery and bulk updates (experimental) |
339340
| **External SQL** | `SQL CONNECT`, `SQL <alias> SELECT ...`, `mxcli sql` | Direct SQL queries against PostgreSQL, Oracle, SQL Server (credential isolation) |

cmd/mxcli/cmd_diff.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ var diffLocalCmd = &cobra.Command{
102102
This command finds modified mxunit files in the mprcontents/ folder and shows
103103
the differences as MDL. Only works with MPR v2 format (Mendix 10.18+).
104104
105+
Use --ref for single-ref comparison (working tree vs ref), or --base with --ref
106+
to compare two arbitrary revisions (e.g., main vs feature branch).
107+
105108
Examples:
106109
# Show uncommitted changes vs HEAD
107110
mxcli diff-local -p app.mpr
@@ -112,12 +115,19 @@ Examples:
112115
# Compare against a branch
113116
mxcli diff-local -p app.mpr --ref main
114117
118+
# Compare two arbitrary revisions
119+
mxcli diff-local -p app.mpr --base main --ref feature-branch
120+
121+
# Compare two commits
122+
mxcli diff-local -p app.mpr --base abc1234 --ref def5678
123+
115124
# With structural format
116125
mxcli diff-local -p app.mpr --format struct --color
117126
`,
118127
Run: func(cmd *cobra.Command, args []string) {
119128
projectPath, _ := cmd.Flags().GetString("project")
120129
ref, _ := cmd.Flags().GetString("ref")
130+
base, _ := cmd.Flags().GetString("base")
121131
format, _ := cmd.Flags().GetString("format")
122132
useColor, _ := cmd.Flags().GetBool("color")
123133
width, _ := cmd.Flags().GetInt("width")
@@ -132,6 +142,13 @@ Examples:
132142
ref = "HEAD"
133143
}
134144

145+
// Build the git ref spec
146+
gitRef := ref
147+
if base != "" {
148+
// Two-revision comparison: base..ref
149+
gitRef = base + ".." + ref
150+
}
151+
135152
// Create executor and connect
136153
exec, logger := newLoggedExecutor("subcommand")
137154
defer logger.Close()
@@ -152,7 +169,7 @@ Examples:
152169
Width: width,
153170
}
154171

155-
if err := exec.DiffLocal(ref, opts); err != nil {
172+
if err := exec.DiffLocal(gitRef, opts); err != nil {
156173
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
157174
os.Exit(1)
158175
}

cmd/mxcli/main.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,8 @@ func init() {
228228
diffCmd.Flags().IntP("width", "w", 120, "Terminal width for side-by-side format")
229229

230230
// Diff-local command flags
231-
diffLocalCmd.Flags().StringP("ref", "r", "HEAD", "Git reference to compare against")
231+
diffLocalCmd.Flags().StringP("ref", "r", "HEAD", "Git reference to compare against (target revision)")
232+
diffLocalCmd.Flags().StringP("base", "b", "", "Base revision for two-revision diff (e.g., --base main --ref feature)")
232233
diffLocalCmd.Flags().StringP("format", "f", "unified", "Output format: unified, side, struct")
233234
diffLocalCmd.Flags().BoolP("color", "", false, "Use colored output")
234235
diffLocalCmd.Flags().IntP("width", "w", 120, "Terminal width for side-by-side format")

docs-site/src/appendixes/quick-reference.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,7 @@ Cross-reference commands require `REFRESH CATALOG FULL` to populate reference da
541541
| Test | `mxcli test tests/ -p app.mpr` | `.test.mdl` / `.test.md` files |
542542
| Diff script | `mxcli diff -p app.mpr changes.mdl` | Compare script vs project |
543543
| Diff local | `mxcli diff-local -p app.mpr --ref HEAD` | Git diff for MPR v2 |
544+
| Diff revisions | `mxcli diff-local -p app.mpr --base main --ref feature` | Compare two git revisions |
544545
| OQL | `mxcli oql -p app.mpr "SELECT ..."` | Query running Mendix runtime |
545546
| External SQL | `mxcli sql --driver postgres --dsn '...' "SELECT 1"` | Direct database query |
546547
| Docker build | `mxcli docker build -p app.mpr` | Build with PAD patching |

docs-site/src/tools/diff.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ mxcli diff-local -p app.mpr --ref HEAD~1
3434

3535
# Compare against a branch
3636
mxcli diff-local -p app.mpr --ref main
37+
38+
# Compare two arbitrary revisions
39+
mxcli diff-local -p app.mpr --base main --ref feature-branch
40+
41+
# Compare two specific commits
42+
mxcli diff-local -p app.mpr --base abc1234 --ref def5678
3743
```
3844

3945
### MPR v2 Requirement
@@ -64,3 +70,13 @@ mxcli diff-local -p app.mpr --ref HEAD
6470
# See changes since two commits ago
6571
mxcli diff-local -p app.mpr --ref HEAD~2
6672
```
73+
74+
### Compare Branches
75+
76+
```bash
77+
# What changed between main and your feature branch
78+
mxcli diff-local -p app.mpr --base main --ref feature-branch
79+
80+
# Feed diff into an LLM for review
81+
mxcli diff-local -p app.mpr --base main --ref feature-branch > changes.diff
82+
```

mdl/executor/cmd_diff_local.go

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ import (
2222

2323
// DiffLocal compares local changes in mxunit files against a git reference.
2424
// This only works with MPR v2 format (Mendix 10.18+) which stores units in mprcontents/.
25+
//
26+
// The ref parameter can be:
27+
// - A single ref (e.g., "HEAD", "main") — compares working tree vs ref
28+
// - A range "base..target" — compares two revisions (no working tree)
2529
func (e *Executor) DiffLocal(ref string, opts DiffOptions) error {
2630
if e.reader == nil {
2731
return fmt.Errorf("not connected to a project")
@@ -142,25 +146,46 @@ func (e *Executor) findChangedMxunitFiles(contentsDir, ref string) ([]gitChange,
142146
return changes, nil
143147
}
144148

145-
// diffMxunitFile generates a diff for a single mxunit file
149+
// diffMxunitFile generates a diff for a single mxunit file.
150+
// For two-revision diffs (ref contains ".."), both sides are read from git.
151+
// For single-ref diffs, the "current" side is read from the working tree.
146152
func (e *Executor) diffMxunitFile(change gitChange, contentsDir, ref string) (*DiffResult, error) {
147153
var currentContent, gitContent []byte
148154
var err error
149155

150-
// Read current content (for modified or new files)
151-
if change.Status != "D" {
152-
currentContent, err = readFile(change.FilePath)
153-
if err != nil {
154-
return nil, fmt.Errorf("failed to read current file %s: %w", change.FilePath, err)
155-
}
156-
}
156+
// Determine if this is a two-revision diff
157+
baseRef, targetRef, isTwoRevision := parseRefRange(ref)
157158

158-
// Read git content (for modified or deleted files)
159-
if change.Status != "A" {
160-
cmd := execCommand("git", "show", ref+":"+change.FilePath)
161-
gitContent, err = cmd.Output()
162-
if err != nil {
163-
return nil, fmt.Errorf("failed to read git version of %s: %w", change.FilePath, err)
159+
if isTwoRevision {
160+
// Two-revision mode: both sides from git
161+
if change.Status != "D" {
162+
cmd := execCommand("git", "show", targetRef+":"+change.FilePath)
163+
currentContent, err = cmd.Output()
164+
if err != nil {
165+
return nil, fmt.Errorf("failed to read %s version of %s: %w", targetRef, change.FilePath, err)
166+
}
167+
}
168+
if change.Status != "A" {
169+
cmd := execCommand("git", "show", baseRef+":"+change.FilePath)
170+
gitContent, err = cmd.Output()
171+
if err != nil {
172+
return nil, fmt.Errorf("failed to read %s version of %s: %w", baseRef, change.FilePath, err)
173+
}
174+
}
175+
} else {
176+
// Single-ref mode: current from working tree, old from git
177+
if change.Status != "D" {
178+
currentContent, err = readFile(change.FilePath)
179+
if err != nil {
180+
return nil, fmt.Errorf("failed to read current file %s: %w", change.FilePath, err)
181+
}
182+
}
183+
if change.Status != "A" {
184+
cmd := execCommand("git", "show", ref+":"+change.FilePath)
185+
gitContent, err = cmd.Output()
186+
if err != nil {
187+
return nil, fmt.Errorf("failed to read git version of %s: %w", change.FilePath, err)
188+
}
164189
}
165190
}
166191

@@ -694,6 +719,15 @@ func (e *Executor) compareGeneric(current, proposed string) []StructuralChange {
694719
// Helper Functions for Git and BSON Parsing
695720
// ============================================================================
696721

722+
// parseRefRange splits a ref like "base..target" into its parts.
723+
// Returns (base, target, true) for ranges, or ("", ref, false) for single refs.
724+
func parseRefRange(ref string) (base, target string, isRange bool) {
725+
if idx := strings.Index(ref, ".."); idx >= 0 {
726+
return ref[:idx], ref[idx+2:], true
727+
}
728+
return "", ref, false
729+
}
730+
697731
// execCommand creates an exec.Cmd for running git commands
698732
func execCommand(name string, args ...string) *exec.Cmd {
699733
return exec.Command(name, args...)

0 commit comments

Comments
 (0)