From 1d975979c2698b961a0396d65a568fbc3d8ea625 Mon Sep 17 00:00:00 2001 From: Xiwen Cheng Date: Fri, 24 Jul 2026 16:57:52 +0200 Subject: [PATCH] modelsource is now managed by mxlint in .mendix-cache by default --- README.md | 37 ++++++++++ default.yaml | 2 +- lint/git.go | 177 ++++++++++++++++++++++++++++++++++++++++++---- lint/git_test.go | 179 +++++++++++++++++++++++++++++++++++++++++++++++ main.go | 136 ++++++++++++++++++++++++++++++----- mpr/mpr.go | 5 +- mpr/mpr_test.go | 58 +++++++++++++++ 7 files changed, 563 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index c7cc4af..6606a1f 100644 --- a/README.md +++ b/README.md @@ -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 ``` --- diff --git a/default.yaml b/default.yaml index 47c9e6c..e0669cc 100644 --- a/default.yaml +++ b/default.yaml @@ -14,7 +14,7 @@ lint: cache: directory: .mendix-cache/mxlint enable: true -modelsource: modelsource +modelsource: .mendix-cache/modelsource projectDirectory: . export: filter: ".*" diff --git a/lint/git.go b/lint/git.go index 3c1b0e5..aac08b1 100644 --- a/lint/git.go +++ b/lint/git.go @@ -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") @@ -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 { @@ -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 } @@ -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 diff --git a/lint/git_test.go b/lint/git_test.go index 84cb499..0ce00f5 100644 --- a/lint/git_test.go +++ b/lint/git_test.go @@ -18,6 +18,185 @@ func TestGitUnstagedChangedFilesRequiresRepository(t *testing.T) { } } +func TestEnsureGitRepositoryInitializesModelsourceRoot(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is not available") + } + + tempDir := t.TempDir() + modelDir := filepath.Join(tempDir, "modelsource") + if err := os.MkdirAll(modelDir, 0755); err != nil { + t.Fatalf("failed to create model directory: %v", err) + } + + created, err := EnsureGitRepository(modelDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !created { + t.Fatal("expected a new git repository to be created") + } + + isRoot, err := isGitRoot(modelDir) + if err != nil { + t.Fatalf("failed to check git root: %v", err) + } + if !isRoot { + t.Fatal("expected modelsource to be a git root") + } + + createdAgain, err := EnsureGitRepository(modelDir) + if err != nil { + t.Fatalf("unexpected error on second ensure: %v", err) + } + if createdAgain { + t.Fatal("expected ensure to be idempotent") + } +} + +func TestEnsureGitRepositoryCreatesMissingDirectory(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is not available") + } + + tempDir := t.TempDir() + modelDir := filepath.Join(tempDir, "modelsource") + + created, err := EnsureGitRepository(modelDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !created { + t.Fatal("expected a new git repository to be created") + } + + info, err := os.Stat(modelDir) + if err != nil { + t.Fatalf("expected modelsource directory to exist: %v", err) + } + if !info.IsDir() { + t.Fatal("expected modelsource path to be a directory") + } + + isRoot, err := isGitRoot(modelDir) + if err != nil { + t.Fatalf("failed to check git root: %v", err) + } + if !isRoot { + t.Fatal("expected modelsource to be a git root") + } +} + +func TestEnsureGitRepositoryCreatesNestedRootInsideParentRepo(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is not available") + } + + tempDir := t.TempDir() + runGit(t, tempDir, "init") + + modelDir := filepath.Join(tempDir, "modelsource") + if err := os.MkdirAll(modelDir, 0755); err != nil { + t.Fatalf("failed to create model directory: %v", err) + } + + created, err := EnsureGitRepository(modelDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !created { + t.Fatal("expected nested modelsource git repository to be created") + } + + topLevel, err := gitTopLevel(modelDir) + if err != nil { + t.Fatalf("failed to resolve git toplevel: %v", err) + } + if cleanPath(topLevel) != cleanPath(modelDir) { + t.Fatalf("expected git toplevel %q, got %q", cleanPath(modelDir), cleanPath(topLevel)) + } +} + +func TestPersistGitRepositoryCommitsModelsourceState(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is not available") + } + + tempDir := t.TempDir() + modelDir := filepath.Join(tempDir, "modelsource") + if err := os.MkdirAll(modelDir, 0755); err != nil { + t.Fatalf("failed to create model directory: %v", err) + } + + docPath := filepath.Join(modelDir, "Security$ProjectSecurity.yaml") + if err := os.WriteFile(docPath, []byte("name: initial\n"), 0644); err != nil { + t.Fatalf("failed to write model document: %v", err) + } + + committed, err := PersistGitRepository(modelDir, "snapshot") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !committed { + t.Fatal("expected modelsource changes to be committed") + } + + committedAgain, err := PersistGitRepository(modelDir, "snapshot") + if err != nil { + t.Fatalf("unexpected error on second persist: %v", err) + } + if committedAgain { + t.Fatal("expected nothing to commit on second persist") + } + + if err := os.WriteFile(docPath, []byte("name: changed\n"), 0644); err != nil { + t.Fatalf("failed to update model document: %v", err) + } + + changedFiles, err := GitUnstagedChangedFiles(modelDir) + if err != nil { + t.Fatalf("unexpected error listing changes: %v", err) + } + if len(changedFiles) != 1 { + t.Fatalf("expected 1 changed file, got %d (%v)", len(changedFiles), changedFiles) + } + if changedFiles[0] != cleanPath(docPath) { + t.Fatalf("expected %q, got %q", cleanPath(docPath), changedFiles[0]) + } +} + +func TestGitUnstagedChangedFilesDetectsUntrackedModelDocument(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is not available") + } + + tempDir := t.TempDir() + modelDir := filepath.Join(tempDir, "modelsource") + if err := os.MkdirAll(modelDir, 0755); err != nil { + t.Fatalf("failed to create model directory: %v", err) + } + + if _, err := EnsureGitRepository(modelDir); err != nil { + t.Fatalf("failed to ensure git repository: %v", err) + } + + docPath := filepath.Join(modelDir, "Security$ProjectSecurity.yaml") + if err := os.WriteFile(docPath, []byte("name: initial\n"), 0644); err != nil { + t.Fatalf("failed to write model document: %v", err) + } + + changedFiles, err := GitUnstagedChangedFiles(modelDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(changedFiles) != 1 { + t.Fatalf("expected 1 untracked file, got %d (%v)", len(changedFiles), changedFiles) + } + if changedFiles[0] != cleanPath(docPath) { + t.Fatalf("expected %q, got %q", cleanPath(docPath), changedFiles[0]) + } +} + func TestGitUnstagedChangedFilesDetectsUnstagedModelDocument(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git is not available") diff --git a/main.go b/main.go index ff4d537..d87a8e0 100644 --- a/main.go +++ b/main.go @@ -136,26 +136,25 @@ func main() { var changedFiles []string if diffOnly { - changedFiles, err = lint.GitUnstagedChangedFiles(projectDir) + changedFiles, err = lint.GitUnstagedChangedFiles(modelDirectory) if err != nil { if err == lint.ErrNotGitRepository { - log.Warnf("--diff ignored: project is not tracked in git; linting all documents") + log.Errorf("--diff requires a modelsource git repository; run 'mxlint-cli init' first") } else { - log.Errorf("failed to resolve unstaged git changes: %s", err) - os.Exit(1) + log.Errorf("failed to resolve modelsource git changes: %s", err) } - } else { - changedFiles, err = lint.FilterFilesUnderDirectory(changedFiles, modelDirectory) - if err != nil { - log.Errorf("failed to filter changed files: %s", err) - os.Exit(1) - } - if len(changedFiles) == 0 { - log.Infof("No unstaged changes found in %s; nothing to lint", config.Modelsource) - return - } - log.Infof("Linting %d changed document(s) with unstaged git changes", len(changedFiles)) + os.Exit(1) + } + changedFiles, err = lint.FilterFilesUnderDirectory(changedFiles, modelDirectory) + if err != nil { + log.Errorf("failed to filter changed files: %s", err) + os.Exit(1) } + if len(changedFiles) == 0 { + log.Infof("No unstaged or untracked changes found in %s; nothing to lint", config.Modelsource) + return + } + log.Infof("Linting %d changed document(s) with unstaged or untracked git changes", len(changedFiles)) } err = lint.EvalAll( @@ -173,9 +172,114 @@ func main() { } }, } - cmdLint.Flags().Bool("diff", false, "Only lint model documents with unstaged git changes") + cmdLint.Flags().Bool("diff", false, "Only lint model documents with unstaged or untracked changes in the modelsource git repository") rootCmd.AddCommand(cmdLint) + var cmdInit = &cobra.Command{ + Use: "init", + Short: "Initialize the modelsource directory as a git repository", + Long: "Creates the modelsource directory if needed and initializes it as a git repository root for diff linting. Run 'commit' afterward to snapshot the current modelsource state.", + Run: func(cmd *cobra.Command, args []string) { + projectDir, err := os.Getwd() + if err != nil { + fmt.Printf("failed to resolve current working directory: %s\n", err) + os.Exit(1) + } + + config, err := lint.LoadMergedConfigFromPath(projectDir, configPathForCommand(cmd)) + if err != nil { + fmt.Printf("failed to load configuration: %s\n", err) + os.Exit(1) + } + log := logrus.New() + if isVerbose(cmd) { + log.SetLevel(logrus.DebugLevel) + } else { + log.SetLevel(logrus.InfoLevel) + } + lint.SetLogger(log) + lint.SetConfig(config) + + modelDirectory := config.Modelsource + if !filepath.IsAbs(modelDirectory) { + modelDirectory = filepath.Join(projectDir, modelDirectory) + } + + _, err = os.Stat(modelDirectory) + dirMissing := err != nil && os.IsNotExist(err) + if err != nil && !os.IsNotExist(err) { + log.Errorf("failed to inspect modelsource directory: %s", err) + os.Exit(1) + } + + createdRepo, err := lint.EnsureGitRepository(modelDirectory) + if err != nil { + log.Errorf("failed to initialize modelsource: %s", err) + os.Exit(1) + } + if dirMissing { + log.Infof("Created modelsource directory %s", config.Modelsource) + } + if createdRepo { + log.Infof("Initialized git repository in %s; run 'commit' to snapshot the current modelsource", config.Modelsource) + } else { + log.Infof("Modelsource git repository already initialized in %s", config.Modelsource) + } + }, + } + rootCmd.AddCommand(cmdInit) + + var cmdCommit = &cobra.Command{ + Use: "commit", + Short: "Commit the current modelsource state for diff linting", + Long: "Initializes a git repository in modelsource if needed, then stages and commits all modelsource files. Use this to snapshot a baseline so that 'lint --diff' can lint only subsequent changes.", + Run: func(cmd *cobra.Command, args []string) { + projectDir, err := os.Getwd() + if err != nil { + fmt.Printf("failed to resolve current working directory: %s\n", err) + os.Exit(1) + } + + config, err := lint.LoadMergedConfigFromPath(projectDir, configPathForCommand(cmd)) + if err != nil { + fmt.Printf("failed to load configuration: %s\n", err) + os.Exit(1) + } + log := logrus.New() + if isVerbose(cmd) { + log.SetLevel(logrus.DebugLevel) + } else { + log.SetLevel(logrus.InfoLevel) + } + lint.SetLogger(log) + lint.SetConfig(config) + + modelDirectory := config.Modelsource + if !filepath.IsAbs(modelDirectory) { + modelDirectory = filepath.Join(projectDir, modelDirectory) + } + + message, err := cmd.Flags().GetString("message") + if err != nil { + log.Errorf("failed to read --message flag: %s", err) + os.Exit(1) + } + + committed, err := lint.PersistGitRepository(modelDirectory, message) + if err != nil { + log.Errorf("failed to commit modelsource: %s", err) + os.Exit(1) + } + if committed { + log.Infof("Committed modelsource snapshot in %s", config.Modelsource) + } else { + log.Infof("No modelsource changes to commit in %s", config.Modelsource) + } + }, + } + cmdCommit.Flags().StringP("message", "m", "mxlint: commit modelsource", "Commit message for the modelsource snapshot") + rootCmd.AddCommand(cmdCommit) + var cmdConfig = &cobra.Command{ Use: "config", Short: "Show merged active configuration", diff --git a/mpr/mpr.go b/mpr/mpr.go index 3b09896..2c82598 100644 --- a/mpr/mpr.go +++ b/mpr/mpr.go @@ -1040,8 +1040,9 @@ func buildFileStructure(basePath string, currentPath string) (*FileNode, error) } for _, entry := range entries { - // Skip app.yaml to avoid self-reference - if entry.Name() == "app.yaml" { + // Skip app.yaml to avoid self-reference, and hidden files/directories (e.g. .git) + name := entry.Name() + if name == "app.yaml" || strings.HasPrefix(name, ".") { continue } childRelPath := filepath.Join(currentPath, entry.Name()) diff --git a/mpr/mpr_test.go b/mpr/mpr_test.go index f3c6153..ce46bb5 100644 --- a/mpr/mpr_test.go +++ b/mpr/mpr_test.go @@ -1440,6 +1440,64 @@ func TestGenerateAppYamlExcludesItself(t *testing.T) { } } +func TestGenerateAppYamlExcludesDotEntries(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "mpr-test-dotexclude-*") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tmpDir) + + testFile := filepath.Join(tmpDir, "test.yaml") + if err := os.WriteFile(testFile, []byte("test content"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + gitDir := filepath.Join(tmpDir, ".git") + if err := os.MkdirAll(gitDir, 0755); err != nil { + t.Fatalf("Failed to create .git directory: %v", err) + } + gitHead := filepath.Join(gitDir, "HEAD") + if err := os.WriteFile(gitHead, []byte("ref: refs/heads/main\n"), 0644); err != nil { + t.Fatalf("Failed to create .git/HEAD: %v", err) + } + + hiddenFile := filepath.Join(tmpDir, ".DS_Store") + if err := os.WriteFile(hiddenFile, []byte("hidden"), 0644); err != nil { + t.Fatalf("Failed to create hidden file: %v", err) + } + + if err := generateAppYaml(tmpDir); err != nil { + t.Fatalf("generateAppYaml() unexpected error: %v", err) + } + + yamlContent, err := os.ReadFile(filepath.Join(tmpDir, "app.yaml")) + if err != nil { + t.Fatalf("Failed to read app.yaml: %v", err) + } + + var appStructure AppStructure + if err := yaml.Unmarshal(yamlContent, &appStructure); err != nil { + t.Fatalf("Failed to unmarshal app.yaml: %v", err) + } + + for _, node := range appStructure.Content { + if strings.HasPrefix(node.Name, ".") { + t.Errorf("dot entry %q should not be included in app.yaml structure", node.Name) + } + } + + foundTestYaml := false + for _, node := range appStructure.Content { + if node.Name == "test.yaml" { + foundTestYaml = true + break + } + } + if !foundTestYaml { + t.Errorf("test.yaml should be included in app.yaml structure") + } +} + func TestExportMetadata_SortsModulesByName(t *testing.T) { tmpDir, err := os.MkdirTemp("", "mpr-test-metadata-sort-*") if err != nil {