diff --git a/.gitignore b/.gitignore index 2ada91e..1f717ca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .idea/ .gradle/ +.kotlintest/ /build/ node_modules/ !gradle-wrapper.jar diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index b4de394..0000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -11 diff --git a/.travis.yml b/.travis.yml index 8b095f0..566f7ed 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,22 @@ +os: + - linux + - osx language: java jdk: - openjdk11 script: - ./gradlew clean build -after_success: - - nvm install - - yarn install - - yarn semantic-release + +jobs: + + include: + - stage: release + deploy: + provider: script + skip_cleanup: true + script: + - nvm install lts/* + - yarn install + - yarn semantic-release + on: + all_branches: true diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..c8fb1db --- /dev/null +++ b/build.gradle @@ -0,0 +1,72 @@ +import com.gradle.publish.LoginTask +import com.gradle.publish.PublishTask + +import static java.lang.System.* + +plugins { + id "org.jetbrains.kotlin.jvm" version "1.3.31" + id "com.adarshr.test-logger" version "1.6.0" + id "org.jlleitschuh.gradle.ktlint" version "7.2.1" + id "com.gradle.plugin-publish" version "0.10.1" + id "java-gradle-plugin" +} + +group = "com.gtramontina.ghooks.gradle" +compileKotlin { kotlinOptions.jvmTarget = "1.8" } +compileTestKotlin { kotlinOptions.jvmTarget = "1.8" } +repositories { jcenter() } +dependencies { + implementation(group: "org.jetbrains.kotlin", name: "kotlin-stdlib-jdk8", version: "1.3.31") + testImplementation(group: "io.kotlintest", name: "kotlintest-runner-junit5", version: "3.3.2") + testImplementation(group: "org.junit.jupiter", name: "junit-jupiter-api", version: "5.4.1") + testImplementation(group: "org.awaitility", name: "awaitility", version: "3.1.6") + testRuntime(group: "org.junit.platform", name: "junit-platform-launcher", version: "1.4.1") + testRuntime(group: "org.junit.jupiter", name: "junit-jupiter-engine", version: "5.4.1") + testRuntime(group: "org.junit.vintage", name: "junit-vintage-engine", version: "5.4.1") +} + +test { useJUnitPlatform() } + +// Plugin Definition --------------------------------------------------------------------------------------------------- + +gradlePlugin { + plugins { + create(rootProject.name.toString()) { + id = group.toString() + displayName = "ghooks" + description = "Simple Git hooks" + implementationClass = "com.gtramontina.ghooks.GHooks" + } + } +} + +pluginBundle { + website = "https://github.com/gtramontina/ghooks.gradle" + vcsUrl = "https://github.com/gtramontina/ghooks.gradle" + tags = ["gradle", "plugin", "git", "hook", "ghook", "ghooks", "versioned"] +} + +// Plugin Publishing Custom Tasks -------------------------------------------------------------------------------------- + +tasks.create("publish") { + group = "plugin portal" + description = "Alias for 'publishPlugins'" + dependsOn("publishPlugins") +} + +tasks.create("checkPublishCredentials") { + group = "plugin portal" + description = "Checks if your environment has the publishing credentials properly setup." + + def key = getProperty("gradle.publish.key", getenv("GRADLE_PUBLISH_KEY")) + def secret = getProperty("gradle.publish.secret", getenv("GRADLE_PUBLISH_SECRET")) + doLast { + if (!key?.trim()) throw new GradleException("Could not find either the system property 'gradle.publish.key' or the environment variable 'GRADLE_PUBLISH_KEY'") + if (!secret?.trim()) throw new GradleException("Could not find either the system property 'gradle.publish.secret' or the environment variable 'GRADLE_PUBLISH_SECRET'") + setProperty("gradle.publish.key", key) + setProperty("gradle.publish.secret", secret) + } +} + +tasks.withType(LoginTask) { enabled = false } +tasks.withType(PublishTask) { dependsOn("checkPublishCredentials") } diff --git a/build.gradle.kts b/build.gradle.kts deleted file mode 100644 index e133ca3..0000000 --- a/build.gradle.kts +++ /dev/null @@ -1,61 +0,0 @@ -import com.gradle.publish.LoginTask -import com.gradle.publish.PublishTask - -group = "com.gtramontina.ghooks.gradle" - -repositories { - jcenter() -} - -plugins { - kotlin("jvm") version "1.3.30" - id("org.jlleitschuh.gradle.ktlint") version "7.2.1" - id("com.gradle.plugin-publish") version "0.10.1" - `java-gradle-plugin` -} - -dependencies { - implementation(kotlin("stdlib-jdk8")) -} - -pluginBundle { - website = "https://github.com/gtramontina/ghooks.gradle" - vcsUrl = "https://github.com/gtramontina/ghooks.gradle" - tags = listOf("gradle", "plugin", "git", "hook", "ghook", "ghooks", "versioned") -} - -gradlePlugin { - plugins { - create(rootProject.name) { - displayName = "ghooks" - description = "Simple Git hooks" - id = group.toString() - implementationClass = "com.gtramontina.ghooks.Main" - } - } -} - -tasks { - val checkPublishCredentials by creating { - group = "plugin portal" - description = "Checks if your environment has the publishing credentials properly setup." - - val key = System.getProperty("gradle.publish.key", System.getenv("GRADLE_PUBLISH_KEY")) - val secret = System.getProperty("gradle.publish.secret", System.getenv("GRADLE_PUBLISH_SECRET")) - doLast { - if (key.isNullOrBlank()) throw Exception("Could not find either the system property 'gradle.publish.key' or the environment variable 'GRADLE_PUBLISH_KEY'") - if (secret.isNullOrBlank()) throw Exception("Could not find either the system property 'gradle.publish.secret' or the environment variable 'GRADLE_PUBLISH_SECRET'") - System.setProperty("gradle.publish.key", key) - System.setProperty("gradle.publish.secret", secret) - } - - } - withType { dependsOn(checkPublishCredentials) } - withType { enabled = false } - - create("publish") { - group = "plugin portal" - description = "Alias for 'publishPlugins'" - dependsOn("publishPlugins") - } -} diff --git a/package.json b/package.json index b53e2cc..491f0d9 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,5 @@ { + "repository": "https://github.com/gtramontina/ghooks.gradle.git", "devDependencies": { "@semantic-release/changelog": "3.0.2", "@semantic-release/commit-analyzer": "6.1.0", diff --git a/src/main/kotlin/com/gtramontina/ghooks/GHooks.kt b/src/main/kotlin/com/gtramontina/ghooks/GHooks.kt new file mode 100644 index 0000000..e17f95a --- /dev/null +++ b/src/main/kotlin/com/gtramontina/ghooks/GHooks.kt @@ -0,0 +1,65 @@ +package com.gtramontina.ghooks + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.internal.impldep.org.apache.commons.io.FileUtils.forceDelete +import java.nio.file.Files.createSymbolicLink +import java.nio.file.Files.exists +import java.nio.file.Files.isDirectory +import java.nio.file.Files.isSymbolicLink +import java.nio.file.Path + +class GHooks : Plugin { + companion object { + private const val GIT_HOOKS_TARGET = ".githooks" + private const val GIT_HOOKS_SOURCE = ".git/hooks" + private const val DESCRIPTION = "Installs the configured Git hooks." + private const val GROUP = "git hooks" + private const val TASK_NAME = "installGitHooks" + private const val GIT_CDUP = "git rev-parse --show-cdup" + private const val GIT_HOOKS_TARGET_WARNING = """ + + Something went wrong while installing your Git hooks. + Please make sure you have the `$GIT_HOOKS_TARGET` directory on the root of your project. + Once these conditions are satisfied, this plugin will ensure the hooks get installed. + + """ + private const val GIT_WARNING = """ + + Something went wrong while installing your Git hooks. + Please make sure you have `git` installed and that this is a Git repository. + Once these conditions are satisfied, this plugin will ensure the hooks get installed. + + """ + } + + override fun apply(project: Project) { + project.task(TASK_NAME) { task -> + task.description = DESCRIPTION + task.group = GROUP + + val root = project.rootDir + val target = root.resolve(GIT_HOOKS_TARGET).toPath() + + if (!isDirectory(target)) + task.logger.warn(GIT_HOOKS_TARGET_WARNING.trimIndent()).also { return@task } + + GIT_CDUP.exec(root) + .thenApply { root.resolve(it.trim()) } + .thenApply { it.resolve(GIT_HOOKS_SOURCE).toPath() } + .thenAccept { it.symLinkTo(target) } + .exceptionally { task.logger.warn(GIT_WARNING.trimIndent(), it).run { throw it } } + } + } + + private fun Path.symLinkTo(target: Path) { + if (isSymLinkTo(target)) return + deleteIfExists().also { createSymbolicLink(this, target) } + } + + private fun Path.isSymLinkTo(target: Path) = exists(this) + .and(isSymbolicLink(this)) + .and(toRealPath() == target.toRealPath()) + + private fun Path.deleteIfExists() = forceDelete(toFile()) +} diff --git a/src/main/kotlin/com/gtramontina/ghooks/Main.kt b/src/main/kotlin/com/gtramontina/ghooks/Main.kt deleted file mode 100644 index ecc3c56..0000000 --- a/src/main/kotlin/com/gtramontina/ghooks/Main.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.gtramontina.ghooks - -import org.gradle.api.Plugin -import org.gradle.api.Project - -internal class Main : Plugin { - override fun apply(target: Project) { - TODO("not implemented") //To change body of created functions use File | Settings | File Templates. - } -} diff --git a/src/main/kotlin/com/gtramontina/ghooks/String.exec.kt b/src/main/kotlin/com/gtramontina/ghooks/String.exec.kt new file mode 100644 index 0000000..869ffdc --- /dev/null +++ b/src/main/kotlin/com/gtramontina/ghooks/String.exec.kt @@ -0,0 +1,24 @@ +package com.gtramontina.ghooks + +import java.io.File +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletableFuture.supplyAsync +import java.util.concurrent.TimeUnit.SECONDS + +private const val SUCCESSFUL = 0 +private val BLANK_SPACES = "\\s".toRegex() + +internal fun String.exec(cwd: File, timeoutInSeconds: Long = 60): CompletableFuture = supplyAsync { + ProcessBuilder(*split(BLANK_SPACES).toTypedArray()) + .redirectErrorStream(true) + .directory(cwd) + .start() + .apply { waitFor(timeoutInSeconds, SECONDS) } + .let { it.exitValue() to it.inputStream.bufferedReader().readText() } + .let { (code, output) -> + when (code) { + SUCCESSFUL -> output + else -> throw Error(output) + } + } +} diff --git a/src/test/kotlin/com/gtramontina/ghooks/Inhospitable Environment.kt b/src/test/kotlin/com/gtramontina/ghooks/Inhospitable Environment.kt new file mode 100644 index 0000000..b1556fc --- /dev/null +++ b/src/test/kotlin/com/gtramontina/ghooks/Inhospitable Environment.kt @@ -0,0 +1,53 @@ +package com.gtramontina.ghooks + +import com.gtramontina.ghooks.support.TestLogger +import com.gtramontina.ghooks.support.createCustomHooks +import com.gtramontina.ghooks.support.initializeGitRepository +import com.gtramontina.ghooks.support.toRegexGI +import io.kotlintest.matchers.collections.shouldHaveSize +import io.kotlintest.matchers.string.shouldMatch +import org.awaitility.Awaitility.await +import org.awaitility.Duration.ONE_SECOND +import org.gradle.api.Project +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class `Inhospitable Environment` { + private lateinit var project: Project + private lateinit var logger: TestLogger + + @BeforeEach + fun beforeEach() { + project = ProjectBuilder.builder().build() + logger = TestLogger() + } + + @AfterEach + fun afterEach() = logger.reset() + + @Test + fun `warns when not a git repository`() { + project.createCustomHooks(".githooks") + project.pluginManager.apply(GHooks::class.java) + + await().atMost(ONE_SECOND).untilAsserted { + logger.warnings shouldHaveSize 1 + logger.warnings.first() shouldMatch ".*something went wrong.*".toRegexGI() + logger.warnings.first() shouldMatch ".*that this is a git repository.*".toRegexGI() + } + } + + @Test + fun `warns when the target directory does not exist`() { + project.initializeGitRepository() + project.pluginManager.apply(GHooks::class.java) + + await().atMost(ONE_SECOND).untilAsserted { + logger.warnings shouldHaveSize 1 + logger.warnings.first() shouldMatch ".*something went wrong.*".toRegexGI() + logger.warnings.first() shouldMatch ".*make sure you have the `.*` directory on the root of your project.*".toRegexGI() + } + } +} diff --git a/src/test/kotlin/com/gtramontina/ghooks/Standard Scenarios.kt b/src/test/kotlin/com/gtramontina/ghooks/Standard Scenarios.kt new file mode 100644 index 0000000..d0346a9 --- /dev/null +++ b/src/test/kotlin/com/gtramontina/ghooks/Standard Scenarios.kt @@ -0,0 +1,42 @@ +package com.gtramontina.ghooks + +import com.gtramontina.ghooks.support.createCustomHooks +import com.gtramontina.ghooks.support.initializeGitRepository +import io.kotlintest.matchers.collections.containExactlyInAnyOrder +import io.kotlintest.should +import org.awaitility.Awaitility.await +import org.awaitility.Duration.ONE_SECOND +import org.gradle.api.Project +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.nio.file.Files.list +import java.util.stream.Collectors.toList + +class `Standard Scenarios` { + private lateinit var project: Project + private var customHooks = listOf("pre-commit", "prepare-commit-msg") + + @BeforeEach + fun `setup project as a git repository`() { + project = ProjectBuilder.builder().build() + project.createCustomHooks(".githooks", *customHooks.toTypedArray()) + project.initializeGitRepository() + } + + @Test + fun `has proper plugin ID`() { + project.pluginManager.apply("com.gtramontina.ghooks.gradle") + } + + @Test + fun `installs the git hooks`() { + project.pluginManager.apply(GHooks::class.java) + + await().atMost(ONE_SECOND).untilAsserted { + val ls = list(project.rootDir.resolve(".git/hooks").toPath()).collect(toList()) + val installed = ls.map { it.fileName.toString() } + installed should containExactlyInAnyOrder(customHooks) + } + } +} diff --git a/src/test/kotlin/com/gtramontina/ghooks/support/Extensions.kt b/src/test/kotlin/com/gtramontina/ghooks/support/Extensions.kt new file mode 100644 index 0000000..f825729 --- /dev/null +++ b/src/test/kotlin/com/gtramontina/ghooks/support/Extensions.kt @@ -0,0 +1,16 @@ +package com.gtramontina.ghooks.support + +import com.gtramontina.ghooks.exec +import org.gradle.api.Project +import java.nio.file.Files.createDirectories +import java.nio.file.Files.createFile +import kotlin.text.RegexOption.DOT_MATCHES_ALL +import kotlin.text.RegexOption.IGNORE_CASE +import kotlin.text.RegexOption.MULTILINE + +fun String.toRegexGI() = toRegex(setOf(IGNORE_CASE, DOT_MATCHES_ALL, MULTILINE)) +fun Project.initializeGitRepository(): String = "git init".exec(rootDir).get() +fun Project.createCustomHooks(dirname: String, vararg hooks: String) = rootDir + .resolve(dirname).toPath() + .let { createDirectories(it) } + .let { target -> hooks.map { target.resolve(it) }.forEach { createFile(it) } } diff --git a/src/test/kotlin/com/gtramontina/ghooks/support/TestLogger.kt b/src/test/kotlin/com/gtramontina/ghooks/support/TestLogger.kt new file mode 100644 index 0000000..30a686a --- /dev/null +++ b/src/test/kotlin/com/gtramontina/ghooks/support/TestLogger.kt @@ -0,0 +1,20 @@ +package com.gtramontina.ghooks.support + +import org.gradle.api.logging.LogLevel +import org.gradle.api.logging.LogLevel.WARN +import org.gradle.internal.logging.events.LogEvent +import org.gradle.internal.logging.slf4j.OutputEventListenerBackedLoggerContext +import org.slf4j.impl.StaticLoggerBinder.getSingleton + +internal class TestLogger { + fun reset() = context.reset() + val warnings: List get() = entries.getValue(WARN).toList() + private val context = getSingleton().loggerFactory as OutputEventListenerBackedLoggerContext + private val entries = with(mutableMapOf>()) { + withDefault { getOrPut(it, { mutableListOf() }) } + } + + init { + context.setOutputEventListener { it as LogEvent; entries.getValue(it.logLevel!!).add(it.message) } + } +}