├── sample ├── .gitignore ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── com │ └── lordcodes │ └── turtle │ └── sample │ └── Main.kt ├── turtle ├── .gitignore ├── src │ ├── main │ │ └── kotlin │ │ │ └── com │ │ │ └── lordcodes │ │ │ └── turtle │ │ │ ├── WithArgument.kt │ │ │ ├── internal │ │ │ ├── EmptyOutputStream.kt │ │ │ ├── EmptyInputStream.kt │ │ │ └── EmptyProcess.kt │ │ │ ├── ShellCommandNotFoundException.kt │ │ │ ├── ShellExecutableNotFoundException.kt │ │ │ ├── ShellFailedException.kt │ │ │ ├── ProcessCallbacks.kt │ │ │ ├── ShellLocation.kt │ │ │ ├── ProcessOutput.kt │ │ │ ├── ShellRunException.kt │ │ │ ├── Platform.kt │ │ │ ├── Executable.kt │ │ │ ├── Shell.kt │ │ │ ├── Arguments.kt │ │ │ ├── FileCommands.kt │ │ │ ├── Command.kt │ │ │ ├── ShellScript.kt │ │ │ └── GitCommands.kt │ └── test │ │ └── kotlin │ │ └── com │ │ └── lordcodes │ │ └── turtle │ │ ├── ShellFailedExceptionTest.kt │ │ ├── ShellRunExceptionTest.kt │ │ ├── ShellLocationTest.kt │ │ ├── SystemOut.kt │ │ ├── ExecutableTest.kt │ │ ├── FileCommandsTest.kt │ │ ├── ShellTest.kt │ │ ├── ShellScriptTest.kt │ │ ├── ArgumentsTest.kt │ │ ├── CommandTest.kt │ │ └── GitCommandsTest.kt ├── module.md └── build.gradle.kts ├── art ├── logo.png └── social.png ├── design └── logo.xcf ├── settings.gradle.kts ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .github ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature-request.md │ └── bug-report.md ├── workflows │ └── gradle.yml └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .editorconfig ├── scripts └── build-verify.gradle.kts ├── gradle.properties ├── CONTRIBUTING.md ├── CHANGELOG.md ├── gradlew.bat ├── CODE_OF_CONDUCT.md ├── config └── detekt │ └── detekt.yml ├── gradlew ├── README.md └── LICENSE /sample/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /out 3 | -------------------------------------------------------------------------------- /turtle/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /out 3 | -------------------------------------------------------------------------------- /art/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lordcodes/turtle/HEAD/art/logo.png -------------------------------------------------------------------------------- /art/social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lordcodes/turtle/HEAD/art/social.png -------------------------------------------------------------------------------- /design/logo.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lordcodes/turtle/HEAD/design/logo.xcf -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "turtle" 2 | 3 | include( 4 | "turtle", 5 | "sample" 6 | ) 7 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lordcodes/turtle/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gradle 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | 3 | contact_links: 4 | - name: Get help on Twitter 🐦 5 | url: https://twitter.com/lordcodes 6 | about: Ask questions or discuss issues. 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # MacOS 2 | .DS_Store 3 | 4 | # Gradle files 5 | .gradle/ 6 | build/ 7 | out/ 8 | 9 | 10 | # Local configuration file (SDK path, etc) 11 | /local.properties 12 | 13 | # IntelliJ / Android Studio 14 | *.iml 15 | **/.idea 16 | -------------------------------------------------------------------------------- /turtle/src/main/kotlin/com/lordcodes/turtle/WithArgument.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle 2 | 3 | /** 4 | * An argument provider that allows types to contain or provide a command [argument]. 5 | */ 6 | interface WithArgument { 7 | val argument: String 8 | } 9 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip 4 | networkTimeout=10000 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /sample/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("jvm") 3 | id("org.jmailen.kotlinter") 4 | } 5 | 6 | dependencies { 7 | implementation(project(":turtle")) 8 | implementation("org.jetbrains.kotlin:kotlin-stdlib") 9 | } 10 | 11 | kotlinter { 12 | failBuildWhenCannotAutoFormat = true 13 | reporters = arrayOf("checkstyle", "html") 14 | } 15 | -------------------------------------------------------------------------------- /turtle/src/main/kotlin/com/lordcodes/turtle/internal/EmptyOutputStream.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle.internal 2 | 3 | import java.io.OutputStream 4 | 5 | internal class EmptyOutputStream : OutputStream() { 6 | override fun write(value: Int) {} 7 | 8 | override fun write(bytes: ByteArray, offset: Int, length: Int) {} 9 | 10 | override fun close() {} 11 | } 12 | -------------------------------------------------------------------------------- /turtle/src/main/kotlin/com/lordcodes/turtle/ShellCommandNotFoundException.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle 2 | 3 | /** 4 | * A shell command failed to run due to not being found, it is likely it needs to be installed. 5 | */ 6 | class ShellCommandNotFoundException( 7 | command: String, 8 | cause: Throwable?, 9 | ) : RuntimeException("Command $command not found", cause) 10 | -------------------------------------------------------------------------------- /turtle/src/test/kotlin/com/lordcodes/turtle/ShellFailedExceptionTest.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle 2 | 3 | import kotlin.test.Test 4 | import kotlin.test.assertEquals 5 | 6 | internal class ShellFailedExceptionTest { 7 | @Test 8 | fun message() { 9 | val message = ShellFailedException(RuntimeException()).message 10 | 11 | assertEquals(message, "Running shell command failed") 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /turtle/src/main/kotlin/com/lordcodes/turtle/ShellExecutableNotFoundException.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle 2 | 3 | /** 4 | * A shell command executable failed to run due to not being found, it is likely it needs to be installed. 5 | */ 6 | class ShellExecutableNotFoundException( 7 | executable: Executable, 8 | cause: Throwable?, 9 | ) : RuntimeException("Command ${executable.name} not found. See ${executable.helpUrl}.", cause) 10 | -------------------------------------------------------------------------------- /turtle/module.md: -------------------------------------------------------------------------------- 1 | # Module turtle 2 | 3 | Run shell commands from a Kotlin script or application with ease. 4 | 5 | Turtle simplifies the process of running external commands and processes from your Kotlin (or Java) code. It comes 6 | with a selection of built-in functions, such as opening MacOS applications and dealing with Git. Running shell commands 7 | easily is particularly useful from within Kotlin scripts, command line applications and Gradle tasks. 8 | -------------------------------------------------------------------------------- /turtle/src/main/kotlin/com/lordcodes/turtle/ShellFailedException.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle 2 | 3 | /** 4 | * A shell command failed to run. A [ShellFailedException] will be thrown in situations when the command couldn't 5 | * be started, or it failed to run in some way. The issue may relate to the environment or with an invalid input. 6 | */ 7 | class ShellFailedException(cause: Throwable) : RuntimeException("Running shell command failed", cause) 8 | -------------------------------------------------------------------------------- /turtle/src/test/kotlin/com/lordcodes/turtle/ShellRunExceptionTest.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle 2 | 3 | import kotlin.test.Test 4 | import kotlin.test.assertEquals 5 | 6 | internal class ShellRunExceptionTest { 7 | @Test 8 | fun message() { 9 | val message = ShellRunException(1, "unexpected input").message 10 | 11 | assertEquals(message, "Running shell command failed with code 1 and message: unexpected input") 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /turtle/src/main/kotlin/com/lordcodes/turtle/ProcessCallbacks.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle 2 | 3 | /** 4 | * Callbacks into the underlying process, useful for monitoring or customising behaviour. 5 | */ 6 | interface ProcessCallbacks { 7 | /** 8 | * The process started, can be used to access the [Process] object. 9 | * 10 | * @param [process] The process that was started. 11 | */ 12 | fun onProcessStart(process: Process) {} 13 | } 14 | -------------------------------------------------------------------------------- /turtle/src/test/kotlin/com/lordcodes/turtle/ShellLocationTest.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle 2 | 3 | import kotlin.test.Test 4 | import kotlin.test.assertTrue 5 | 6 | internal class ShellLocationTest { 7 | @Test 8 | fun home() { 9 | assertTrue(ShellLocation.HOME.absolutePath.isNotEmpty()) 10 | } 11 | 12 | @Test 13 | fun currentWorking() { 14 | assertTrue(ShellLocation.CURRENT_WORKING.absolutePath.isNotEmpty()) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = space 3 | indent_size = 4 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | 9 | [*.{kt,kts}] 10 | ktlint_code_style = intellij_idea 11 | ij_kotlin_allow_trailing_comma = true 12 | ij_kotlin_allow_trailing_comma_on_call_site = true 13 | ij_kotlin_imports_layout = * 14 | max_line_length = 120 15 | ktlint_standard_annotation = disabled 16 | ktlint_standard_filename = disabled 17 | ktlint_standard_function-naming = disabled 18 | -------------------------------------------------------------------------------- /turtle/src/main/kotlin/com/lordcodes/turtle/ShellLocation.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle 2 | 3 | import java.io.File 4 | 5 | /** 6 | * Useful file system locations. 7 | */ 8 | object ShellLocation { 9 | /** 10 | * The current user's home directory. For example on MacOS, '/Users/lordcodes/'. 11 | */ 12 | val HOME = File(System.getProperty("user.home")) 13 | 14 | /** 15 | * The current working directory. 16 | */ 17 | val CURRENT_WORKING = File(System.getProperty("user.dir")) 18 | } 19 | -------------------------------------------------------------------------------- /turtle/src/test/kotlin/com/lordcodes/turtle/SystemOut.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle 2 | 3 | import java.io.ByteArrayOutputStream 4 | import java.io.PrintStream 5 | 6 | internal fun withCapturedSystemOut(testBody: (ByteArrayOutputStream) -> Unit) { 7 | val standardOut = System.out 8 | val outputStreamCaptor = ByteArrayOutputStream() 9 | System.setOut(PrintStream(outputStreamCaptor)) 10 | 11 | try { 12 | testBody(outputStreamCaptor) 13 | } finally { 14 | System.setOut(standardOut) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /scripts/build-verify.gradle.kts: -------------------------------------------------------------------------------- 1 | tasks { 2 | runCodeFormatting() 3 | runChecks() 4 | } 5 | 6 | fun TaskContainerScope.runCodeFormatting() { 7 | val tasks = rootProject.getTasksByName("formatKotlin", true) 8 | register("lcformat") { 9 | dependsOn(tasks) 10 | group = "lordcodes" 11 | } 12 | } 13 | 14 | fun TaskContainerScope.runChecks() { 15 | val tasks = rootProject.getTasksByName("lintKotlin", true) 16 | register("lcchecks") { 17 | dependsOn(tasks, "detekt") 18 | group = "lordcodes" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /turtle/src/main/kotlin/com/lordcodes/turtle/internal/EmptyInputStream.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle.internal 2 | 3 | import java.io.InputStream 4 | 5 | internal class EmptyInputStream : InputStream() { 6 | override fun available(): Int = 0 7 | 8 | override fun read(): Int = -1 9 | 10 | override fun read(bytes: ByteArray, offset: Int, length: Int): Int { 11 | if (length == 0) { 12 | return 0 13 | } 14 | return -1 15 | } 16 | 17 | override fun skip(n: Long): Long = 0L 18 | 19 | override fun close() {} 20 | } 21 | -------------------------------------------------------------------------------- /turtle/src/main/kotlin/com/lordcodes/turtle/internal/EmptyProcess.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle.internal 2 | 3 | import java.io.InputStream 4 | import java.io.OutputStream 5 | 6 | internal class EmptyProcess : Process() { 7 | override fun getOutputStream(): OutputStream = EmptyOutputStream() 8 | 9 | override fun getInputStream(): InputStream = EmptyInputStream() 10 | 11 | override fun getErrorStream(): InputStream = EmptyInputStream() 12 | 13 | override fun waitFor(): Int = 0 14 | 15 | override fun exitValue(): Int = 0 16 | 17 | override fun destroy() {} 18 | } 19 | -------------------------------------------------------------------------------- /turtle/src/main/kotlin/com/lordcodes/turtle/ProcessOutput.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle 2 | 3 | import java.io.InputStream 4 | 5 | /** 6 | * Output of running a shell command. 7 | * 8 | * @property [exitCode] The exit code of the process. By convention, the value {@code 0} indicates normal termination. 9 | * @property [standardOutput] The standard output produced by running a command or series of commands. 10 | * @property [standardError] The error output produced by running a command or series of commands. 11 | */ 12 | data class ProcessOutput( 13 | val exitCode: Int, 14 | val standardOutput: InputStream, 15 | val standardError: InputStream, 16 | ) 17 | -------------------------------------------------------------------------------- /turtle/src/main/kotlin/com/lordcodes/turtle/ShellRunException.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle 2 | 3 | /** 4 | * A shell command completed with an error exit code and error output. 5 | * 6 | * @property [exitCode] The exit code of the process. By convention, the value {@code 0} indicates normal termination. 7 | * @property [errorText] The error output produced by running a command or series of commands. 8 | */ 9 | data class ShellRunException( 10 | val exitCode: Int, 11 | val errorText: String? = null, 12 | ) : RuntimeException( 13 | if (errorText == null) { 14 | "Running shell command failed with code $exitCode" 15 | } else { 16 | "Running shell command failed with code $exitCode and message: $errorText" 17 | }, 18 | ) 19 | -------------------------------------------------------------------------------- /turtle/src/main/kotlin/com/lordcodes/turtle/Platform.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle 2 | 3 | /** 4 | * The platform commands are being executed on. 5 | */ 6 | enum class Platform { 7 | LINUX, 8 | MAC, 9 | WINDOWS, 10 | ; 11 | 12 | override fun toString(): String = when (this) { 13 | LINUX -> "Linux" 14 | MAC -> "Mac" 15 | WINDOWS -> "Windows" 16 | } 17 | 18 | companion object { 19 | internal fun fromSystem(): Platform { 20 | val osName = System.getProperty("os.name") 21 | return when { 22 | osName.contains("Mac") -> MAC 23 | osName.contains("Windows") -> WINDOWS 24 | else -> LINUX 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/gradle.yml: -------------------------------------------------------------------------------- 1 | name: Gradle 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | check: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Set up JDK 16 | uses: actions/setup-java@v1 17 | with: 18 | java-version: 11 19 | 20 | - name: Cache Gradle packages 21 | uses: actions/cache@v1 22 | with: 23 | path: ~/.gradle/caches 24 | key: ${{ runner.os }}-gradle-${{ hashFiles('buildSrc/src/main/kotlin/Dependencies.kt') }}-${{ hashFiles('buildSrc/src/main/kotlin/Versions.kt') }}-${{ hashFiles('**/*.gradle.kts') }} 25 | restore-keys: ${{ runner.os }}-gradle 26 | 27 | - name: Make gradlew executable 28 | run: chmod +x gradlew 29 | 30 | - name: Gradle checks 31 | run: ./gradlew lcchecks 32 | 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Feature request \U0001F6E0" 3 | about: "Suggest an idea for a new feature \U0001F9E0" 4 | labels: enhancement 5 | --- 6 | 7 | **Describe the problem** 8 | 9 | 10 | **Describe your solution** 11 | 12 | 13 | **Checklist** 14 | 15 | 16 | - [ ] I've read the [guide for contributing](https://github.com/lordcodes/turtle/blob/master/CONTRIBUTING.md). 17 | - [ ] I've checked there are no other [open pull requests](https://github.com/lordcodes/turtle/pulls) for the feature. 18 | - [ ] I've checked there are no other [open issues](https://github.com/lordcodes/turtle/issues) for the same feature. 19 | 20 | **Additional context** 21 | 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Bug report \U0001F41B" 3 | about: "Something isn't working correctly \U0001F915" 4 | labels: bug 5 | --- 6 | 7 | **Describe the bug** 8 | 9 | 10 | **Checklist** 11 | 12 | 13 | - [ ] I've read the [guide for contributing](https://github.com/lordcodes/turtle/blob/master/CONTRIBUTING.md). 14 | - [ ] I've checked there are no other [open pull requests](https://github.com/lordcodes/turtle/pulls) for the issue. 15 | - [ ] I've checked there are no other [open issues](https://github.com/lordcodes/turtle/issues) for the same issue. 16 | 17 | **To Reproduce** 18 | 19 | 20 | **Expected behavior** 21 | 22 | 23 | **Additional context** 24 | 25 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ### Checklist 4 | - [ ] I've read the [guide for contributing](https://github.com/lordcodes/turtle/blob/master/CONTRIBUTING.md). 5 | - [ ] I've checked there are no other [open pull requests](https://github.com/lordcodes/turtle/pulls) for the same change. 6 | - [ ] I've formatted all code changes with `./gradlew lcformat`. 7 | - [ ] I've ran all checks with `./gradlew lcchecks`. 8 | - [ ] I've ran all checks with `./gradlew check`. 9 | - [ ] I've updated documentation if needed. 10 | - [ ] I've added or updated tests for changes. 11 | 12 | ### Reason for change 13 | 14 | 15 | 16 | ### Description 17 | 18 | -------------------------------------------------------------------------------- /turtle/src/test/kotlin/com/lordcodes/turtle/ExecutableTest.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle 2 | 3 | import com.lordcodes.turtle.ArgumentsTest.LsFlag 4 | import java.net.URL 5 | import kotlin.test.assertEquals 6 | import org.junit.jupiter.api.Test 7 | 8 | internal class ExecutableTest { 9 | private val ls = Executable( 10 | "ls", 11 | URL("https://www.cyberciti.biz/faq/linux-unix-bash-shell-list-all-builtin-commands/"), 12 | ) 13 | 14 | @Test 15 | fun `plus with Arguments`() { 16 | val arguments = Arguments("-l", "-a") 17 | 18 | val actual = ls + arguments 19 | 20 | assertEquals(Command(ls, arguments), actual) 21 | } 22 | 23 | @Test 24 | fun `plus with multiple WithArgument`() { 25 | val flags: List = listOf(LsFlag.OnePerLine, LsFlag.BySizeDesc) 26 | 27 | val actual = ls + flags 28 | 29 | assertEquals(Command(ls, Arguments(listOf("-1", "-S"))), actual) 30 | } 31 | 32 | @Test 33 | fun `plus with single WithArgument`() { 34 | val actual = ls + LsFlag.OnePerLine 35 | 36 | assertEquals(Command(ls, Arguments(listOf("-1"))), actual) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | 3 | # Kotlin 4 | kotlin.code.style=official 5 | kotlin.incremental.useClasspathSnapshot=true 6 | 7 | # Gradle 8 | org.gradle.caching=true 9 | org.gradle.configureondemand=true 10 | org.gradle.kotlin.dsl.allWarningsAsErrors=true 11 | org.gradle.parallel=true 12 | org.gradle.unsafe.configuration-cache=true 13 | org.gradle.unsafe.configuration-cache-problems=warn 14 | org.gradle.jvmargs=-Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -Xms2g -Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseParallelGC 15 | 16 | # Maven Central publishing 17 | GROUP=com.lordcodes.turtle 18 | VERSION_NAME=0.10.0 19 | POM_ARTIFACT_ID=turtle 20 | 21 | POM_NAME=Turtle 22 | POM_PACKAGING=jar 23 | 24 | POM_DESCRIPTION=Run shell commands from a Kotlin script or application with ease 🐢 25 | POM_INCEPTION_YEAR=2020 26 | 27 | POM_URL=https://github.com/lordcodes/turtle/ 28 | POM_SCM_URL=https://github.com/lordcodes/turtle/ 29 | POM_SCM_CONNECTION=scm:git:git://github.com/lordcodes/turtle.git 30 | POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com:lordcodes/turtle.git 31 | 32 | POM_LICENCE_NAME=The Apache Software License, Version 2.0 33 | POM_LICENCE_URL=http://www.apache.org/licenses/LICENSE-2.0.txt 34 | POM_LICENCE_DIST=repo 35 | 36 | POM_DEVELOPER_ID=lordcodes 37 | POM_DEVELOPER_NAME=Andrew Lord 38 | POM_DEVELOPER_URL=https://github.com/lordcodes/ 39 | 40 | SONATYPE_HOST=S01 41 | RELEASE_SIGNING_ENABLED=true 42 | -------------------------------------------------------------------------------- /turtle/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | 3 | import java.net.URL 4 | 5 | plugins { 6 | kotlin("jvm") 7 | id("org.jetbrains.dokka") version "2.0.0" 8 | id("com.vanniktech.maven.publish") version "0.33.0" 9 | id("org.jmailen.kotlinter") 10 | } 11 | 12 | dependencies { 13 | implementation("org.jetbrains.kotlin:kotlin-stdlib:2.2.0") 14 | 15 | testImplementation("org.jetbrains.kotlin:kotlin-test-junit5:2.2.0") 16 | testImplementation("io.mockk:mockk:1.14.4") 17 | } 18 | 19 | tasks.test { 20 | useJUnitPlatform() 21 | environment("LANG", "en_US.UTF8") 22 | } 23 | 24 | kotlinter { 25 | failBuildWhenCannotAutoFormat = true 26 | reporters = arrayOf("checkstyle", "html") 27 | } 28 | 29 | tasks.dokkaHtml.configure { 30 | dokkaSourceSets { 31 | configureEach { 32 | jdkVersion.set(8) 33 | 34 | includes.from("module.md") 35 | 36 | sourceLink { 37 | localDirectory.set(file("./")) 38 | remoteUrl.set(URL("https://github.com/lordcodes/turtle/blob/master/")) 39 | remoteLineSuffix.set("#L") 40 | } 41 | } 42 | } 43 | } 44 | 45 | signing { 46 | useInMemoryPgpKeys(propertyOrEmpty("Turtle_Signing_Key"), propertyOrEmpty("Turtle_Signing_Password")) 47 | } 48 | 49 | fun Project.propertyOrEmpty(name: String): String { 50 | val property = findProperty(name) as String? 51 | return property ?: environmentVariable(name) 52 | } 53 | 54 | fun environmentVariable(name: String) = System.getenv(name) ?: "" 55 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Turtle 2 | 3 | If this is your first time contributing to Turtle, please have a read through our [Code of Conduct](https://github.com/lordcodes/turtle/blob/master/CODE_OF_CONDUCT.md). 4 | 5 | ## Reporting a bug 6 | 7 | * Compare the version you have installed against [the latest version](https://github.com/lordcodes/turtle/releases). 8 | * Check the issue hasn't [already been reported](https://github.com/lordcodes/turtle/issues). 9 | * Check there isn't already an [open pull request](https://github.com/lordcodes/turtle/pulls) to fix the issue. 10 | * [Open an issue](https://github.com/lordcodes/turtle/issues/new/choose) providing as much information as possible. 11 | 12 | ## Suggesting a new feature 13 | 14 | * Check the feature hasn't [already been requested](https://github.com/lordcodes/turtle/issues). 15 | * [Open an issue](https://github.com/lordcodes/turtle/issues/new/choose) providing a detailed description of the new feature, why you think it is needed and how it will be useful to other users. 16 | * If it makes sense for the feature to be added to Turtle, [a pull request](https://github.com/lordcodes/turtle/compare) adding the feature would be very much appreciated. 17 | 18 | ## Developing 19 | 20 | If you want to make changes, please make sure to discuss anything big before putting in the effort of creating the PR. 21 | 22 | * Clone the repository. 23 | * Make your changes. 24 | * Format code with: `./gradlew lcchecks`. 25 | * Make sure all checks pass with: `./gradlew lcchecks`. 26 | * Make sure all checks pass with: `./gradlew check`. 27 | * Submit a pull request. 28 | 29 | Thanks! 30 | -------------------------------------------------------------------------------- /sample/src/main/kotlin/com/lordcodes/turtle/sample/Main.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle.sample 2 | 3 | import com.lordcodes.turtle.Arguments 4 | import com.lordcodes.turtle.Command 5 | import com.lordcodes.turtle.Executable 6 | import com.lordcodes.turtle.GitCommands 7 | import com.lordcodes.turtle.shellRun 8 | import java.io.File 9 | import java.net.URL 10 | 11 | fun main() { 12 | shellRun { 13 | val gitLocation = files.which("git") ?: error("git must be installed") 14 | println("git: $gitLocation") 15 | 16 | println(git.gitAuthorName()) 17 | 18 | Command(Executable("mkdir"), Arguments("Test")).executeOrElse { it.message ?: "Failed" } 19 | 20 | command("mkdir", listOf("Test2")) 21 | command("rm", listOf("-rf", "BLAH")) 22 | command("rm", listOf("-rf", "Test", "Test2")) 23 | 24 | println(Git.add(listOf()).executeOrElse { it.message ?: "Error" }) 25 | 26 | "Finished" 27 | } 28 | } 29 | 30 | fun GitCommands.gitAuthorName() = gitCommand(listOf("--no-pager", "show", "-s", "--format=%an")) 31 | 32 | object Git { 33 | val executable = Executable("git", URL("https://git-scm.com/docs")) 34 | 35 | enum class Action { 36 | Init, 37 | Clone, 38 | Add, 39 | ; 40 | 41 | fun command(): Command = Command(executable, Arguments(name.lowercase())) 42 | } 43 | 44 | fun clone(url: URL, destination: File? = null): Command = Action.Clone.command() + Arguments(url, destination) 45 | 46 | fun init(): Command = Action.Init.command() 47 | 48 | fun add(files: List): Command = Action.Add.command() + Arguments(files) 49 | } 50 | -------------------------------------------------------------------------------- /turtle/src/main/kotlin/com/lordcodes/turtle/Executable.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle 2 | 3 | import java.net.URL 4 | 5 | /** 6 | * Command executable, e.g. 'cd', with its [name] and a [helpUrl] to provide the user help if the executable isn't 7 | * found on the system. 8 | * 9 | * @property [name] The command executable name, e.g. 'cd'. 10 | * @property [helpUrl] A url that gives help for the executable if it isn't found on the system. 11 | */ 12 | data class Executable( 13 | val name: String, 14 | val helpUrl: URL? = null, 15 | ) { 16 | /** 17 | * Creates a [Command] using this executable with the provided [arguments]. 18 | * 19 | * ``` 20 | * ls + Arguments("-l", "-a") 21 | * ``` 22 | * 23 | * @return [Command] The created command. 24 | * 25 | * @param [arguments] The arguments to pass to this executable. 26 | */ 27 | operator fun plus(arguments: Arguments): Command = Command(this, arguments) 28 | 29 | /** 30 | * Creates a [Command] using this executable with the provided [withArguments]. 31 | * 32 | * ``` 33 | * ls + withArgs 34 | * ``` 35 | * 36 | * @return [Command] The created command. 37 | * 38 | * @param [withArguments] The arguments to pass to this executable. 39 | */ 40 | operator fun plus(withArguments: Iterable): Command = Command(this, Arguments(withArguments)) 41 | 42 | /** 43 | * Creates a [Command] using this executable with the provided [withArgument]. 44 | * 45 | * ``` 46 | * ls + withArg 47 | * ``` 48 | * 49 | * @return [Command] The created command. 50 | * 51 | * @param [withArgument] The argument to pass to this executable. 52 | */ 53 | operator fun plus(withArgument: WithArgument): Command = Command(this, Arguments(withArgument)) 54 | } 55 | -------------------------------------------------------------------------------- /turtle/src/main/kotlin/com/lordcodes/turtle/Shell.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle 2 | 3 | import java.io.File 4 | 5 | /** 6 | * Run a block of shell commands within the provided function. Commands are created using [ShellScript]. 7 | * 8 | * @param [workingDirectory] The location to run commands from. It can be changed between commands within the 9 | * [script] function. By default, the current working directory will be used. 10 | * @param [dryRun] Use dry-run mode which prints executed commands instead of launching processes. 11 | * @param [script] A function that runs a series of shell commands. 12 | * 13 | * @return [String] The output of the [script] function. If the function just runs a series of shell commands, it 14 | * will be the output of running the final command. Using properties within the function, it could return the output 15 | * of running one of the other commands instead. 16 | * 17 | * @throws [ShellFailedException] There was an issue running one of the commands. 18 | * @throws [ShellRunException] Running one of the commands produced error output. 19 | */ 20 | fun shellRun(workingDirectory: File? = null, dryRun: Boolean = false, script: ShellScript.() -> String): String = 21 | ShellScript(workingDirectory, dryRun = dryRun).script() 22 | 23 | /** 24 | * Run a shell [command] with the specified [arguments]. Specify the [workingDirectory], or if unspecified the 25 | * current working directory will be used. Use [dryRun] to print the executed commands instead of actually executing 26 | * them and launching processes. The output will be provided as a [String]. 27 | * 28 | * @throws [ShellFailedException] There was an issue running the command. 29 | * @throws [ShellRunException] Running the command produced error output. 30 | */ 31 | fun shellRun( 32 | command: String, 33 | arguments: List = listOf(), 34 | workingDirectory: File? = null, 35 | dryRun: Boolean = false, 36 | ): String = shellRun(workingDirectory, dryRun = dryRun) { command(command, arguments) } 37 | -------------------------------------------------------------------------------- /turtle/src/test/kotlin/com/lordcodes/turtle/FileCommandsTest.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle 2 | 3 | import java.io.File 4 | import kotlin.test.Test 5 | import kotlin.test.assertEquals 6 | import kotlin.test.assertTrue 7 | import org.junit.jupiter.api.io.TempDir 8 | 9 | internal class FileCommandsTest { 10 | @TempDir 11 | lateinit var temporaryFolder: File 12 | 13 | private val shell by lazy { ShellScript(temporaryFolder) } 14 | private val files by lazy { FileCommands(shell) } 15 | 16 | @Test 17 | fun createSymlink_fileArguments() { 18 | val target = File(temporaryFolder, "targetFolder") 19 | target.mkdir() 20 | 21 | files.createSymlink(File("targetFolder"), File("linkedFrom")) 22 | 23 | assertEquals(shell.command("readlink", listOf("linkedFrom")), "targetFolder") 24 | } 25 | 26 | @Test 27 | fun createSymlink_stringArguments() { 28 | val target = File(temporaryFolder, "targetFolder") 29 | target.mkdir() 30 | 31 | files.createSymlink("targetFolder", "linkedFrom") 32 | 33 | assertEquals(shell.command("readlink", listOf("linkedFrom")), "targetFolder") 34 | } 35 | 36 | @Test 37 | fun readSymlink_fileArguments() { 38 | val target = File(temporaryFolder, "targetFolder") 39 | target.mkdir() 40 | shell.command("ln", listOf("-s", "targetFolder", "linkedFrom")) 41 | 42 | val output = files.readSymlink(File("linkedFrom")) 43 | 44 | assertEquals(output, "targetFolder") 45 | } 46 | 47 | @Test 48 | fun readSymlink_stringArguments() { 49 | val target = File(temporaryFolder, "targetFolder") 50 | target.mkdir() 51 | shell.command("ln", listOf("-s", "targetFolder", "linkedFrom")) 52 | 53 | val output = files.readSymlink("linkedFrom") 54 | 55 | assertEquals(output, "targetFolder") 56 | } 57 | 58 | @Test 59 | fun which() { 60 | assertEquals(shell.files.which("ls"), "/bin/ls") 61 | assertTrue(shell.files.which("xrearsKJlsa") == null) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Un-released changes (master) 4 | 5 | ## v0.10.0 6 | 7 | * Stream output as a Sequence [PR #239](https://github.com/lordcodes/turtle/pull/239). 8 | * Now using [kotlinter](https://github.com/jeremymailen/kotlinter-gradle) for formatting and linting Kotlin. 9 | * Gradle 8.7. 10 | * JDK 17 and Kotlin JVM Target 17. 11 | * Dependency updates. 12 | 13 | ## v0.9.0 14 | 15 | * Multi-platform open command. [PR #115](https://github.com/lordcodes/turtle/pull/119). 16 | * Handle different platforms for File Commands. [PR #213](https://github.com/lordcodes/turtle/pull/213). 17 | * Kotlin 1.9.20, latest Gradle, targeting Kotlin language version 11 and many dependency updates. 18 | 19 | ## v0.8.0 20 | 21 | * Add which command. [PR #117](https://github.com/lordcodes/turtle/pull/117) 22 | * Add dry run command. [PR #118](https://github.com/lordcodes/turtle/pull/118) 23 | * Command and Arguments abstraction. [PR #127](https://github.com/lordcodes/turtle/pull/127) 24 | * Many dependency updates. 25 | 26 | ## v0.7.0 27 | 28 | * Make ShellScript constructor accessible, so that consumers can create their own instances, such as creating a wrapper class. [PR #99](https://github.com/lordcodes/turtle/pull/99) 29 | * Many dependency updates. 30 | 31 | ## v0.6.0 32 | 33 | * Specify a process callback for a single command or a default one to use for all commands. This can be used to access the underlying process to monitor it or customise behaviour such as implementing a timeout. [PR #66](https://github.com/lordcodes/turtle/pull/66). 34 | * Access command output using the `InputStreams` rather than waiting for it to complete fully and accessing as a `String`. [PR #67](https://github.com/lordcodes/turtle/pull/67). 35 | * Many dependency updates. 36 | 37 | ## v0.5.0 38 | 39 | Fixed JDK version for consumers. 40 | 41 | ## v0.4.0 42 | 43 | :rotating_light: IMPORTANT: Only usable by certain JDK versions, please use v0.5.0 or later. 44 | 45 | Fixed the broken POM file from v0.3.0. 46 | 47 | ## v0.3.0 48 | 49 | :rotating_light: IMPORTANT: Broken release, please use v0.5.0 or later. 50 | 51 | * Maven Central publishing. 52 | * Updated dependencies to latest. 53 | 54 | ## v0.2.0 55 | 56 | Allow users to add extra Git commands to `GitCommands` via extension functions. 57 | 58 | ## v0.1.0 59 | 60 | Initial release of turtle 61 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /turtle/src/test/kotlin/com/lordcodes/turtle/ShellTest.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle 2 | 3 | import java.io.File 4 | import java.util.UUID 5 | import kotlin.test.Test 6 | import kotlin.test.assertEquals 7 | import org.junit.jupiter.api.io.TempDir 8 | 9 | internal class ShellTest { 10 | @Test 11 | fun shellRun_functionSyntax_singleCommand() { 12 | val output = shellRun { command("uuidgen") } 13 | 14 | val uuid = UUID.fromString(output) 15 | assertEquals(uuid.toString(), output.lowercase()) 16 | } 17 | 18 | @Test 19 | fun shellRun_functionSyntax_seriesOfCommands() { 20 | val output = shellRun { 21 | command("uuidgen") 22 | command("echo", listOf("Hello world!")) 23 | } 24 | 25 | assertEquals(output, "Hello world!") 26 | } 27 | 28 | @Test 29 | fun shellRun_functionSyntax_commandWithWorkingDirectory(@TempDir temporaryFolder: File) { 30 | val testFile = File(temporaryFolder, "testFile") 31 | testFile.createNewFile() 32 | testFile.writeText("expectedValue") 33 | 34 | val output = shellRun(temporaryFolder) { 35 | command("cat", listOf(testFile.name)) 36 | } 37 | 38 | assertEquals(output, "expectedValue") 39 | } 40 | 41 | @Test 42 | fun shellRun_functionSyntax_commandWithChangingWorkingDirectory(@TempDir temporaryFolder: File) { 43 | val testFile = File(temporaryFolder, "testFile") 44 | testFile.createNewFile() 45 | testFile.writeText("expectedValue") 46 | 47 | val output = shellRun { 48 | changeWorkingDirectory(temporaryFolder) 49 | command("cat", listOf(testFile.name)) 50 | } 51 | 52 | assertEquals(output, "expectedValue") 53 | } 54 | 55 | @Test 56 | fun shellRun_singleCommandWithoutArguments() { 57 | val output = shellRun("uuidgen") 58 | 59 | val uuid = UUID.fromString(output) 60 | assertEquals(uuid.toString(), output.lowercase()) 61 | } 62 | 63 | @Test 64 | fun shellRun_singleCommandWithArguments() { 65 | val output = shellRun("echo", listOf("Hello world!")) 66 | 67 | assertEquals(output, "Hello world!") 68 | } 69 | 70 | @Test 71 | fun shellRun_singleCommandWithWorkingDirectory(@TempDir temporaryFolder: File) { 72 | val testFile = File(temporaryFolder, "testFile") 73 | testFile.createNewFile() 74 | testFile.writeText("expectedValue") 75 | 76 | val output = shellRun("cat", listOf(testFile.name), temporaryFolder) 77 | 78 | assertEquals(output, "expectedValue") 79 | } 80 | 81 | @Test 82 | fun shellRun_dryRun() = withCapturedSystemOut { systemOutStream -> 83 | val output = shellRun(dryRun = true) { 84 | git.currentBranch() 85 | } 86 | 87 | assertEquals(output, "") 88 | assertEquals(systemOutStream.toString().trim(), "git rev-parse --abbrev-ref HEAD") 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at andrew@lordcodes.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /turtle/src/test/kotlin/com/lordcodes/turtle/ShellScriptTest.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle 2 | 3 | import java.io.File 4 | import java.util.UUID 5 | import kotlin.test.Test 6 | import kotlin.test.assertEquals 7 | import kotlin.test.assertTrue 8 | import org.junit.jupiter.api.io.TempDir 9 | 10 | internal class ShellScriptTest { 11 | @Test 12 | fun command_withoutArguments() { 13 | val output = ShellScript().command("uuidgen") 14 | 15 | val uuid = UUID.fromString(output) 16 | assertEquals(uuid.toString(), output.lowercase()) 17 | } 18 | 19 | @Test 20 | fun command_withArguments() { 21 | val output = ShellScript().command("echo", listOf("Hello world!")) 22 | 23 | assertEquals(output, "Hello world!") 24 | } 25 | 26 | @Test 27 | fun command_withWorkingDirectory(@TempDir temporaryFolder: File) { 28 | val testFile = File(temporaryFolder, "testFile") 29 | testFile.createNewFile() 30 | testFile.writeText("expectedValue") 31 | val script = ShellScript(temporaryFolder) 32 | 33 | val output = script.command("cat", listOf(testFile.name)) 34 | 35 | assertEquals(output, "expectedValue") 36 | } 37 | 38 | @Test 39 | fun command_processCallback() { 40 | var defaultCallbacksProcess: Process? = null 41 | val script = ShellScript() 42 | script.defaultCallbacks = object : ProcessCallbacks { 43 | override fun onProcessStart(process: Process) { 44 | defaultCallbacksProcess = process 45 | } 46 | } 47 | var commandProcess: Process? = null 48 | val callback = object : ProcessCallbacks { 49 | override fun onProcessStart(process: Process) { 50 | commandProcess = process 51 | } 52 | } 53 | 54 | script.command("echo", listOf("Hello world!"), callback) 55 | 56 | assertTrue(commandProcess != null) 57 | assertTrue(defaultCallbacksProcess != null) 58 | assertEquals(defaultCallbacksProcess, commandProcess) 59 | } 60 | 61 | @Test 62 | fun commandSequence(@TempDir temporaryFolder: File) { 63 | val testFile = File(temporaryFolder, "testFile") 64 | testFile.createNewFile() 65 | testFile.appendText("Line 1\n") 66 | testFile.appendText("Line 2\n") 67 | testFile.appendText("Line 3\n") 68 | val shell = ShellScript(temporaryFolder) 69 | 70 | val sequence = shell.commandSequence("cat", listOf(testFile.name)) 71 | 72 | assertEquals(sequence.toList(), listOf("Line 1", "Line 2", "Line 3")) 73 | } 74 | 75 | @Test 76 | fun changeWorkingDirectory_stringPath(@TempDir temporaryFolder: File) { 77 | val testFile = File(temporaryFolder, "testFile") 78 | testFile.createNewFile() 79 | testFile.writeText("expectedValue") 80 | val script = ShellScript() 81 | 82 | script.changeWorkingDirectory(temporaryFolder.absolutePath) 83 | val output = script.command("cat", listOf(testFile.name)) 84 | 85 | assertEquals(output, "expectedValue") 86 | } 87 | 88 | @Test 89 | fun changeWorkingDirectory_file(@TempDir temporaryFolder: File) { 90 | val testFile = File(temporaryFolder, "testFile") 91 | testFile.createNewFile() 92 | testFile.writeText("expectedValue") 93 | val script = ShellScript() 94 | 95 | script.changeWorkingDirectory(temporaryFolder) 96 | val output = script.command("cat", listOf(testFile.name)) 97 | 98 | assertEquals(output, "expectedValue") 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /config/detekt/detekt.yml: -------------------------------------------------------------------------------- 1 | build: 2 | maxIssues: 1 3 | 4 | comments: 5 | DeprecatedBlockTag: 6 | active: true 7 | EndOfSentenceFormat: 8 | active: true 9 | KDocReferencesNonPublicProperty: 10 | active: true 11 | OutdatedDocumentation: 12 | active: true 13 | UndocumentedPublicClass: 14 | active: true 15 | UndocumentedPublicFunction: 16 | active: true 17 | UndocumentedPublicProperty: 18 | active: true 19 | 20 | complexity: 21 | CognitiveComplexMethod: 22 | active: true 23 | ComplexInterface: 24 | active: true 25 | CyclomaticComplexMethod: 26 | ignoreSingleWhenExpression: true 27 | LargeClass: 28 | active: false 29 | LongMethod: 30 | active: false 31 | LongParameterList: 32 | active: false 33 | TooManyFunctions: 34 | active: false 35 | 36 | coroutines: 37 | GlobalCoroutineUsage: 38 | active: true 39 | SuspendFunSwallowedCancellation: 40 | active: true 41 | SuspendFunWithCoroutineScopeReceiver: 42 | active: true 43 | 44 | empty-blocks: 45 | EmptyFunctionBlock: 46 | ignoreOverridden: true 47 | 48 | exceptions: 49 | NotImplementedDeclaration: 50 | active: true 51 | ObjectExtendsThrowable: 52 | active: true 53 | ThrowingExceptionInMain: 54 | active: true 55 | 56 | naming: 57 | BooleanPropertyNaming: 58 | active: true 59 | 60 | performance: 61 | CouldBeSequence: 62 | active: true 63 | UnnecessaryPartOfBinaryExpression: 64 | active: true 65 | 66 | potential-bugs: 67 | CastNullableToNonNullableType: 68 | active: true 69 | CastToNullableType: 70 | active: true 71 | Deprecation: 72 | active: true 73 | DontDowncastCollectionTypes: 74 | active: true 75 | ElseCaseInsteadOfExhaustiveWhen: 76 | active: true 77 | ExitOutsideMain: 78 | active: true 79 | ImplicitUnitReturnType: 80 | active: true 81 | LateinitUsage: 82 | active: true 83 | NullCheckOnMutableProperty: 84 | active: true 85 | NullableToStringCall: 86 | active: true 87 | PropertyUsedBeforeDeclaration: 88 | active: true 89 | UnconditionalJumpStatementInLoop: 90 | active: true 91 | UnnecessaryNotNullCheck: 92 | active: true 93 | 94 | style: 95 | AlsoCouldBeApply: 96 | active: true 97 | BracesOnIfStatements: 98 | active: true 99 | singleLine: 'always' 100 | multiLine: 'always' 101 | BracesOnWhenStatements: 102 | active: true 103 | singleLine: 'necessary' 104 | multiLine: 'always' 105 | CanBeNonNullable: 106 | active: true 107 | DataClassShouldBeImmutable: 108 | active: true 109 | DoubleNegativeLambda: 110 | active: true 111 | EqualsOnSignatureLine: 112 | active: true 113 | ExplicitCollectionElementAccessMethod: 114 | active: true 115 | ExpressionBodySyntax: 116 | active: true 117 | ForbiddenAnnotation: 118 | active: true 119 | ForbiddenMethodCall: 120 | active: true 121 | MagicNumber: 122 | ignoreAnnotation: true 123 | MandatoryBracesLoops: 124 | active: true 125 | MultilineLambdaItParameter: 126 | active: true 127 | NoTabs: 128 | active: true 129 | OptionalUnit: 130 | active: true 131 | PreferToOverPairSyntax: 132 | active: true 133 | RedundantExplicitType: 134 | active: true 135 | RedundantVisibilityModifierRule: 136 | active: true 137 | ReturnCount: 138 | active: false 139 | SpacingBetweenPackageAndImports: 140 | active: true 141 | StringShouldBeRawString: 142 | active: true 143 | TrailingWhitespace: 144 | active: true 145 | ThrowsCount: 146 | active: false 147 | UnderscoresInNumericLiterals: 148 | active: true 149 | UnnecessaryBackticks: 150 | active: true 151 | UnnecessaryInnerClass: 152 | active: true 153 | UnnecessaryLet: 154 | active: true 155 | UnnecessaryParentheses: 156 | active: true 157 | UntilInsteadOfRangeTo: 158 | active: true 159 | UnusedImports: 160 | active: true 161 | UseEmptyCounterpart: 162 | active: true 163 | UseIfEmptyOrIfBlank: 164 | active: true 165 | UseLet: 166 | active: true 167 | UseSumOfInsteadOfFlatMapSize: 168 | active: true 169 | -------------------------------------------------------------------------------- /turtle/src/test/kotlin/com/lordcodes/turtle/ArgumentsTest.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle 2 | 3 | import java.io.File 4 | import java.net.URI 5 | import java.net.URL 6 | import kotlin.random.Random 7 | import kotlin.test.assertEquals 8 | import org.junit.jupiter.api.Test 9 | import org.junit.jupiter.api.assertThrows 10 | 11 | internal class ArgumentsTest { 12 | @Test 13 | fun `plus with another Arguments`() { 14 | val before = Arguments(listOf("-a", "-l")) 15 | 16 | val actual = before + 17 | Arguments(listOf("--color=when")) + 18 | Arguments(File("src"), File("build")) 19 | 20 | assertEquals(Arguments(listOf("-a", "-l", "--color=when", "src", "build")), actual) 21 | } 22 | 23 | @Test 24 | fun `plus with multiple WithArgument`() { 25 | val before = Arguments("-a") 26 | val withArguments: Iterable = listOf(LsFlag.OnlyDirectory, LsFlag.OnePerLine) 27 | 28 | val actual = before + withArguments 29 | 30 | assertEquals(Arguments(listOf("-a", "-d", "-1")), actual) 31 | } 32 | 33 | @Test 34 | fun `plus with single WithArgument`() { 35 | val before = Arguments("-a") 36 | 37 | val actual = before + LsFlag.OnlyDirectory 38 | 39 | assertEquals(Arguments(listOf("-a", "-d")), actual) 40 | } 41 | 42 | @Test 43 | fun `minus with another Arguments`() { 44 | val before = Arguments(listOf("-a", "-l", "src", "build")) 45 | 46 | val actual = before - 47 | Arguments(listOf("--color=when")) - 48 | Arguments(File("src"), File("build")) 49 | 50 | assertEquals(Arguments(listOf("-a", "-l")), actual) 51 | } 52 | 53 | @Test 54 | fun `minus with multiple WithArgument`() { 55 | val before = Arguments(listOf("-a", "-l", "src", "build")) 56 | val withArguments: Iterable = listOf(LsFlag.AllFiles, LsFlag.LongFormat) 57 | 58 | val actual = before - withArguments 59 | 60 | assertEquals(Arguments(listOf("src", "build")), actual) 61 | } 62 | 63 | @Test 64 | fun `minus with single WithArgument`() { 65 | val before = Arguments(listOf("-a", "-l", "src", "build")) 66 | 67 | val actual = before - LsFlag.LongFormat 68 | 69 | assertEquals(Arguments(listOf("-a", "src", "build")), actual) 70 | } 71 | 72 | @Test 73 | fun `Arguments with vararg Strings`() { 74 | assertEquals(Arguments(listOf("a", "b")), Arguments("a", "b")) 75 | } 76 | 77 | @Test 78 | fun `Arguments with vararg supported types`() { 79 | val rawUrl = "http://example.com" 80 | val expected = Arguments(listOf("0", "1", "10.0", "true", "false", "c", rawUrl, rawUrl, "src")) 81 | 82 | val actual = Arguments(0, 1, 10.0, true, false, 'c', URL(rawUrl), URI(rawUrl), File("src")) 83 | 84 | assertEquals(expected, actual) 85 | } 86 | 87 | @Test 88 | fun `Arguments with vararg flatten`() { 89 | val expected = Arguments( 90 | listOf( 91 | "a", 92 | "b", 93 | "c", 94 | "d", 95 | "d", 96 | "e", 97 | "g", 98 | "h", 99 | "i", 100 | "j", 101 | "k", 102 | "l", 103 | "m", 104 | "n", 105 | "--color", 106 | "blue", 107 | "--max", 108 | "42", 109 | ), 110 | ) 111 | 112 | val actual = Arguments( 113 | "a", 114 | listOf("b", "c", "d"), 115 | setOf("d", "e", "e"), 116 | Pair("g", "h"), 117 | Triple("i", "j", File("k")), 118 | listOf(listOf("l", "m"), setOf("n")), 119 | mapOf( 120 | "--color" to "blue", 121 | "--max" to 42, 122 | ), 123 | ) 124 | 125 | assertEquals(expected, actual) 126 | } 127 | 128 | @Test 129 | fun `Arguments with vararg containing invalid arguments`() { 130 | val exception = assertThrows { 131 | Arguments("invalid", Random(42), ProcessBuilder()) 132 | } 133 | 134 | assertEquals( 135 | "Classes couldn't be converted to Arguments: [XorWowRandom, ProcessBuilder]", 136 | exception.message, 137 | ) 138 | } 139 | 140 | @Test 141 | fun `Arguments with vararg using WithArgument`() { 142 | val actual = Arguments(LsFlag.LongFormat, LsFlag.AllFiles, LsFlag.BySizeDesc) 143 | 144 | assertEquals(Arguments(listOf("-l", "-a", "-S")), actual) 145 | } 146 | 147 | enum class LsFlag(override val argument: String) : WithArgument { 148 | OnePerLine("-1"), 149 | AllFiles("-a"), 150 | LongFormat("-l"), 151 | BySizeDesc("-S"), 152 | OnlyDirectory("-d"), 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Turtle 3 |

