Skip to content

Commit

Permalink
#3 use arrowKt to check for git and process errors, reorder git comma…
Browse files Browse the repository at this point in the history
…nd package structure
  • Loading branch information
simonhauck committed May 5, 2024
1 parent c59fa61 commit daef554
Show file tree
Hide file tree
Showing 15 changed files with 256 additions and 87 deletions.
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ testLoggerPlugin = { module = "com.adarshr:gradle-test-logger-plugin", version =
# Kotlin related
kotlinLogging = { module = "io.github.oshai:kotlin-logging-jvm", version = "6.0.9" }
ztExec = { module = "org.zeroturnaround:zt-exec", version = "1.12" }
arrowKt = { module = "io.arrow-kt:arrow-core", version = "1.2.4" }

# Testing related
junitApi = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junitVersion" }
Expand Down
1 change: 1 addition & 0 deletions plugin/git-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ plugins {

dependencies {
implementation(libs.ztExec)
api(libs.arrowKt)

testImplementation(libs.bundles.junit)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.github.simonhauck.git

import com.github.simonhauck.git.process.ProcessConfig
import com.github.simonhauck.git.wrapper.GitCommandApi
import com.github.simonhauck.git.wrapper.GitCommandProcessWrapper
import org.gradle.api.DefaultTask
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.Optional

abstract class BaseGitTask : DefaultTask() {

@get:Input @get:Optional abstract val processConfig: Property<ProcessConfig>

init {
group = "git"
}

fun getGitCommandApi(): GitCommandApi {
return GitCommandProcessWrapper(config = processConfig.getOrElse(ProcessConfig()))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.github.simonhauck.git

import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.TaskAction

abstract class CreateBranchTask : BaseGitTask() {

@get:Input abstract val branchName: Property<String>

@TaskAction
fun action() {
getGitCommandApi().createBranch(branchName.get())
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package com.github.simonhauck.git.process

import java.io.File

// TODO Simon.Hauck 2024-05-05 - this should be internal because the plugin should not care how this
// is executed
data class ProcessConfig(
val environment: Map<String, String> = emptyMap(),
val workingDir: File? = null
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,14 @@
package com.github.simonhauck.git.process

sealed interface ProcessResult {
import arrow.core.Either

val exitCode: Int?
internal typealias ProcessResult = Either<ProcessError, ProcessSuccess>

data class OK(override val exitCode: Int) : ProcessResult
internal data class ProcessSuccess(val exitCode: Int, val output: List<String>)

data class Error(override val exitCode: Int?, val message: String, val throwable: Throwable?) :
ProcessResult

companion object {
fun fromExitCode(exitCode: Int): ProcessResult {
if (exitCode == 0) return OK(exitCode)

return Error(exitCode, "Process failed with exit code $exitCode", null)
}
}
}
internal data class ProcessError(
val exitCode: Int?,
val output: List<String>,
val error: Throwable,
val message: String
)
Original file line number Diff line number Diff line change
@@ -1,26 +1,31 @@
package com.github.simonhauck.git.process

import java.util.concurrent.TimeUnit
import arrow.core.Either
import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform
import org.zeroturnaround.exec.ProcessExecutor
import org.zeroturnaround.exec.stream.LogOutputStream
import java.util.concurrent.TimeUnit

internal class ProcessWrapper {

fun runCommand(command: List<String>, config: ProcessConfig = ProcessConfig()): ProcessResult {
return runCatching {
ProcessExecutor()
.directory(config.workingDir)
.environment(config.environment)
.addOsSpecificCommands(*command.toTypedArray())
.addConsolePrinter()
.destroyWithDescendants()
.timeout(40, TimeUnit.SECONDS)
.execute()
fun runCommand(command: List<String>, config: ProcessConfig = ProcessConfig()): ProcessResult =
Either.catch {
val processOutputCaptor: MutableList<String> = mutableListOf()
val result =
ProcessExecutor()
.directory(config.workingDir)
.environment(config.environment)
.addOsSpecificCommands(*command.toTypedArray())
.handleConsoleOutput(processOutputCaptor)
.destroyWithDescendants()
.exitValueNormal()
.timeout(40, TimeUnit.SECONDS)
.execute()

ProcessSuccess(exitCode = result.exitValue, output = processOutputCaptor)
}
.map { ProcessResult.fromExitCode(it.exitValue) }
.getOrElse { ProcessResult.Error(null, "Process failed with an exception", it) }
}
// TODO Simon.Hauck 2024-05-04 - get exit code from process
.mapLeft { ProcessError(null, emptyList(), it, "Process failed with an exception") }

private fun ProcessExecutor.addOsSpecificCommands(vararg command: String): ProcessExecutor {
val linuxCommand = listOf(*command)
Expand All @@ -33,8 +38,11 @@ internal class ProcessWrapper {
return this.command(commandToExecute)
}

private fun ProcessExecutor.addConsolePrinter(): ProcessExecutor {
return this.redirectOutput(ProcessLogger(false)).redirectError(ProcessLogger(true))
private fun ProcessExecutor.handleConsoleOutput(
outputCaptor: MutableList<String>
): ProcessExecutor {
return this.redirectOutput(ProcessOutputHandler(false, outputCaptor))
.redirectError(ProcessOutputHandler(true, outputCaptor))
}

private fun ProcessExecutor.destroyWithDescendants(): ProcessExecutor {
Expand All @@ -51,8 +59,12 @@ internal class ProcessWrapper {
println("Process is terminated")
}

private class ProcessLogger(private val isError: Boolean) : LogOutputStream() {
private class ProcessOutputHandler(
private val isError: Boolean,
private val outputCaptor: MutableList<String>
) : LogOutputStream() {
override fun processLine(line: String) {
outputCaptor.add(line)
if (isError) System.err.println(line) else println(line)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.github.simonhauck.git.wrapper

interface GitCommandApi {
fun gitInit(branchName: String): GitVoidResult

fun gitStatus(): GitVoidResult

fun createBranch(branchName: String): GitVoidResult

fun gitAdd(filePattern: String): GitVoidResult

fun gitCommit(message: String): GitVoidResult

fun gitLog(): GitResult<List<GitLogEntry>>

fun getLocalBranchNames(): GitResult<List<String>>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.github.simonhauck.git.wrapper

import arrow.core.Either
import com.github.simonhauck.git.process.ProcessConfig
import com.github.simonhauck.git.process.ProcessSuccess
import com.github.simonhauck.git.process.ProcessWrapper

internal class GitCommandProcessWrapper(
private val processWrapper: ProcessWrapper = ProcessWrapper(),
private val config: ProcessConfig = ProcessConfig()
) : GitCommandApi {

override fun gitInit(branchName: String): GitVoidResult {
return gitVoidCommand(listOf("init", "--initial-branch=$branchName"))
}

override fun gitStatus(): GitVoidResult {
return gitVoidCommand(listOf("status"))
}

override fun gitAdd(filePattern: String): GitVoidResult {
return gitVoidCommand(listOf("add", filePattern))
}

override fun gitCommit(message: String): GitVoidResult {
return gitVoidCommand(listOf("commit", "-m", message))
}

override fun gitLog(): GitResult<List<GitLogEntry>> {
return gitCommand(listOf("log", "--pretty=oneline")).map { processSuccess ->
processSuccess.output.map { line ->
val split = line.split(" ")
GitLogEntry(split[0], split.drop(1).joinToString(" "))
}
}
}

override fun createBranch(branchName: String): GitVoidResult {
return gitVoidCommand(listOf("branch", branchName))
}

override fun getLocalBranchNames(): GitResult<List<String>> {
return gitCommand(listOf("--no-pager", "branch")).map { processSuccess ->
processSuccess.output.map { it.trim() }
}
}

private fun gitVoidCommand(command: List<String>): Either<GitError, GitOk> {
val runCommand = gitCommand(command)
return runCommand.map { GitOk }
}

private fun gitCommand(command: List<String>): Either<GitError, ProcessSuccess> {
val runCommand = processWrapper.runCommand(listOf("git").plus(command), config)
return runCommand.mapLeft { GitError(it.message, it.error) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.github.simonhauck.git.wrapper

import arrow.core.Either

typealias GitVoidResult = Either<GitError, GitOk>

typealias GitResult<T> = Either<GitError, T>

fun Either<GitError, *>.isOk(): Boolean = isRight()

object GitOk

data class GitError(val message: String, val throwable: Throwable? = null)

data class GitLogEntry(
val hash: String,
val message: String,
)

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package com.github.simonhauck.git.wrapper

import arrow.core.Either
import com.github.simonhauck.git.process.ProcessConfig
import java.io.File
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir

class GitCommandProcessWrapperTest {

@TempDir lateinit var tempDir: File
private lateinit var gitCommandApi: GitCommandApi

@BeforeEach
fun setup() {
gitCommandApi = GitCommandProcessWrapper(config = ProcessConfig(workingDir = tempDir))
}

@Test
fun `can create a git repository and correctly return the status`() {
val actualInit = gitCommandApi.gitInit("main")
assertThat(actualInit.isOk()).isTrue()

val actualStatus = gitCommandApi.gitStatus()
assertThat(actualStatus.isOk()).isTrue()
}

@Test
fun `check that the git command fails if no git repository is available`() {
val actual = gitCommandApi.gitStatus()

assertThat(actual.isOk()).isFalse()
}

@Test
fun `should be able to create a git repository and commit a file`() {
gitCommandApi.gitInit("main")

val file = File(tempDir, "file.txt")
file.writeText("Hello World")

val actualAdd = gitCommandApi.gitAdd("file.txt")
assertThat(actualAdd.isOk()).isTrue()

val actualCommit = gitCommandApi.gitCommit("Initial commit")
assertThat(actualCommit.isOk()).isTrue()

val actual = gitCommandApi.gitLog().get().map { it.message }
assertThat(actual).containsExactly("Initial commit")
}

@Test
fun `should contain the names of all local created branches`() {
gitCommandApi.gitInit("main")

File("$tempDir/file.txt").writeText("Hello World")
gitCommandApi.gitAdd("file.txt")
gitCommandApi.gitCommit("Initial commit")

gitCommandApi.createBranch("feature-1")
gitCommandApi.createBranch("feature-2")

val actual = gitCommandApi.getLocalBranchNames()
assertThat(actual.isOk()).isTrue()
assertThat(actual.get()).containsExactly("main", "feature-1", "feature-2")
}
}

fun <T, E> Either<T, E>.get(): E {
return when (this) {
is Either.Left -> throw IllegalStateException("Expected Right but got Left")
is Either.Right -> this.value
}
}
Loading

0 comments on commit daef554

Please sign in to comment.