Skip to content
Closed
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
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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" }
Expand Down
1 change: 1 addition & 0 deletions packages/gradle/project-graph/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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<FastIgnoreRule> = 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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, GitIgnoreClassifier>()

/**
* Parse task and get inputs for this task
*
Expand All @@ -102,83 +108,87 @@ fun getInputsForTask(
workspaceRoot: String,
externalNodes: MutableMap<String, ExternalNode>? = null
): List<Any>? {
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<Any> = mutableListOf()

val dependsOnOutputs: MutableSet<File> = mutableSetOf()
val combinedDependsOn: Set<Task> = 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<Any>()
val externalDependencies = mutableListOf<String>()

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<String>()
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)
null
}
}

/**
* 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
*
Expand Down
Loading
Loading