Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,43 @@ Evaluate Mendix model against rules. Requires the model to be exported first.
**Usage:**
```bash
mxlint-cli lint
mxlint-cli lint --diff
```

`--diff` only evaluates model documents with unstaged or untracked changes in the modelsource git repository. Run `init` and `commit` first to create a baseline snapshot. This does not require the Mendix project itself to track modelsource in git.

---

### init

Create the modelsource directory if needed and initialize it as a git repository root for diff linting.

**Usage:**
```bash
mxlint-cli init
```

---

### commit

Commit the current modelsource state so later `lint --diff` runs can lint only subsequent changes.

**Usage:**
```bash
mxlint-cli commit
mxlint-cli commit -m "baseline after export"
```

Typical workflow:

```bash
mxlint-cli export
mxlint-cli init
mxlint-cli commit
# ... model changes ...
mxlint-cli export
mxlint-cli lint --diff
```

---
Expand Down
2 changes: 1 addition & 1 deletion default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ lint:
cache:
directory: .mendix-cache/mxlint
enable: true
modelsource: modelsource
modelsource: .mendix-cache/modelsource
projectDirectory: .
export:
filter: ".*"
Expand Down
177 changes: 165 additions & 12 deletions lint/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,21 @@ package lint
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)

var ErrNotGitRepository = errors.New("not a git repository")

const (
mxlintGitUserName = "mxlint"
mxlintGitUserEmail = "mxlint@localhost"
defaultPersistMsg = "mxlint: commit modelsource"
)

