diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a4d710df776c56..2bf723a5eaae7e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ shadow = "8.1.1" ktfmt = "0.52.0" nx-project-graph = "0.1.7" gradle-plugin-publish = "1.2.1" +jgit = "6.8.0.202311291450-r" [libraries] gson = { module = "com.google.code.gson:gson", version.ref = "gson" } @@ -23,6 +24,7 @@ junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "jun kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" } gradle-tooling-api = { module = "org.gradle:gradle-tooling-api", version.ref = "gradle-tooling-api" } +jgit = { module = "org.eclipse.jgit:org.eclipse.jgit", version.ref = "jgit" } [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } diff --git a/packages/gradle/project-graph/build.gradle.kts b/packages/gradle/project-graph/build.gradle.kts index e784ca010e54f3..7e91719749b57c 100644 --- a/packages/gradle/project-graph/build.gradle.kts +++ b/packages/gradle/project-graph/build.gradle.kts @@ -18,6 +18,7 @@ dependencies { implementation(libs.gson) implementation(libs.javaparser.core) implementation(libs.kotlinx.coroutines.core) + implementation(libs.jgit) // Use compileOnly to avoid runtime conflicts with Kotlin Gradle plugin compileOnly(libs.kotlin.compiler.embeddable) { exclude(group = "org.jetbrains.kotlin", module = "kotlin-gradle-plugin") diff --git a/packages/gradle/project-graph/src/main/kotlin/dev/nx/gradle/utils/GitIgnoreClassifier.kt b/packages/gradle/project-graph/src/main/kotlin/dev/nx/gradle/utils/GitIgnoreClassifier.kt new file mode 100644 index 00000000000000..d19bdcdcb394e0 --- /dev/null +++ b/packages/gradle/project-graph/src/main/kotlin/dev/nx/gradle/utils/GitIgnoreClassifier.kt @@ -0,0 +1,99 @@ +package dev.nx.gradle.utils + +import org.eclipse.jgit.ignore.FastIgnoreRule +import java.io.File + +/** + * Determines if files match gitignore patterns + * Provides heuristic for parameter classification: ignored files are likely outputs, tracked files are likely inputs + */ +class GitIgnoreClassifier( + private val workspaceRoot: File +) { + private val ignoreRules: MutableList = mutableListOf() + + init { + loadIgnoreRules() + } + + private fun loadIgnoreRules() { + try { + val gitIgnoreFile = File(workspaceRoot, ".gitignore") + if (gitIgnoreFile.exists()) { + gitIgnoreFile.readLines().forEach { line -> + val trimmed = line.trim() + if (trimmed.isNotEmpty() && !trimmed.startsWith("#")) { + try { + val rule = FastIgnoreRule(trimmed) + ignoreRules.add(rule) + } catch (e: Exception) { + // Skip invalid rules silently + } + } + } + } + } catch (e: Exception) { + // If we can't load gitignore rules, continue without them + } + } + + private fun isPartOfWorkspace(path: File): Boolean { + // Use canonicalPath to resolve symlinks and normalize paths + val workspaceRootPath = try { + workspaceRoot.canonicalPath + } catch (e: Exception) { + workspaceRoot.absolutePath + } + + val filePath = try { + path.canonicalPath + } catch (e: Exception) { + path.absolutePath + } + + // Ensure the file path starts with the workspace root and is followed by a separator + // or is exactly the workspace root (which we exclude) + if (filePath == workspaceRootPath) { + return false + } + + return filePath.startsWith(workspaceRootPath + File.separator) + } + + /** + * Determines if a file path should be ignored according to gitignore rules + * Works for both existing and non-existent paths by using pattern matching + */ + fun isIgnored(path: File): Boolean { + if (ignoreRules.isEmpty()) { + return false + } + + if (!isPartOfWorkspace(path)) { + return false + } + + val relativePath = try { + path.relativeTo(workspaceRoot).path + } catch (e: IllegalArgumentException) { + return false + } + + return try { + // Check path against all ignore rules + var isIgnored = false + + for (rule in ignoreRules) { + val isDirectory = path.isDirectory || relativePath.endsWith("/") + if (rule.isMatch(relativePath, isDirectory)) { + // FastIgnoreRule.getResult() returns true if should be ignored + isIgnored = rule.result + } + } + + isIgnored + } catch (e: Exception) { + false + } + } +} diff --git a/packages/gradle/project-graph/src/main/kotlin/dev/nx/gradle/utils/TaskUtils.kt b/packages/gradle/project-graph/src/main/kotlin/dev/nx/gradle/utils/TaskUtils.kt index 107522bebfb842..0dd6cc225fb009 100644 --- a/packages/gradle/project-graph/src/main/kotlin/dev/nx/gradle/utils/TaskUtils.kt +++ b/packages/gradle/project-graph/src/main/kotlin/dev/nx/gradle/utils/TaskUtils.kt @@ -86,6 +86,12 @@ fun getGradlewCommand(): String { } } +/** + * Cache the gitignore classifier per workspace root to avoid recreating it for every task + * TODO(lourw): refactor this out. The structure of this plugin should be refactored to use dependency injection rather than maintaining a cached instance + */ +private val gitignoreClassifierCache = mutableMapOf() + /** * Parse task and get inputs for this task * @@ -102,76 +108,59 @@ fun getInputsForTask( workspaceRoot: String, externalNodes: MutableMap? = null ): List? { - fun getDependentTasksOutputFile(file: File): String { - val relativePathToWorkspaceRoot = - file.path.substring(workspaceRoot.length + 1) // also remove the file separator - val dependentTasksOutputFiles = - if (file.name.contains('.') || - (file.exists() && - file.isFile)) { // if file does not exists, file.isFile would always be false - relativePathToWorkspaceRoot - } else { - "$relativePathToWorkspaceRoot${File.separator}**${File.separator}*" - } - return dependentTasksOutputFiles - } - return try { - val mappedInputsIncludeExternal: MutableList = mutableListOf() - - val dependsOnOutputs: MutableSet = mutableSetOf() - val combinedDependsOn: Set = dependsOnTasks ?: getDependsOnTask(task) - combinedDependsOn.forEach { dependsOnTask -> - dependsOnTask.outputs.files.files.forEach { file -> - if (file.path.startsWith(workspaceRoot + File.separator)) { - dependsOnOutputs.add(file) - val dependentTasksOutputFiles = getDependentTasksOutputFile(file) - mappedInputsIncludeExternal.add( - mapOf("dependentTasksOutputFiles" to dependentTasksOutputFiles)) + val inputs = mutableListOf() + val externalDependencies = mutableListOf() + + val classifier = gitignoreClassifierCache.getOrPut(workspaceRoot) { + GitIgnoreClassifier(File(workspaceRoot)) + } + + // Collect outputs from dependent tasks + val tasksToProcess = dependsOnTasks ?: getDependsOnTask(task) + tasksToProcess.forEach { dependentTask -> + dependentTask.outputs.files.files.forEach { outputFile -> + if (isFileInWorkspace(outputFile, workspaceRoot)) { + val relativePath = toRelativePathOrGlob(outputFile, workspaceRoot) + inputs.add(mapOf("dependentTasksOutputFiles" to relativePath)) } } } - val externalDependencies = mutableListOf() - val buildDir = task.project.layout.buildDirectory.get().asFile - - task.inputs.files.forEach { file -> - val path: String = file.path - val pathWithReplacedRoot = replaceRootInPath(path, projectRoot, workspaceRoot) - - if (pathWithReplacedRoot != null) { - val isInTaskOutputBuildDir = file.path.startsWith(buildDir.path + File.separator) - if (!isInTaskOutputBuildDir) { - mappedInputsIncludeExternal.add(pathWithReplacedRoot) - } else { - val isInDependsOnOutputs = - dependsOnOutputs.any { outputFile -> - file == outputFile || file.path.startsWith(outputFile.path + File.separator) - } - if (!isInDependsOnOutputs) { - val dependentTasksOutputFile = getDependentTasksOutputFile(file) - mappedInputsIncludeExternal.add( - mapOf("dependentTasksOutputFiles" to dependentTasksOutputFile)) + // Process each tasks's input files from the tooling API + task.inputs.files.forEach { inputFile -> + val relativePath = replaceRootInPath(inputFile.path, projectRoot, workspaceRoot) + + when { + // File is outside workspace - treat as external dependency + relativePath == null -> { + try { + val externalDep = getExternalDepFromInputFile(inputFile.path, externalNodes, task.logger) + externalDep?.let { externalDependencies.add(it) } + } catch (e: Exception) { + task.logger.info("Error resolving external dependency for ${inputFile.path}: $e") } } - } else { - try { - val externalDep = getExternalDepFromInputFile(path, externalNodes, task.logger) - externalDep?.let { externalDependencies.add(it) } - } catch (e: Exception) { - task.logger.info("${task}: get external dependency error $e") + + // File matches gitignore pattern - treat as dependentTasksOutputFiles (build artifact) + classifier.isIgnored(inputFile) -> { + val relativePathOrGlob = toRelativePathOrGlob(inputFile, workspaceRoot) + inputs.add(mapOf("dependentTasksOutputFiles" to relativePathOrGlob)) + } + + // Regular source file - add as direct input + else -> { + inputs.add(relativePath) } } } + // Step 3: Add external dependencies if any if (externalDependencies.isNotEmpty()) { - mappedInputsIncludeExternal.add(mapOf("externalDependencies" to externalDependencies)) + inputs.add(mapOf("externalDependencies" to externalDependencies)) } - if (mappedInputsIncludeExternal.isNotEmpty()) { - return mappedInputsIncludeExternal - } - return null + inputs.ifEmpty { null } } catch (e: Exception) { task.logger.info("Error getting inputs for ${task.path}: ${e.message}") task.logger.debug("Stack trace:", e) @@ -179,6 +168,27 @@ fun getInputsForTask( } } +/** + * Checks if a file is within the workspace. + */ +private fun isFileInWorkspace(file: File, workspaceRoot: String): Boolean { + return file.path.startsWith(workspaceRoot + File.separator) +} + +/** + * Converts a file to a relative path. If it's a directory, returns a glob pattern. + */ +private fun toRelativePathOrGlob(file: File, workspaceRoot: String): String { + val relativePath = file.path.substring(workspaceRoot.length + 1) + val isFile = file.name.contains('.') || (file.exists() && file.isFile) + + return if (isFile) { + relativePath + } else { + "$relativePath${File.separator}**${File.separator}*" + } +} + /** * Get outputs for task * diff --git a/packages/gradle/project-graph/src/test/kotlin/dev/nx/gradle/utils/GitIgnoreClassifierTest.kt b/packages/gradle/project-graph/src/test/kotlin/dev/nx/gradle/utils/GitIgnoreClassifierTest.kt new file mode 100644 index 00000000000000..14e4820f629f17 --- /dev/null +++ b/packages/gradle/project-graph/src/test/kotlin/dev/nx/gradle/utils/GitIgnoreClassifierTest.kt @@ -0,0 +1,272 @@ +package dev.nx.gradle.utils + +import java.io.File +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +class GitIgnoreClassifierTest { + + @Test + fun `test isIgnored with no gitignore file`(@TempDir tempDir: File) { + val classifier = GitIgnoreClassifier(tempDir) + + val file = File(tempDir, "src/main.kt") + assertFalse(classifier.isIgnored(file)) + } + + @Test + fun `test isIgnored with simple patterns`(@TempDir tempDir: File) { + // Create .gitignore file + val gitignore = File(tempDir, ".gitignore") + gitignore.writeText( + """ + # Comments should be ignored + node_modules + *.log + dist + build + """ + .trimIndent()) + + val classifier = GitIgnoreClassifier(tempDir) + + // Test exact directory matches + assertTrue(classifier.isIgnored(File(tempDir, "node_modules"))) + assertTrue(classifier.isIgnored(File(tempDir, "node_modules/package.json"))) + assertTrue(classifier.isIgnored(File(tempDir, "dist"))) + assertTrue(classifier.isIgnored(File(tempDir, "dist/app.js"))) + assertTrue(classifier.isIgnored(File(tempDir, "build"))) + assertTrue(classifier.isIgnored(File(tempDir, "build/classes/Main.class"))) + + // Test wildcard patterns + assertTrue(classifier.isIgnored(File(tempDir, "app.log"))) + assertTrue(classifier.isIgnored(File(tempDir, "logs/error.log"))) + + // Test files that should NOT be ignored + assertFalse(classifier.isIgnored(File(tempDir, "src/main.kt"))) + assertFalse(classifier.isIgnored(File(tempDir, "README.md"))) + assertFalse(classifier.isIgnored(File(tempDir, "package.json"))) + } + + @Test + fun `test isIgnored with nested paths`(@TempDir tempDir: File) { + val gitignore = File(tempDir, ".gitignore") + gitignore.writeText( + """ + build + .gradle + out + """ + .trimIndent()) + + val classifier = GitIgnoreClassifier(tempDir) + + // Test nested build directories + assertTrue(classifier.isIgnored(File(tempDir, "build"))) + assertTrue(classifier.isIgnored(File(tempDir, "build/libs"))) + assertTrue(classifier.isIgnored(File(tempDir, "build/libs/app.jar"))) + assertTrue(classifier.isIgnored(File(tempDir, "project/build"))) + assertTrue(classifier.isIgnored(File(tempDir, "project/build/classes/Main.class"))) + + // Test .gradle directories + assertTrue(classifier.isIgnored(File(tempDir, ".gradle"))) + assertTrue(classifier.isIgnored(File(tempDir, ".gradle/caches"))) + + // Test out directories + assertTrue(classifier.isIgnored(File(tempDir, "out"))) + assertTrue(classifier.isIgnored(File(tempDir, "out/production"))) + } + + @Test + fun `test isIgnored with wildcard patterns`(@TempDir tempDir: File) { + val gitignore = File(tempDir, ".gitignore") + gitignore.writeText( + """ + *.class + *.jar + *.log + **/*.tmp + """ + .trimIndent()) + + val classifier = GitIgnoreClassifier(tempDir) + + // Test file extension wildcards + assertTrue(classifier.isIgnored(File(tempDir, "Main.class"))) + assertTrue(classifier.isIgnored(File(tempDir, "build/classes/Main.class"))) + assertTrue(classifier.isIgnored(File(tempDir, "app.jar"))) + assertTrue(classifier.isIgnored(File(tempDir, "libs/dependency.jar"))) + assertTrue(classifier.isIgnored(File(tempDir, "debug.log"))) + + // Test nested tmp files + assertTrue(classifier.isIgnored(File(tempDir, "temp.tmp"))) + assertTrue(classifier.isIgnored(File(tempDir, "cache/temp.tmp"))) + + // Files that should NOT match + assertFalse(classifier.isIgnored(File(tempDir, "Main.kt"))) + assertFalse(classifier.isIgnored(File(tempDir, "app.json"))) + } + + @Test + fun `test isIgnored with directory slash patterns`(@TempDir tempDir: File) { + val gitignore = File(tempDir, ".gitignore") + gitignore.writeText( + """ + /build/ + /dist/ + """ + .trimIndent()) + + // Create the directories so they exist + File(tempDir, "build").mkdirs() + File(tempDir, "dist").mkdirs() + + val classifier = GitIgnoreClassifier(tempDir) + + // Test root-level directories + assertTrue(classifier.isIgnored(File(tempDir, "build"))) + assertTrue(classifier.isIgnored(File(tempDir, "build/output.jar"))) + assertTrue(classifier.isIgnored(File(tempDir, "dist"))) + assertTrue(classifier.isIgnored(File(tempDir, "dist/app.js"))) + } + + @Test + fun `test isIgnored with negation patterns`(@TempDir tempDir: File) { + val gitignore = File(tempDir, ".gitignore") + gitignore.writeText( + """ + *.log + !important.log + """ + .trimIndent()) + + val classifier = GitIgnoreClassifier(tempDir) + + // Test that .log files are ignored + assertTrue(classifier.isIgnored(File(tempDir, "debug.log"))) + assertTrue(classifier.isIgnored(File(tempDir, "error.log"))) + + // Test negation pattern (important.log should NOT be ignored) + assertFalse(classifier.isIgnored(File(tempDir, "important.log"))) + } + + @Test + fun `test isIgnored with files outside workspace`(@TempDir tempDir: File) { + val gitignore = File(tempDir, ".gitignore") + gitignore.writeText("build") + + val classifier = GitIgnoreClassifier(tempDir) + + // File outside workspace should return false + val externalFile = File("/tmp/external/build/app.jar") + assertFalse(classifier.isIgnored(externalFile)) + } + + @Test + fun `test isIgnored with common build artifacts`(@TempDir tempDir: File) { + val gitignore = File(tempDir, ".gitignore") + gitignore.writeText( + """ + # Build outputs + build + .gradle + dist + out + target + + # IDE + .idea + .vscode + + # Logs + *.log + + # OS + .DS_Store + """ + .trimIndent()) + + val classifier = GitIgnoreClassifier(tempDir) + + // Build outputs + assertTrue(classifier.isIgnored(File(tempDir, "build/libs/app.jar"))) + assertTrue(classifier.isIgnored(File(tempDir, ".gradle/caches"))) + assertTrue(classifier.isIgnored(File(tempDir, "dist/bundle.js"))) + assertTrue(classifier.isIgnored(File(tempDir, "out/production"))) + assertTrue(classifier.isIgnored(File(tempDir, "target/classes"))) + + // IDE files + assertTrue(classifier.isIgnored(File(tempDir, ".idea/workspace.xml"))) + assertTrue(classifier.isIgnored(File(tempDir, ".vscode/settings.json"))) + + // Logs + assertTrue(classifier.isIgnored(File(tempDir, "app.log"))) + assertTrue(classifier.isIgnored(File(tempDir, "logs/error.log"))) + + // OS files + assertTrue(classifier.isIgnored(File(tempDir, ".DS_Store"))) + + // Source files should NOT be ignored + assertFalse(classifier.isIgnored(File(tempDir, "src/main/kotlin/Main.kt"))) + assertFalse(classifier.isIgnored(File(tempDir, "build.gradle.kts"))) + assertFalse(classifier.isIgnored(File(tempDir, "settings.gradle.kts"))) + } + + @Test + fun `test isIgnored caching behavior`(@TempDir tempDir: File) { + val gitignore = File(tempDir, ".gitignore") + gitignore.writeText("build\n*.log") + + // Create classifier - should load gitignore + val classifier = GitIgnoreClassifier(tempDir) + + // Test initial checks + assertTrue(classifier.isIgnored(File(tempDir, "build/app.jar"))) + assertTrue(classifier.isIgnored(File(tempDir, "debug.log"))) + + // Modify gitignore file (classifier should still use cached rules) + gitignore.writeText("other") + + // Should still use original rules + assertTrue(classifier.isIgnored(File(tempDir, "build/app.jar"))) + assertTrue(classifier.isIgnored(File(tempDir, "debug.log"))) + + // Create new classifier to pick up changes + val newClassifier = GitIgnoreClassifier(tempDir) + assertFalse(newClassifier.isIgnored(File(tempDir, "build/app.jar"))) + assertFalse(newClassifier.isIgnored(File(tempDir, "debug.log"))) + assertTrue(newClassifier.isIgnored(File(tempDir, "other"))) + } + + @Test + fun `test isIgnored with empty gitignore`(@TempDir tempDir: File) { + val gitignore = File(tempDir, ".gitignore") + gitignore.writeText("") + + val classifier = GitIgnoreClassifier(tempDir) + + // Nothing should be ignored + assertFalse(classifier.isIgnored(File(tempDir, "anything.txt"))) + assertFalse(classifier.isIgnored(File(tempDir, "build/app.jar"))) + } + + @Test + fun `test isIgnored with only comments`(@TempDir tempDir: File) { + val gitignore = File(tempDir, ".gitignore") + gitignore.writeText( + """ + # This is a comment + # Another comment + + # Yet another comment + """ + .trimIndent()) + + val classifier = GitIgnoreClassifier(tempDir) + + // Nothing should be ignored + assertFalse(classifier.isIgnored(File(tempDir, "anything.txt"))) + assertFalse(classifier.isIgnored(File(tempDir, "build/app.jar"))) + } +} diff --git a/packages/gradle/project-graph/src/test/kotlin/dev/nx/gradle/utils/ProcessTaskUtilsTest.kt b/packages/gradle/project-graph/src/test/kotlin/dev/nx/gradle/utils/ProcessTaskUtilsTest.kt index 01a1c620aadc0a..0290d325e046e5 100644 --- a/packages/gradle/project-graph/src/test/kotlin/dev/nx/gradle/utils/ProcessTaskUtilsTest.kt +++ b/packages/gradle/project-graph/src/test/kotlin/dev/nx/gradle/utils/ProcessTaskUtilsTest.kt @@ -2,9 +2,12 @@ package dev.nx.gradle.utils import dev.nx.gradle.data.Dependency import dev.nx.gradle.data.ExternalNode +import org.gradle.api.Project +import org.gradle.internal.serialize.codecs.core.NodeOwner import org.gradle.testfixtures.ProjectBuilder import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test class ProcessTaskUtilsTest { @@ -106,293 +109,331 @@ class ProcessTaskUtilsTest { assertNotNull(result["options"]) } - @Test - fun `test getInputsForTask with dependsOn outputs exclusion`() { - val project = ProjectBuilder.builder().build() - val workspaceRoot = project.rootDir.path - val projectRoot = project.projectDir.path - - // Create dependent task with outputs - val dependentTask = project.tasks.register("dependentTask").get() - val outputFile = java.io.File("$workspaceRoot/dist/output.jar") - dependentTask.outputs.file(outputFile) - - // Create main task with inputs and dependsOn - val mainTask = project.tasks.register("mainTask").get() - mainTask.dependsOn(dependentTask) - - // Add inputs - one that matches dependent output, one that doesn't - val inputFile1 = java.io.File("$workspaceRoot/dist/output.jar") // Should be excluded - val inputFile2 = java.io.File("$workspaceRoot/src/main.kt") // Should be included - mainTask.inputs.files(inputFile1, inputFile2) - - val result = getInputsForTask(null, mainTask, projectRoot, workspaceRoot, mutableMapOf()) - - assertNotNull(result) - - // Should contain dependentTasksOutputFiles for the output - assertTrue( + @Nested + inner class GetInputsForTaskTests { + lateinit var project: Project + lateinit var workspaceRoot: String + lateinit var projectRoot: String + + @BeforeEach + fun projectSetup() { + project = ProjectBuilder.builder().build() + workspaceRoot = project.rootDir.path + projectRoot = project.projectDir.path + + val gitIgnore = java.io.File(workspaceRoot, ".gitignore") + // Any inputs of tasks that are found in ignored files are considered dependent task output files + gitIgnore.writeText("dist") + } + + @Test + fun `test getInputsForTask with dependsOn outputs exclusion`() { + // Create dependent task with outputs + val dependentTask = project.tasks.register("dependentTask").get() + val outputFile = java.io.File("$workspaceRoot/dist/output.jar") + dependentTask.outputs.file(outputFile) + + // Create main task with inputs and dependsOn + val mainTask = project.tasks.register("mainTask").get() + mainTask.dependsOn(dependentTask) + + // Add inputs - one that matches dependent output, one that doesn't + val inputFile1 = java.io.File("$workspaceRoot/dist/output.jar") // Should be excluded + val inputFile2 = java.io.File("$workspaceRoot/src/main.kt") // Should be included + mainTask.inputs.files(inputFile1, inputFile2) + + val result = getInputsForTask(null, mainTask, projectRoot, workspaceRoot, mutableMapOf()) + + assertNotNull(result) + + // Should contain dependentTasksOutputFiles for the output + assertTrue( result!!.any { it is Map<*, *> && it["dependentTasksOutputFiles"] == "dist/output.jar" }) - // Should contain the non-conflicting input file - assertTrue(result.any { it == "{projectRoot}/src/main.kt" }) - - // Should NOT contain the input file that matches dependent output - assertFalse(result.any { it == "{workspaceRoot}/dist/output.jar" }) - } - - @Test - fun `test getInputsForTask directory vs file patterns`() { - val project = ProjectBuilder.builder().build() - val workspaceRoot = project.rootDir.path - val projectRoot = project.projectDir.path + // Should contain the non-conflicting input file + assertTrue(result.any { it == "{projectRoot}/src/main.kt" }) + } - val dependentTask = project.tasks.register("dependentTask").get() + @Test + fun `test getInputsForTask directory vs file patterns`() { + val dependentTask = project.tasks.register("dependentTask").get() - // Add file output (should get exact path) - val outputFile = java.io.File("$workspaceRoot/dist/app.jar") - dependentTask.outputs.file(outputFile) + // Add file output (should get exact path) + val outputFile = java.io.File("$workspaceRoot/dist/app.jar") + dependentTask.outputs.file(outputFile) - // Add directory output (should get /**/* pattern) - val outputDir = java.io.File("$workspaceRoot/build/classes") - dependentTask.outputs.dir(outputDir) + // Add directory output (should get /**/* pattern) + val outputDir = java.io.File("$workspaceRoot/dist/classes") + dependentTask.outputs.dir(outputDir) - val mainTask = project.tasks.register("mainTask").get() - mainTask.dependsOn(dependentTask) + val mainTask = project.tasks.register("mainTask").get() + mainTask.dependsOn(dependentTask) - val result = getInputsForTask(null, mainTask, projectRoot, workspaceRoot, mutableMapOf()) + val result = getInputsForTask(null, mainTask, projectRoot, workspaceRoot, mutableMapOf()) - assertNotNull(result) + assertNotNull(result) - // File should get exact path - assertTrue( + // File should get exact path + assertTrue( result!!.any { it is Map<*, *> && it["dependentTasksOutputFiles"] == "dist/app.jar" }) - // Directory should get glob pattern - assertTrue( + // Directory should get glob pattern + assertTrue( result.any { it is Map<*, *> && (it["dependentTasksOutputFiles"] as String).endsWith("/**/*") }) - } - - @Test - fun `test getInputsForTask excludes build directory files`() { - val project = ProjectBuilder.builder().build() - val workspaceRoot = project.rootDir.path - val projectRoot = project.projectDir.path - val buildDir = project.layout.buildDirectory.get().asFile - - val mainTask = project.tasks.register("mainTask").get() + } - // Add inputs - one in build dir, one outside - val buildDirFile = java.io.File("${buildDir.path}/classes/Main.class") - val sourceFile = java.io.File("$workspaceRoot/src/main.kt") - mainTask.inputs.files(buildDirFile, sourceFile) + @Test + fun `test getInputsForTask with pre-computed dependsOnTasks`() { + // Create dependent task with output + val dependentTask = project.tasks.register("dependentTask").get() + val outputFile = java.io.File("$workspaceRoot/dist/output.jar") + dependentTask.outputs.file(outputFile) - val result = getInputsForTask(null, mainTask, projectRoot, workspaceRoot, mutableMapOf()) + // Create main task with dependsOn + val mainTask = project.tasks.register("mainTask").get() + mainTask.dependsOn(dependentTask) - assertNotNull(result) + // Add input file + val inputFile = java.io.File("$workspaceRoot/src/main.kt") + mainTask.inputs.files(inputFile) - // Should contain the source file - assertTrue(result!!.any { it == "{projectRoot}/src/main.kt" }) - - // Should NOT contain the build directory file - assertFalse(result.any { it.toString().contains("build") && it !is Map<*, *> }) - } - - @Test - fun `test getInputsForTask with build dir input as dependentTasksOutputFiles`() { - val project = ProjectBuilder.builder().build() - val workspaceRoot = project.rootDir.path - val projectRoot = project.projectDir.path - val buildDir = project.layout.buildDirectory.get().asFile - - // Create dependent task with build dir output - val dependentTask = project.tasks.register("dependentTask").get() - val buildDirOutput = java.io.File("${buildDir.path}/libs/app.jar") - dependentTask.outputs.file(buildDirOutput) - - val mainTask = project.tasks.register("mainTask").get() - mainTask.dependsOn(dependentTask) - - // Add build dir file as input that matches dependent output - mainTask.inputs.files(buildDirOutput) - - val result = getInputsForTask(null, mainTask, projectRoot, workspaceRoot, mutableMapOf()) - - assertNotNull(result) - - // Should contain dependentTasksOutputFiles for the build dir output - assertTrue( - result!!.any { - it is Map<*, *> && (it["dependentTasksOutputFiles"] as String).contains("libs/app.jar") - }) - - // Should NOT contain it as a regular input - assertFalse(result.any { it.toString().contains("build") && it !is Map<*, *> }) - } - - @Test - fun `test getInputsForTask with pre-computed dependsOnTasks`() { - val project = ProjectBuilder.builder().build() - val workspaceRoot = project.rootDir.path - val projectRoot = project.projectDir.path - - // Create dependent task with output - val dependentTask = project.tasks.register("dependentTask").get() - val outputFile = java.io.File("$workspaceRoot/dist/output.jar") - dependentTask.outputs.file(outputFile) - - // Create main task with dependsOn - val mainTask = project.tasks.register("mainTask").get() - mainTask.dependsOn(dependentTask) - - // Add input file - val inputFile = java.io.File("$workspaceRoot/src/main.kt") - mainTask.inputs.files(inputFile) + // Pre-compute dependsOnTasks using getDependsOnTask + val preComputedDependsOn = getDependsOnTask(mainTask) - // Pre-compute dependsOnTasks using getDependsOnTask - val preComputedDependsOn = getDependsOnTask(mainTask) - - // Test with pre-computed dependsOnTasks - val resultWithPreComputed = + // Test with pre-computed dependsOnTasks + val resultWithPreComputed = getInputsForTask(preComputedDependsOn, mainTask, projectRoot, workspaceRoot, mutableMapOf()) - // Test without pre-computed (should compute internally) - val resultWithoutPreComputed = + // Test without pre-computed (should compute internally) + val resultWithoutPreComputed = getInputsForTask(null, mainTask, projectRoot, workspaceRoot, mutableMapOf()) - // Both results should be identical - assertNotNull(resultWithPreComputed) - assertNotNull(resultWithoutPreComputed) - assertEquals(resultWithPreComputed!!.size, resultWithoutPreComputed!!.size) + // Both results should be identical + assertNotNull(resultWithPreComputed) + assertNotNull(resultWithoutPreComputed) + assertEquals(resultWithPreComputed!!.size, resultWithoutPreComputed!!.size) - // Should contain dependentTasksOutputFiles for the dependent task output - assertTrue( + // Should contain dependentTasksOutputFiles for the dependent task output + assertTrue( resultWithPreComputed.any { it is Map<*, *> && it["dependentTasksOutputFiles"] == "dist/output.jar" }) - assertTrue( + assertTrue( resultWithoutPreComputed.any { it is Map<*, *> && it["dependentTasksOutputFiles"] == "dist/output.jar" }) - // Should contain the input file - assertTrue(resultWithPreComputed.any { it == "{projectRoot}/src/main.kt" }) - assertTrue(resultWithoutPreComputed.any { it == "{projectRoot}/src/main.kt" }) - } + // Should contain the input file + assertTrue(resultWithPreComputed.any { it == "{projectRoot}/src/main.kt" }) + assertTrue(resultWithoutPreComputed.any { it == "{projectRoot}/src/main.kt" }) + } + + @Test + fun `test getDependsOnForTask with pre-computed dependsOnTasks`() { + // Create a build file so the task dependencies are properly detected + val buildFile = java.io.File(project.projectDir, "build.gradle") + buildFile.writeText("// test build file") + + val taskA = project.tasks.register("taskA").get() + val taskB = project.tasks.register("taskB").get() + val taskC = project.tasks.register("taskC").get() + + taskA.dependsOn(taskB, taskC) + + val dependencies = mutableSetOf() + + // Pre-compute dependsOnTasks using getDependsOnTask + val preComputedDependsOn = getDependsOnTask(taskA) + + // Test with pre-computed dependsOnTasks + val resultWithPreComputed = getDependsOnForTask(preComputedDependsOn, taskA, dependencies) + + // Test without pre-computed (should compute internally) + val dependencies2 = mutableSetOf() + val resultWithoutPreComputed = getDependsOnForTask(null, taskA, dependencies2) + + // Both results should be identical + assertNotNull(resultWithPreComputed) + assertNotNull(resultWithoutPreComputed) + assertEquals(resultWithPreComputed!!.size, resultWithoutPreComputed!!.size) + assertEquals(2, resultWithPreComputed.size) + + // Should contain both dependencies + assertTrue(resultWithPreComputed.contains("test:taskB")) + assertTrue(resultWithPreComputed.contains("test:taskC")) + assertTrue(resultWithoutPreComputed.contains("test:taskB")) + assertTrue(resultWithoutPreComputed.contains("test:taskC")) + } + + @Test + fun `test dependentTasksOutputFiles generation with reused dependsOnTasks`() { + val project = ProjectBuilder.builder().build() + val workspaceRoot = project.rootDir.path + val projectRoot = project.projectDir.path + + // Create multiple dependent tasks with different output types + val dependentTask1 = project.tasks.register("dependentTask1").get() + val fileOutput = java.io.File("$workspaceRoot/dist/app.jar") + dependentTask1.outputs.file(fileOutput) + + val dependentTask2 = project.tasks.register("dependentTask2").get() + val dirOutput = java.io.File("$workspaceRoot/build/classes") + dependentTask2.outputs.dir(dirOutput) + + val dependentTask3 = project.tasks.register("dependentTask3").get() + val multipleOutputs = + listOf( + java.io.File("$workspaceRoot/reports/test.xml"), + java.io.File("$workspaceRoot/reports/coverage")) + dependentTask3.outputs.files(multipleOutputs) - @Test - fun `test getDependsOnForTask with pre-computed dependsOnTasks`() { - val project = ProjectBuilder.builder().withName("testProject").build() - // Create a build file so the task dependencies are properly detected - val buildFile = java.io.File(project.projectDir, "build.gradle") - buildFile.writeText("// test build file") + // Create main task that depends on all three + val mainTask = project.tasks.register("mainTask").get() + mainTask.dependsOn(dependentTask1, dependentTask2, dependentTask3) - val taskA = project.tasks.register("taskA").get() - val taskB = project.tasks.register("taskB").get() - val taskC = project.tasks.register("taskC").get() + // Add some input files + val inputFiles = + listOf( + java.io.File("$workspaceRoot/src/main.kt"), + java.io.File("$workspaceRoot/config/app.properties")) + mainTask.inputs.files(inputFiles) - taskA.dependsOn(taskB, taskC) + // Get dependsOnTasks once and reuse + val dependsOnTasks = getDependsOnTask(mainTask) + val result = + getInputsForTask(dependsOnTasks, mainTask, projectRoot, workspaceRoot, mutableMapOf()) - val dependencies = mutableSetOf() + assertNotNull(result) - // Pre-compute dependsOnTasks using getDependsOnTask - val preComputedDependsOn = getDependsOnTask(taskA) + // Should contain dependentTasksOutputFiles for file output (exact path) + assertTrue( + result!!.any { it is Map<*, *> && it["dependentTasksOutputFiles"] == "dist/app.jar" }) - // Test with pre-computed dependsOnTasks - val resultWithPreComputed = getDependsOnForTask(preComputedDependsOn, taskA, dependencies) + // Should contain dependentTasksOutputFiles for directory output (with /**/* pattern) + assertTrue( + result.any { + it is Map<*, *> && (it["dependentTasksOutputFiles"] as String) == "build/classes/**/*" + }) - // Test without pre-computed (should compute internally) - val dependencies2 = mutableSetOf() - val resultWithoutPreComputed = getDependsOnForTask(null, taskA, dependencies2) + // Should contain dependentTasksOutputFiles for test report file + assertTrue( + result.any { it is Map<*, *> && it["dependentTasksOutputFiles"] == "reports/test.xml" }) - // Both results should be identical - assertNotNull(resultWithPreComputed) - assertNotNull(resultWithoutPreComputed) - assertEquals(resultWithPreComputed!!.size, resultWithoutPreComputed!!.size) - assertEquals(2, resultWithPreComputed.size) + // Should contain dependentTasksOutputFiles for coverage directory (with /**/* pattern) + assertTrue( + result.any { + it is Map<*, *> && (it["dependentTasksOutputFiles"] as String) == "reports/coverage/**/*" + }) - // Should contain both dependencies - assertTrue(resultWithPreComputed.contains("testProject:taskB")) - assertTrue(resultWithPreComputed.contains("testProject:taskC")) - assertTrue(resultWithoutPreComputed!!.contains("testProject:taskB")) - assertTrue(resultWithoutPreComputed.contains("testProject:taskC")) - } + // Should contain regular input files + assertTrue(result.any { it == "{projectRoot}/src/main.kt" }) + assertTrue(result.any { it == "{projectRoot}/config/app.properties" }) - @Test - fun `test dependentTasksOutputFiles generation with reused dependsOnTasks`() { - val project = ProjectBuilder.builder().build() - val workspaceRoot = project.rootDir.path - val projectRoot = project.projectDir.path + // Verify we have the expected number of dependentTasksOutputFiles entries (4 outputs from 3 + // tasks) + val dependentTasksOutputFilesCount = + result.count { it is Map<*, *> && it.containsKey("dependentTasksOutputFiles") } + assertEquals(4, dependentTasksOutputFilesCount) + + // Verify we have the expected number of regular input files (2) + val regularInputsCount = result.count { it is String && it.startsWith("{projectRoot}") } + assertEquals(2, regularInputsCount) + } + + @Test + fun `test getInputsForTask with gitignore classification`() { + val project = ProjectBuilder.builder().build() + val workspaceRoot = project.rootDir.path + val projectRoot = project.projectDir.path + + // Create .gitignore file + val gitignore = java.io.File(project.rootDir, ".gitignore") + gitignore.writeText( + """ + build + .gradle + *.log + dist + """ + .trimIndent()) + + val mainTask = project.tasks.register("mainTask").get() + + // Add inputs with mixed types + val sourceFile = java.io.File("$workspaceRoot/src/main.kt") // Not ignored - should be input + val buildFile = + java.io.File("$workspaceRoot/build/classes/Main.class") // Ignored - should be + // dependentTasksOutputFiles + val logFile = java.io.File("$workspaceRoot/app.log") // Ignored - should be dependentTasksOutputFiles + val configFile = + java.io.File("$workspaceRoot/config/app.properties") // Not ignored - should be input + + mainTask.inputs.files(sourceFile, buildFile, logFile, configFile) + + val result = getInputsForTask(null, mainTask, projectRoot, workspaceRoot, mutableMapOf()) + + assertNotNull(result) + + // Source file should be regular input + assertTrue(result!!.any { it == "{projectRoot}/src/main.kt" }) + + // Config file should be regular input + assertTrue(result.any { it == "{projectRoot}/config/app.properties" }) + + // Build file should be dependentTasksOutputFiles (matches gitignore) + assertTrue( + result.any { + it is Map<*, *> && (it["dependentTasksOutputFiles"] as String).contains("build") + }) - // Create multiple dependent tasks with different output types - val dependentTask1 = project.tasks.register("dependentTask1").get() - val fileOutput = java.io.File("$workspaceRoot/dist/app.jar") - dependentTask1.outputs.file(fileOutput) + // Log file should be dependentTasksOutputFiles (matches gitignore) + assertTrue( + result.any { it is Map<*, *> && (it["dependentTasksOutputFiles"] as String).contains("log") }) + } - val dependentTask2 = project.tasks.register("dependentTask2").get() - val dirOutput = java.io.File("$workspaceRoot/build/classes") - dependentTask2.outputs.dir(dirOutput) + @Test + fun `test getInputsForTask gitignore patterns with nested paths`() { + val project = ProjectBuilder.builder().build() + val workspaceRoot = project.rootDir.path + val projectRoot = project.projectDir.path - val dependentTask3 = project.tasks.register("dependentTask3").get() - val multipleOutputs = - listOf( - java.io.File("$workspaceRoot/reports/test.xml"), - java.io.File("$workspaceRoot/reports/coverage")) - dependentTask3.outputs.files(multipleOutputs) + // Create .gitignore with common patterns + val gitignore = java.io.File(project.rootDir, ".gitignore") + gitignore.writeText( + """ + target + dist + """ + .trimIndent()) - // Create main task that depends on all three - val mainTask = project.tasks.register("mainTask").get() - mainTask.dependsOn(dependentTask1, dependentTask2, dependentTask3) + val mainTask = project.tasks.register("mainTask").get() - // Add some input files - val inputFiles = - listOf( - java.io.File("$workspaceRoot/src/main.kt"), - java.io.File("$workspaceRoot/config/app.properties")) - mainTask.inputs.files(inputFiles) + // Add inputs + val javaSource = java.io.File("$workspaceRoot/src/Main.java") // Not ignored + val compiledClass = + java.io.File("$workspaceRoot/dist/production/Main.class") // Ignored (*.class pattern) + val jarTarget = java.io.File("$workspaceRoot/dist/app.jar") // Ignored (target) - // Get dependsOnTasks once and reuse - val dependsOnTasks = getDependsOnTask(mainTask) - val result = - getInputsForTask(dependsOnTasks, mainTask, projectRoot, workspaceRoot, mutableMapOf()) + mainTask.inputs.files(javaSource, compiledClass, jarTarget) - assertNotNull(result) + val result = getInputsForTask(null, mainTask, projectRoot, workspaceRoot, mutableMapOf()) - // Should contain dependentTasksOutputFiles for file output (exact path) - assertTrue( - result!!.any { it is Map<*, *> && it["dependentTasksOutputFiles"] == "dist/app.jar" }) + assertNotNull(result) - // Should contain dependentTasksOutputFiles for directory output (with /**/* pattern) - assertTrue( + assertTrue(result!!.any { it == "{projectRoot}/src/Main.java" }) + + assertTrue( result.any { - it is Map<*, *> && (it["dependentTasksOutputFiles"] as String) == "build/classes/**/*" + it is Map<*, *> && (it["dependentTasksOutputFiles"] as String).contains("Main.class") }) - // Should contain dependentTasksOutputFiles for test report file - assertTrue( - result.any { it is Map<*, *> && it["dependentTasksOutputFiles"] == "reports/test.xml" }) - - // Should contain dependentTasksOutputFiles for coverage directory (with /**/* pattern) - assertTrue( + assertTrue( result.any { - it is Map<*, *> && (it["dependentTasksOutputFiles"] as String) == "reports/coverage/**/*" + it is Map<*, *> && (it["dependentTasksOutputFiles"] as String).contains("dist") }) - - // Should contain regular input files - assertTrue(result.any { it == "{projectRoot}/src/main.kt" }) - assertTrue(result.any { it == "{projectRoot}/config/app.properties" }) - - // Verify we have the expected number of dependentTasksOutputFiles entries (4 outputs from 3 - // tasks) - val dependentTasksOutputFilesCount = - result.count { it is Map<*, *> && it.containsKey("dependentTasksOutputFiles") } - assertEquals(4, dependentTasksOutputFilesCount) - - // Verify we have the expected number of regular input files (2) - val regularInputsCount = result.count { it is String && it.startsWith("{projectRoot}") } - assertEquals(2, regularInputsCount) + } } @Test @@ -447,4 +488,6 @@ class ProcessTaskUtilsTest { it is Map<*, *> && (it["dependentTasksOutputFiles"] as String) == "build/classes/**/*" }) } + + } diff --git a/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/GitIgnoreClassifier.kt b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/GitIgnoreClassifier.kt index 9da34caee5ad03..6ec5cfc6fd60ae 100644 --- a/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/GitIgnoreClassifier.kt +++ b/packages/maven/maven-plugin/src/main/kotlin/dev/nx/maven/GitIgnoreClassifier.kt @@ -9,78 +9,94 @@ import java.io.File * Provides heuristic for parameter classification: ignored files are likely outputs, tracked files are likely inputs */ class GitIgnoreClassifier( - private val workspaceRoot: File + private val workspaceRoot: File ) { private val log = LoggerFactory.getLogger(GitIgnoreClassifier::class.java) private val ignoreRules: MutableList = mutableListOf() - init { - loadIgnoreRules() - } - - private fun loadIgnoreRules() { + init { + loadIgnoreRules() + } - try { - val gitIgnoreFile = File(workspaceRoot, ".gitignore") - if (gitIgnoreFile.exists()) { - gitIgnoreFile.readLines().forEach { line -> - val trimmed = line.trim() - if (trimmed.isNotEmpty() && !trimmed.startsWith("#")) { - try { - val rule = FastIgnoreRule(trimmed) - ignoreRules.add(rule) - log.debug("Loaded gitignore rule: $trimmed") - } catch (e: Exception) { - log.debug("Failed to parse gitignore rule '$trimmed': ${e.message}") - } - } - } - log.debug("Loaded ${ignoreRules.size} gitignore rules") - } else { - log.debug("No .gitignore file found at: ${gitIgnoreFile.path}") + private fun loadIgnoreRules() { + try { + val gitIgnoreFile = File(workspaceRoot, ".gitignore") + if (gitIgnoreFile.exists()) { + gitIgnoreFile.readLines().forEach { line -> + val trimmed = line.trim() + if (trimmed.isNotEmpty() && !trimmed.startsWith("#")) { + try { + val rule = FastIgnoreRule(trimmed) + ignoreRules.add(rule) + } catch (e: Exception) { + // Skip invalid rules silently } - } catch (e: Exception) { - log.debug("Error loading gitignore rules: ${e.message}") + } } + } + } catch (e: Exception) { + // If we can't load gitignore rules, continue without them } + } - /** - * Determines if a file path should be ignored according to gitignore rules - * Works for both existing and non-existent paths by using pattern matching - */ - fun isIgnored(path: File): Boolean { - if (ignoreRules.isEmpty()) { - return false - } + private fun isPartOfWorkspace(path: File): Boolean { + // Use canonicalPath to resolve symlinks and normalize paths + val workspaceRootPath = try { + workspaceRoot.canonicalPath + } catch (e: Exception) { + workspaceRoot.absolutePath + } - val relativePath = try { - path.relativeTo(workspaceRoot).path - } catch (e: IllegalArgumentException) { - // Path is outside workspace - return false - } + val filePath = try { + path.canonicalPath + } catch (e: Exception) { + path.absolutePath + } - return try { - // Check path against all ignore rules - var isIgnored = false + // Ensure the file path starts with the workspace root and is followed by a separator + // or is exactly the workspace root (which we exclude) + if (filePath == workspaceRootPath) { + return false + } - for (rule in ignoreRules) { - val isDirectory = path.isDirectory || relativePath.endsWith("/") - if (rule.isMatch(relativePath, isDirectory)) { - // FastIgnoreRule.getResult() returns true if should be ignored - isIgnored = rule.result - log.debug("Path '$relativePath' matched rule, ignored: $isIgnored") - } - } + return filePath.startsWith(workspaceRootPath + File.separator) + } - log.debug("Path '$relativePath' final ignored status: $isIgnored") - isIgnored + /** + * Determines if a file path should be ignored according to gitignore rules + * Works for both existing and non-existent paths by using pattern matching + */ + fun isIgnored(path: File): Boolean { + if (ignoreRules.isEmpty()) { + return false + } - } catch (e: Exception) { - log.debug("Error checking ignore status for path '$path': ${e.message}") - false - } + if (!isPartOfWorkspace(path)) { + return false + } + + val relativePath = try { + path.relativeTo(workspaceRoot).path + } catch (e: IllegalArgumentException) { + return false } + return try { + // Check path against all ignore rules + var isIgnored = false + + for (rule in ignoreRules) { + val isDirectory = path.isDirectory || relativePath.endsWith("/") + if (rule.isMatch(relativePath, isDirectory)) { + // FastIgnoreRule.getResult() returns true if should be ignored + isIgnored = rule.result + } + } + + isIgnored + } catch (e: Exception) { + false + } + } }