diff --git a/build.gradle.kts b/build.gradle.kts index d913d2c..7855de0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,6 +9,7 @@ repositories { dependencies { implementation("org.kargs:kargs:1.0.8") + implementation("com.varabyte.kotter:kotter-jvm:1.2.1") } application { diff --git a/src/main/kotlin/io/pledge/Pledge.kt b/src/main/kotlin/io/pledge/Pledge.kt index 5c62373..de7b7d3 100644 --- a/src/main/kotlin/io/pledge/Pledge.kt +++ b/src/main/kotlin/io/pledge/Pledge.kt @@ -7,7 +7,7 @@ fun main(args: Array) { val parser = Parser("pledge") parser.subcommands( - CheckCommand() + LintCommand() ) try { diff --git a/src/main/kotlin/io/pledge/commands/CheckCommand.kt b/src/main/kotlin/io/pledge/commands/CheckCommand.kt deleted file mode 100644 index 946bb3a..0000000 --- a/src/main/kotlin/io/pledge/commands/CheckCommand.kt +++ /dev/null @@ -1,9 +0,0 @@ -package io.pledge.commands - -import org.kargs.* - -class CheckCommand : Subcommand("check", "Checks if commit title is valid") { - override fun execute() { - - } -} diff --git a/src/main/kotlin/io/pledge/commands/LintCommand.kt b/src/main/kotlin/io/pledge/commands/LintCommand.kt new file mode 100644 index 0000000..e88a021 --- /dev/null +++ b/src/main/kotlin/io/pledge/commands/LintCommand.kt @@ -0,0 +1,11 @@ +package io.pledge.commands + +import org.kargs.* + +class LintCommand : Subcommand("lint", "Checks if commit title is valid") { + val message by Option(ArgType.String, "message", "m", description = "Message to lint", required = true) + + override fun execute() { + + } +} diff --git a/src/main/kotlin/io/pledge/core/App.kt b/src/main/kotlin/io/pledge/core/App.kt new file mode 100644 index 0000000..04acdfc --- /dev/null +++ b/src/main/kotlin/io/pledge/core/App.kt @@ -0,0 +1,82 @@ +package io.pledge.core + +import io.pledge.core.* + +class App { + private var type: CommitType = CommitType.EMPTY + private var scope: String? = null + private var description: String = "" + private val body: MutableList = mutableListOf() + private val changes: MutableList = mutableListOf() + + /* Sets the type of the commit */ + fun setType(type: CommitType): Result { + if (type == CommitType.EMPTY) return Result.failure(Exception("Commit type cannot be EMPTY")) + + this.type = type + return Result.success(Unit) + } + + /* Sets the scope of the commit */ + fun setScope(value: String): Result { + val validScopePattern = Regex("^[a-z0-9-]+$") // lowercase/hyphens/numbers only + if (!validScopePattern.matches(value)) return Result.failure(Exception("Scope can only be lowercase/hyphens/numbers")) + if (value.length > 20) return Result.failure(Exception("Scope cannot be longer than 20 characters")) + + this.scope = value + return Result.success(Unit) + } + + /* Sets the description of the commit */ + fun setDescription(value: String): Result { + if (value.isBlank()) return Result.failure(Exception("Description cannot be empty")) + if (!value.first().isLetter() || !value.first().isLowerCase()) return Result.failure(Exception("Description must start with lowercase letter")) + if (value.endsWith(".")) return Result.failure(Exception("Description cannot end with a period")) + if (value.length > 50) return Result.failure(Exception("Description cannot be longer than 50 characters")) + + this.description = value + return Result.success(Unit) + } + + /* Sets the body of the commit */ + fun setBody(body: List): Result { + this.body.clear() + this.body.addAll(body) + return Result.success(Unit) + } + + /* Add a single body line */ + fun addBodyLine(line: String): Result { + if (line.length > 72) return Result.failure(Exception("Body line cannot be longer than 72 characters")) + this.body.add(line) + return Result.success(Unit) + } + + /* Sets the changes in the commit */ + fun setChanges(changes: List): Result { + this.changes.clear() + this.changes.addAll(changes) + return Result.success(Unit) + } + + /* Add a single change */ + fun addChange(change: Change): Result { + this.changes.add(change) + return Result.success(Unit) + } + + /* Returns current state for preview */ + fun getCurrentState(): Commit = Commit(type, scope, description, body.toList(), changes.toList()) + + /* Retuns immutable commit */ + fun generateCommit(): Commit = Commit(type, scope, description, body.toList(), changes.toList()) + + /* Reset the app state */ + fun reset() { + type = CommitType.EMPTY + scope = null + description = "" + body.clear() + changes.clear() + } +} diff --git a/src/main/kotlin/io/pledge/core/Commit.kt b/src/main/kotlin/io/pledge/core/Commit.kt new file mode 100644 index 0000000..7ca95e0 --- /dev/null +++ b/src/main/kotlin/io/pledge/core/Commit.kt @@ -0,0 +1,12 @@ +package io.pledge.core + +import io.pledge.core.CommitType +import io.pledge.core.Change + +data class Commit( + val type: CommitType, + val scope: String? = null, + val description: String, + val body: List = emptyList(), + val changes: List = emptyList(), +) diff --git a/src/main/kotlin/io/pledge/core/CommitTypes.kt b/src/main/kotlin/io/pledge/core/CommitTypes.kt new file mode 100644 index 0000000..04a6595 --- /dev/null +++ b/src/main/kotlin/io/pledge/core/CommitTypes.kt @@ -0,0 +1,15 @@ +package io.pledge.core + +enum class CommitType(val value: String, val desc: String) { + FEAT("feat", "A new feature"), + FIX("fix", "A bug fix"), + REFACTOR("refactor", "Code changes that neither fixes a bug nor adds a feature"), + DOCS("docs", "Documentation only changes"), + STYLE("style", "Changes that do not affect the meaning of the code"), + TEST("test", "Add missing tests or correcting existing tests"), + CHORE("chore", "Changes to the build process or auxilliary tools"), + REVERT("revert", "Reverts to a previous commit"), + MERGE("merge", "Merging branches"), + DEPLOY("deploy", "Deployment related changes"), + EMPTY("", ""), +} diff --git a/src/main/kotlin/io/pledge/core/Converter.kt b/src/main/kotlin/io/pledge/core/Converter.kt new file mode 100644 index 0000000..b80a0b3 --- /dev/null +++ b/src/main/kotlin/io/pledge/core/Converter.kt @@ -0,0 +1,145 @@ +package io.pledge.core + +import io.pledge.core.Commit +import io.pledge.core.CommitType +import io.pledge.core.ChangeType + +object Converter { + /* Converts Commit object into a git commit */ + fun convertToGitCommit(commit: Commit): String { + val typeString = commit.type.toString().lowercase() + + // Build the commit header: type(scope): description + val header = buildString { + append(typeString) + commit.scope?.let { scope -> append("($scope)") } + append(": ") + append(commit.description) + } + + // Build the full commit message + return buildString { + append(header) + + // Add body if present + if (commit.body.isNotEmpty()) { + append("\n\n") + append(commit.body.joinToString("\n")) + } + + // Add file changes if present + if (commit.changes.isNotEmpty()) { + append("\n\n") + commit.changes.forEach { change -> + val changeSymbol = when (change.change) { + ChangeType.ADDED -> "+" + ChangeType.MODIFIED -> "~" + ChangeType.DELETED -> "-" + } + append("$changeSymbol ${change.file}") + if (change != commit.changes.last()) { + append("\n") + } + } + } + + } + } + + /* Converts git commit into Commit object */ + fun convertFromGitCommit(commitMessage: String): Commit { + val lines = commitMessage.split("\n") + if (lines.isEmpty()) return Commit(CommitType.EMPTY, null, "", emptyList(), emptyList()) + + // Parse the header line: type(scope): description + val headerLine = lines[0] + val (type, scope, description) = parseHeader(headerLine) + + // Find where the body starts and ends, and where the file changes start + var bodyStartIndex = -1 + var bodyEndIndex = -1 + var changesStartIndex = -1 + var changesEndIndex = -1 + + for (i in 1 until lines.size) { + val line = lines[i] + when { + line.isEmpty() && bodyStartIndex == -1 -> { + // First empty line after header - body might start next + if (i + 1 < lines.size && lines[i + 1].isNotEmpty() && !isFileChangeLine(lines[i + 1])) { + bodyStartIndex = i + 1 + } + } + + line.isEmpty() && bodyStartIndex != -1 && bodyEndIndex == -1 -> { + // Empty line after body content - body ends here + bodyEndIndex = i - 1 + } + + isFileChangeLine(line) && changesStartIndex == -1 -> { + // First file change line + changesStartIndex = i + if (bodyStartIndex != -1 && bodyEndIndex == -1) { + bodyEndIndex = i - 2 // Account for empty line before changes + } + break + } + } + } + + // Extract body + val body = if (bodyStartIndex != -1 && bodyEndIndex != -1 && bodyEndIndex >= bodyStartIndex) { + lines.subList(bodyStartIndex, bodyEndIndex + 1).filter { it.isNotEmpty() } + } else if (bodyStartIndex != -1 && changesStartIndex == -1) { + // Body goes to end of commit if no file changes + lines.subList(bodyStartIndex, lines.size).filter { it.isNotEmpty() } + } else { + emptyList() + } + + // Extract file changes + val changes = if (changesStartIndex != -1) { + lines.subList(changesStartIndex, lines.size) + .filter { it.isNotEmpty() && isFileChangeLine(it) } + .mapNotNull { parseFileChange(it) } + } else { + emptyList() + } + + return Commit(type, scope, description, body, changes) + } + + private fun parseHeader(headerLine: String): Triple { + // Regex to match: type(scope): description or type: description + val regex = Regex("""^(\w+)(?:\(([^)]+)\))?\s*:\s*(.+)$""") + val matchResult = regex.find(headerLine) + + return if (matchResult != null) { + val typeString = matchResult.groupValues[1].uppercase() + val scope = matchResult.groupValues[2].takeIf { it.isNotEmpty() } + val description = matchResult.groupValues[3] + + val commitType = try { + CommitType.valueOf(typeString) + } catch (e: IllegalArgumentException) { + CommitType.EMPTY // Fallback for unknown types + } + + Triple(commitType, scope, description) + } else { + // Fallback if header doesn't match expected format + Triple(CommitType.EMPTY, null, headerLine) + } + } + + private fun isFileChangeLine(line: String): Boolean = line.startsWith("+ ") || line.startsWith("~ ") || line.startsWith("- ") + + private fun parseFileChange(line: String): Change? { + return when { + line.startsWith("+ ") -> Change(line.substring(2), ChangeType.ADDED) + line.startsWith("~ ") -> Change(line.substring(2), ChangeType.MODIFIED) + line.startsWith("- ") -> Change(line.substring(2), ChangeType.DELETED) + else -> null + } + } +} diff --git a/src/main/kotlin/io/pledge/core/TrackedFiles.kt b/src/main/kotlin/io/pledge/core/TrackedFiles.kt new file mode 100644 index 0000000..89ba81f --- /dev/null +++ b/src/main/kotlin/io/pledge/core/TrackedFiles.kt @@ -0,0 +1,53 @@ +package io.pledge.core + +import java.io.File +import java.io.BufferedReader +import java.io.InputStreamReader + +fun getTrackedFiles(repoPath: String): List { + val changes = mutableListOf() + + // Get staged changes + val stagedProcess = ProcessBuilder("git", "status", "--porcelain") + .directory(File(repoPath)) + .start() + + stagedProcess.inputStream.bufferedReader().useLines { lines -> + lines.forEach { line -> + if (line.length >= 3) { + val gitStatus = line.take(2) + val filePath = line.substring(3) + + val changeType = mapGitStatusToChangeType(gitStatus) + changeType?.let { + changes.add(Change(filePath, it)) + } + } + } + } + + return changes +} + +private fun mapGitStatusToChangeType(gitStatus: String): ChangeType? { + return when { + "A" in gitStatus -> ChangeType.ADDED + "D" in gitStatus -> ChangeType.DELETED + "M" in gitStatus -> ChangeType.MODIFIED + "R" in gitStatus -> ChangeType.MODIFIED // Renamed files are considered changed + "C" in gitStatus -> ChangeType.MODIFIED // Copied files are considered changed + "U" in gitStatus -> ChangeType.MODIFIED // Unmerged files are considered modified + else -> null // Ignore untracked (??) and other statuses + } +} + +data class Change( + val file: String, + val change: ChangeType +) + +enum class ChangeType { + ADDED, + DELETED, + MODIFIED, +} diff --git a/src/main/kotlin/io/pledge/core/Validator.kt b/src/main/kotlin/io/pledge/core/Validator.kt new file mode 100644 index 0000000..794f57d --- /dev/null +++ b/src/main/kotlin/io/pledge/core/Validator.kt @@ -0,0 +1,170 @@ +package io.pledge.core + +import io.pledge.core.* + +object Validator { + + fun validateCommit(commit: Commit): ValidationResult { + val errors = mutableListOf() + + // Validate commit type + if (commit.type == CommitType.EMPTY) { + errors.add("Commit type cannot be EMPTY") + } + + // Validate description + validateDescription(commit.description)?.let { error -> + errors.add(error) + } + + // Validate scope if present + commit.scope?.let { scope -> + validateScope(scope)?.let { error -> + errors.add(error) + } + } + + // Validate body lines + commit.body.forEachIndexed { index, line -> + validateBodyLine(line, index)?.let { error -> + errors.add(error) + } + } + + // Validate changes + commit.changes.forEach { change -> + validateChange(change)?.let { error -> + errors.add(error) + } + } + + return ValidationResult(errors.isEmpty(), errors) + } + + private fun validateDescription(description: String): String? { + return when { + description.isBlank() -> "Description cannot be empty" + description.length > 72 -> "Description should not exceed 72 characters (current: ${description.length})" + description.first().isUpperCase() -> "Description should start with lowercase letter" + description.endsWith(".") -> "Description should not end with a period" + else -> null + } + } + + private fun validateScope(scope: String): String? { + return when { + scope.isBlank() -> "Scope cannot be empty if provided" + scope.length > 20 -> "Scope should not exceed 20 characters (current: ${scope.length})" + !scope.matches(Regex("^[a-z0-9-]+$")) -> "Scope should only contain lowercase letters, numbers, and hyphens" + else -> null + } + } + + private fun validateBodyLine(line: String, index: Int): String? { + return when { + line.length > 72 -> "Body line ${index + 1} should not exceed 72 characters (current: ${line.length})" + else -> null + } + } + + private fun validateChange(change: Change): String? { + return when { + change.file.isBlank() -> "File path cannot be empty" + change.file.contains("..") -> "File path should not contain '..' (security risk)" + change.file.startsWith("/") -> "File path should be relative, not absolute" + else -> null + } + } + + // Additional validation methods for specific scenarios + + fun validateCommitType(type: String): ValidationResult { + val errors = mutableListOf() + + if (type.isBlank()) { + errors.add("Commit type cannot be empty") + } else { + try { + val commitType = CommitType.valueOf(type.uppercase()) + if (commitType == CommitType.EMPTY) { + errors.add("Commit type cannot be EMPTY") + } + } catch (e: IllegalArgumentException) { + errors.add("Invalid commit type: '$type'. Valid types are: ${CommitType.values().filter { it != CommitType.EMPTY }.joinToString(", ")}") + } + } + + return ValidationResult(errors.isEmpty(), errors) + } + + fun validateGitCommitMessage(commitMessage: String): ValidationResult { + val errors = mutableListOf() + + if (commitMessage.isBlank()) { + errors.add("Commit message cannot be empty") + return ValidationResult(false, errors) + } + + val lines = commitMessage.split("\n") + val headerLine = lines[0] + + // Validate header format + val headerRegex = Regex("""^(\w+)(?:\(([^)]+)\))?\s*:\s*(.+)$""") + if (!headerRegex.matches(headerLine)) { + errors.add("Header line must follow format: 'type(scope): description' or 'type: description'") + } else { + val matchResult = headerRegex.find(headerLine)!! + val type = matchResult.groupValues[1] + val scope = matchResult.groupValues[2].takeIf { it.isNotEmpty() } + val description = matchResult.groupValues[3] + + // Validate each component + validateCommitType(type).errors.forEach { errors.add(it) } + scope?.let { validateScope(it)?.let { error -> errors.add(error) } } + validateDescription(description)?.let { error -> errors.add(error) } + } + + // Validate total length + if (commitMessage.length > 2000) { + errors.add("Commit message is too long (${commitMessage.length} characters). Consider keeping it under 2000 characters.") + } + + return ValidationResult(errors.isEmpty(), errors) + } + + fun validateFileChanges(changes: List): ValidationResult { + val errors = mutableListOf() + + if (changes.isEmpty()) { + errors.add("At least one file change is required") + return ValidationResult(false, errors) + } + + // Check for duplicate files + val duplicateFiles = changes.groupBy { it.file } + .filter { it.value.size > 1 } + .keys + + if (duplicateFiles.isNotEmpty()) { + errors.add("Duplicate files found: ${duplicateFiles.joinToString(", ")}") + } + + // Validate each change + changes.forEach { change -> + validateChange(change)?.let { error -> + errors.add(error) + } + } + + return ValidationResult(errors.isEmpty(), errors) + } +} + +data class ValidationResult( + val isValid: Boolean, + val errors: List = emptyList() +) { + fun hasErrors(): Boolean = errors.isNotEmpty() + + fun getErrorMessage(): String = errors.joinToString("; ") +} diff --git a/src/main/kotlin/io/pledge/ui/CommitBuilder.kt b/src/main/kotlin/io/pledge/ui/CommitBuilder.kt new file mode 100644 index 0000000..d9c30e9 --- /dev/null +++ b/src/main/kotlin/io/pledge/ui/CommitBuilder.kt @@ -0,0 +1,11 @@ +package io.pledge.ui + +import com.varabyte.kotter.foundation.* +import com.varabyte.kotter.foundation.input.* +import com.varabyte.kotter.foundation.text.* +import com.varabyte.kotter.runtime.concurrent.* +import io.pledge.core.* + +class CommitBuilder { + +}