// IsGitRepository reports whether dir is inside a git work tree.
func IsGitRepository(dir string) (bool, error) {
cmd := exec.Command("git", "rev-parse", "--is-inside-work-tree")
Expand All @@ -24,7 +32,101 @@ func IsGitRepository(dir string) (bool, error) {
return strings.TrimSpace(string(output)) == "true", nil
}

// GitUnstagedChangedFiles returns absolute paths of files with unstaged changes.
// EnsureGitRepository ensures dir exists and is a git repository root.
// When dir is missing it is created. When dir is not already a git root,
// it runs git init and configures a local identity.
// Returns true when a new repository was created.
func EnsureGitRepository(dir string) (bool, error) {
absDir, err := filepath.Abs(dir)
if err != nil {
return false, fmt.Errorf("failed to resolve directory %q: %w", dir, err)
}

info, err := os.Stat(absDir)
if err != nil {
if !os.IsNotExist(err) {
return false, fmt.Errorf("failed to inspect modelsource directory %q: %w", absDir, err)
}
if err := os.MkdirAll(absDir, 0755); err != nil {
return false, fmt.Errorf("failed to create modelsource directory %q: %w", absDir, err)
}
} else if !info.IsDir() {
return false, fmt.Errorf("modelsource path %q is not a directory", absDir)
}

isRoot, err := isGitRoot(absDir)
if err != nil {
return false, err
}
if isRoot {
return false, nil
}

if _, err := exec.LookPath("git"); err != nil {
return false, fmt.Errorf("git is not installed or not available in PATH")
}

cmd := exec.Command("git", "init")
cmd.Dir = absDir
if output, err := cmd.CombinedOutput(); err != nil {
return false, fmt.Errorf("failed to initialize git repository in %q: %w (%s)", absDir, err, strings.TrimSpace(string(output)))
}

if err := configureMxlintGitIdentity(absDir); err != nil {
return false, err
}

return true, nil
}

// PersistGitRepository stages all changes in dir and creates a commit.
// Ensures the directory is a git repository first.
// Returns false when there is nothing to commit.
func PersistGitRepository(dir string, message string) (bool, error) {
if _, err := EnsureGitRepository(dir); err != nil {
return false, err
}

absDir, err := filepath.Abs(dir)
if err != nil {
return false, fmt.Errorf("failed to resolve directory %q: %w", dir, err)
}

if strings.TrimSpace(message) == "" {
message = defaultPersistMsg
}

addCmd := exec.Command("git", "add", "-A")
addCmd.Dir = absDir
if output, err := addCmd.CombinedOutput(); err != nil {
return false, fmt.Errorf("failed to stage modelsource changes: %w (%s)", err, strings.TrimSpace(string(output)))
}

statusCmd := exec.Command("git", "status", "--porcelain")
statusCmd.Dir = absDir
statusOutput, err := statusCmd.Output()
if err != nil {
return false, fmt.Errorf("failed to inspect modelsource git status: %w", err)
}
if strings.TrimSpace(string(statusOutput)) == "" {
return false, nil
}

commitCmd := exec.Command("git",
"-c", "user.name="+mxlintGitUserName,
"-c", "user.email="+mxlintGitUserEmail,
"commit", "-m", message,
)
commitCmd.Dir = absDir
if output, err := commitCmd.CombinedOutput(); err != nil {
return false, fmt.Errorf("failed to commit modelsource changes: %w (%s)", err, strings.TrimSpace(string(output)))
}

return true, nil
}

// GitUnstagedChangedFiles returns absolute paths of files with unstaged changes
// and untracked files (excluding ignored paths).
func GitUnstagedChangedFiles(dir string) ([]string, error) {
isRepo, err := IsGitRepository(dir)
if err != nil {
Expand All @@ -39,22 +141,33 @@ func GitUnstagedChangedFiles(dir string) ([]string, error) {
return nil, err
}

cmd := exec.Command("git", "diff", "--name-only", "--diff-filter=ACMR")
cmd.Dir = dir
output, err := cmd.Output()
changed := make(map[string]struct{})

diffCmd := exec.Command("git", "diff", "--name-only", "--diff-filter=ACMR")
diffCmd.Dir = dir
diffOutput, err := diffCmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to list unstaged changes: %w", err)
}
for _, line := range splitNonEmptyLines(string(diffOutput)) {
changed[cleanPath(filepath.Join(gitRoot, line))] = struct{}{}
}

lines := strings.Split(strings.TrimSpace(string(output)), "\n")
changedFiles := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
changedFiles = append(changedFiles, cleanPath(filepath.Join(gitRoot, line)))
untrackedCmd := exec.Command("git", "ls-files", "--others", "--exclude-standard")
untrackedCmd.Dir = dir
untrackedOutput, err := untrackedCmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to list untracked files: %w", err)
}
for _, line := range splitNonEmptyLines(string(untrackedOutput)) {
changed[cleanPath(filepath.Join(gitRoot, line))] = struct{}{}
}

changedFiles := make([]string, 0, len(changed))
for path := range changed {
changedFiles = append(changedFiles, path)
}
sort.Strings(changedFiles)
return changedFiles, nil
}

Expand Down Expand Up @@ -82,6 +195,46 @@ func FilterFilesUnderDirectory(files []string, directory string) ([]string, erro
return filtered, nil
}

func isGitRoot(dir string) (bool, error) {
gitPath := filepath.Join(dir, ".git")
info, err := os.Stat(gitPath)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("failed to inspect %q: %w", gitPath, err)
}
// .git may be a directory (normal repo) or a file (worktree/submodule).
return info.IsDir() || info.Mode().IsRegular(), nil
}

func configureMxlintGitIdentity(dir string) error {
for _, args := range [][]string{
{"config", "user.name", mxlintGitUserName},
{"config", "user.email", mxlintGitUserEmail},
} {
cmd := exec.Command("git", args...)
cmd.Dir = dir
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to configure git %s: %w (%s)", args[1], err, strings.TrimSpace(string(output)))
}
}
return nil
}

func splitNonEmptyLines(output string) []string {
lines := strings.Split(strings.TrimSpace(output), "\n")
result := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
result = append(result, line)
}
return result
}

func gitTopLevel(dir string) (string, error) {
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
cmd.Dir = dir
Expand Down
Loading
Loading