4 | 5 |

6 | 7 | Pure Kotlin 8 | 9 | 10 | Latest release 11 | 12 | 13 | Gradle build status 14 | 15 | 16 | Twitter: @lordcodes 17 | 18 |

19 | 20 | --- 21 | 22 | Run shell commands from Kotlin scripts, apps or Gradle tasks with ease. 23 | 24 | Turtle simplifies the process of running external commands and processes from your Kotlin (or Java) code. It comes bundled with a selection of built-in functions, such as opening MacOS applications and dealing with Git. Running shell commands easily is particularly useful from within Kotlin scripts, command line applications and Gradle tasks. 25 | 26 | Turtle is mainly aimed at MacOS, some of the shell commands may not exist or work as expected on Linux or Windows. It is planned in the future to alter the API surface to make this clearer when calling commands. 27 | 28 |   29 | 30 |

31 | FeaturesInstallUsageContributing 32 |

33 | 34 | ## Features 35 | 36 | #### ▶︎ Run shell commands with minimal boilerplate 37 | 38 | Simply specify the comamnd and its arguments to easily run and retrieve output. 39 | 40 | #### ▶︎ Call any of the built-in shell commands 41 | 42 | Various commands are provided, such as creating a Git commit and opening files. 43 | 44 | #### ▶︎ Use the function syntax to run a series of commands 45 | 46 | Specify a sequence of commands to run within a function block. 47 | 48 | #### ▶︎ Capture error exit code and output 49 | 50 | When a command produces an error, the exit code and error output is thrown as an exception. 51 | 52 | ## Install 53 | 54 | Turtle is provided as a Gradle/Maven dependency. 55 | 56 | * v0.5.0 onwards are available via Maven Central. 57 | * v0.3.0 and v0.4.0 had issues, so please use v0.5.0 or later. 58 | * Earlier releases were available via Bintray/JCenter. 59 | 60 | #### ▶︎ Gradle Kotlin DSL 61 | 62 | ```gradle 63 | dependencies { 64 | implementation("com.lordcodes.turtle:turtle:0.10.0") 65 | } 66 | ``` 67 | 68 | #### ▶︎ Gradle Groovy DSL 69 | 70 | ```gradle 71 | dependencies { 72 | implementation 'com.lordcodes.turtle:turtle:0.10.0' 73 | } 74 | ``` 75 | 76 | ## Usage 77 | 78 | Note: As mentioned above Turtle is mainly aimed at MacOS and so some shell commands may not exist or work as expected on Linux or Windows. 79 | 80 | --- 81 | 82 | To run a single custom command, just call `shellRun()` and provide the command and arguments. 83 | 84 | ```kotlin 85 | val output = shellRun("git", listOf("rev-parse", "--abbrev-ref", "HEAD")) 86 | println(output) // Current branch name, e.g. master 87 | ``` 88 | 89 | The working directory can be provided, to run the command in a particular location. `ShellLocation` provides easy access to some useful locations, such as the user's home directory. 90 | 91 | ```kotlin 92 | val turtleProject = ShellLocation.HOME.resolve("projects/turtle") 93 | val output = shellRun("git", listOf("rev-parse", "--abbrev-ref", "HEAD"), turtleProject) 94 | println(output) // Current branch name, e.g. master 95 | ``` 96 | 97 | To run a series of commands or use the built-in commands, just call `shellRun {}`. 98 | 99 | ```kotlin 100 | shellRun { 101 | command("mkdir tortoise") 102 | 103 | changeWorkingDirectory("tortoise") 104 | 105 | git.commit("Initial commit") 106 | git.addTag("v1.2", "Release v1.2") 107 | 108 | files.openApplication("Spotify") 109 | } 110 | ``` 111 | 112 | The initial working directory can be specified. 113 | 114 | ```kotlin 115 | val turtleProject = ShellLocation.HOME.resolve("projects/turtle") 116 | shellRun(turtleProject) { 117 | … 118 | } 119 | ``` 120 | 121 | ### Built-in commands 122 | 123 | #### ▶︎ Git 124 | 125 | ```kotlin 126 | shellRun { 127 | git.init() 128 | git.status() 129 | git.commit("Commit message") 130 | git.commitAllChanges("Commit message") 131 | git.push("origin", "master") 132 | git.pull() 133 | git.checkout("release") 134 | git.clone("https://github.com/lordcodes/turtle.git") 135 | git.addTag("v1.1", "Release v1.1") 136 | git.pushTag("v1.1") 137 | git.currentBranch() 138 | } 139 | ``` 140 | 141 | #### ▶︎ Files 142 | 143 | ```kotlin 144 | shellRun { 145 | files.openFile("script.kts") 146 | files.openApplication("Mail") 147 | files.createSymlink("target", "link") 148 | files.readSymlink("link") 149 | } 150 | ``` 151 | 152 | #### ▶︎ More 153 | 154 | Extra commands can easily be added by either calling `command` or by extending `ShellScript`. If you have created a command that you think should be built in, please feel free to [open a PR](https://github.com/lordcodes/turtle/pull/new/master). 155 | 156 | ### Streaming output 157 | 158 | Instead of returning output as a String via `command`, you can instead receive it as a `Sequence` using `commandSequence`. The sequence provides standard output and standard error line-by-line. 159 | 160 | ```kotlin 161 | commandSequence("cat", listOf("/path/to/largeFile.txt")).forEach { line -> 162 | println(line) 163 | } 164 | ``` 165 | 166 | ## Contributing or Help 167 | 168 | If you notice any bugs or have a new feature to suggest, please check out the [contributing guide](https://github.com/lordcodes/turtle/blob/master/CONTRIBUTING.md). If you want to make changes, please make sure to discuss anything big before putting in the effort of creating the PR. 169 | 170 | To reach out, please contact [@lordcodes on Twitter](https://twitter.com/lordcodes). 171 | -------------------------------------------------------------------------------- /turtle/src/main/kotlin/com/lordcodes/turtle/Arguments.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle 2 | 3 | import java.io.File 4 | import java.net.URI 5 | import java.net.URL 6 | 7 | /** 8 | * A list of command-line [arguments]. Implements List by delegating to the provided arguments list. 9 | * 10 | * @property [arguments] The list of arguments. 11 | */ 12 | data class Arguments( 13 | val arguments: List, 14 | ) : List by arguments { 15 | /** 16 | * Returns an [Arguments] copy of these arguments with the provided [arguments] added. 17 | * 18 | * ``` 19 | * Arguments("src", "dest") + Arguments("--verbose") 20 | * ``` 21 | * 22 | * @return [Arguments] An arguments copy with the provided arguments added. 23 | * 24 | * @param [arguments] The arguments to add to these arguments. 25 | */ 26 | operator fun plus(arguments: Arguments): Arguments = Arguments(this.arguments + arguments) 27 | 28 | /** 29 | * Returns an [Arguments] copy of these arguments with the provided [arguments] added. 30 | * 31 | * ``` 32 | * Arguments("src", "dest") + withArguments 33 | * ``` 34 | * 35 | * @return [Arguments] An arguments copy with the provided arguments added. 36 | * 37 | * @param [withArguments] The arguments to add to these arguments. 38 | */ 39 | operator fun plus(withArguments: Iterable): Arguments = 40 | Arguments(arguments + withArguments.map { it.argument }) 41 | 42 | /** 43 | * Returns an [Arguments] copy of these arguments with the provided [withArgument] added. 44 | * 45 | * ``` 46 | * Arguments("src", "dest") + withArgument 47 | * ``` 48 | * 49 | * @return [Arguments] An arguments copy with the provided argument added. 50 | * 51 | * @param [withArgument] The argument to add to these arguments. 52 | */ 53 | operator fun plus(withArgument: WithArgument): Arguments = Arguments(arguments + withArgument.argument) 54 | 55 | /** 56 | * Returns an [Arguments] copy of these arguments with the provided [arguments] removed. 57 | * 58 | * ``` 59 | * Arguments("src", "dest") - Arguments("dest") 60 | * ``` 61 | * 62 | * @return [Arguments] An arguments copy with the provided arguments removed. 63 | * 64 | * @param [arguments] The arguments to remove from these arguments. 65 | */ 66 | operator fun minus(arguments: Arguments): Arguments = Arguments(this.arguments - arguments.toSet()) 67 | 68 | /** 69 | * Returns an [Arguments] copy of these arguments with the provided [withArguments] removed. 70 | * 71 | * ``` 72 | * Arguments("src", "dest") - withArguments 73 | * ``` 74 | * 75 | * @return [Arguments] An arguments copy with the provided arguments removed. 76 | * 77 | * @param [withArguments] The arguments to remove from these arguments. 78 | */ 79 | operator fun minus(withArguments: Iterable): Arguments = 80 | Arguments(arguments - withArguments.map { it.argument }.toSet()) 81 | 82 | /** 83 | * Returns an [Arguments] copy of these arguments with the provided [withArgument] removed. 84 | * 85 | * ``` 86 | * Arguments("src", "dest") - withArgument 87 | * ``` 88 | * 89 | * @return [Arguments] An arguments copy with the provided argument removed. 90 | * 91 | * @param [withArgument] The argument to remove from these arguments. 92 | */ 93 | operator fun minus(withArgument: WithArgument): Arguments = Arguments(arguments - withArgument.argument) 94 | } 95 | 96 | /** 97 | * Create [Arguments] from any number of supported types, converting them to strings. 98 | * 99 | * - Boolean 100 | * - Char 101 | * - File 102 | * - Number 103 | * - String 104 | * - URI 105 | * - URL 106 | * - WithArgument 107 | * 108 | * or collections of the supported types. 109 | * 110 | * - Iterable 111 | * - List 112 | * - Map 113 | * - Pair 114 | * - Triple 115 | */ 116 | fun Arguments(vararg arguments: Any?): Arguments { 117 | val flattenedArguments = arguments.toList().recursivelyFlatten() 118 | val invalidArgumentClasses = flattenedArguments 119 | .mapNotNull { argument -> 120 | if (argument.asArgumentOrNull() == null) { 121 | argument::class.simpleName 122 | } else { 123 | null 124 | } 125 | } 126 | .distinct() 127 | require(invalidArgumentClasses.isEmpty()) { 128 | "Classes couldn't be converted to Arguments: $invalidArgumentClasses" 129 | } 130 | val validArguments = flattenedArguments.mapNotNull { it.asArgumentOrNull() } 131 | return Arguments(validArguments) 132 | } 133 | 134 | private fun Any.asArgumentOrNull(): String? = when (this) { 135 | is Boolean -> toString() 136 | is Char -> toString() 137 | is File -> path 138 | is Number -> toString() 139 | is String -> this 140 | is URI -> toString() 141 | is URL -> toString() 142 | is WithArgument -> argument 143 | else -> null 144 | } 145 | 146 | private fun Iterable.recursivelyFlatten(): List { 147 | val result = mutableListOf() 148 | for (element in this) { 149 | when (element) { 150 | null -> { 151 | continue 152 | } 153 | 154 | is Iterable<*> -> { 155 | result.addAll(element.recursivelyFlatten()) 156 | } 157 | 158 | is Map<*, *> -> { 159 | result.addAll( 160 | element.entries 161 | .flatMap { listOf(it.key, it.value) } 162 | .recursivelyFlatten(), 163 | ) 164 | } 165 | 166 | is Pair<*, *> -> { 167 | result.addAll(element.toList().recursivelyFlatten()) 168 | } 169 | 170 | is Triple<*, *, *> -> { 171 | result.addAll(element.toList().recursivelyFlatten()) 172 | } 173 | 174 | else -> { 175 | result.add(element) 176 | } 177 | } 178 | } 179 | return result 180 | } 181 | -------------------------------------------------------------------------------- /turtle/src/test/kotlin/com/lordcodes/turtle/CommandTest.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("UndocumentedPublicClass", "UndocumentedPublicFunction") 2 | 3 | package com.lordcodes.turtle 4 | 5 | import com.lordcodes.turtle.ArgumentsTest.LsFlag 6 | import java.net.URL 7 | import kotlin.test.assertEquals 8 | import org.junit.jupiter.api.Test 9 | import org.junit.jupiter.api.assertThrows 10 | 11 | internal class CommandTest { 12 | private val builtinUnixCommands = URL( 13 | "https://www.cyberciti.biz/faq/linux-unix-bash-shell-list-all-builtin-commands/", 14 | ) 15 | private val echo = Executable("echo", builtinUnixCommands) 16 | private val ls = Executable("ls", builtinUnixCommands) 17 | 18 | @Test 19 | fun `plus with Arguments`() { 20 | val expected = Command(ls, Arguments(listOf("-1", "-a", "-l"))) 21 | 22 | val actual = Command(ls, Arguments("-1")) + Arguments("-a", "-l") 23 | 24 | assertEquals(expected, actual) 25 | } 26 | 27 | @Test 28 | fun `plus with multiple WithArgument`() { 29 | val withArguments: Iterable = listOf(LsFlag.AllFiles, LsFlag.LongFormat) 30 | val expected = Command(ls, Arguments(listOf("-1", "-a", "-l"))) 31 | 32 | val actual = Command(ls, Arguments("-1")) + withArguments 33 | 34 | assertEquals(expected, actual) 35 | } 36 | 37 | @Test 38 | fun `plus with single WithArgument`() { 39 | val expected = Command(ls, Arguments(listOf("-1", "-a"))) 40 | 41 | val actual = Command(ls, Arguments("-1")) + LsFlag.AllFiles 42 | 43 | assertEquals(expected, actual) 44 | } 45 | 46 | @Test 47 | fun `minus with Arguments`() { 48 | val expected = Command(ls, Arguments(listOf("-c"))) 49 | 50 | val actual = Command(ls, Arguments("-a", "-b", "-c")) - Arguments("-a", "-b", "-d") 51 | 52 | assertEquals(expected, actual) 53 | } 54 | 55 | @Test 56 | fun `minus with multiple WithArgument`() { 57 | val withArguments: Iterable = listOf(LsFlag.AllFiles, LsFlag.LongFormat) 58 | val expected = Command(ls, Arguments(listOf("-1"))) 59 | 60 | val actual = Command(ls, Arguments("-1", "-a", "-l")) - withArguments 61 | 62 | assertEquals(expected, actual) 63 | } 64 | 65 | @Test 66 | fun `minus with single WithArgument`() { 67 | val expected = Command(ls, Arguments(listOf("-1", "-l"))) 68 | 69 | val actual = Command(ls, Arguments("-1", "-a", "-l")) - LsFlag.AllFiles 70 | 71 | assertEquals(expected, actual) 72 | } 73 | 74 | @Test 75 | fun contains() { 76 | val command = Command(ls, Arguments("-1", "-a", "-l")) 77 | val withArguments: Iterable = listOf(LsFlag.AllFiles, LsFlag.LongFormat) 78 | 79 | assertEquals(true, Arguments("-a", "-l") in command) 80 | assertEquals(true, withArguments in command) 81 | assertEquals(true, LsFlag.AllFiles in command) 82 | assertEquals(false, LsFlag.OnlyDirectory in command) 83 | } 84 | 85 | @Test 86 | fun `toString without escaping or quotes`() { 87 | val command = Command( 88 | ls, 89 | Arguments("-a", "-l", "--color=when", "src", "build"), 90 | ) 91 | 92 | assertEquals("ls -a -l --color=when src build", command.toString()) 93 | } 94 | 95 | @Test 96 | fun `toString with escaping and quotes`() { 97 | val command = Command( 98 | ls, 99 | Arguments("ab", "a b", "a\"b", "a\'b", "'ab'", "\"ab\"", "", "''", "\"''\""), 100 | ) 101 | val expected = """ 102 | ls ab 'a b' 'a"b' 'a'b' ab ab '' '' '' 103 | """.trim() 104 | 105 | assertEquals(expected, command.toString()) 106 | } 107 | 108 | @Test 109 | fun `executeOrThrow when success`() { 110 | val command = Command(echo, Arguments("hello", "world")) 111 | 112 | val actual = command.executeOrThrow() 113 | 114 | assertEquals("hello world", actual) 115 | } 116 | 117 | @Test 118 | fun `executeOrThrow when failure`() { 119 | val command = Command(ls, Arguments("/invalid/path")) 120 | val expectedError = 121 | "Running shell command failed with code 1 and message: ls: /invalid/path: No such file or directory" 122 | 123 | val error = assertThrows { 124 | command.executeOrThrow() 125 | } 126 | 127 | assertEquals(expectedError, error.message) 128 | } 129 | 130 | @Test 131 | fun `executeOrThrow when command not found`() { 132 | val notInstalled = Executable( 133 | name = "sdlpop", 134 | helpUrl = URL("https://github.com/NagyD/SDLPoP"), 135 | ) 136 | val command = Command(notInstalled, Arguments("--version")) 137 | val expectedError = "Command ${notInstalled.name} not found. See ${notInstalled.helpUrl}." 138 | 139 | val error = assertThrows { 140 | command.executeOrThrow() 141 | } 142 | 143 | assertEquals(expectedError, error.message) 144 | } 145 | 146 | @Test 147 | fun `executeOrElse when success`() { 148 | val command = Command(echo, Arguments("hello", "world")) 149 | 150 | val actual = command.executeOrElse { error("should have worked") } 151 | 152 | assertEquals("hello world", actual) 153 | } 154 | 155 | @Test 156 | fun `executeOrElse when failure`() { 157 | val command = Command(ls, Arguments("/invalid/path")) 158 | val resultIfFailed = "skipping invalid path" 159 | 160 | val actual = command.executeOrElse { resultIfFailed } 161 | 162 | assertEquals(resultIfFailed, actual) 163 | } 164 | 165 | @Test 166 | fun `executeOrElse when command not found`() { 167 | val notInstalled = Executable( 168 | name = "sdlpop", 169 | helpUrl = URL("https://github.com/NagyD/SDLPoP"), 170 | ) 171 | val command = Command(notInstalled, Arguments("--version")) 172 | val expectedError = "Command ${notInstalled.name} not found. See ${notInstalled.helpUrl}." 173 | 174 | val actual = command.executeOrElse { it.message ?: "Failed" } 175 | 176 | assertEquals(expectedError, actual) 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /turtle/src/test/kotlin/com/lordcodes/turtle/GitCommandsTest.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle 2 | 3 | import java.io.File 4 | import kotlin.test.Test 5 | import kotlin.test.assertContains 6 | import kotlin.test.assertEquals 7 | import kotlin.test.assertFalse 8 | import kotlin.test.assertTrue 9 | import org.junit.jupiter.api.assertThrows 10 | import org.junit.jupiter.api.io.TempDir 11 | 12 | internal class GitCommandsTest { 13 | // push - create remote that is a local repo and push to it, check changes in other repo 14 | // pull - pull changes from the local remote 15 | // clone - clone from local remote 16 | // pushTag - push to local remote and check it is there in the remote repo 17 | 18 | @TempDir 19 | lateinit var temporaryFolder: File 20 | 21 | private val shell by lazy { ShellScript(temporaryFolder) } 22 | private val git by lazy { GitCommands(shell) } 23 | 24 | @Test 25 | fun gitInit() { 26 | git.gitInit() 27 | 28 | assertTrue(File(temporaryFolder, ".git").isDirectory) 29 | } 30 | 31 | @Test 32 | fun status() { 33 | initUsableRepository() 34 | val newFile = File(temporaryFolder, "testFile.txt") 35 | newFile.createNewFile() 36 | 37 | val output = git.status() 38 | 39 | assertEquals(output, "?? ${newFile.name}") 40 | } 41 | 42 | @Test 43 | fun addAll() { 44 | initUsableRepository() 45 | val modifiedFile = File(temporaryFolder, "testFile.txt") 46 | modifiedFile.createNewFile() 47 | shell.command("git", listOf("add", "testFile.txt")) 48 | shell.command("git", listOf("commit", "-a", "-m", "Add testFile", "--quiet")) 49 | modifiedFile.writeText("changes") 50 | val newFile = File(temporaryFolder, "anotherFile.txt") 51 | newFile.createNewFile() 52 | 53 | git.addAll() 54 | 55 | val status = git.status() 56 | assertEquals( 57 | status, 58 | """ 59 | A ${newFile.name} 60 | M ${modifiedFile.name} 61 | """.trimIndent(), 62 | ) 63 | } 64 | 65 | @Test 66 | fun commit() { 67 | initUsableRepository() 68 | val newFile = File(temporaryFolder, "testFile.txt") 69 | newFile.createNewFile() 70 | git.addAll() 71 | 72 | git.commit("Add testFile") 73 | 74 | val message = shell.command("git", listOf("log", "-1", "--pretty=%B")) 75 | assertEquals(message, "Add testFile") 76 | } 77 | 78 | @Test 79 | fun commit_includesChanges_doesNotIncludeNewFiles() { 80 | initUsableRepository() 81 | val modifiedFile = File(temporaryFolder, "testFile.txt") 82 | modifiedFile.createNewFile() 83 | git.addAll() 84 | git.commit("Add testFile") 85 | modifiedFile.writeText("changes") 86 | val newFile = File(temporaryFolder, "anotherFile.txt") 87 | newFile.createNewFile() 88 | 89 | git.commit("Change testFile") 90 | 91 | val lastCommit = shell.command("git", listOf("show")) 92 | assertContains(lastCommit, "Change testFile") 93 | assertContains(lastCommit, "testFile.txt") 94 | assertFalse(lastCommit.contains("anotherFile.txt")) 95 | } 96 | 97 | @Test 98 | fun commitAllChanges_includesChangesAndNewFiles() { 99 | initUsableRepository() 100 | val modifiedFile = File(temporaryFolder, "testFile.txt") 101 | modifiedFile.createNewFile() 102 | git.addAll() 103 | git.commit("Add testFile") 104 | modifiedFile.writeText("changes") 105 | val newFile = File(temporaryFolder, "anotherFile.txt") 106 | newFile.createNewFile() 107 | 108 | git.commitAllChanges("Change testFile") 109 | 110 | val lastCommit = shell.command("git", listOf("show")) 111 | assertContains(lastCommit, "Change testFile") 112 | assertContains(lastCommit, "testFile.txt") 113 | assertContains(lastCommit, "anotherFile.txt") 114 | } 115 | 116 | @Test 117 | fun checkout_createIfNecessary() { 118 | initUsableRepository() 119 | 120 | git.checkout("newBranch") 121 | 122 | assertEquals(git.currentBranch(), "newBranch") 123 | } 124 | 125 | @Test 126 | fun checkout_doNotCreateIfNecessary_givenBranchDoesNotExist() { 127 | initUsableRepository() 128 | 129 | val exception = assertThrows { 130 | git.checkout("newBranch", createIfNecessary = false) 131 | } 132 | 133 | val message = exception.message ?: "" 134 | assertContains(message, "pathspec 'newBranch' did not match") 135 | } 136 | 137 | @Test 138 | fun checkout_doNotCreateIfNecessary_givenBranchExists() { 139 | initUsableRepository() 140 | shell.command("git", listOf("branch", "newBranch")) 141 | 142 | git.checkout("newBranch", createIfNecessary = false) 143 | 144 | assertEquals(git.currentBranch(), "newBranch") 145 | } 146 | 147 | @Test 148 | fun addTag() { 149 | initUsableRepository() 150 | 151 | git.addTag(tagName = "v1.1.0", message = "Release v1.1.0") 152 | 153 | val lastTag = shell.command("git", listOf("describe", "--tags", "--abbrev=0")) 154 | assertEquals(lastTag, "v1.1.0") 155 | } 156 | 157 | @Test 158 | fun currentBranch() { 159 | initUsableRepository() 160 | git.checkout("newBranch") 161 | 162 | val currentBranch = git.currentBranch() 163 | 164 | assertEquals(currentBranch, "newBranch") 165 | assertEquals(currentBranch, shell.command("git", listOf("rev-parse", "--abbrev-ref", "HEAD"))) 166 | } 167 | 168 | @Test 169 | fun currentCommit() { 170 | initUsableRepository() 171 | val newFile = File(temporaryFolder, "testFile.txt") 172 | newFile.createNewFile() 173 | git.addAll() 174 | git.commit("Add testFile") 175 | 176 | val currentCommit = git.currentCommit() 177 | 178 | assertEquals(currentCommit, shell.command("git", listOf("rev-parse", "--verify", "HEAD"))) 179 | } 180 | 181 | @Test 182 | fun currentCommitAuthorEmail() { 183 | initUsableRepository() 184 | val newFile = File(temporaryFolder, "testFile.txt") 185 | newFile.createNewFile() 186 | git.addAll() 187 | git.commit("Add testFile") 188 | 189 | val email = git.currentCommitAuthorEmail() 190 | 191 | assertEquals(email, shell.command("git", listOf("--no-pager", "show", "-s", "--format=%ae"))) 192 | } 193 | 194 | @Test 195 | fun currentCommitAuthorName() { 196 | initUsableRepository() 197 | val newFile = File(temporaryFolder, "testFile.txt") 198 | newFile.createNewFile() 199 | git.addAll() 200 | git.commit("Add testFile") 201 | 202 | val email = git.currentCommitAuthorName() 203 | 204 | assertEquals(email, shell.command("git", listOf("--no-pager", "show", "-s", "--format=%an"))) 205 | } 206 | 207 | private fun initUsableRepository() { 208 | git.gitInit() 209 | shell.command("git", listOf("commit", "--allow-empty", "-n", "-m", "Initial commit", "--quiet")) 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /turtle/src/main/kotlin/com/lordcodes/turtle/FileCommands.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle 2 | 3 | import java.io.File 4 | 5 | /** 6 | * Commands that deal with files and operate on the file system. 7 | */ 8 | class FileCommands internal constructor( 9 | private val shell: ShellScript, 10 | ) { 11 | /** 12 | * Open a [url] using its default application. This URL can be a file path or web URL. 13 | * 14 | * @param [url] The URL to open, file path or web URL. 15 | * 16 | * @return [String] The output of running the command. 17 | * 18 | * @throws [ShellFailedException] There was an issue running the command. 19 | * @throws [ShellRunException] Running the command produced error output. 20 | */ 21 | @Suppress("unused", "MemberVisibilityCanBePrivate") 22 | fun open(url: String): String = shell.multiplatform { platform -> 23 | when (platform) { 24 | Platform.LINUX -> Executable("xdg-open") + Arguments(url) 25 | Platform.MAC -> Executable("open") + Arguments(url) 26 | Platform.WINDOWS -> Executable("cmd.exe") + Arguments("/c", "start", url) 27 | } 28 | } 29 | 30 | /** 31 | * Open a file at [path] using its default application, returning any output as a [String]. 32 | * 33 | * @param [path] The file to open. 34 | * 35 | * @return [String] The output of running the command. 36 | * 37 | * @throws [ShellFailedException] There was an issue running the command. 38 | * @throws [ShellRunException] Running the command produced error output. 39 | */ 40 | @Suppress("unused", "MemberVisibilityCanBePrivate") 41 | fun openFile(path: File): String = open(path.toString()) 42 | 43 | /** 44 | * Open a file at [path] using its default application, returning any output as a [String]. 45 | * 46 | * @param [path] The full file path to open. 47 | * 48 | * @return [String] The output of running the command. 49 | * 50 | * @throws [ShellFailedException] There was an issue running the command. 51 | * @throws [ShellRunException] Running the command produced error output. 52 | */ 53 | @Suppress("unused") 54 | fun openFile(path: String): String = open(path) 55 | 56 | /** 57 | * Open an application by [name], returning any output as a [String]. Only available on Mac. 58 | * 59 | * @param [name] The name of the application to open. 60 | * 61 | * @return [String] The output of running the command. 62 | * 63 | * @throws [ShellCommandNotFoundException] When called on Linux or Windows. 64 | * @throws [ShellFailedException] There was an issue running the command. 65 | * @throws [ShellRunException] Running the command produced error output. 66 | */ 67 | @Suppress("unused") 68 | fun openApplication(name: String): String = shell.multiplatform { platform -> 69 | when (platform) { 70 | Platform.LINUX -> null 71 | Platform.MAC -> Executable("open") + Arguments("-a", name) 72 | Platform.WINDOWS -> null 73 | } 74 | } 75 | 76 | /** 77 | * Create a symbolic link at a given [linkPath], that links back to [targetPath] at a different location. Any 78 | * output will be returned as a [String]. Only available on Mac or Linux. 79 | * 80 | * @param [targetPath] The target to link to. 81 | * @param [linkPath] The location for the symlink. 82 | * 83 | * @return [String] The output of running the command. 84 | * 85 | * @throws [ShellCommandNotFoundException] When called on Windows. 86 | * @throws [ShellFailedException] There was an issue running the command. 87 | * @throws [ShellRunException] Running the command produced error output. 88 | */ 89 | fun createSymlink(targetPath: File, linkPath: File): String = 90 | createSymlink(targetPath.toString(), linkPath.toString()) 91 | 92 | /** 93 | * Create a symbolic link at a given [linkPath], that links back to [targetPath] at a different location. Any 94 | * output will be returned as a [String]. Only available on Mac or Linux. 95 | * 96 | * @param [targetPath] The full file path of the target to link to. 97 | * @param [linkPath] The full file path for the symlink. 98 | * 99 | * @return [String] The output of running the command. 100 | * 101 | * @throws [ShellCommandNotFoundException] When called on Windows. 102 | * @throws [ShellFailedException] There was an issue running the command. 103 | * @throws [ShellRunException] Running the command produced error output. 104 | */ 105 | fun createSymlink(targetPath: String, linkPath: String): String = shell.multiplatform { platform -> 106 | when (platform) { 107 | Platform.LINUX -> Executable("ln") + Arguments("-s", targetPath, linkPath) 108 | Platform.MAC -> Executable("ln") + Arguments("-s", targetPath, linkPath) 109 | Platform.WINDOWS -> null 110 | } 111 | } 112 | 113 | /** 114 | * Read the target path as a [String] of a symbolic link located at [linkPath]. Only available on Mac or Linux. 115 | * 116 | * @param [linkPath] The location for the symlink. 117 | * 118 | * @return [String] The target file path the symlink links to. 119 | * 120 | * @throws [ShellCommandNotFoundException] When called on Windows. 121 | * @throws [ShellFailedException] There was an issue running the command. 122 | * @throws [ShellRunException] Running the command produced error output. 123 | */ 124 | fun readSymlink(linkPath: File): String = readSymlink(linkPath.toString()) 125 | 126 | /** 127 | * Read the target path of a symbolic link. Only available on Mac or Linux. 128 | * 129 | * @param [linkPath] The full file path for the symlink. 130 | * 131 | * @return [String] The target file path the symlink links to. 132 | * 133 | * @throws [ShellCommandNotFoundException] When called on Windows. 134 | * @throws [ShellFailedException] There was an issue running the command. 135 | * @throws [ShellRunException] Running the command produced error output. 136 | */ 137 | fun readSymlink(linkPath: String): String = shell.multiplatform { platform -> 138 | when (platform) { 139 | Platform.LINUX -> Executable("readlink") + Arguments(linkPath) 140 | Platform.MAC -> Executable("readlink") + Arguments(linkPath) 141 | Platform.WINDOWS -> null 142 | } 143 | } 144 | 145 | /** 146 | * Retrieve the location of the provided command, can be used to determine if a command is available. 147 | * Only available on Mac and Linux. 148 | * 149 | * ```kotlin 150 | * shellRun { 151 | * which("git") ?: error("git is not installed") 152 | * } 153 | * ``` 154 | * 155 | * @param [command] The command for which to get the location. 156 | * 157 | * @return [String] The location of the provided command or null if not found. 158 | * 159 | * @throws [ShellCommandNotFoundException] When called on Windows. 160 | * @throws [ShellFailedException] There was an issue running the command. 161 | */ 162 | @Suppress("SwallowedException") 163 | fun which(command: String): String? = try { 164 | shell.multiplatform { platform -> 165 | when (platform) { 166 | Platform.LINUX -> Executable("which") + Arguments(command) 167 | Platform.MAC -> Executable("which") + Arguments(command) 168 | Platform.WINDOWS -> null 169 | } 170 | } 171 | } catch (ex: ShellRunException) { 172 | null 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /turtle/src/main/kotlin/com/lordcodes/turtle/Command.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle 2 | 3 | /** 4 | * A shell command, made up of its [executable] (e.g. 'cd') and [arguments]. 5 | * 6 | * @property [executable] The command executable, e.g. 'cd'. 7 | * @property [arguments] The arguments to be passed to the executable. 8 | */ 9 | data class Command( 10 | val executable: Executable, 11 | val arguments: Arguments = Arguments(emptyList()), 12 | ) { 13 | /** 14 | * Returns a [Command] copy of this command with the provided [arguments] added. 15 | * 16 | * ``` 17 | * command + Arguments("-l", "-a") 18 | * ``` 19 | * 20 | * @return [Command] A command copy with the provided arguments added. 21 | * 22 | * @param [arguments] The arguments to add to this command. 23 | */ 24 | operator fun plus(arguments: Arguments): Command = copy(arguments = this.arguments + arguments) 25 | 26 | /** 27 | * Returns a [Command] copy of this command with the provided [withArguments] added. 28 | * 29 | * ``` 30 | * command + withArguments 31 | * ``` 32 | * 33 | * @return [Command] A command copy with the provided arguments added. 34 | * 35 | * @param [withArguments] The arguments to add to this command. 36 | */ 37 | operator fun plus(withArguments: Iterable): Command = copy(arguments = arguments + withArguments) 38 | 39 | /** 40 | * Returns a [Command] copy of this command with the provided [withArgument] added. 41 | * 42 | * ``` 43 | * command + withArgument 44 | * ``` 45 | * 46 | * @return [Command] A command copy with the provided argument added. 47 | * 48 | * @param [withArgument] The argument to add to this command. 49 | */ 50 | operator fun plus(withArgument: WithArgument): Command = copy(arguments = arguments + withArgument) 51 | 52 | /** 53 | * Returns a [Command] copy of this command with the provided [arguments] removed. 54 | * 55 | * ``` 56 | * command - Arguments("-l", "-a") 57 | * ``` 58 | * 59 | * @return [Command] A command copy with the provided arguments removed. 60 | * 61 | * @param [arguments] The arguments to remove from this command. 62 | */ 63 | operator fun minus(arguments: Arguments): Command = copy(arguments = this.arguments - arguments) 64 | 65 | /** 66 | * Returns a [Command] copy of this command with the provided [withArguments] removed. 67 | * 68 | * ``` 69 | * command - withArguments 70 | * ``` 71 | * 72 | * @return [Command] A command copy with the provided arguments removed. 73 | * 74 | * @param [withArguments] The arguments to remove from this command. 75 | */ 76 | operator fun minus(withArguments: Iterable): Command = 77 | copy(arguments = arguments - withArguments.toSet()) 78 | 79 | /** 80 | * Returns a [Command] copy of this command with the provided [withArgument] removed. 81 | * 82 | * ``` 83 | * command - withArgument 84 | * ``` 85 | * 86 | * @return [Command] A command copy with the provided argument removed. 87 | * 88 | * @param [withArgument] The argument to remove from this command. 89 | */ 90 | operator fun minus(withArgument: WithArgument): Command = copy(arguments = arguments - withArgument) 91 | 92 | /** 93 | * Returns whether this command's arguments contain the provided [arguments]. 94 | * 95 | * ``` 96 | * Arguments("-l", "-a") in command 97 | * ``` 98 | * 99 | * @return [Boolean] Whether the command's arguments contain the provided arguments. 100 | * 101 | * @param [arguments] The arguments to check for. 102 | */ 103 | operator fun contains(arguments: Arguments): Boolean = this.arguments.containsAll(arguments) 104 | 105 | /** 106 | * Returns whether this command's [arguments] contain the provided [withArguments]. 107 | * 108 | * ``` 109 | * withArguments in command 110 | * ``` 111 | * 112 | * @return [Boolean] Whether the command's arguments contain the provided arguments. 113 | * 114 | * @param [withArguments] The arguments to check for. 115 | */ 116 | operator fun contains(withArguments: Iterable) = Arguments(withArguments) in this 117 | 118 | /** 119 | * Returns whether this command's [arguments] contain the provided [argument]. 120 | * 121 | * ``` 122 | * "--verbose" in command 123 | * ``` 124 | * 125 | * @return [Boolean] Whether the command's arguments contain the provided argument. 126 | * 127 | * @param [argument] The argument to check for. 128 | */ 129 | operator fun contains(argument: String) = Arguments(listOf(argument)) in this 130 | 131 | /** 132 | * Returns whether this command's [arguments] contain the provided [withArgument]. 133 | * 134 | * ``` 135 | * withArgument in command 136 | * ``` 137 | * 138 | * @return [Boolean] Whether the command's arguments contain the provided argument. 139 | * 140 | * @param [withArgument] The argument to check for. 141 | */ 142 | operator fun contains(withArgument: WithArgument) = Arguments(withArgument) in this 143 | 144 | /** 145 | * Command with its [executable] and [arguments] formatted similarly as a shell command-line, as a [String]. 146 | * 147 | * @return [String] The command formatted as a string. 148 | */ 149 | override fun toString(): String = 150 | "${executable.name} " + arguments.joinToString(separator = " ", transform = ::quoteArgumentIfNecessary) 151 | 152 | private fun quoteArgumentIfNecessary(argument: String): String { 153 | val unquoted = argument 154 | .trim('\'') 155 | .trim('\"') 156 | .trim('\'') 157 | val unescaped = unquoted 158 | .replace("\\\'", "\'") 159 | .replace("\\\"", "\"") 160 | return if (unquoted.isBlank() || unquoted.any { it in " '\"" }) { 161 | "'$unescaped'" 162 | } else { 163 | unescaped 164 | } 165 | } 166 | 167 | /** 168 | * Run the command [executable] with [arguments], receiving the output as a [String]. 169 | * 170 | * @throws [ShellExecutableNotFoundException] The command executable wasn't found. 171 | * @throws [ShellFailedException] There was an issue running the command. 172 | * @throws [ShellRunException] Running the command produced error output. 173 | * 174 | * @returns [String] Command output. 175 | */ 176 | fun executeOrThrow(shellScript: ShellScript = ShellScript()): String = try { 177 | shellScript.command(executable.name, arguments) 178 | } catch (ex: ShellCommandNotFoundException) { 179 | throw ShellExecutableNotFoundException(executable, ex) 180 | } 181 | 182 | /** 183 | * Run the command [executable] with [arguments], receiving the output as a [String] and handling errors via the 184 | * [onError] lambda. 185 | * 186 | * @param [shellScript] The [ShellScript] to use for execution. 187 | * @param [onError] Handle errors that occur when running the command. 188 | * 189 | * @returns [String] Command output or output of [onError]. 190 | */ 191 | fun executeOrElse(shellScript: ShellScript = ShellScript(), onError: (Throwable) -> String): String = try { 192 | shellScript.command(executable.name, arguments) 193 | } catch (ex: ShellCommandNotFoundException) { 194 | onError(ShellExecutableNotFoundException(executable, ex.cause)) 195 | } catch (ex: ShellFailedException) { 196 | onError(ex) 197 | } catch (ex: ShellRunException) { 198 | onError(ex) 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /turtle/src/main/kotlin/com/lordcodes/turtle/ShellScript.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle 2 | 3 | import com.lordcodes.turtle.internal.EmptyInputStream 4 | import java.io.BufferedReader 5 | import java.io.File 6 | import java.io.IOException 7 | import java.util.concurrent.TimeUnit 8 | 9 | /** 10 | * Create and run either built-in or specified shell commands. 11 | */ 12 | class ShellScript constructor( 13 | workingDirectory: File? = null, 14 | private val dryRun: Boolean = false, 15 | ) { 16 | private val processBuilder = ProcessBuilder(listOf()) 17 | .directory(workingDirectory) 18 | .redirectOutput(ProcessBuilder.Redirect.PIPE) 19 | .redirectError(ProcessBuilder.Redirect.PIPE) 20 | 21 | /** 22 | * Default callbacks into the process started for each command that is executed. 23 | */ 24 | var defaultCallbacks: ProcessCallbacks = EmptyProcessCallbacks 25 | 26 | /** 27 | * Access commands that deal with files or the filesystem. 28 | */ 29 | val files = FileCommands(this) 30 | 31 | /** 32 | * Access commands that deal with Git. 33 | */ 34 | val git = GitCommands(this) 35 | 36 | /** 37 | * Run a shell command with the specified arguments, receiving the output as a String. 38 | * 39 | * @param [command] A command to run. 40 | * @param [arguments] The arguments to pass to the command. 41 | * @param [callbacks] Callbacks into the process 42 | * 43 | * @return [String] The output of running the command. 44 | * 45 | * @throws [ShellCommandNotFoundException] The command wasn't found. 46 | * @throws [ShellFailedException] There was an issue running the command. 47 | * @throws [ShellRunException] Running the command produced error output. 48 | */ 49 | fun command( 50 | command: String, 51 | arguments: List = listOf(), 52 | callbacks: ProcessCallbacks = EmptyProcessCallbacks, 53 | ): String { 54 | if (dryRun) { 55 | println(dryRunCommand(command, arguments)) 56 | return "" 57 | } 58 | return try { 59 | val splitCommand = listOf(command) + arguments 60 | val process = processBuilder 61 | .command(splitCommand) 62 | .start() 63 | onProcessStart(process, callbacks) 64 | process.waitFor(COMMAND_TIMEOUT, TimeUnit.MINUTES) 65 | process.retrieveOutput() 66 | } catch (exception: IOException) { 67 | if (exception.message?.contains("Cannot run program") == true) { 68 | throw ShellCommandNotFoundException(command, exception) 69 | } 70 | throw ShellFailedException(exception) 71 | } catch (exception: InterruptedException) { 72 | throw ShellFailedException(exception) 73 | } 74 | } 75 | 76 | private fun dryRunCommand(command: String, arguments: List): String { 77 | val formattedArguments = arguments.joinToString(" ") 78 | return "$command $formattedArguments" 79 | } 80 | 81 | private fun Process.retrieveOutput(): String { 82 | val outputText = inputStream.bufferedReader().use(BufferedReader::readText) 83 | val exitCode = exitValue() 84 | if (exitCode != 0) { 85 | val errorText = errorStream.bufferedReader().use(BufferedReader::readText) 86 | throw ShellRunException(exitCode, errorText.trim()) 87 | } 88 | return outputText.trim() 89 | } 90 | 91 | /** 92 | * Run a shell [command] with the specified [arguments], allowing standard output or error to be read as a stream, 93 | * within [ProcessOutput]. 94 | * 95 | * @throws [ShellCommandNotFoundException] The command wasn't found. 96 | * @throws [ShellFailedException] There was an issue running the command. 97 | * @throws [ShellRunException] Running the command produced error output. 98 | */ 99 | @Deprecated( 100 | message = "Will be removed in the next major release as it hangs when streaming large amount of output. " + 101 | "Please use 'commandSequence' instead.", 102 | replaceWith = ReplaceWith( 103 | "commandSequence(command, arguments, callbacks)", 104 | ), 105 | ) 106 | fun commandStreaming( 107 | command: String, 108 | arguments: List = listOf(), 109 | callbacks: ProcessCallbacks = EmptyProcessCallbacks, 110 | ): ProcessOutput { 111 | if (dryRun) { 112 | println(dryRunCommand(command, arguments)) 113 | return ProcessOutput( 114 | exitCode = 0, 115 | standardOutput = EmptyInputStream(), 116 | standardError = EmptyInputStream(), 117 | ) 118 | } 119 | return try { 120 | val splitCommand = listOf(command) + arguments 121 | val process = processBuilder 122 | .command(splitCommand) 123 | .start() 124 | onProcessStart(process, callbacks) 125 | process.waitFor(COMMAND_TIMEOUT, TimeUnit.MINUTES) 126 | ProcessOutput( 127 | exitCode = process.exitValue(), 128 | standardOutput = process.inputStream, 129 | standardError = process.errorStream, 130 | ) 131 | } catch (exception: IOException) { 132 | if (exception.message?.contains("Cannot run program") == true) { 133 | throw ShellCommandNotFoundException(command, exception) 134 | } 135 | throw ShellFailedException(exception) 136 | } catch (exception: InterruptedException) { 137 | throw ShellFailedException(exception) 138 | } 139 | } 140 | 141 | /** 142 | * Run a shell [command] with the specified [arguments], returning standard output and standard error 143 | * line-by-line as a [Sequence]. 144 | * 145 | * @throws [ShellCommandNotFoundException] The command wasn't found. 146 | * @throws [ShellFailedException] There was an issue running the command. 147 | * @throws [ShellRunException] Running the command produced error output. 148 | */ 149 | fun commandSequence( 150 | command: String, 151 | arguments: List = listOf(), 152 | callbacks: ProcessCallbacks = EmptyProcessCallbacks, 153 | ): Sequence { 154 | if (dryRun) { 155 | println(dryRunCommand(command, arguments)) 156 | return sequenceOf("") 157 | } 158 | return try { 159 | val splitCommand = listOf(command) + arguments 160 | val process = processBuilder 161 | .redirectErrorStream(true) 162 | .command(splitCommand) 163 | .start() 164 | onProcessStart(process, callbacks) 165 | 166 | sequence { 167 | yieldAll(process.inputStream.bufferedReader().lineSequence()) 168 | 169 | val exitCode = process.waitFor() 170 | if (exitCode != 0) { 171 | throw ShellRunException(exitCode) 172 | } 173 | } 174 | } catch (exception: IOException) { 175 | if (exception.message?.contains("Cannot run program") == true) { 176 | throw ShellCommandNotFoundException(command, exception) 177 | } 178 | throw ShellFailedException(exception) 179 | } catch (exception: InterruptedException) { 180 | throw ShellFailedException(exception) 181 | } 182 | } 183 | 184 | internal fun multiplatform(createCommand: (Platform) -> Command?): String { 185 | val operatingSystem = Platform.fromSystem() 186 | val command = createCommand(operatingSystem) 187 | ?: throw ShellCommandNotFoundException("Command not available for $operatingSystem", null) 188 | return command.executeOrThrow(this) 189 | } 190 | 191 | /** 192 | * Change the working directory for subsequent shell commands. 193 | * 194 | * @param [path] The path to set the working directory to. 195 | */ 196 | fun changeWorkingDirectory(path: String) { 197 | changeWorkingDirectory(File(path)) 198 | } 199 | 200 | /** 201 | * Change the working directory for subsequent shell commands. 202 | * 203 | * @param [path] The path to set the working directory to. 204 | */ 205 | fun changeWorkingDirectory(path: File) { 206 | processBuilder.directory(path) 207 | } 208 | 209 | private fun onProcessStart(process: Process, callbacks: ProcessCallbacks) { 210 | defaultCallbacks.onProcessStart(process) 211 | callbacks.onProcessStart(process) 212 | } 213 | 214 | private object EmptyProcessCallbacks : ProcessCallbacks 215 | 216 | companion object { 217 | private const val COMMAND_TIMEOUT = 60L 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /turtle/src/main/kotlin/com/lordcodes/turtle/GitCommands.kt: -------------------------------------------------------------------------------- 1 | package com.lordcodes.turtle 2 | 3 | import java.io.File 4 | import java.net.URL 5 | 6 | /** 7 | * Commands that deal with Git. 8 | */ 9 | class GitCommands internal constructor( 10 | private val shell: ShellScript, 11 | ) { 12 | /** 13 | * Initialize a Git repository. 14 | * 15 | * @return [String] The output of running the command. 16 | * 17 | * @throws [ShellFailedException] There was an issue running the command. 18 | * @throws [ShellRunException] Running the command produced error output. 19 | */ 20 | fun gitInit(): String = gitCommand(listOf("init")) 21 | 22 | /** 23 | * Get the working tree status of a Git repository. The status can signify if there are any uncommitted changes. 24 | * 25 | * @return [String] The current working tree status. 26 | * 27 | * @throws [ShellFailedException] There was an issue running the command. 28 | * @throws [ShellRunException] Running the command produced error output. 29 | */ 30 | fun status(): String = gitCommand(listOf("status", "--porcelain")) 31 | 32 | /** 33 | * Stage any new, modified or deleted files, to be included in the next commit. 34 | * 35 | * @return [String] The output of running the command. 36 | * 37 | * @throws [ShellFailedException] There was an issue running the command. 38 | * @throws [ShellRunException] Running the command produced error output. 39 | */ 40 | fun addAll(): String = gitCommand(listOf("add", "--all")) 41 | 42 | /** 43 | * Create a Git commit with the given commit [message]. Any modified or deleted files will also be staged and 44 | * included in the commit. 45 | * 46 | * @param [message] The commit message to use. 47 | * 48 | * @return [String] The output of running the command. 49 | * 50 | * @throws [ShellFailedException] There was an issue running the command. 51 | * @throws [ShellRunException] Running the command produced error output. 52 | */ 53 | fun commit(message: String): String = gitCommand(listOf("commit", "-a", "-m", message, "--quiet")) 54 | 55 | /** 56 | * Create a Git commit with the given commit [message]. Any new, modified or deleted files will also be staged and 57 | * included in the commit. 58 | * 59 | * @param [message] The commit message to use. 60 | * 61 | * @return [String] The output of running the command. 62 | * 63 | * @throws [ShellFailedException] There was an issue running the command. 64 | * @throws [ShellRunException] Running the command produced error output. 65 | */ 66 | fun commitAllChanges(message: String): String { 67 | addAll() 68 | return commit(message) 69 | } 70 | 71 | /** 72 | * Push changes to a Git repository, optionally specifying the [remote] to push to and the [branch] to push. 73 | * 74 | * @param [remote] The remote to push to, optional. 75 | * @param [branch] The branch to push, optional. 76 | * 77 | * @return [String] The output of running the command. 78 | * 79 | * @throws [ShellFailedException] There was an issue running the command. 80 | * @throws [ShellRunException] Running the command produced error output. 81 | */ 82 | fun push(remote: String? = null, branch: String? = null): String { 83 | val arguments = mutableListOf("push") 84 | remote?.let { arguments.add(it) } 85 | branch?.let { arguments.add(it) } 86 | arguments.add("--quiet") 87 | return gitCommand(arguments) 88 | } 89 | 90 | /** 91 | * Push changes at HEAD to the origin remote on a Git repository. 92 | * 93 | * @return [String] The output of running the command. 94 | * 95 | * @throws [ShellFailedException] There was an issue running the command. 96 | * @throws [ShellRunException] Running the command produced error output. 97 | */ 98 | fun pushToOrigin(): String = push(remote = "origin", branch = "HEAD") 99 | 100 | /** 101 | * Pull changes for a Git repository, optionally specifying the [remote] to pull from and the [branch] to pull. 102 | * 103 | * @param [remote] The remote to pull from, optional. 104 | * @param [branch] The branch to pull, optional. 105 | * 106 | * @return [String] The output of running the command. 107 | * 108 | * @throws [ShellFailedException] There was an issue running the command. 109 | * @throws [ShellRunException] Running the command produced error output. 110 | */ 111 | fun pull(remote: String? = null, branch: String? = null): String { 112 | val arguments = mutableListOf("pull") 113 | remote?.let { arguments.add(it) } 114 | branch?.let { arguments.add(it) } 115 | arguments.add("--quiet") 116 | return gitCommand(arguments) 117 | } 118 | 119 | /** 120 | * Check out a given Git [branch], optionally specifying whether to create it if it doesn't exist using 121 | * [createIfNecessary]. 122 | * 123 | * @param [branch] The branch to check out. 124 | * @param [createIfNecessary] Whether to create the branch if not found. 125 | * 126 | * @return [String] The output of running the command. 127 | * 128 | * @throws [ShellFailedException] There was an issue running the command. 129 | * @throws [ShellRunException] Running the command produced error output. 130 | */ 131 | fun checkout(branch: String, createIfNecessary: Boolean = true): String { 132 | val arguments = mutableListOf("checkout") 133 | if (createIfNecessary) { 134 | arguments.add("-B") 135 | } 136 | arguments.add(branch) 137 | arguments.add("--quiet") 138 | return gitCommand(arguments) 139 | } 140 | 141 | /** 142 | * Clone a Git repository at a given [repositoryUrl], to [destination]. 143 | * 144 | * @param [repositoryUrl] The URL of the Git repository to clone. 145 | * @param [destination] The path to create the clone at. 146 | * 147 | * @return [String] The output of running the command. 148 | * 149 | * @throws [ShellFailedException] There was an issue running the command. 150 | * @throws [ShellRunException] Running the command produced error output. 151 | */ 152 | fun clone(repositoryUrl: URL, destination: File? = null): String = 153 | clone(repositoryUrl.toString(), destination?.toString()) 154 | 155 | /** 156 | * Clone a Git repository at a given [repositoryUrl] to [destination]. 157 | * 158 | * @param [repositoryUrl] The URL of the Git repository to clone. 159 | * @param [destination] The path to create the clone at. 160 | * 161 | * @return [String] The output of running the command. 162 | * 163 | * @throws [ShellFailedException] There was an issue running the command. 164 | * @throws [ShellRunException] Running the command produced error output. 165 | */ 166 | fun clone(repositoryUrl: String, destination: String? = null): String { 167 | val arguments = mutableListOf("clone", repositoryUrl) 168 | destination?.let { arguments.add(it) } 169 | arguments.add("--quiet") 170 | return gitCommand(arguments) 171 | } 172 | 173 | /** 174 | * Tag a Git commit with [tagName] and [message]. 175 | * 176 | * @param [tagName] The name of the tag to add. 177 | * @param [message] The message to use in the tag. 178 | * 179 | * @return [String] The output of running the command. 180 | * 181 | * @throws [ShellFailedException] There was an issue running the command. 182 | * @throws [ShellRunException] Running the command produced error output. 183 | */ 184 | fun addTag(tagName: String, message: String): String = gitCommand(listOf("tag", "-a", tagName, "-m", message)) 185 | 186 | /** 187 | * Push the given Git [tagName] to origin. 188 | * 189 | * @param [tagName] The name of the tag to push. 190 | * 191 | * @return [String] The output of running the command. 192 | * 193 | * @throws [ShellFailedException] There was an issue running the command. 194 | * @throws [ShellRunException] Running the command produced error output. 195 | */ 196 | fun pushTag(tagName: String): String = gitCommand(listOf("push", "origin", tagName)) 197 | 198 | /** 199 | * Get the current Git branch name. 200 | * 201 | * @return [String] The current branch name. 202 | * 203 | * @throws [ShellFailedException] There was an issue running the command. 204 | * @throws [ShellRunException] Running the command produced error output. 205 | */ 206 | fun currentBranch(): String = gitCommand(listOf("rev-parse", "--abbrev-ref", "HEAD")) 207 | 208 | /** 209 | * Get the current Git commit. 210 | * 211 | * @return [String] The current commit. 212 | * 213 | * @throws [ShellFailedException] There was an issue running the command. 214 | * @throws [ShellRunException] Running the command produced error output. 215 | */ 216 | fun currentCommit(): String = gitCommand(listOf("rev-parse", "--verify", "HEAD")) 217 | 218 | /** 219 | * Get the current Git commit author email. 220 | * 221 | * @return [String] The current commit author email. 222 | * 223 | * @throws [ShellFailedException] There was an issue running the command. 224 | * @throws [ShellRunException] Running the command produced error output. 225 | */ 226 | fun currentCommitAuthorEmail(): String = gitCommand(listOf("--no-pager", "show", "-s", "--format=%ae")) 227 | 228 | /** 229 | * Get the current Git commit author name. 230 | * 231 | * @return [String] The current commit author name. 232 | * 233 | * @throws [ShellFailedException] There was an issue running the command. 234 | * @throws [ShellRunException] Running the command produced error output. 235 | */ 236 | fun currentCommitAuthorName(): String = gitCommand(listOf("--no-pager", "show", "-s", "--format=%an")) 237 | 238 | /** 239 | * Run a Git command with the specified [arguments]. 240 | * 241 | * @param [arguments] The arguments to pass to the Git command. 242 | * 243 | * @return [String] The output of running the command. 244 | * 245 | * @throws [ShellFailedException] There was an issue running the command. 246 | * @throws [ShellRunException] Running the command produced error output. 247 | */ 248 | fun gitCommand(arguments: List): String = shell.command("git", arguments) 249 | } 250 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------