├── .editorconfig ├── .github ├── .java-version ├── renovate.json5 └── workflows │ └── build.yaml ├── .gitignore ├── LICENSE ├── README.md ├── build-deps.sh ├── build.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── scripts └── deterministic-env.sh ├── settings.gradle.kts └── src ├── commonMain ├── kotlin │ └── com │ │ └── mattprecious │ │ └── stacker │ │ ├── StackerDeps.kt │ │ ├── cli │ │ ├── StackerCli.kt │ │ ├── StackerCliktCommand.kt │ │ ├── branch │ │ │ ├── Bottom.kt │ │ │ ├── Branch.kt │ │ │ ├── Checkout.kt │ │ │ ├── Create.kt │ │ │ ├── Delete.kt │ │ │ ├── Down.kt │ │ │ ├── Rename.kt │ │ │ ├── Restack.kt │ │ │ ├── Submit.kt │ │ │ ├── Top.kt │ │ │ ├── Track.kt │ │ │ ├── Untrack.kt │ │ │ └── Up.kt │ │ ├── downstack │ │ │ ├── Downstack.kt │ │ │ └── Edit.kt │ │ ├── log │ │ │ ├── Log.kt │ │ │ └── Short.kt │ │ ├── rebase │ │ │ └── Rebase.kt │ │ ├── repo │ │ │ ├── Init.kt │ │ │ ├── Repo.kt │ │ │ └── Sync.kt │ │ ├── stack │ │ │ ├── Stack.kt │ │ │ └── Submit.kt │ │ └── upstack │ │ │ ├── Onto.kt │ │ │ ├── Restack.kt │ │ │ └── Upstack.kt │ │ ├── collections │ │ └── collections.kt │ │ ├── command │ │ ├── StackerCommand.kt │ │ ├── StackerCommandScope.kt │ │ ├── branch │ │ │ ├── BranchBottom.kt │ │ │ ├── BranchCheckout.kt │ │ │ ├── BranchCreate.kt │ │ │ ├── BranchDelete.kt │ │ │ ├── BranchDown.kt │ │ │ ├── BranchRename.kt │ │ │ ├── BranchRestack.kt │ │ │ ├── BranchSubmit.kt │ │ │ ├── BranchTop.kt │ │ │ ├── BranchTrack.kt │ │ │ ├── BranchUntrack.kt │ │ │ └── BranchUp.kt │ │ ├── branches.kt │ │ ├── downstack │ │ │ └── DownstackEdit.kt │ │ ├── locks.kt │ │ ├── log │ │ │ └── LogShort.kt │ │ ├── rebase │ │ │ ├── RebaseAbort.kt │ │ │ └── RebaseContinue.kt │ │ ├── remotes.kt │ │ ├── repo │ │ │ ├── RepoInit.kt │ │ │ └── RepoSync.kt │ │ ├── stack │ │ │ └── StackSubmit.kt │ │ └── upstack │ │ │ ├── UpstackOnto.kt │ │ │ └── UpstackRestack.kt │ │ ├── config │ │ ├── ConfigManager.kt │ │ ├── RealConfigManager.kt │ │ └── UserConfig.kt │ │ ├── db.kt │ │ ├── db │ │ └── adapters.kt │ │ ├── delegates │ │ ├── Optional.kt │ │ ├── jsonFile.kt │ │ └── mutableLazy.kt │ │ ├── lock │ │ ├── BranchState.kt │ │ ├── Locker.kt │ │ └── RealLocker.kt │ │ ├── remote │ │ ├── GitHubRemote.kt │ │ ├── NoRemote.kt │ │ ├── Remote.kt │ │ └── github │ │ │ ├── CreatePull.kt │ │ │ ├── GitHubError.kt │ │ │ ├── Pull.kt │ │ │ └── UpdatePull.kt │ │ ├── rendering │ │ ├── printer.kt │ │ ├── prompt.kt │ │ └── styles.kt │ │ ├── shell │ │ ├── RealShell.kt │ │ └── Shell.kt │ │ ├── stack │ │ ├── RealStackManager.kt │ │ └── StackManager.kt │ │ ├── stacker.kt │ │ └── vc │ │ ├── GitVersionControl.kt │ │ └── VersionControl.kt └── sqldelight │ ├── com │ └── mattprecious │ │ └── stacker │ │ └── db │ │ ├── branch.sq │ │ ├── lock.sq │ │ └── repoConfig.sq │ ├── databases │ └── 1.db │ └── migrations │ ├── 1.sqm │ └── 2.sqm └── commonTest └── kotlin └── com └── mattprecious └── stacker └── test ├── collections ├── RadiateTest.kt └── TreeTest.kt ├── command ├── BranchBottomTest.kt ├── BranchCheckoutTest.kt ├── BranchCreateTest.kt ├── BranchDeleteTest.kt ├── BranchDownTest.kt ├── BranchRenameTest.kt ├── BranchTopTest.kt ├── BranchTrackTest.kt ├── BranchUntrackTest.kt ├── BranchUpTest.kt ├── DownstackEditTest.kt ├── LogShortTest.kt ├── RepoInitTest.kt └── UpstackOntoTest.kt ├── remote └── FakeRemote.kt ├── rendering ├── InteractivePromptTest.kt ├── PromptTest.kt └── YesNoPromptTest.kt └── util ├── EnvironmentTest.kt ├── command.kt ├── environment.kt ├── git.kt └── mosaic.kt /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.yaml] 12 | indent_style = space 13 | 14 | [*.md] 15 | indent_style = space -------------------------------------------------------------------------------- /.github/.java-version: -------------------------------------------------------------------------------- 1 | 23 2 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | $schema: 'https://docs.renovatebot.com/renovate-schema.json', 3 | extends: [ 4 | 'config:recommended', 5 | ], 6 | ignorePresets: [ 7 | // Ensure we get the latest version and are not pinned to old versions. 8 | 'workarounds:javaLTSVersions', 9 | ], 10 | customManagers: [ 11 | // Update .java-version file with the latest JDK version. 12 | { 13 | customType: 'regex', 14 | fileMatch: [ 15 | '\\.java-version$', 16 | ], 17 | matchStrings: [ 18 | '(?.*)\\n', 19 | ], 20 | datasourceTemplate: 'java-version', 21 | depNameTemplate: 'java', 22 | // Only write the major version. 23 | extractVersionTemplate: '^(?\\d+)', 24 | }, 25 | ], 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | pull_request: {} 5 | workflow_dispatch: {} 6 | push: 7 | branches: 8 | - 'main' 9 | tags-ignore: 10 | - '**' 11 | 12 | env: 13 | GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4g -Dorg.gradle.daemon=false -Dkotlin.incremental=false" 14 | 15 | jobs: 16 | spotless: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: actions/setup-java@v4 21 | with: 22 | distribution: 'zulu' 23 | java-version-file: .github/.java-version 24 | 25 | - uses: gradle/actions/setup-gradle@v4 26 | continue-on-error: true 27 | 28 | - run: ./gradlew spotlessCheck 29 | 30 | database: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | - uses: actions/setup-java@v4 35 | with: 36 | distribution: 'zulu' 37 | java-version-file: .github/.java-version 38 | 39 | - uses: gradle/actions/setup-gradle@v4 40 | continue-on-error: true 41 | 42 | - run: ./gradlew verifySqlDelightMigration 43 | 44 | build: 45 | strategy: 46 | fail-fast: false 47 | matrix: 48 | include: 49 | # TODO: Linking is not working on linux. 50 | # - os: ubuntu-latest 51 | # task: linkReleaseExecutableLinuxX64 linuxX64Test 52 | - os: macOS-13 53 | task: linkReleaseExecutableMacosX64 macosX64Test 54 | target: 'macosX64' 55 | - os: macOS-14 56 | task: linkReleaseExecutableMacosArm64 macosArm64Test 57 | target: 'macosArm64' 58 | # TODO: build on 'windows-latest' 59 | 60 | runs-on: ${{ matrix.os }} 61 | 62 | steps: 63 | - uses: actions/checkout@v4 64 | - uses: actions/setup-java@v4 65 | with: 66 | distribution: 'zulu' 67 | java-version-file: .github/.java-version 68 | 69 | - uses: gradle/actions/setup-gradle@v4 70 | continue-on-error: true 71 | 72 | - run: ./gradlew buildDependencies ${{ matrix.task }} 73 | 74 | - uses: dorny/test-reporter@v2 75 | # Doesn't work with forks. 76 | if: (success() || failure()) && (github.event.pull_request == null || github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name) 77 | with: 78 | name: ${{ matrix.os }} Tests 79 | path: '**/build/test-results/**/TEST-*.xml' 80 | reporter: java-junit 81 | 82 | - name: Upload distribution 83 | uses: actions/upload-artifact@v4 84 | with: 85 | name: stacker-${{ matrix.target }} 86 | path: build/bin/${{ matrix.target }}/releaseExecutable/stacker.kexe 87 | if-no-files-found: error 88 | 89 | final-status: 90 | if: always() 91 | runs-on: ubuntu-latest 92 | needs: 93 | - build 94 | - database 95 | - spotless 96 | steps: 97 | - name: Check 98 | run: | 99 | results=$(tr -d '\n' <<< '${{ toJSON(needs.*.result) }}') 100 | if ! grep -q -v -E '(failure|cancelled)' <<< "$results"; then 101 | echo "One or more required jobs failed" 102 | exit 1 103 | fi 104 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Gradle 2 | build 3 | .gradle 4 | /reports 5 | 6 | # IntelliJ 7 | .idea 8 | 9 | # Kotlin 10 | .kotlin 11 | 12 | ### Mac OS ### 13 | .DS_Store 14 | 15 | src/nativeInterop/cinterop/libgit2.def 16 | deps/ 17 | curl/ 18 | libgit2/ 19 | libssh2/ 20 | openssl/ 21 | sqlite-*/ 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stacker 2 | This project is currently **experimental** and undergoing frequent infrastructure/API changes, thus is not ready for 3 | general usage. If you're feeling adventurous, you're welcome to build the project locally, but please familiarize 4 | yourself with the current list of [issues](https://github.com/mattprecious/stacker/issues) and 5 | [git-reflog](https://git-scm.com/docs/git-reflog). 6 | 7 | ## Development 8 | 9 | ### Building 10 | 11 | The generic `assemble` and `build` tasks will attempt to build for all supported architectures, which cannot be done 12 | locally. Instead, run the gradle task for your current architecture: 13 | 14 | * `./gradlew linkReleaseExecutableMacosArm` 15 | * `./gradlew linkReleaseExecutableMacosX64` 16 | 17 | Debug variants are available by replacing `Release` with `Debug`. Other architectures are not currently supported. 18 | 19 | ### Running 20 | 21 | After building, an executable will be available in the `build` folder. Its exact path will depend on the task that was 22 | used to build it. For reference, a release macOS ARM build will be available at 23 | `build/bin/macosArm64/debugExecutable/stacker.kexe`. 24 | 25 | # License 26 | 27 | Copyright 2023 Matthew Precious 28 | 29 | Licensed under the Apache License, Version 2.0 (the "License"); 30 | you may not use this file except in compliance with the License. 31 | You may obtain a copy of the License at 32 | 33 | http://www.apache.org/licenses/LICENSE-2.0 34 | 35 | Unless required by applicable law or agreed to in writing, software 36 | distributed under the License is distributed on an "AS IS" BASIS, 37 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 38 | See the License for the specific language governing permissions and 39 | limitations under the License. 40 | -------------------------------------------------------------------------------- /build-deps.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | export CMAKE_BUILD_PARALLEL_LEVEL=$((`nproc`+1)) 5 | 6 | DEF_PATH="" 7 | BUILD_PATH="" 8 | CMAKE_ARCH="" 9 | SQLITE_ARCH="" 10 | 11 | function usage { 12 | cat << EOF 13 | Usage ./build-deps.sh 14 | Build needs to be run in root project directory. 15 | 16 | -h Usage 17 | 18 | -d def file path 19 | Example: -d src/nativeInterop/cinterop/libgit2.def 20 | 21 | -b build output directory 22 | Example: -b deps 23 | EOF 24 | exit 0 25 | } 26 | 27 | function autoDetect() { 28 | RAW_ARCH=$(uname -m) 29 | if [[ $OSTYPE == "darwin"* ]]; then 30 | if [[ "$RAW_ARCH" == "arm64" ]]; then 31 | CMAKE_ARCH="arm64" 32 | SQLITE_ARCH="arm64-apple-macos" 33 | elif [[ "$RAW_ARCH" == "x86_64" ]]; then 34 | CMAKE_ARCH="x86_64" 35 | SQLITE_ARCH="x64-apple-macos" 36 | else 37 | echo "Unable to detect Mac architecture." 38 | exit 1 39 | fi 40 | elif [[ $OSTYPE == "linux-gnu"* ]]; then 41 | if [[ "$RAW_ARCH" == "x86_64" ]]; then 42 | CMAKE_ARCH="x86_64" 43 | SQLITE_ARCH="x64-" 44 | else 45 | echo "Unable to detect Linux architecture." 46 | exit 1 47 | fi 48 | else 49 | echo "Unable to detect OS." 50 | exit 1 51 | fi 52 | } 53 | 54 | function build() { 55 | echo "DEF_PATH=${DEF_PATH}" 56 | echo "BUILD_PATH=${BUILD_PATH}" 57 | echo "CMAKE_ARCH=${CMAKE_ARCH}" 58 | echo "SQLITE_ARCH=${SQLITE_ARCH}" 59 | echo "" 60 | 61 | if [[ $DEF_PATH == "" ]]; then 62 | echo "Def file path must be specified." 63 | echo "" 64 | usage 65 | exit 1 66 | elif [[ $BUILD_PATH = "" ]]; then 67 | echo "Build output directory must be specified." 68 | echo "" 69 | usage 70 | exit 1 71 | fi 72 | 73 | set -x 74 | 75 | # Clean the directories to prevent confusing failure cases 76 | rm -rf libgit2/ sqlite-*/ $BUILD_PATH 77 | 78 | mkdir $BUILD_PATH 79 | 80 | curl -L https://www.sqlite.org/2023/sqlite-autoconf-3440100.tar.gz > sqlite.tar.gz 81 | tar -xf sqlite.tar.gz 82 | rm sqlite.tar.gz 83 | pushd sqlite-* 84 | CFLAGS="-Os" ./configure --host=$SQLITE_ARCH --prefix=$BUILD_PATH --disable-shared 85 | make -j$CMAKE_BUILD_PARALLEL_LEVEL 86 | make -j$CMAKE_BUILD_PARALLEL_LEVEL install 87 | popd 88 | 89 | curl -L https://github.com/libgit2/libgit2/archive/refs/tags/v1.9.0.zip > libgit2.zip 90 | # unzip can't handle the encoding of one of the test files/folders and tar won't extract zip files 91 | # on ubuntu. 92 | unzip libgit2.zip -x "libgit2-1.9.0/tests/*" 93 | rm libgit2.zip 94 | mv libgit2-1.9.0 libgit2 95 | mkdir -p libgit2/build 96 | cmake -S libgit2 -B libgit2/build\ 97 | -DUSE_SSH=exec \ 98 | -DBUILD_TESTS=OFF \ 99 | -DCMAKE_PREFIX_PATH="$BUILD_PATH" \ 100 | -DCMAKE_INSTALL_PREFIX="$BUILD_PATH" \ 101 | -DCMAKE_IGNORE_PREFIX_PATH="/usr" \ 102 | -DCMAKE_OSX_ARCHITECTURES=$CMAKE_ARCH \ 103 | -DBUILD_SHARED_LIBS=OFF \ 104 | -DCMAKE_BUILD_TYPE=Release 105 | cmake --build libgit2/build --target install 106 | 107 | linkerOpts="$(PKG_CONFIG_PATH=`pwd`/deps/lib/pkgconfig pkg-config --libs libgit2 --static)" 108 | 109 | mkdir -p `dirname $DEF_PATH` 110 | cat > $DEF_PATH <("buildDependencies") { 25 | script = layout.projectDirectory.file("build-deps.sh") 26 | defFile = libgitDefFile 27 | outputDir = layout.projectDirectory.dir("deps") 28 | } 29 | 30 | val checkDependenciesTask = tasks.register("checkDependencies") { 31 | outputs.upToDateWhen { false } 32 | 33 | doFirst { 34 | check(file(libgitDefFile).exists()) { 35 | "libgit2 def file does not exist. Please run the buildDependencies task." 36 | } 37 | } 38 | } 39 | 40 | tasks.withType().configureEach { 41 | dependsOn(checkDependenciesTask) 42 | mustRunAfter(buildDependenciesTask) 43 | } 44 | 45 | kotlin { 46 | macosArm64() 47 | macosX64() 48 | 49 | sourceSets { 50 | configureEach { 51 | languageSettings.optIn("kotlin.ExperimentalStdlibApi") 52 | languageSettings.optIn("kotlinx.cinterop.ExperimentalForeignApi") 53 | } 54 | 55 | getByName("commonMain") { 56 | dependencies { 57 | implementation(libs.clikt) 58 | implementation(libs.coroutines.core) 59 | implementation(libs.kotlin.collections.immutable) 60 | implementation(libs.kotlin.serialization.json) 61 | implementation(libs.ktor.client.core) 62 | implementation(libs.ktor.client.curl) 63 | implementation(libs.ktor.client.negotiation) 64 | implementation(libs.ktor.serialization.json) 65 | implementation(libs.mosaic.runtime) 66 | implementation(libs.okio) 67 | implementation(libs.sqldelight.driver) 68 | } 69 | } 70 | 71 | getByName("commonTest") { 72 | dependencies { 73 | implementation(libs.assertk) 74 | implementation(libs.coroutines.test) 75 | implementation(libs.kotlin.test) 76 | implementation(libs.mosaic.testing) 77 | } 78 | } 79 | } 80 | 81 | targets.withType().configureEach { 82 | binaries.executable { 83 | entryPoint = "com.mattprecious.stacker.main" 84 | } 85 | compilations.getByName("main").cinterops { 86 | create("libgit2") { 87 | definitionFile.set(libgitDefFile) 88 | } 89 | } 90 | } 91 | } 92 | 93 | sqldelight { 94 | databases { 95 | create("RepoDatabase") { 96 | packageName.set("com.mattprecious.stacker.db") 97 | dialect(libs.sqldelight.dialect) 98 | schemaOutputDirectory.set(file("src/commonMain/sqldelight/databases")) 99 | } 100 | } 101 | } 102 | 103 | spotless { 104 | kotlin { 105 | target("src/**/*.kt") 106 | ktlint("0.48.2").editorConfigOverride( 107 | mapOf( 108 | "ktlint_standard_filename" to "disabled", 109 | ) 110 | ) 111 | } 112 | } 113 | 114 | abstract class BuildDependenciesTask : Exec() { 115 | @get:InputFile 116 | abstract val script: RegularFileProperty 117 | 118 | @get:OutputFile 119 | abstract val defFile: RegularFileProperty 120 | 121 | @get:OutputDirectory 122 | abstract val outputDir: DirectoryProperty 123 | 124 | override fun exec() { 125 | setExecutable(script.get().asFile) 126 | setArgs( 127 | listOf( 128 | "-d", defFile.get().asFile.absolutePath, 129 | "-b", outputDir.get().asFile.absolutePath, 130 | ) 131 | ) 132 | 133 | super.exec() 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx1G 2 | kotlin.code.style=official 3 | kotlin.mpp.enableCInteropCommonization=true 4 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | coroutines = "1.10.2" 3 | kotlin = "2.1.21" 4 | ktor = "3.1.3" 5 | mosaic = "0.16.0" 6 | sqldelight = "2.1.0" 7 | 8 | [libraries] 9 | assertk = "com.willowtreeapps.assertk:assertk:0.28.1" 10 | clikt = "com.github.ajalt.clikt:clikt:5.0.3" 11 | coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } 12 | coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } 13 | kotlin-collections-immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0" 14 | kotlin-serialization-json = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1" 15 | kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test" } 16 | ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } 17 | ktor-client-curl = { module = "io.ktor:ktor-client-curl", version.ref = "ktor" } 18 | ktor-client-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } 19 | ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } 20 | mosaic-runtime = { module = "com.jakewharton.mosaic:mosaic-runtime", version.ref = "mosaic" } 21 | mosaic-testing = { module = "com.jakewharton.mosaic:mosaic-testing", version.ref = "mosaic" } 22 | okio = "com.squareup.okio:okio:3.12.0" 23 | sqldelight-driver = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" } 24 | sqldelight-dialect = { module = "app.cash.sqldelight:sqlite-3-38-dialect", version.ref = "sqldelight" } 25 | 26 | [plugins] 27 | burst = "app.cash.burst:2.5.0" 28 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } 29 | spotless = "com.diffplug.spotless:7.0.4" 30 | sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } 31 | kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } 32 | kotlin-plugin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } 33 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattprecious/stacker/b2202884478eb9c05fd4e5529206446826469794/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionSha256Sum=845952a9d6afa783db70bb3b0effaae45ae5542ca2bb7929619e8af49cb634cf 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip 5 | networkTimeout=10000 6 | validateDistributionUrl=true 7 | zipStoreBase=GRADLE_USER_HOME 8 | zipStorePath=wrapper/dists 9 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH= 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /scripts/deterministic-env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | FOLDER="$TMPDIR/stackerEnv-$(date +%Y%m%d%H%M%S)" 4 | mkdir $FOLDER 5 | pushd $FOLDER > /dev/null 6 | 7 | GIT_AUTHOR_DATE="2020-01-01T12:00:00Z" \ 8 | GIT_COMMITTER_DATE="2020-01-01T12:00:00Z" \ 9 | GIT_AUTHOR_EMAIL="stacker@example.com" \ 10 | GIT_COMMITTER_EMAIL="stacker@example.com" \ 11 | GIT_AUTHOR_NAME="Stacker" \ 12 | GIT_COMMITTER_NAME="Stacker" \ 13 | env $SHELL 14 | 15 | popd > /dev/null 16 | rm -rf $FOLDER 17 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "stacker" 2 | 3 | pluginManagement { 4 | repositories { 5 | gradlePluginPortal() 6 | mavenCentral() 7 | maven { 8 | setUrl("https://oss.sonatype.org/content/repositories/snapshots/") 9 | } 10 | } 11 | } 12 | 13 | plugins { 14 | id("com.gradle.develocity") version ("4.0.2") 15 | } 16 | 17 | develocity { 18 | buildScan { 19 | termsOfUseUrl = "https://gradle.com/help/legal-terms-of-use" 20 | termsOfUseAgree = "yes" 21 | if (System.getenv("CI") == "true") { 22 | tag("CI") 23 | } else { 24 | publishing.onlyIf { false } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/StackerDeps.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker 2 | 3 | import com.mattprecious.stacker.config.ConfigManager 4 | import com.mattprecious.stacker.lock.Locker 5 | import com.mattprecious.stacker.remote.Remote 6 | import com.mattprecious.stacker.stack.StackManager 7 | import com.mattprecious.stacker.vc.VersionControl 8 | 9 | // TODO: DI framework. 10 | class StackerDeps( 11 | val configManager: ConfigManager, 12 | val locker: Locker, 13 | val remote: Remote, 14 | val stackManager: StackManager, 15 | val useFancySymbols: Boolean, 16 | val vc: VersionControl, 17 | ) 18 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/cli/StackerCli.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.cli 2 | 3 | import com.github.ajalt.clikt.core.subcommands 4 | import com.mattprecious.stacker.StackerDeps 5 | import com.mattprecious.stacker.cli.branch.Branch 6 | import com.mattprecious.stacker.cli.downstack.Downstack 7 | import com.mattprecious.stacker.cli.log.Log 8 | import com.mattprecious.stacker.cli.rebase.Rebase 9 | import com.mattprecious.stacker.cli.repo.Repo 10 | import com.mattprecious.stacker.cli.stack.Stack 11 | import com.mattprecious.stacker.cli.upstack.Upstack 12 | 13 | internal class StackerCli( 14 | stacker: StackerDeps, 15 | ) : StackerCliktCommand("st") { 16 | init { 17 | subcommands( 18 | Branch(stacker), 19 | Downstack(stacker), 20 | Log(stacker), 21 | Rebase(stacker), 22 | Repo(stacker), 23 | Stack(stacker), 24 | Upstack(stacker), 25 | ) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/cli/StackerCliktCommand.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.cli 2 | 3 | import com.github.ajalt.clikt.core.Abort 4 | import com.github.ajalt.clikt.core.CliktCommand 5 | import com.mattprecious.stacker.command.StackerCommand 6 | import com.mattprecious.stacker.command.run 7 | import kotlinx.coroutines.runBlocking 8 | 9 | internal abstract class StackerCliktCommand( 10 | name: String? = null, 11 | private val shortAlias: String? = null, 12 | ) : CliktCommand( 13 | name = name, 14 | ) { 15 | final override fun run() = runBlocking { 16 | if (command?.run() == false) { 17 | throw Abort() 18 | } 19 | } 20 | 21 | open val command: StackerCommand? = null 22 | 23 | final override fun aliases(): Map> { 24 | return buildMap { 25 | registeredSubcommands() 26 | .filterIsInstance() 27 | .forEach { it.addShortAliases(this, "", emptyList()) } 28 | } 29 | } 30 | 31 | private fun addShortAliases( 32 | destination: MutableMap>, 33 | currentPrefix: String, 34 | currentChain: List, 35 | ) { 36 | if (shortAlias != null) { 37 | val withMyAlias = currentPrefix + shortAlias 38 | val withMyCommand = currentChain + commandName 39 | 40 | check(!destination.contains(withMyAlias)) { 41 | "Conflicting aliases! Command ${this::class.simpleName} tried to add '$withMyAlias', but it already " + 42 | "points to ${destination[withMyAlias]}." 43 | } 44 | 45 | destination[withMyAlias] = withMyCommand 46 | registeredSubcommands() 47 | .filterIsInstance() 48 | .forEach { it.addShortAliases(destination, withMyAlias, withMyCommand) } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/cli/branch/Bottom.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.cli.branch 2 | 3 | import com.mattprecious.stacker.StackerDeps 4 | import com.mattprecious.stacker.cli.StackerCliktCommand 5 | import com.mattprecious.stacker.command.branch.branchBottom 6 | 7 | internal class Bottom( 8 | private val stacker: StackerDeps, 9 | ) : StackerCliktCommand(shortAlias = "b") { 10 | override val command get() = stacker.branchBottom() 11 | } 12 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/cli/branch/Branch.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.cli.branch 2 | 3 | import com.github.ajalt.clikt.core.subcommands 4 | import com.mattprecious.stacker.StackerDeps 5 | import com.mattprecious.stacker.cli.StackerCliktCommand 6 | 7 | internal class Branch( 8 | stacker: StackerDeps, 9 | ) : StackerCliktCommand(shortAlias = "b") { 10 | init { 11 | subcommands( 12 | Bottom(stacker), 13 | Checkout(stacker), 14 | Create(stacker), 15 | Delete(stacker), 16 | Down(stacker), 17 | Rename(stacker), 18 | Restack(stacker), 19 | Submit(stacker), 20 | Top(stacker), 21 | Track(stacker), 22 | Untrack(stacker), 23 | Up(stacker), 24 | ) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/cli/branch/Checkout.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.cli.branch 2 | 3 | import com.github.ajalt.clikt.parameters.arguments.argument 4 | import com.github.ajalt.clikt.parameters.arguments.optional 5 | import com.mattprecious.stacker.StackerDeps 6 | import com.mattprecious.stacker.cli.StackerCliktCommand 7 | import com.mattprecious.stacker.command.branch.branchCheckout 8 | 9 | internal class Checkout( 10 | private val stacker: StackerDeps, 11 | ) : StackerCliktCommand(shortAlias = "co") { 12 | private val branchName: String? by argument().optional() 13 | 14 | override val command get() = stacker.branchCheckout(branchName) 15 | } 16 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/cli/branch/Create.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.cli.branch 2 | 3 | import com.github.ajalt.clikt.parameters.arguments.argument 4 | import com.mattprecious.stacker.StackerDeps 5 | import com.mattprecious.stacker.cli.StackerCliktCommand 6 | import com.mattprecious.stacker.command.branch.branchCreate 7 | 8 | internal class Create( 9 | private val stacker: StackerDeps, 10 | ) : StackerCliktCommand(shortAlias = "c") { 11 | private val branchName by argument() 12 | 13 | override val command get() = stacker.branchCreate(branchName) 14 | } 15 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/cli/branch/Delete.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.cli.branch 2 | 3 | import com.github.ajalt.clikt.parameters.arguments.argument 4 | import com.github.ajalt.clikt.parameters.arguments.optional 5 | import com.mattprecious.stacker.StackerDeps 6 | import com.mattprecious.stacker.cli.StackerCliktCommand 7 | import com.mattprecious.stacker.command.branch.branchDelete 8 | 9 | internal class Delete( 10 | private val stacker: StackerDeps, 11 | ) : StackerCliktCommand(shortAlias = "dl") { 12 | private val branchName: String? by argument().optional() 13 | 14 | override val command get() = stacker.branchDelete(branchName) 15 | } 16 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/cli/branch/Down.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.cli.branch 2 | 3 | import com.mattprecious.stacker.StackerDeps 4 | import com.mattprecious.stacker.cli.StackerCliktCommand 5 | import com.mattprecious.stacker.command.branch.branchDown 6 | 7 | internal class Down( 8 | private val stacker: StackerDeps, 9 | ) : StackerCliktCommand(shortAlias = "d") { 10 | override val command get() = stacker.branchDown() 11 | } 12 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/cli/branch/Rename.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.cli.branch 2 | 3 | import com.github.ajalt.clikt.parameters.arguments.argument 4 | import com.mattprecious.stacker.StackerDeps 5 | import com.mattprecious.stacker.cli.StackerCliktCommand 6 | import com.mattprecious.stacker.command.branch.branchRename 7 | 8 | internal class Rename( 9 | private val stacker: StackerDeps, 10 | ) : StackerCliktCommand(shortAlias = "rn") { 11 | private val newName by argument() 12 | 13 | override val command get() = stacker.branchRename(newName) 14 | } 15 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/cli/branch/Restack.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.cli.branch 2 | 3 | import com.github.ajalt.clikt.parameters.arguments.argument 4 | import com.github.ajalt.clikt.parameters.arguments.optional 5 | import com.mattprecious.stacker.StackerDeps 6 | import com.mattprecious.stacker.cli.StackerCliktCommand 7 | import com.mattprecious.stacker.command.branch.branchRestack 8 | 9 | internal class Restack( 10 | private val stacker: StackerDeps, 11 | ) : StackerCliktCommand(shortAlias = "r") { 12 | private val branchName: String? by argument().optional() 13 | 14 | override val command get() = stacker.branchRestack(branchName) 15 | } 16 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/cli/branch/Submit.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.cli.branch 2 | 3 | import com.mattprecious.stacker.StackerDeps 4 | import com.mattprecious.stacker.cli.StackerCliktCommand 5 | import com.mattprecious.stacker.command.branch.branchSubmit 6 | 7 | internal class Submit( 8 | private val stacker: StackerDeps, 9 | ) : StackerCliktCommand(shortAlias = "s") { 10 | override val command get() = stacker.branchSubmit() 11 | } 12 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/cli/branch/Top.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.cli.branch 2 | 3 | import com.mattprecious.stacker.StackerDeps 4 | import com.mattprecious.stacker.cli.StackerCliktCommand 5 | import com.mattprecious.stacker.command.branch.branchTop 6 | 7 | internal class Top( 8 | private val stacker: StackerDeps, 9 | ) : StackerCliktCommand(shortAlias = "t") { 10 | override val command get() = stacker.branchTop() 11 | } 12 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/cli/branch/Track.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.cli.branch 2 | 3 | import com.github.ajalt.clikt.parameters.arguments.argument 4 | import com.github.ajalt.clikt.parameters.arguments.optional 5 | import com.mattprecious.stacker.StackerDeps 6 | import com.mattprecious.stacker.cli.StackerCliktCommand 7 | import com.mattprecious.stacker.command.branch.branchTrack 8 | 9 | internal class Track( 10 | private val stacker: StackerDeps, 11 | ) : StackerCliktCommand(shortAlias = "tr") { 12 | private val branchName: String? by argument().optional() 13 | 14 | override val command get() = stacker.branchTrack(branchName) 15 | } 16 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/cli/branch/Untrack.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.cli.branch 2 | 3 | import com.github.ajalt.clikt.parameters.arguments.argument 4 | import com.github.ajalt.clikt.parameters.arguments.optional 5 | import com.mattprecious.stacker.StackerDeps 6 | import com.mattprecious.stacker.cli.StackerCliktCommand 7 | import com.mattprecious.stacker.command.branch.branchUntrack 8 | 9 | internal class Untrack( 10 | private val stacker: StackerDeps, 11 | ) : StackerCliktCommand(shortAlias = "ut") { 12 | private val branchName: String? by argument().optional() 13 | 14 | override val command get() = stacker.branchUntrack(branchName) 15 | } 16 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/cli/branch/Up.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.cli.branch 2 | 3 | import com.mattprecious.stacker.StackerDeps 4 | import com.mattprecious.stacker.cli.StackerCliktCommand 5 | import com.mattprecious.stacker.command.branch.branchUp 6 | 7 | internal class Up( 8 | private val stacker: StackerDeps, 9 | ) : StackerCliktCommand(shortAlias = "u") { 10 | override val command get() = stacker.branchUp() 11 | } 12 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/cli/downstack/Downstack.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.cli.downstack 2 | 3 | import com.github.ajalt.clikt.core.subcommands 4 | import com.mattprecious.stacker.StackerDeps 5 | import com.mattprecious.stacker.cli.StackerCliktCommand 6 | 7 | internal class Downstack( 8 | stacker: StackerDeps, 9 | ) : StackerCliktCommand(shortAlias = "ds") { 10 | init { 11 | subcommands( 12 | Edit(stacker), 13 | ) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/cli/downstack/Edit.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.cli.downstack 2 | 3 | import com.mattprecious.stacker.StackerDeps 4 | import com.mattprecious.stacker.cli.StackerCliktCommand 5 | import com.mattprecious.stacker.command.downstack.downstackEdit 6 | 7 | internal class Edit( 8 | private val stacker: StackerDeps, 9 | ) : StackerCliktCommand(shortAlias = "e") { 10 | override val command get() = stacker.downstackEdit() 11 | } 12 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/cli/log/Log.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.cli.log 2 | 3 | import com.github.ajalt.clikt.core.subcommands 4 | import com.mattprecious.stacker.StackerDeps 5 | import com.mattprecious.stacker.cli.StackerCliktCommand 6 | 7 | internal class Log( 8 | stacker: StackerDeps, 9 | ) : StackerCliktCommand(shortAlias = "l") { 10 | init { 11 | subcommands( 12 | Short(stacker), 13 | ) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/cli/log/Short.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.cli.log 2 | 3 | import com.mattprecious.stacker.StackerDeps 4 | import com.mattprecious.stacker.cli.StackerCliktCommand 5 | import com.mattprecious.stacker.command.log.logShort 6 | 7 | internal class Short( 8 | private val stacker: StackerDeps, 9 | ) : StackerCliktCommand(shortAlias = "s") { 10 | override val command get() = stacker.logShort() 11 | } 12 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/cli/rebase/Rebase.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.cli.rebase 2 | 3 | import com.github.ajalt.clikt.core.PrintHelpMessage 4 | import com.github.ajalt.clikt.parameters.options.flag 5 | import com.github.ajalt.clikt.parameters.options.option 6 | import com.mattprecious.stacker.StackerDeps 7 | import com.mattprecious.stacker.cli.StackerCliktCommand 8 | import com.mattprecious.stacker.command.StackerCommand 9 | import com.mattprecious.stacker.command.rebase.rebaseAbort 10 | import com.mattprecious.stacker.command.rebase.rebaseContinue 11 | 12 | internal class Rebase( 13 | private val stacker: StackerDeps, 14 | ) : StackerCliktCommand() { 15 | private val abort: Boolean by option().flag() 16 | private val cont: Boolean by option("--continue").flag() 17 | 18 | override val command: StackerCommand 19 | get() = when { 20 | abort -> stacker.rebaseAbort() 21 | cont -> stacker.rebaseContinue() 22 | else -> throw PrintHelpMessage(currentContext, error = true) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/cli/repo/Init.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.cli.repo 2 | 3 | import com.mattprecious.stacker.StackerDeps 4 | import com.mattprecious.stacker.cli.StackerCliktCommand 5 | import com.mattprecious.stacker.command.repo.repoInit 6 | 7 | internal class Init( 8 | private val stacker: StackerDeps, 9 | ) : StackerCliktCommand() { 10 | override val command get() = stacker.repoInit() 11 | } 12 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/cli/repo/Repo.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.cli.repo 2 | 3 | import com.github.ajalt.clikt.core.subcommands 4 | import com.mattprecious.stacker.StackerDeps 5 | import com.mattprecious.stacker.cli.StackerCliktCommand 6 | 7 | internal class Repo( 8 | stacker: StackerDeps, 9 | ) : StackerCliktCommand(shortAlias = "r") { 10 | init { 11 | subcommands( 12 | Init(stacker), 13 | Sync(stacker), 14 | ) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/cli/repo/Sync.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.cli.repo 2 | 3 | import com.github.ajalt.clikt.parameters.options.flag 4 | import com.github.ajalt.clikt.parameters.options.option 5 | import com.mattprecious.stacker.StackerDeps 6 | import com.mattprecious.stacker.cli.StackerCliktCommand 7 | import com.mattprecious.stacker.command.repo.repoSync 8 | 9 | internal class Sync( 10 | private val stacker: StackerDeps, 11 | ) : StackerCliktCommand(shortAlias = "s") { 12 | private val ask: Boolean by option().flag() 13 | 14 | override val command get() = stacker.repoSync(ask) 15 | } 16 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/cli/stack/Stack.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.cli.stack 2 | 3 | import com.github.ajalt.clikt.core.subcommands 4 | import com.mattprecious.stacker.StackerDeps 5 | import com.mattprecious.stacker.cli.StackerCliktCommand 6 | 7 | internal class Stack( 8 | stacker: StackerDeps, 9 | ) : StackerCliktCommand(shortAlias = "s") { 10 | init { 11 | subcommands( 12 | Submit(stacker), 13 | ) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/cli/stack/Submit.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.cli.stack 2 | 3 | import com.mattprecious.stacker.StackerDeps 4 | import com.mattprecious.stacker.cli.StackerCliktCommand 5 | import com.mattprecious.stacker.command.stack.stackSubmit 6 | 7 | internal class Submit( 8 | private val stacker: StackerDeps, 9 | ) : StackerCliktCommand(shortAlias = "s") { 10 | override val command get() = stacker.stackSubmit() 11 | } 12 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/cli/upstack/Onto.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.cli.upstack 2 | 3 | import com.mattprecious.stacker.StackerDeps 4 | import com.mattprecious.stacker.cli.StackerCliktCommand 5 | import com.mattprecious.stacker.command.upstack.upstackOnto 6 | 7 | internal class Onto( 8 | private val stacker: StackerDeps, 9 | ) : StackerCliktCommand(shortAlias = "o") { 10 | override val command get() = stacker.upstackOnto() 11 | } 12 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/cli/upstack/Restack.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.cli.upstack 2 | 3 | import com.mattprecious.stacker.StackerDeps 4 | import com.mattprecious.stacker.cli.StackerCliktCommand 5 | import com.mattprecious.stacker.command.upstack.upstackRestack 6 | 7 | internal class Restack( 8 | private val stacker: StackerDeps, 9 | ) : StackerCliktCommand(shortAlias = "r") { 10 | override val command get() = stacker.upstackRestack() 11 | } 12 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/cli/upstack/Upstack.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.cli.upstack 2 | 3 | import com.github.ajalt.clikt.core.subcommands 4 | import com.mattprecious.stacker.StackerDeps 5 | import com.mattprecious.stacker.cli.StackerCliktCommand 6 | 7 | internal class Upstack( 8 | stacker: StackerDeps, 9 | ) : StackerCliktCommand(shortAlias = "us") { 10 | init { 11 | subcommands( 12 | Onto(stacker), 13 | Restack(stacker), 14 | ) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/collections/collections.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.collections 2 | 3 | import androidx.compose.runtime.Stable 4 | 5 | fun List.radiateFrom(index: Int) = sequence { 6 | yield(get(index)) 7 | 8 | var left = index - 1 9 | var right = index + 1 10 | while (left >= 0 || right < size) { 11 | if (left >= 0) yield(get(left--)) 12 | if (right < size) yield(get(right++)) 13 | } 14 | } 15 | 16 | fun treeOf( 17 | elements: Collection, 18 | keySelector: (T) -> K, 19 | parentSelector: (T) -> K?, 20 | ): TreeNode { 21 | require(elements.isNotEmpty()) { 22 | "elements must not be empty." 23 | } 24 | 25 | val elementsByKey = elements.associateBy(keySelector) 26 | val parents = elementsByKey.mapValues { 27 | elements.indexOf(elementsByKey[parentSelector(it.value)]) 28 | } 29 | val children = elements.groupBy(parentSelector) { elements.indexOf(it) } 30 | 31 | require(children[null]!!.size == 1) { 32 | val elementsList = elements.toList() 33 | val roots = children[null]!!.map { keySelector(elementsList[it]) } 34 | "Multiple elements have a null parent: $roots." 35 | } 36 | 37 | return Tree( 38 | elements = elements, 39 | keySelector, 40 | parentMap = parents, 41 | childMap = children, 42 | ).root 43 | } 44 | 45 | internal class Tree( 46 | elements: Collection, 47 | private val keySelector: (T) -> K, 48 | private val parentMap: Map, 49 | private val childMap: Map>, 50 | ) { 51 | private val nodes = elements.map { TreeNode(this, it) } 52 | 53 | val root: TreeNode = nodes[childMap[null]!!.single()] 54 | 55 | fun getParent(element: T): TreeNode? { 56 | val index = parentMap[keySelector(element)]!! 57 | return if (index == -1) { 58 | null 59 | } else { 60 | nodes[index] 61 | } 62 | } 63 | 64 | fun getChildren(element: T): List> { 65 | return childMap[keySelector(element)]?.map { nodes[it] } ?: emptyList() 66 | } 67 | } 68 | 69 | @Stable 70 | class TreeNode internal constructor( 71 | private val tree: Tree<*, T>, 72 | val value: T, 73 | ) { 74 | val root: TreeNode 75 | get() = tree.root 76 | 77 | val parent: TreeNode? 78 | get() = tree.getParent(value) 79 | 80 | val children: List> 81 | get() = tree.getChildren(value) 82 | } 83 | 84 | /** Returns a sequence over the parent chain of this node. */ 85 | val TreeNode.ancestors 86 | get() = generateSequence(parent) { it.parent } 87 | 88 | /** Returns a sequence over the children of this node. This performs a depth-first traversal. */ 89 | val TreeNode.descendants: Sequence> 90 | get() = sequence { children.forEach { yieldAll(it.all) } } 91 | 92 | /** Returns a sequence over this node and its descendants. */ 93 | val TreeNode.all: Sequence> 94 | get() = sequence { 95 | yield(this@all) 96 | yieldAll(descendants) 97 | } 98 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/command/StackerCommand.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.command 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.CompositionLocalProvider 5 | import androidx.compose.runtime.LaunchedEffect 6 | import androidx.compose.runtime.SideEffect 7 | import androidx.compose.runtime.Stable 8 | import androidx.compose.runtime.collectAsState 9 | import androidx.compose.runtime.getValue 10 | import androidx.compose.runtime.mutableStateOf 11 | import androidx.compose.runtime.remember 12 | import androidx.compose.runtime.setValue 13 | import androidx.compose.runtime.snapshotFlow 14 | import com.jakewharton.mosaic.runMosaic 15 | import com.mattprecious.stacker.rendering.LocalPrinter 16 | import com.mattprecious.stacker.rendering.Printer 17 | import com.mattprecious.stacker.vc.LibGit2Error 18 | import kotlinx.coroutines.Dispatchers 19 | import kotlinx.coroutines.IO 20 | import kotlinx.coroutines.flow.transformWhile 21 | import kotlinx.coroutines.withContext 22 | 23 | abstract class StackerCommand { 24 | internal sealed interface State { 25 | /** [work] is executing and we are not rendering any dynamic content. */ 26 | data object Working : State 27 | 28 | /** 29 | * `work` has provided dynamic content to render. 30 | * 31 | * @param content A callback to invoke when rendering should be finished. 32 | */ 33 | data class Rendering( 34 | val content: @Composable (onResult: (R) -> Unit) -> Unit, 35 | ) : State 36 | 37 | /** The [Rendering] state has returned a `result`. We will deliver it back to [work]. */ 38 | data class DeliveringRenderResult(val result: R) : State 39 | 40 | sealed interface TerminalState : State 41 | 42 | /** `work` has finished and we are tearing down. */ 43 | data object Finished : TerminalState 44 | 45 | /** `work` has aborted and we are tearing down. */ 46 | data object Aborted : TerminalState 47 | } 48 | 49 | @Composable 50 | internal fun rememberWorkState(): WorkState { 51 | return remember { WorkState() } 52 | } 53 | 54 | @Stable 55 | internal class WorkState { 56 | var state by mutableStateOf(State.Working) 57 | } 58 | 59 | @Composable 60 | internal fun Work( 61 | onFinish: (Boolean) -> Unit, 62 | workState: WorkState = rememberWorkState(), 63 | ) { 64 | val printer = remember { Printer() } 65 | 66 | // Mosaic will wait for all effects to finish before exiting. Finished and Aborted signal that 67 | // we should terminate this collect in order to tear down. 68 | val currentState = remember { 69 | snapshotFlow { workState.state } 70 | .transformWhile { 71 | // We need to emit the terminal state so that the LaunchedEffect below can be interrupted. 72 | emit(it) 73 | it !is State.TerminalState 74 | } 75 | } 76 | .collectAsState(workState.state) 77 | .value 78 | 79 | printer.Messages() 80 | 81 | if (currentState is State.TerminalState) { 82 | SideEffect { onFinish(currentState is State.Finished) } 83 | } else { 84 | LaunchedEffect(Unit) { 85 | // Don't block the render thread. 86 | withContext(Dispatchers.IO) { 87 | with(StackerCommandScope(printer, workState)) { 88 | try { 89 | work() 90 | } catch (e: LibGit2Error) { 91 | printStatic(e.message!!) 92 | abort() 93 | } 94 | } 95 | 96 | workState.state = State.Finished 97 | } 98 | } 99 | } 100 | 101 | if (currentState is State.Rendering<*>) { 102 | CompositionLocalProvider(LocalPrinter provides printer) { 103 | currentState.content { workState.state = State.DeliveringRenderResult(it) } 104 | } 105 | } 106 | } 107 | 108 | protected open suspend fun StackerCommandScope.work() {} 109 | } 110 | 111 | suspend fun StackerCommand.run(): Boolean { 112 | var result = false 113 | 114 | runMosaic { 115 | Work(onFinish = { result = it }) 116 | } 117 | 118 | return result 119 | } 120 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/command/StackerCommandScope.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.command 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.snapshotFlow 5 | import com.jakewharton.mosaic.text.AnnotatedString 6 | import com.jakewharton.mosaic.text.buildAnnotatedString 7 | import com.mattprecious.stacker.command.StackerCommand.WorkState 8 | import com.mattprecious.stacker.config.ConfigManager 9 | import com.mattprecious.stacker.lock.Locker 10 | import com.mattprecious.stacker.rendering.Printer 11 | import com.mattprecious.stacker.rendering.code 12 | import kotlinx.coroutines.awaitCancellation 13 | import kotlinx.coroutines.flow.filterIsInstance 14 | import kotlinx.coroutines.flow.first 15 | 16 | class StackerCommandScope internal constructor( 17 | private val printer: Printer, 18 | private val workState: WorkState, 19 | ) { 20 | fun printStatic(message: String) { 21 | printer.printStatic(message) 22 | } 23 | 24 | fun printStatic(message: AnnotatedString) { 25 | printer.printStatic(message) 26 | } 27 | 28 | fun printStaticError(message: String) { 29 | // TODO: Red. 30 | printer.printStatic(message) 31 | } 32 | 33 | fun printStaticError(message: AnnotatedString) { 34 | // TODO: Red. 35 | printer.printStatic(message) 36 | } 37 | 38 | suspend fun render(content: @Composable (onResult: (R) -> Unit) -> Unit): R { 39 | workState.state = StackerCommand.State.Rendering(content) 40 | return snapshotFlow { workState.state } 41 | .filterIsInstance>() 42 | .first() 43 | .result 44 | .also { 45 | workState.state = StackerCommand.State.Working 46 | } 47 | } 48 | 49 | suspend fun requireInitialized(configManager: ConfigManager) { 50 | if (!configManager.repoInitialized) { 51 | printStaticError( 52 | buildAnnotatedString { 53 | append("Stacker must be initialized first. Please run ") 54 | code { append("st repo init") } 55 | append(".") 56 | }, 57 | ) 58 | abort() 59 | } 60 | } 61 | 62 | suspend fun requireNoLock(locker: Locker) { 63 | if (locker.hasLock()) { 64 | printStaticError( 65 | buildAnnotatedString { 66 | append("A restack is currently in progress. Please run ") 67 | code { append("st rebase --abort") } 68 | append(" or resolve any conflicts and run ") 69 | code { append("st rebase --continue") } 70 | append(".") 71 | }, 72 | ) 73 | abort() 74 | } 75 | } 76 | 77 | suspend fun abort(): Nothing { 78 | require(workState.state !is StackerCommand.State.TerminalState) 79 | workState.state = StackerCommand.State.Aborted 80 | awaitCancellation() 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/command/branch/BranchBottom.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.command.branch 2 | 3 | import com.jakewharton.mosaic.text.buildAnnotatedString 4 | import com.mattprecious.stacker.StackerDeps 5 | import com.mattprecious.stacker.command.StackerCommand 6 | import com.mattprecious.stacker.command.StackerCommandScope 7 | import com.mattprecious.stacker.command.name 8 | import com.mattprecious.stacker.config.ConfigManager 9 | import com.mattprecious.stacker.lock.Locker 10 | import com.mattprecious.stacker.rendering.branch 11 | import com.mattprecious.stacker.stack.StackManager 12 | import com.mattprecious.stacker.vc.VersionControl 13 | 14 | fun StackerDeps.branchBottom(): StackerCommand { 15 | return BranchBottom( 16 | configManager = configManager, 17 | locker = locker, 18 | stackManager = stackManager, 19 | vc = vc, 20 | ) 21 | } 22 | 23 | internal class BranchBottom( 24 | private val configManager: ConfigManager, 25 | private val locker: Locker, 26 | private val stackManager: StackManager, 27 | private val vc: VersionControl, 28 | ) : StackerCommand() { 29 | override suspend fun StackerCommandScope.work() { 30 | requireInitialized(configManager) 31 | requireNoLock(locker) 32 | 33 | val trunk = configManager.trunk 34 | val trailingTrunk = configManager.trailingTrunk 35 | val currentBranchName = vc.currentBranchName 36 | 37 | if (currentBranchName == trailingTrunk || currentBranchName == trunk) { 38 | printStatic("Not in a stack.") 39 | abort() 40 | } 41 | 42 | val currentBranch = stackManager.getBranch(currentBranchName) 43 | if (currentBranch == null) { 44 | printStaticError( 45 | buildAnnotatedString { 46 | append("Branch ") 47 | this.branch { append(currentBranch) } 48 | append(" is not tracked.") 49 | }, 50 | ) 51 | abort() 52 | } 53 | 54 | var bottom = currentBranch!! 55 | while (bottom.parent!!.name != trailingTrunk && bottom.parent!!.name != trunk) { 56 | bottom = bottom.parent!! 57 | } 58 | 59 | vc.checkout(bottom.name) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/command/branch/BranchCheckout.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.command.branch 2 | 3 | import androidx.compose.runtime.remember 4 | import com.jakewharton.mosaic.text.buildAnnotatedString 5 | import com.mattprecious.stacker.StackerDeps 6 | import com.mattprecious.stacker.command.StackerCommand 7 | import com.mattprecious.stacker.command.StackerCommandScope 8 | import com.mattprecious.stacker.command.name 9 | import com.mattprecious.stacker.command.prettyTree 10 | import com.mattprecious.stacker.config.ConfigManager 11 | import com.mattprecious.stacker.lock.Locker 12 | import com.mattprecious.stacker.rendering.InteractivePrompt 13 | import com.mattprecious.stacker.rendering.PromptState 14 | import com.mattprecious.stacker.rendering.branch 15 | import com.mattprecious.stacker.rendering.toAnnotatedString 16 | import com.mattprecious.stacker.stack.StackManager 17 | import com.mattprecious.stacker.vc.VersionControl 18 | import kotlinx.collections.immutable.toPersistentList 19 | 20 | fun StackerDeps.branchCheckout( 21 | branchName: String?, 22 | ): StackerCommand { 23 | return BranchCheckout( 24 | branchName = branchName, 25 | configManager = configManager, 26 | locker = locker, 27 | stackManager = stackManager, 28 | useFancySymbols = useFancySymbols, 29 | vc = vc, 30 | ) 31 | } 32 | 33 | internal class BranchCheckout( 34 | private val branchName: String?, 35 | private val configManager: ConfigManager, 36 | private val locker: Locker, 37 | private val stackManager: StackManager, 38 | private val useFancySymbols: Boolean, 39 | private val vc: VersionControl, 40 | ) : StackerCommand() { 41 | override suspend fun StackerCommandScope.work() { 42 | requireInitialized(configManager) 43 | requireNoLock(locker) 44 | 45 | val branch = if (branchName == null) { 46 | val options = stackManager.getBase()!!.prettyTree(useFancySymbols = useFancySymbols) 47 | if (options.size == 1) { 48 | options.single().branch.name 49 | } else { 50 | render { onResult -> 51 | InteractivePrompt( 52 | message = "Checkout a branch", 53 | state = remember { 54 | PromptState( 55 | options = options.toPersistentList(), 56 | default = options.find { it.branch.name == vc.currentBranchName }, 57 | displayTransform = { it.pretty.toAnnotatedString() }, 58 | valueTransform = { it.branch.name.toAnnotatedString() }, 59 | ) 60 | }, 61 | onSelected = { onResult(it.branch.name) }, 62 | ) 63 | } 64 | } 65 | } else if (vc.branches.contains(branchName)) { 66 | branchName 67 | } else { 68 | printStaticError( 69 | buildAnnotatedString { 70 | branch { append(branchName) } 71 | append(" does not match any branches known to git.") 72 | }, 73 | ) 74 | 75 | abort() 76 | } 77 | 78 | vc.checkout(branch) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/command/branch/BranchCreate.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.command.branch 2 | 3 | import com.jakewharton.mosaic.text.buildAnnotatedString 4 | import com.mattprecious.stacker.StackerDeps 5 | import com.mattprecious.stacker.command.StackerCommand 6 | import com.mattprecious.stacker.command.StackerCommandScope 7 | import com.mattprecious.stacker.command.name 8 | import com.mattprecious.stacker.config.ConfigManager 9 | import com.mattprecious.stacker.lock.Locker 10 | import com.mattprecious.stacker.rendering.branch 11 | import com.mattprecious.stacker.rendering.code 12 | import com.mattprecious.stacker.stack.StackManager 13 | import com.mattprecious.stacker.vc.VersionControl 14 | import com.mattprecious.stacker.vc.VersionControl.BranchCreateResult 15 | 16 | fun StackerDeps.branchCreate( 17 | branchName: String, 18 | ): StackerCommand { 19 | return BranchCreate( 20 | branchName = branchName, 21 | configManager = configManager, 22 | locker = locker, 23 | stackManager = stackManager, 24 | vc = vc, 25 | ) 26 | } 27 | 28 | internal class BranchCreate( 29 | private val branchName: String, 30 | private val configManager: ConfigManager, 31 | private val locker: Locker, 32 | private val stackManager: StackManager, 33 | private val vc: VersionControl, 34 | ) : StackerCommand() { 35 | override suspend fun StackerCommandScope.work() { 36 | requireInitialized(configManager) 37 | requireNoLock(locker) 38 | 39 | val currentBranch = stackManager.getBranch(vc.currentBranchName) 40 | if (currentBranch == null) { 41 | printStaticError( 42 | buildAnnotatedString { 43 | append("Cannot branch from ") 44 | branch { append(vc.currentBranchName) } 45 | append(" since it is not tracked. Please track with ") 46 | code { append("st branch track") } 47 | append(".") 48 | }, 49 | ) 50 | abort() 51 | } 52 | 53 | when (vc.createBranchFromCurrent(branchName)) { 54 | BranchCreateResult.Success -> { 55 | stackManager.trackBranch(branchName, currentBranch.name, vc.getSha(currentBranch.name)) 56 | } 57 | BranchCreateResult.AlreadyExists -> { 58 | printStaticError( 59 | buildAnnotatedString { 60 | append("Branch ") 61 | branch { append(branchName) } 62 | append(" already exists.") 63 | }, 64 | ) 65 | 66 | abort() 67 | } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/command/branch/BranchDelete.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.command.branch 2 | 3 | import com.mattprecious.stacker.StackerDeps 4 | import com.mattprecious.stacker.command.StackerCommand 5 | import com.mattprecious.stacker.command.StackerCommandScope 6 | import com.mattprecious.stacker.command.name 7 | import com.mattprecious.stacker.config.ConfigManager 8 | import com.mattprecious.stacker.lock.Locker 9 | import com.mattprecious.stacker.stack.StackManager 10 | import com.mattprecious.stacker.vc.VersionControl 11 | 12 | fun StackerDeps.branchDelete( 13 | branchName: String?, 14 | ): StackerCommand { 15 | return BranchDelete( 16 | branchName = branchName, 17 | configManager = configManager, 18 | locker = locker, 19 | stackManager = stackManager, 20 | vc = vc, 21 | ) 22 | } 23 | 24 | internal class BranchDelete( 25 | private val branchName: String?, 26 | private val configManager: ConfigManager, 27 | private val locker: Locker, 28 | private val stackManager: StackManager, 29 | private val vc: VersionControl, 30 | ) : StackerCommand() { 31 | override suspend fun StackerCommandScope.work() { 32 | requireInitialized(configManager) 33 | requireNoLock(locker) 34 | 35 | val currentBranchName = vc.currentBranchName 36 | 37 | val branchName = branchName ?: currentBranchName 38 | if (branchName == configManager.trunk || branchName == configManager.trailingTrunk) { 39 | printStaticError("Cannot delete a trunk branch.") 40 | abort() 41 | } 42 | 43 | val branch = stackManager.getBranch(branchName) 44 | if (branch != null) { 45 | if (branch.children.isNotEmpty()) { 46 | printStaticError("Branch has children. Please retarget or untrack them.") 47 | abort() 48 | } 49 | 50 | if (branchName == currentBranchName) { 51 | vc.checkout(branch.parent!!.name) 52 | } 53 | 54 | stackManager.untrackBranch(branch.value) 55 | } else if (branchName == currentBranchName) { 56 | vc.checkout(configManager.trailingTrunk ?: configManager.trunk!!) 57 | } 58 | 59 | vc.delete(branchName) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/command/branch/BranchDown.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.command.branch 2 | 3 | import com.jakewharton.mosaic.text.buildAnnotatedString 4 | import com.mattprecious.stacker.StackerDeps 5 | import com.mattprecious.stacker.command.StackerCommand 6 | import com.mattprecious.stacker.command.StackerCommandScope 7 | import com.mattprecious.stacker.command.name 8 | import com.mattprecious.stacker.config.ConfigManager 9 | import com.mattprecious.stacker.lock.Locker 10 | import com.mattprecious.stacker.rendering.branch 11 | import com.mattprecious.stacker.stack.StackManager 12 | import com.mattprecious.stacker.vc.VersionControl 13 | 14 | fun StackerDeps.branchDown(): StackerCommand { 15 | return BranchDown( 16 | configManager = configManager, 17 | locker = locker, 18 | stackManager = stackManager, 19 | vc = vc, 20 | ) 21 | } 22 | 23 | internal class BranchDown( 24 | private val configManager: ConfigManager, 25 | private val locker: Locker, 26 | private val stackManager: StackManager, 27 | private val vc: VersionControl, 28 | ) : StackerCommand() { 29 | override suspend fun StackerCommandScope.work() { 30 | requireInitialized(configManager) 31 | requireNoLock(locker) 32 | 33 | val branchName = vc.currentBranchName 34 | val branch = stackManager.getBranch(branchName) 35 | if (branch == null) { 36 | printStaticError( 37 | buildAnnotatedString { 38 | append("Branch ") 39 | this.branch { append(branchName) } 40 | append(" is not tracked.") 41 | }, 42 | ) 43 | abort() 44 | } 45 | 46 | val parent = branch.parent 47 | if (parent != null) { 48 | vc.checkout(parent.name) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/command/branch/BranchRename.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.command.branch 2 | 3 | import com.mattprecious.stacker.StackerDeps 4 | import com.mattprecious.stacker.command.StackerCommand 5 | import com.mattprecious.stacker.command.StackerCommandScope 6 | import com.mattprecious.stacker.config.ConfigManager 7 | import com.mattprecious.stacker.lock.Locker 8 | import com.mattprecious.stacker.stack.StackManager 9 | import com.mattprecious.stacker.vc.VersionControl 10 | 11 | fun StackerDeps.branchRename( 12 | newName: String, 13 | ): StackerCommand { 14 | return BranchRename( 15 | newName = newName, 16 | configManager = configManager, 17 | locker = locker, 18 | stackManager = stackManager, 19 | vc = vc, 20 | ) 21 | } 22 | 23 | internal class BranchRename( 24 | private val newName: String, 25 | private val configManager: ConfigManager, 26 | private val locker: Locker, 27 | private val stackManager: StackManager, 28 | private val vc: VersionControl, 29 | ) : StackerCommand() { 30 | override suspend fun StackerCommandScope.work() { 31 | requireInitialized(configManager) 32 | requireNoLock(locker) 33 | 34 | val currentBranchName = vc.currentBranchName 35 | if (currentBranchName == configManager.trunk || currentBranchName == configManager.trailingTrunk) { 36 | printStaticError("Cannot rename a trunk branch.") 37 | abort() 38 | } 39 | 40 | val currentBranch = stackManager.getBranch(currentBranchName) 41 | if (currentBranch != null) { 42 | stackManager.renameBranch(currentBranch.value, newName) 43 | } 44 | 45 | vc.renameBranch(branchName = currentBranchName, newName = newName) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/command/branch/BranchRestack.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.command.branch 2 | 3 | import com.jakewharton.mosaic.text.buildAnnotatedString 4 | import com.mattprecious.stacker.StackerDeps 5 | import com.mattprecious.stacker.command.StackerCommand 6 | import com.mattprecious.stacker.command.StackerCommandScope 7 | import com.mattprecious.stacker.command.perform 8 | import com.mattprecious.stacker.config.ConfigManager 9 | import com.mattprecious.stacker.lock.Locker 10 | import com.mattprecious.stacker.rendering.branch 11 | import com.mattprecious.stacker.rendering.code 12 | import com.mattprecious.stacker.stack.StackManager 13 | import com.mattprecious.stacker.vc.VersionControl 14 | 15 | fun StackerDeps.branchRestack( 16 | branchName: String?, 17 | ): StackerCommand { 18 | return BranchRestack( 19 | branchName = branchName, 20 | configManager = configManager, 21 | locker = locker, 22 | stackManager = stackManager, 23 | vc = vc, 24 | ) 25 | } 26 | 27 | internal class BranchRestack( 28 | private val branchName: String?, 29 | private val configManager: ConfigManager, 30 | private val locker: Locker, 31 | private val stackManager: StackManager, 32 | private val vc: VersionControl, 33 | ) : StackerCommand() { 34 | override suspend fun StackerCommandScope.work() { 35 | requireInitialized(configManager) 36 | requireNoLock(locker) 37 | 38 | val currentBranchName = vc.currentBranchName 39 | val branchName = branchName ?: currentBranchName 40 | if (stackManager.getBranch(currentBranchName) == null) { 41 | printStaticError( 42 | buildAnnotatedString { 43 | append("Cannot restack ") 44 | branch { append(currentBranchName) } 45 | append(" since it is not tracked. Please track with ") 46 | code { append("st branch track") } 47 | append(".") 48 | }, 49 | ) 50 | abort() 51 | } 52 | 53 | val operation = Locker.Operation.Restack( 54 | startingBranch = currentBranchName, 55 | branches = listOf(branchName), 56 | ) 57 | 58 | locker.beginOperation(operation) { 59 | operation.perform(this@work, this@beginOperation, stackManager, vc) 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/command/branch/BranchSubmit.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.command.branch 2 | 3 | import com.jakewharton.mosaic.text.buildAnnotatedString 4 | import com.mattprecious.stacker.StackerDeps 5 | import com.mattprecious.stacker.command.StackerCommand 6 | import com.mattprecious.stacker.command.StackerCommandScope 7 | import com.mattprecious.stacker.command.name 8 | import com.mattprecious.stacker.command.requireAuthenticated 9 | import com.mattprecious.stacker.command.submit 10 | import com.mattprecious.stacker.config.ConfigManager 11 | import com.mattprecious.stacker.lock.Locker 12 | import com.mattprecious.stacker.remote.Remote 13 | import com.mattprecious.stacker.rendering.branch 14 | import com.mattprecious.stacker.rendering.code 15 | import com.mattprecious.stacker.stack.StackManager 16 | import com.mattprecious.stacker.vc.VersionControl 17 | 18 | fun StackerDeps.branchSubmit(): StackerCommand { 19 | return BranchSubmit( 20 | configManager = configManager, 21 | locker = locker, 22 | remote = remote, 23 | stackManager = stackManager, 24 | vc = vc, 25 | ) 26 | } 27 | 28 | internal class BranchSubmit( 29 | private val configManager: ConfigManager, 30 | private val locker: Locker, 31 | private val remote: Remote, 32 | private val stackManager: StackManager, 33 | private val vc: VersionControl, 34 | ) : StackerCommand() { 35 | override suspend fun StackerCommandScope.work() { 36 | requireInitialized(configManager) 37 | requireNoLock(locker) 38 | 39 | val currentBranch = stackManager.getBranch(vc.currentBranchName) 40 | if (currentBranch == null) { 41 | printStaticError( 42 | buildAnnotatedString { 43 | append("Cannot create a pull request from ") 44 | branch { append(vc.currentBranchName) } 45 | append(" since it is not tracked. Please track with ") 46 | code { append("st branch track") } 47 | append(".") 48 | }, 49 | ) 50 | abort() 51 | } 52 | 53 | if (currentBranch.name == configManager.trunk || currentBranch.name == configManager.trailingTrunk) { 54 | printStaticError( 55 | buildAnnotatedString { 56 | append("Cannot create a pull request from trunk branch ") 57 | branch { append(currentBranch.name) } 58 | append(".") 59 | }, 60 | ) 61 | abort() 62 | } 63 | 64 | requireAuthenticated(remote) 65 | 66 | vc.pushBranches(listOf(currentBranch.name)) 67 | currentBranch.submit(this@work, configManager, remote, stackManager, vc) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/command/branch/BranchTop.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.command.branch 2 | 3 | import androidx.compose.runtime.remember 4 | import com.jakewharton.mosaic.text.buildAnnotatedString 5 | import com.mattprecious.stacker.StackerDeps 6 | import com.mattprecious.stacker.collections.TreeNode 7 | import com.mattprecious.stacker.command.StackerCommand 8 | import com.mattprecious.stacker.command.StackerCommandScope 9 | import com.mattprecious.stacker.command.name 10 | import com.mattprecious.stacker.config.ConfigManager 11 | import com.mattprecious.stacker.db.Branch 12 | import com.mattprecious.stacker.lock.Locker 13 | import com.mattprecious.stacker.rendering.InteractivePrompt 14 | import com.mattprecious.stacker.rendering.PromptState 15 | import com.mattprecious.stacker.rendering.branch 16 | import com.mattprecious.stacker.rendering.toAnnotatedString 17 | import com.mattprecious.stacker.stack.StackManager 18 | import com.mattprecious.stacker.vc.VersionControl 19 | import kotlinx.collections.immutable.toPersistentList 20 | 21 | fun StackerDeps.branchTop(): StackerCommand { 22 | return BranchTop( 23 | configManager = configManager, 24 | locker = locker, 25 | stackManager = stackManager, 26 | vc = vc, 27 | ) 28 | } 29 | 30 | internal class BranchTop( 31 | private val configManager: ConfigManager, 32 | private val locker: Locker, 33 | private val stackManager: StackManager, 34 | private val vc: VersionControl, 35 | ) : StackerCommand() { 36 | override suspend fun StackerCommandScope.work() { 37 | requireInitialized(configManager) 38 | requireNoLock(locker) 39 | 40 | val currentBranchName = vc.currentBranchName 41 | val currentBranch = stackManager.getBranch(currentBranchName) 42 | if (currentBranch == null) { 43 | printStaticError( 44 | buildAnnotatedString { 45 | append("Branch ") 46 | this.branch { append(currentBranchName) } 47 | append(" is not tracked.") 48 | }, 49 | ) 50 | abort() 51 | } 52 | 53 | val options = currentBranch.leaves() 54 | val branch = if (options.size == 1) { 55 | options.single().name 56 | } else { 57 | render { onResult -> 58 | InteractivePrompt( 59 | message = "Move up to", 60 | state = remember { 61 | PromptState( 62 | options.toPersistentList(), 63 | default = null, 64 | displayTransform = { it.name.toAnnotatedString() }, 65 | valueTransform = { it.name.toAnnotatedString() }, 66 | ) 67 | }, 68 | onSelected = { onResult(it.name) }, 69 | ) 70 | } 71 | } 72 | 73 | vc.checkout(branch) 74 | } 75 | 76 | private fun TreeNode.leaves(): List> { 77 | return if (children.isEmpty()) { 78 | listOf(this) 79 | } else { 80 | children.flatMap { it.leaves() } 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/command/branch/BranchTrack.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.command.branch 2 | 3 | import androidx.compose.runtime.remember 4 | import com.jakewharton.mosaic.text.buildAnnotatedString 5 | import com.mattprecious.stacker.StackerDeps 6 | import com.mattprecious.stacker.command.StackerCommand 7 | import com.mattprecious.stacker.command.StackerCommandScope 8 | import com.mattprecious.stacker.command.name 9 | import com.mattprecious.stacker.command.prettyTree 10 | import com.mattprecious.stacker.config.ConfigManager 11 | import com.mattprecious.stacker.lock.Locker 12 | import com.mattprecious.stacker.rendering.InteractivePrompt 13 | import com.mattprecious.stacker.rendering.PromptState 14 | import com.mattprecious.stacker.rendering.branch 15 | import com.mattprecious.stacker.rendering.toAnnotatedString 16 | import com.mattprecious.stacker.stack.StackManager 17 | import com.mattprecious.stacker.vc.VersionControl 18 | import kotlinx.collections.immutable.toPersistentList 19 | 20 | fun StackerDeps.branchTrack( 21 | branchName: String?, 22 | ): StackerCommand { 23 | return BranchTrack( 24 | branchName = branchName, 25 | configManager = configManager, 26 | locker = locker, 27 | stackManager = stackManager, 28 | useFancySymbols = useFancySymbols, 29 | vc = vc, 30 | ) 31 | } 32 | 33 | internal class BranchTrack( 34 | private val branchName: String?, 35 | private val configManager: ConfigManager, 36 | private val locker: Locker, 37 | private val stackManager: StackManager, 38 | private val useFancySymbols: Boolean, 39 | private val vc: VersionControl, 40 | ) : StackerCommand() { 41 | override suspend fun StackerCommandScope.work() { 42 | requireInitialized(configManager) 43 | requireNoLock(locker) 44 | 45 | val branchName = branchName ?: vc.currentBranchName 46 | val currentBranch = stackManager.getBranch(branchName) 47 | if (currentBranch != null) { 48 | printStaticError( 49 | buildAnnotatedString { 50 | append("Branch ") 51 | branch { append(branchName) } 52 | append(" is already tracked.") 53 | }, 54 | ) 55 | return 56 | } 57 | 58 | val trunk = configManager.trunk 59 | val trailingTrunk = configManager.trailingTrunk 60 | 61 | val defaultName = trailingTrunk ?: trunk 62 | 63 | val options = stackManager.getBase()!!.prettyTree(useFancySymbols = useFancySymbols) { 64 | it.name == trunk || it.name == trailingTrunk || vc.isAncestor( 65 | branchName = branchName, 66 | possibleAncestorName = it.name, 67 | ) 68 | } 69 | val parent = if (options.size == 1) { 70 | options.single().branch.name 71 | } else { 72 | render { onResult -> 73 | InteractivePrompt( 74 | message = buildAnnotatedString { 75 | append("Choose a parent branch for ") 76 | branch { append(branchName) } 77 | }, 78 | state = remember { 79 | PromptState( 80 | options.toPersistentList(), 81 | default = options.find { it.branch.name == defaultName }, 82 | displayTransform = { it.pretty.toAnnotatedString() }, 83 | valueTransform = { it.branch.name.toAnnotatedString() }, 84 | ) 85 | }, 86 | onSelected = { onResult(it.branch.name) }, 87 | ) 88 | } 89 | } 90 | 91 | val parentSha = vc.getSha(parent) 92 | 93 | stackManager.trackBranch(branchName, parent, parentSha) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/command/branch/BranchUntrack.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.command.branch 2 | 3 | import com.jakewharton.mosaic.text.buildAnnotatedString 4 | import com.mattprecious.stacker.StackerDeps 5 | import com.mattprecious.stacker.command.StackerCommand 6 | import com.mattprecious.stacker.command.StackerCommandScope 7 | import com.mattprecious.stacker.config.ConfigManager 8 | import com.mattprecious.stacker.lock.Locker 9 | import com.mattprecious.stacker.rendering.branch 10 | import com.mattprecious.stacker.stack.StackManager 11 | import com.mattprecious.stacker.vc.VersionControl 12 | 13 | fun StackerDeps.branchUntrack( 14 | branchName: String?, 15 | ): StackerCommand { 16 | return BranchUntrack( 17 | branchName = branchName, 18 | configManager = configManager, 19 | locker = locker, 20 | stackManager = stackManager, 21 | vc = vc, 22 | ) 23 | } 24 | 25 | internal class BranchUntrack( 26 | private val branchName: String?, 27 | private val configManager: ConfigManager, 28 | private val locker: Locker, 29 | private val stackManager: StackManager, 30 | private val vc: VersionControl, 31 | ) : StackerCommand() { 32 | override suspend fun StackerCommandScope.work() { 33 | requireInitialized(configManager) 34 | requireNoLock(locker) 35 | 36 | val branchName = branchName ?: vc.currentBranchName 37 | 38 | if (branchName == configManager.trunk) { 39 | printStaticError("Cannot untrack trunk branch.") 40 | abort() 41 | } 42 | 43 | if (branchName == configManager.trailingTrunk) { 44 | printStaticError("Cannot untrack trailing trunk branch.") 45 | abort() 46 | } 47 | 48 | val branch = stackManager.getBranch(branchName) 49 | if (branch == null) { 50 | printStaticError( 51 | buildAnnotatedString { 52 | append("Branch ") 53 | this.branch { append(branchName) } 54 | append(" is already not tracked.") 55 | }, 56 | ) 57 | return 58 | } 59 | 60 | if (branch.children.isNotEmpty()) { 61 | printStaticError( 62 | buildAnnotatedString { 63 | append("Branch ") 64 | this.branch { append(branchName) } 65 | append(" has children. Please retarget or untrack them.") 66 | }, 67 | ) 68 | abort() 69 | } 70 | 71 | stackManager.untrackBranch(branch.value) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/command/branch/BranchUp.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.command.branch 2 | 3 | import androidx.compose.runtime.remember 4 | import com.jakewharton.mosaic.text.buildAnnotatedString 5 | import com.mattprecious.stacker.StackerDeps 6 | import com.mattprecious.stacker.command.StackerCommand 7 | import com.mattprecious.stacker.command.StackerCommandScope 8 | import com.mattprecious.stacker.command.name 9 | import com.mattprecious.stacker.config.ConfigManager 10 | import com.mattprecious.stacker.lock.Locker 11 | import com.mattprecious.stacker.rendering.InteractivePrompt 12 | import com.mattprecious.stacker.rendering.PromptState 13 | import com.mattprecious.stacker.rendering.branch 14 | import com.mattprecious.stacker.rendering.toAnnotatedString 15 | import com.mattprecious.stacker.stack.StackManager 16 | import com.mattprecious.stacker.vc.VersionControl 17 | import kotlinx.collections.immutable.toPersistentList 18 | 19 | fun StackerDeps.branchUp(): StackerCommand { 20 | return BranchUp( 21 | configManager = configManager, 22 | locker = locker, 23 | stackManager = stackManager, 24 | vc = vc, 25 | ) 26 | } 27 | 28 | internal class BranchUp( 29 | private val configManager: ConfigManager, 30 | private val locker: Locker, 31 | private val stackManager: StackManager, 32 | private val vc: VersionControl, 33 | ) : StackerCommand() { 34 | override suspend fun StackerCommandScope.work() { 35 | requireInitialized(configManager) 36 | requireNoLock(locker) 37 | 38 | val currentBranchName = vc.currentBranchName 39 | val currentBranch = stackManager.getBranch(currentBranchName) 40 | if (currentBranch == null) { 41 | printStaticError( 42 | buildAnnotatedString { 43 | append("Branch ") 44 | this.branch { append(currentBranchName) } 45 | append(" is not tracked.") 46 | }, 47 | ) 48 | abort() 49 | } 50 | 51 | val options = currentBranch.children 52 | if (options.isEmpty()) return 53 | 54 | val upBranch = if (options.size == 1) { 55 | options.single().name 56 | } else { 57 | render { onResult -> 58 | InteractivePrompt( 59 | message = "Move up to", 60 | state = remember { 61 | PromptState( 62 | options.toPersistentList(), 63 | default = options.find { it.name == currentBranchName }, 64 | displayTransform = { it.name.toAnnotatedString() }, 65 | valueTransform = { it.name.toAnnotatedString() }, 66 | ) 67 | }, 68 | onSelected = { onResult(it.name) }, 69 | ) 70 | } 71 | } 72 | 73 | vc.checkout(upBranch) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/command/branches.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.command 2 | 3 | import com.mattprecious.stacker.collections.TreeNode 4 | import com.mattprecious.stacker.db.Branch 5 | 6 | val TreeNode.name: String 7 | get() = value.name 8 | 9 | val TreeNode.parentSha: String? 10 | get() = value.parentSha 11 | 12 | internal class PrettyBranch( 13 | val branch: TreeNode, 14 | val pretty: String, 15 | ) 16 | 17 | internal fun TreeNode.prettyTree( 18 | useFancySymbols: Boolean, 19 | selected: TreeNode? = null, 20 | filter: (TreeNode) -> Boolean = { true }, 21 | ): List { 22 | return if (!filter(this)) { 23 | emptyList() 24 | } else { 25 | buildList { 26 | prettyTree( 27 | builder = this, 28 | symbols = if (useFancySymbols) FancySymbols else NormalSymbols, 29 | hasParent = false, 30 | inset = 0, 31 | treeWidth = treeWidth(filter), 32 | selected = selected, 33 | filter = filter, 34 | ) 35 | } 36 | } 37 | } 38 | 39 | private fun TreeNode.prettyTree( 40 | builder: MutableList, 41 | symbols: Symbols, 42 | hasParent: Boolean, 43 | inset: Int, 44 | treeWidth: Int, 45 | selected: TreeNode? = null, 46 | filter: (TreeNode) -> Boolean, 47 | ) { 48 | val filteredChildren = children.filter(filter) 49 | filteredChildren.forEachIndexed { index, child -> 50 | child.prettyTree(builder, symbols, true, inset + index, treeWidth, selected, filter) 51 | } 52 | 53 | val pretty = buildString { 54 | repeat(inset) { append(symbols.line) } 55 | 56 | append( 57 | symbols.branch( 58 | selected = name == selected?.name, 59 | childCount = children.size, 60 | hasParent = hasParent, 61 | ), 62 | ) 63 | 64 | val horizontalBranches = (filteredChildren.size - 1).coerceAtLeast(0) 65 | if (horizontalBranches > 0) { 66 | repeat(horizontalBranches - 1) { append(symbols.fork) } 67 | append(symbols.corner) 68 | } 69 | 70 | repeat(treeWidth - inset - horizontalBranches - 1) { append(" ") } 71 | 72 | append(" ") 73 | append(name) 74 | } 75 | 76 | builder += PrettyBranch( 77 | branch = this, 78 | pretty = pretty, 79 | ) 80 | } 81 | 82 | private fun TreeNode.treeWidth( 83 | filter: ((TreeNode) -> Boolean), 84 | ): Int { 85 | return if (!filter(this)) { 86 | 0 87 | } else { 88 | children.sumOf { it.treeWidth(filter) }.coerceAtLeast(1) 89 | } 90 | } 91 | 92 | private sealed interface Symbols { 93 | val branchSoloSelected: String 94 | val branchSoloUnselected: String 95 | val branchBottomSelected: String 96 | val branchBottomUnselected: String 97 | val branchBottomForkSelected: String 98 | val branchBottomForkUnselected: String 99 | val branchMiddleSelected: String 100 | val branchMiddleUnselected: String 101 | val branchMiddleForkSelected: String 102 | val branchMiddleForkUnselected: String 103 | val branchTopSelected: String 104 | val branchTopUnselected: String 105 | val line: String 106 | val fork: String 107 | val corner: String 108 | 109 | fun branch( 110 | selected: Boolean, 111 | childCount: Int, 112 | hasParent: Boolean, 113 | ): String { 114 | return when { 115 | hasParent -> when (childCount) { 116 | 0 -> if (selected) branchTopSelected else branchTopUnselected 117 | 1 -> if (selected) branchMiddleSelected else branchMiddleUnselected 118 | else -> if (selected) branchMiddleForkSelected else branchMiddleForkUnselected 119 | } 120 | else -> when (childCount) { 121 | 0 -> if (selected) branchSoloSelected else branchSoloUnselected 122 | 1 -> if (selected) branchBottomSelected else branchBottomUnselected 123 | else -> if (selected) branchBottomForkSelected else branchBottomForkUnselected 124 | } 125 | } 126 | } 127 | } 128 | 129 | private data object NormalSymbols : Symbols { 130 | override val branchSoloSelected = "●" 131 | override val branchSoloUnselected = "○" 132 | override val branchBottomSelected = "●" 133 | override val branchBottomUnselected = "○" 134 | override val branchBottomForkSelected = "●" 135 | override val branchBottomForkUnselected = "○" 136 | override val branchMiddleSelected = "●" 137 | override val branchMiddleUnselected = "○" 138 | override val branchMiddleForkSelected = "●" 139 | override val branchMiddleForkUnselected = "○" 140 | override val branchTopSelected = "●" 141 | override val branchTopUnselected = "○" 142 | override val line = "│ " 143 | override val fork = "─┴" 144 | override val corner = "─┘" 145 | } 146 | 147 | private data object FancySymbols : Symbols { 148 | override val branchSoloSelected = "\uF5EE" 149 | override val branchSoloUnselected = "\uF5EF" 150 | override val branchBottomSelected = "\uF5F8" 151 | override val branchBottomUnselected = "\uF5F9" 152 | override val branchBottomForkSelected = "\uF600" 153 | override val branchBottomForkUnselected = "\uF601" 154 | override val branchMiddleSelected = "\uF5FA" 155 | override val branchMiddleUnselected = "\uF5FB" 156 | override val branchMiddleForkSelected = "\uF604" 157 | override val branchMiddleForkUnselected = "\uF605" 158 | override val branchTopSelected = "\uF5F6" 159 | override val branchTopUnselected = "\uF5F7" 160 | override val line = "\uF5D1 " 161 | override val fork = "\uF5D0\uF5E3" 162 | override val corner = "\uF5D0\uF5D9" 163 | } 164 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/command/locks.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.command 2 | 3 | import com.jakewharton.mosaic.text.buildAnnotatedString 4 | import com.mattprecious.stacker.lock.Locker 5 | import com.mattprecious.stacker.rendering.code 6 | import com.mattprecious.stacker.stack.StackManager 7 | import com.mattprecious.stacker.vc.VersionControl 8 | 9 | internal suspend fun Locker.Operation.Restack.perform( 10 | commandScope: StackerCommandScope, 11 | lockScope: Locker.LockScope, 12 | stackManager: StackManager, 13 | vc: VersionControl, 14 | continuing: Boolean = false, 15 | ) { 16 | branches.forEachIndexed { index, branchName -> 17 | val branch = stackManager.getBranch(branchName)!! 18 | if (!continuing || index > 0) { 19 | if (!vc.restack(branchName = branch.name, parentName = branch.parent!!.name, parentSha = branch.parentSha!!)) { 20 | commandScope.printStaticError( 21 | buildAnnotatedString { 22 | append("Merge conflict. Resolve all conflicts manually and then run ") 23 | code { append("st rebase --continue") } 24 | append(". To abort, run ") 25 | code { append("st rebase --abort") } 26 | append(".") 27 | }, 28 | ) 29 | commandScope.abort() 30 | } 31 | } 32 | 33 | stackManager.updateParentSha(branch.value, vc.getSha(branch.parent!!.name)) 34 | lockScope.updateOperation(copy(branches = branches.subList(index + 1, branches.size))) 35 | } 36 | 37 | vc.checkout(startingBranch) 38 | } 39 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/command/log/LogShort.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.command.log 2 | 3 | import com.mattprecious.stacker.StackerDeps 4 | import com.mattprecious.stacker.command.StackerCommand 5 | import com.mattprecious.stacker.command.StackerCommandScope 6 | import com.mattprecious.stacker.command.name 7 | import com.mattprecious.stacker.command.parentSha 8 | import com.mattprecious.stacker.command.prettyTree 9 | import com.mattprecious.stacker.config.ConfigManager 10 | import com.mattprecious.stacker.stack.StackManager 11 | import com.mattprecious.stacker.vc.VersionControl 12 | 13 | fun StackerDeps.logShort(): StackerCommand { 14 | return LogShort( 15 | configManager = configManager, 16 | stackManager = stackManager, 17 | useFancySymbols = useFancySymbols, 18 | vc = vc, 19 | ) 20 | } 21 | 22 | internal class LogShort( 23 | private val configManager: ConfigManager, 24 | private val stackManager: StackManager, 25 | private val useFancySymbols: Boolean, 26 | private val vc: VersionControl, 27 | ) : StackerCommand() { 28 | override suspend fun StackerCommandScope.work() { 29 | requireInitialized(configManager) 30 | val trunk = configManager.trunk 31 | val trailingTrunk = configManager.trailingTrunk 32 | 33 | stackManager.getBase()?.prettyTree( 34 | selected = stackManager.getBranch(vc.currentBranchName), 35 | useFancySymbols = useFancySymbols, 36 | )?.map { 37 | val needsRestack = run needsRestack@{ 38 | val parent = it.branch.parent ?: return@needsRestack false 39 | val parentSha = vc.getSha(parent.name) 40 | return@needsRestack if (it.branch.name == trunk || it.branch.name == trailingTrunk) { 41 | false 42 | } else { 43 | it.branch.parentSha != parentSha || 44 | !vc.isAncestor(branchName = it.branch.name, possibleAncestorName = parent.name) 45 | } 46 | } 47 | 48 | if (needsRestack) { 49 | "${it.pretty} (needs restack)" 50 | } else { 51 | it.pretty 52 | } 53 | }?.forEach(::printStatic) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/command/rebase/RebaseAbort.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.command.rebase 2 | 3 | import com.mattprecious.stacker.StackerDeps 4 | import com.mattprecious.stacker.command.StackerCommand 5 | import com.mattprecious.stacker.command.StackerCommandScope 6 | import com.mattprecious.stacker.config.ConfigManager 7 | import com.mattprecious.stacker.lock.Locker 8 | import com.mattprecious.stacker.vc.VersionControl 9 | 10 | fun StackerDeps.rebaseAbort(): StackerCommand { 11 | return RebaseAbort( 12 | configManager = configManager, 13 | locker = locker, 14 | vc = vc, 15 | ) 16 | } 17 | 18 | internal class RebaseAbort( 19 | private val configManager: ConfigManager, 20 | private val locker: Locker, 21 | private val vc: VersionControl, 22 | ) : StackerCommand() { 23 | override suspend fun StackerCommandScope.work() { 24 | requireInitialized(configManager) 25 | 26 | if (!locker.hasLock()) { 27 | printStaticError("Nothing to abort.") 28 | abort() 29 | } 30 | 31 | locker.cancelOperation { operation -> 32 | when (operation) { 33 | is Locker.Operation.Restack -> { 34 | vc.abortRebase() 35 | vc.checkout(operation.startingBranch) 36 | } 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/command/rebase/RebaseContinue.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.command.rebase 2 | 3 | import com.mattprecious.stacker.StackerDeps 4 | import com.mattprecious.stacker.command.StackerCommand 5 | import com.mattprecious.stacker.command.StackerCommandScope 6 | import com.mattprecious.stacker.command.perform 7 | import com.mattprecious.stacker.config.ConfigManager 8 | import com.mattprecious.stacker.lock.Locker 9 | import com.mattprecious.stacker.stack.StackManager 10 | import com.mattprecious.stacker.vc.VersionControl 11 | 12 | fun StackerDeps.rebaseContinue(): StackerCommand { 13 | return RebaseContinue( 14 | configManager = configManager, 15 | locker = locker, 16 | stackManager = stackManager, 17 | vc = vc, 18 | ) 19 | } 20 | 21 | internal class RebaseContinue( 22 | private val configManager: ConfigManager, 23 | private val locker: Locker, 24 | private val stackManager: StackManager, 25 | private val vc: VersionControl, 26 | ) : StackerCommand() { 27 | override suspend fun StackerCommandScope.work() { 28 | requireInitialized(configManager) 29 | 30 | if (!locker.hasLock()) { 31 | printStaticError("Nothing to continue.") 32 | abort() 33 | } 34 | 35 | locker.continueOperation { operation -> 36 | when (operation) { 37 | is Locker.Operation.Restack -> { 38 | if (vc.continueRebase(operation.branches.first())) { 39 | operation.perform(this@work, this, stackManager, vc, continuing = true) 40 | } else { 41 | printStaticError("Unresolved merge conflicts.") 42 | abort() 43 | } 44 | } 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/command/remotes.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.command 2 | 3 | import com.github.ajalt.mordant.terminal.ConversionResult 4 | import com.mattprecious.stacker.collections.TreeNode 5 | import com.mattprecious.stacker.config.ConfigManager 6 | import com.mattprecious.stacker.db.Branch 7 | import com.mattprecious.stacker.remote.Remote 8 | import com.mattprecious.stacker.rendering.Prompt 9 | import com.mattprecious.stacker.stack.StackManager 10 | import com.mattprecious.stacker.vc.VersionControl 11 | 12 | internal suspend fun StackerCommandScope.requireAuthenticated(remote: Remote) { 13 | if (!remote.isAuthenticated) { 14 | val token = render { onResult -> 15 | Prompt( 16 | message = "Please enter a GitHub access token", 17 | hideInput = true, 18 | onSubmit = onResult, 19 | ) 20 | } 21 | 22 | when { 23 | token.isBlank() -> ConversionResult.Invalid("Cannot be blank.") 24 | remote.setToken(token) -> ConversionResult.Valid(token) 25 | else -> ConversionResult.Invalid("Invalid token.") 26 | } 27 | } 28 | 29 | if (remote.repoName == null) { 30 | printStaticError("Unable to parse repository name from origin URL.") 31 | abort() 32 | } 33 | 34 | if (!remote.hasRepoAccess) { 35 | printStaticError("Personal token does not have access to ${remote.repoName}.") 36 | abort() 37 | } 38 | } 39 | 40 | internal fun TreeNode.submit( 41 | commandScope: StackerCommandScope, 42 | configManager: ConfigManager, 43 | remote: Remote, 44 | stackManager: StackManager, 45 | vc: VersionControl, 46 | ) { 47 | val target = parent!!.name.let { 48 | if (it == configManager.trailingTrunk) { 49 | configManager.trunk!! 50 | } else { 51 | it 52 | } 53 | } 54 | 55 | val result = remote.openOrRetargetPullRequest( 56 | branchName = name, 57 | targetName = target, 58 | ) { 59 | // TODO: Figure out what to put when there's multiple commits on this branch. 60 | val info = vc.latestCommitInfo(name) 61 | Remote.PrInfo( 62 | title = info.title, 63 | body = info.body, 64 | ) 65 | } 66 | 67 | stackManager.updatePrNumber(value, result.number) 68 | 69 | when (result) { 70 | is Remote.PrResult.Created -> commandScope.printStatic("Pull request created: ${result.url}") 71 | is Remote.PrResult.Updated -> commandScope.printStatic("Pull request updated: ${result.url}") 72 | is Remote.PrResult.NoChange -> { 73 | commandScope.printStatic("Pull request already up-to-date: ${result.url}") 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/command/repo/RepoInit.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.command.repo 2 | 3 | import androidx.compose.runtime.remember 4 | import com.mattprecious.stacker.StackerDeps 5 | import com.mattprecious.stacker.command.StackerCommand 6 | import com.mattprecious.stacker.command.StackerCommandScope 7 | import com.mattprecious.stacker.config.ConfigManager 8 | import com.mattprecious.stacker.delegates.Optional 9 | import com.mattprecious.stacker.lock.Locker 10 | import com.mattprecious.stacker.rendering.InteractivePrompt 11 | import com.mattprecious.stacker.rendering.PromptState 12 | import com.mattprecious.stacker.rendering.YesNoPrompt 13 | import com.mattprecious.stacker.rendering.toAnnotatedString 14 | import com.mattprecious.stacker.vc.VersionControl 15 | import kotlinx.collections.immutable.toPersistentList 16 | 17 | fun StackerDeps.repoInit( 18 | trunk: String? = null, 19 | trailingTrunk: Optional? = null, 20 | ): StackerCommand { 21 | return RepoInit( 22 | trunk = trunk, 23 | trailingTrunk = trailingTrunk, 24 | configManager = configManager, 25 | locker = locker, 26 | vc = vc, 27 | ) 28 | } 29 | 30 | internal class RepoInit( 31 | private val trunk: String?, 32 | private val trailingTrunk: Optional?, 33 | private val configManager: ConfigManager, 34 | private val locker: Locker, 35 | private val vc: VersionControl, 36 | ) : StackerCommand() { 37 | override suspend fun StackerCommandScope.work() { 38 | requireNoLock(locker) 39 | 40 | val (currentTrunk, currentTrailingTrunk) = if (configManager.repoInitialized) { 41 | configManager.trunk to configManager.trailingTrunk 42 | } else { 43 | null to null 44 | } 45 | 46 | val branches = vc.branches 47 | if (branches.isEmpty()) { 48 | // We need a SHA in order to initialize. Additionally, I don't know how to get the current 49 | // branch name when it's an unborn branch. 50 | printStaticError( 51 | "Stacker cannot be initialized in a completely empty repository. Please make a commit " + 52 | "first.", 53 | ) 54 | abort() 55 | } 56 | 57 | val defaultTrunk = run defaultTrunk@{ 58 | if (currentTrunk != null) return@defaultTrunk currentTrunk 59 | 60 | if (branches.size == 1) return@defaultTrunk null 61 | 62 | val defaultBranch = vc.defaultBranch 63 | if (defaultBranch != null && branches.contains(defaultBranch)) { 64 | return@defaultTrunk defaultBranch 65 | } 66 | 67 | return@defaultTrunk vc.currentBranchName 68 | } 69 | 70 | val trunk = trunk ?: render { onResult -> 71 | InteractivePrompt( 72 | message = "Select your trunk branch, which you open pull requests against", 73 | state = remember { 74 | PromptState( 75 | branches.toPersistentList(), 76 | default = defaultTrunk, 77 | displayTransform = { it.toAnnotatedString() }, 78 | valueTransform = { it.toAnnotatedString() }, 79 | ) 80 | }, 81 | onSelected = { onResult(it) }, 82 | ) 83 | } 84 | 85 | val trunkSha = vc.getSha(trunk) 86 | 87 | val useTrailing = if (trailingTrunk == null) { 88 | if (branches.size == 1) { 89 | false 90 | } else { 91 | render { onResult -> 92 | YesNoPrompt( 93 | message = "Do you use a trailing-trunk workflow?", 94 | default = currentTrailingTrunk != null, 95 | onSubmit = { onResult(it) }, 96 | ) 97 | } 98 | } 99 | } else { 100 | trailingTrunk is Optional.Some 101 | } 102 | 103 | val trailingTrunk: String? = if (!useTrailing) { 104 | null 105 | } else { 106 | (trailingTrunk as? Optional.Some)?.value ?: render { onResult -> 107 | InteractivePrompt( 108 | message = "Select your trailing trunk branch, which you branch from", 109 | state = remember { 110 | PromptState( 111 | branches.filterNot { it == trunk }.toPersistentList(), 112 | default = currentTrailingTrunk, 113 | displayTransform = { it.toAnnotatedString() }, 114 | valueTransform = { it.toAnnotatedString() }, 115 | ) 116 | }, 117 | onSelected = { onResult(it) }, 118 | ) 119 | } 120 | } 121 | 122 | configManager.initializeRepo( 123 | scope = this, 124 | trunk = trunk, 125 | trunkSha = trunkSha, 126 | trailingTrunk = trailingTrunk, 127 | ) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/command/repo/RepoSync.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.command.repo 2 | 3 | import com.jakewharton.mosaic.text.buildAnnotatedString 4 | import com.mattprecious.stacker.StackerDeps 5 | import com.mattprecious.stacker.collections.TreeNode 6 | import com.mattprecious.stacker.command.StackerCommand 7 | import com.mattprecious.stacker.command.StackerCommandScope 8 | import com.mattprecious.stacker.command.name 9 | import com.mattprecious.stacker.config.ConfigManager 10 | import com.mattprecious.stacker.db.Branch 11 | import com.mattprecious.stacker.remote.Remote 12 | import com.mattprecious.stacker.rendering.YesNoPrompt 13 | import com.mattprecious.stacker.rendering.branch 14 | import com.mattprecious.stacker.stack.StackManager 15 | import com.mattprecious.stacker.vc.VersionControl 16 | 17 | fun StackerDeps.repoSync( 18 | forceAskToDelete: Boolean, 19 | ): StackerCommand { 20 | return RepoSync( 21 | forceAskToDelete = forceAskToDelete, 22 | configManager = configManager, 23 | remote = remote, 24 | stackManager = stackManager, 25 | vc = vc, 26 | ) 27 | } 28 | 29 | internal class RepoSync( 30 | private val forceAskToDelete: Boolean, 31 | private val configManager: ConfigManager, 32 | private val remote: Remote, 33 | private val stackManager: StackManager, 34 | private val vc: VersionControl, 35 | ) : StackerCommand() { 36 | override suspend fun StackerCommandScope.work() { 37 | val trunk = configManager.trunk!! 38 | val trailingTrunk = configManager.trailingTrunk 39 | 40 | vc.pull(trunk) 41 | trailingTrunk?.let(vc::pull) 42 | 43 | stackManager.getBase()!!.offerBranchDeletion(this) { 44 | it.name != trunk && 45 | it.name != trailingTrunk && 46 | (forceAskToDelete || !it.value.hasAskedToDelete) 47 | } 48 | } 49 | 50 | /** 51 | * Recursively checks the status of any remote PRs associated with this branch and offers to 52 | * delete the branch locally if PR is in a terminal state. 53 | */ 54 | private suspend fun TreeNode.offerBranchDeletion( 55 | commandScope: StackerCommandScope, 56 | filter: (TreeNode) -> Boolean, 57 | ) { 58 | if (filter(this)) { 59 | val status = remote.getPrStatus(name) 60 | val prompt = when (status) { 61 | Remote.PrStatus.Closed -> "closed" 62 | Remote.PrStatus.Merged -> "merged" 63 | Remote.PrStatus.NotFound, 64 | Remote.PrStatus.Open, 65 | -> return 66 | } 67 | 68 | val delete = commandScope.render { onResult -> 69 | YesNoPrompt( 70 | message = buildAnnotatedString { 71 | append("PR for ") 72 | branch { append(name) } 73 | append(" has been $prompt, would you like to delete it?") 74 | }, 75 | default = null, 76 | onSubmit = { onResult(it) }, 77 | ) 78 | } 79 | 80 | if (delete == true) { 81 | if (vc.currentBranchName == name) { 82 | // Our original parent might have been deleted, so we need to re-query the stack. 83 | vc.checkout(stackManager.getBranch(name)!!.parent!!.name) 84 | } 85 | 86 | stackManager.untrackBranch(value) 87 | vc.delete(name) 88 | } else { 89 | stackManager.setHasAskedToDelete(value) 90 | } 91 | } 92 | 93 | children.forEach { it.offerBranchDeletion(commandScope, filter) } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/command/stack/StackSubmit.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.command.stack 2 | 3 | import com.jakewharton.mosaic.text.buildAnnotatedString 4 | import com.mattprecious.stacker.StackerDeps 5 | import com.mattprecious.stacker.collections.all 6 | import com.mattprecious.stacker.collections.ancestors 7 | import com.mattprecious.stacker.command.StackerCommand 8 | import com.mattprecious.stacker.command.StackerCommandScope 9 | import com.mattprecious.stacker.command.name 10 | import com.mattprecious.stacker.command.requireAuthenticated 11 | import com.mattprecious.stacker.command.submit 12 | import com.mattprecious.stacker.config.ConfigManager 13 | import com.mattprecious.stacker.lock.Locker 14 | import com.mattprecious.stacker.remote.Remote 15 | import com.mattprecious.stacker.rendering.branch 16 | import com.mattprecious.stacker.rendering.code 17 | import com.mattprecious.stacker.stack.StackManager 18 | import com.mattprecious.stacker.vc.VersionControl 19 | 20 | fun StackerDeps.stackSubmit(): StackerCommand { 21 | return StackSubmit( 22 | configManager = configManager, 23 | locker = locker, 24 | remote = remote, 25 | stackManager = stackManager, 26 | vc = vc, 27 | ) 28 | } 29 | 30 | internal class StackSubmit( 31 | private val configManager: ConfigManager, 32 | private val locker: Locker, 33 | private val remote: Remote, 34 | private val stackManager: StackManager, 35 | private val vc: VersionControl, 36 | ) : StackerCommand() { 37 | override suspend fun StackerCommandScope.work() { 38 | requireInitialized(configManager) 39 | requireNoLock(locker) 40 | 41 | val currentBranch = stackManager.getBranch(vc.currentBranchName) 42 | if (currentBranch == null) { 43 | printStaticError( 44 | buildAnnotatedString { 45 | append("Cannot create a pull request from ") 46 | branch { append(vc.currentBranchName) } 47 | append(" since it is not tracked. Please track with ") 48 | code { append("st branch track") } 49 | append(".") 50 | }, 51 | ) 52 | abort() 53 | } 54 | 55 | if (currentBranch.name == configManager.trunk || currentBranch.name == configManager.trailingTrunk) { 56 | printStaticError( 57 | buildAnnotatedString { 58 | append("Cannot create a pull request from trunk branch ") 59 | branch { append(currentBranch.name) } 60 | append(".") 61 | }, 62 | ) 63 | abort() 64 | } 65 | 66 | requireAuthenticated(remote) 67 | 68 | val branchesToSubmit = (currentBranch.ancestors.toList().asReversed() + currentBranch.all) 69 | .filterNot { it.name == configManager.trunk || it.name == configManager.trailingTrunk } 70 | 71 | vc.pushBranches(branchesToSubmit.map { it.name }) 72 | branchesToSubmit.forEach { it.submit(this, configManager, remote, stackManager, vc) } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/command/upstack/UpstackOnto.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.command.upstack 2 | 3 | import androidx.compose.runtime.remember 4 | import com.jakewharton.mosaic.text.buildAnnotatedString 5 | import com.mattprecious.stacker.StackerDeps 6 | import com.mattprecious.stacker.collections.all 7 | import com.mattprecious.stacker.command.StackerCommand 8 | import com.mattprecious.stacker.command.StackerCommandScope 9 | import com.mattprecious.stacker.command.name 10 | import com.mattprecious.stacker.command.perform 11 | import com.mattprecious.stacker.command.prettyTree 12 | import com.mattprecious.stacker.config.ConfigManager 13 | import com.mattprecious.stacker.lock.Locker 14 | import com.mattprecious.stacker.rendering.InteractivePrompt 15 | import com.mattprecious.stacker.rendering.PromptState 16 | import com.mattprecious.stacker.rendering.branch 17 | import com.mattprecious.stacker.rendering.code 18 | import com.mattprecious.stacker.rendering.toAnnotatedString 19 | import com.mattprecious.stacker.stack.StackManager 20 | import com.mattprecious.stacker.vc.VersionControl 21 | import kotlinx.collections.immutable.toPersistentList 22 | 23 | fun StackerDeps.upstackOnto(): StackerCommand { 24 | return UpstackOnto( 25 | configManager = configManager, 26 | locker = locker, 27 | stackManager = stackManager, 28 | useFancySymbols = useFancySymbols, 29 | vc = vc, 30 | ) 31 | } 32 | 33 | internal class UpstackOnto( 34 | private val configManager: ConfigManager, 35 | private val locker: Locker, 36 | private val stackManager: StackManager, 37 | private val useFancySymbols: Boolean, 38 | private val vc: VersionControl, 39 | ) : StackerCommand() { 40 | override suspend fun StackerCommandScope.work() { 41 | requireInitialized(configManager) 42 | requireNoLock(locker) 43 | 44 | val currentBranchName = vc.currentBranchName 45 | val currentBranch = stackManager.getBranch(currentBranchName) 46 | if (currentBranch == null) { 47 | printStaticError( 48 | buildAnnotatedString { 49 | append("Cannot retarget ") 50 | branch { append(currentBranchName) } 51 | append(" since it is not tracked. Please track with ") 52 | code { append("st branch track") } 53 | append(".") 54 | }, 55 | ) 56 | abort() 57 | } 58 | 59 | if (currentBranchName == configManager.trunk || currentBranchName == configManager.trailingTrunk) { 60 | printStaticError("Cannot retarget a trunk branch.") 61 | abort() 62 | } 63 | 64 | val options = stackManager.getBase()!!.prettyTree(useFancySymbols = useFancySymbols) { 65 | it.name != currentBranchName 66 | } 67 | 68 | val newParent = render { onResult -> 69 | InteractivePrompt( 70 | message = buildAnnotatedString { 71 | append("Select the parent branch for ") 72 | branch { append(currentBranchName) } 73 | }, 74 | state = remember { 75 | PromptState( 76 | options.toPersistentList(), 77 | default = options.find { it.branch.name == currentBranch.parent!!.name }, 78 | displayTransform = { it.pretty.toAnnotatedString() }, 79 | valueTransform = { it.branch.name.toAnnotatedString() }, 80 | ) 81 | }, 82 | onSelected = { onResult(it.branch) }, 83 | ) 84 | } 85 | 86 | stackManager.updateParent(currentBranch.value, newParent.value) 87 | 88 | val operation = Locker.Operation.Restack( 89 | startingBranch = currentBranch.name, 90 | currentBranch.all.map { it.name }.toList(), 91 | ) 92 | 93 | locker.beginOperation(operation) { 94 | operation.perform(this@work, this@beginOperation, stackManager, vc) 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/command/upstack/UpstackRestack.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.command.upstack 2 | 3 | import com.jakewharton.mosaic.text.buildAnnotatedString 4 | import com.mattprecious.stacker.StackerDeps 5 | import com.mattprecious.stacker.collections.all 6 | import com.mattprecious.stacker.command.StackerCommand 7 | import com.mattprecious.stacker.command.StackerCommandScope 8 | import com.mattprecious.stacker.command.name 9 | import com.mattprecious.stacker.command.perform 10 | import com.mattprecious.stacker.config.ConfigManager 11 | import com.mattprecious.stacker.lock.Locker 12 | import com.mattprecious.stacker.rendering.branch 13 | import com.mattprecious.stacker.rendering.code 14 | import com.mattprecious.stacker.stack.StackManager 15 | import com.mattprecious.stacker.vc.VersionControl 16 | 17 | fun StackerDeps.upstackRestack(): StackerCommand { 18 | return UpstackRestack( 19 | configManager = configManager, 20 | locker = locker, 21 | stackManager = stackManager, 22 | vc = vc, 23 | ) 24 | } 25 | 26 | internal class UpstackRestack( 27 | private val configManager: ConfigManager, 28 | private val locker: Locker, 29 | private val stackManager: StackManager, 30 | private val vc: VersionControl, 31 | ) : StackerCommand() { 32 | override suspend fun StackerCommandScope.work() { 33 | requireInitialized(configManager) 34 | requireNoLock(locker) 35 | 36 | val currentBranchName = vc.currentBranchName 37 | val currentBranch = stackManager.getBranch(currentBranchName) 38 | if (currentBranch == null) { 39 | printStaticError( 40 | buildAnnotatedString { 41 | append("Cannot restack ") 42 | branch { append(currentBranchName) } 43 | append(" since it is not tracked. Please track with ") 44 | code { append("st branch track") } 45 | append(".") 46 | }, 47 | ) 48 | abort() 49 | } 50 | 51 | val operation = Locker.Operation.Restack( 52 | startingBranch = currentBranch.name, 53 | currentBranch.all.map { it.name }.toList(), 54 | ) 55 | 56 | locker.beginOperation(operation) { 57 | operation.perform(this@work, this@beginOperation, stackManager, vc) 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/config/ConfigManager.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.config 2 | 3 | import com.mattprecious.stacker.command.StackerCommandScope 4 | import com.mattprecious.stacker.db.RepoConfig 5 | 6 | interface ConfigManager { 7 | val repoInitialized: Boolean 8 | val repoConfig: RepoConfig 9 | val trunk: String? 10 | val trailingTrunk: String? 11 | 12 | var githubToken: String? 13 | 14 | suspend fun initializeRepo( 15 | scope: StackerCommandScope, 16 | trunk: String, 17 | trunkSha: String, 18 | trailingTrunk: String?, 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/config/RealConfigManager.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.config 2 | 3 | import com.jakewharton.mosaic.text.buildAnnotatedString 4 | import com.mattprecious.stacker.command.StackerCommandScope 5 | import com.mattprecious.stacker.command.name 6 | import com.mattprecious.stacker.db.RepoConfig 7 | import com.mattprecious.stacker.db.RepoDatabase 8 | import com.mattprecious.stacker.delegates.Permissions 9 | import com.mattprecious.stacker.delegates.jsonFile 10 | import com.mattprecious.stacker.rendering.branch 11 | import com.mattprecious.stacker.stack.StackManager 12 | import kotlinx.cinterop.pointed 13 | import kotlinx.cinterop.toKString 14 | import okio.FileSystem 15 | import okio.Path.Companion.toPath 16 | import platform.posix.getpwuid 17 | import platform.posix.getuid 18 | 19 | class RealConfigManager( 20 | db: RepoDatabase, 21 | fs: FileSystem, 22 | private val stackManager: StackManager, 23 | ) : ConfigManager { 24 | private val repoConfigQueries = db.repoConfigQueries 25 | 26 | private var userConfig: UserConfig by jsonFile( 27 | fs = fs, 28 | path = getpwuid(getuid())!!.pointed.pw_dir!!.toKString().toPath() / ".stacker_user_config", 29 | createPermissions = Permissions.Posix(setOf(Permissions.Posix.Permission.OwnerRead)), 30 | maximumAllowedPermissions = Permissions.Posix( 31 | setOf( 32 | Permissions.Posix.Permission.OwnerRead, 33 | Permissions.Posix.Permission.OwnerWrite, 34 | ), 35 | ), 36 | ) { 37 | UserConfig( 38 | githubToken = null, 39 | ) 40 | } 41 | 42 | override val repoInitialized: Boolean 43 | get() = repoConfigQueries.initialized().executeAsOne() 44 | 45 | override val repoConfig: RepoConfig 46 | get() = repoConfigQueries.select().executeAsOne() 47 | 48 | override val trunk: String 49 | get() = repoConfigQueries.trunk().executeAsOne() 50 | 51 | override val trailingTrunk: String? 52 | get() = repoConfigQueries.trailingTrunk().executeAsOne().trailingTrunk 53 | 54 | override var githubToken: String? 55 | get() = userConfig.githubToken 56 | set(value) { 57 | userConfig = userConfig.copy(githubToken = value) 58 | } 59 | 60 | override suspend fun initializeRepo( 61 | scope: StackerCommandScope, 62 | trunk: String, 63 | trunkSha: String, 64 | trailingTrunk: String?, 65 | ) { 66 | val currentConfig = if (repoInitialized) repoConfig else null 67 | 68 | val currentTrunkBranch = currentConfig?.trunk?.let(stackManager::getBranch) 69 | val currentTrailingTrunkBranch = currentConfig?.trailingTrunk?.let(stackManager::getBranch) 70 | 71 | val trunkChanging = trunk != currentConfig?.trunk 72 | val trailingTrunkChanging = trailingTrunk != currentConfig?.trailingTrunk 73 | 74 | if (!trunkChanging && !trailingTrunkChanging) return 75 | 76 | val trailingChangingWithChildren = trailingTrunkChanging && currentTrailingTrunkBranch?.children?.isNotEmpty() == true 77 | val trunkChangingWithChildren = trunkChanging && currentTrunkBranch?.children?.any { it != currentTrailingTrunkBranch } == true 78 | if (trunkChangingWithChildren) { 79 | scope.printStaticError( 80 | buildAnnotatedString { 81 | append("Cannot change trunk. Current trunk branch ") 82 | branch { append(currentTrunkBranch.name) } 83 | append(" has children.") 84 | }, 85 | ) 86 | 87 | scope.abort() 88 | } 89 | 90 | if (trailingChangingWithChildren) { 91 | scope.printStaticError( 92 | buildAnnotatedString { 93 | append("Cannot change trailing trunk. Current trailing trunk branch ") 94 | branch { append(currentTrailingTrunkBranch.name) } 95 | append(" has children.") 96 | }, 97 | ) 98 | 99 | scope.abort() 100 | } 101 | 102 | if (trailingTrunkChanging) { 103 | currentTrailingTrunkBranch?.value?.let(stackManager::untrackBranch) 104 | } 105 | 106 | if (trunkChanging) { 107 | currentTrunkBranch?.value?.let(stackManager::untrackBranch) 108 | } 109 | 110 | repoConfigQueries.insert( 111 | trunk = trunk, 112 | trailingTrunk = trailingTrunk, 113 | ) 114 | 115 | if (trunkChanging) { 116 | stackManager.trackBranch(branchName = trunk, parentName = null, parentSha = null) 117 | } 118 | 119 | if (trailingTrunkChanging) { 120 | trailingTrunk?.let { stackManager.trackBranch(branchName = it, parentName = trunk, parentSha = trunkSha) } 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/config/UserConfig.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.config 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class UserConfig( 7 | val githubToken: String?, 8 | ) 9 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/db.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker 2 | 3 | import app.cash.sqldelight.db.use 4 | import app.cash.sqldelight.driver.native.NativeSqliteDriver 5 | import co.touchlab.sqliter.DatabaseConfiguration 6 | import com.mattprecious.stacker.db.Lock 7 | import com.mattprecious.stacker.db.RepoDatabase 8 | import com.mattprecious.stacker.db.jsonAdapter 9 | import okio.Path 10 | 11 | suspend inline fun withDatabase( 12 | path: Path, 13 | crossinline block: suspend (db: RepoDatabase) -> Unit, 14 | ) { 15 | NativeSqliteDriver( 16 | schema = RepoDatabase.Schema, 17 | name = path.name, 18 | onConfiguration = { config -> 19 | config.copy( 20 | extendedConfig = DatabaseConfiguration.Extended( 21 | foreignKeyConstraints = true, 22 | basePath = path.parent.toString(), 23 | ), 24 | ) 25 | }, 26 | ).use { driver -> 27 | block( 28 | RepoDatabase( 29 | driver = driver, 30 | lockAdapter = Lock.Adapter( 31 | operationAdapter = jsonAdapter(), 32 | ), 33 | ), 34 | ) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/db/adapters.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.db 2 | 3 | import app.cash.sqldelight.ColumnAdapter 4 | import kotlinx.serialization.KSerializer 5 | import kotlinx.serialization.json.Json 6 | import kotlinx.serialization.serializer 7 | import kotlin.reflect.KType 8 | import kotlin.reflect.typeOf 9 | 10 | inline fun jsonAdapter() = JsonColumnAdapter(typeOf()) 11 | 12 | class JsonColumnAdapter( 13 | type: KType, 14 | ) : ColumnAdapter { 15 | @Suppress("UNCHECKED_CAST") 16 | private val serializer = Json.serializersModule.serializer(type) as KSerializer 17 | 18 | override fun decode(databaseValue: String): T { 19 | return Json.decodeFromString(serializer, databaseValue) 20 | } 21 | 22 | override fun encode(value: T): String { 23 | return Json.encodeToString(serializer, value) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/delegates/Optional.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.delegates 2 | 3 | sealed interface Optional { 4 | data class Some(val value: T) : Optional 5 | data object None : Optional 6 | } 7 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/delegates/jsonFile.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.delegates 2 | 3 | import kotlinx.cinterop.alloc 4 | import kotlinx.cinterop.convert 5 | import kotlinx.cinterop.memScoped 6 | import kotlinx.cinterop.ptr 7 | import kotlinx.serialization.KSerializer 8 | import kotlinx.serialization.json.Json 9 | import kotlinx.serialization.serializer 10 | import okio.FileSystem 11 | import okio.Path 12 | import okio.buffer 13 | import okio.use 14 | import platform.posix.chmod 15 | import platform.posix.stat 16 | import kotlin.reflect.KProperty 17 | import kotlin.reflect.KType 18 | import kotlin.reflect.typeOf 19 | 20 | inline fun jsonFile( 21 | fs: FileSystem, 22 | path: Path, 23 | createPermissions: Permissions = Permissions.Default, 24 | maximumAllowedPermissions: Permissions.Posix? = null, 25 | noinline default: (() -> T), 26 | ) = JsonFileDelegate(typeOf(), fs, path, createPermissions, maximumAllowedPermissions, default) 27 | 28 | class JsonFileDelegate( 29 | type: KType, 30 | private val fs: FileSystem, 31 | private val path: Path, 32 | private val createPermissions: Permissions, 33 | private val maximumAllowedPermissions: Permissions.Posix?, 34 | private val default: () -> T, 35 | ) { 36 | @Suppress("UNCHECKED_CAST") 37 | private val serializer = Json.serializersModule.serializer(type) as KSerializer 38 | private var value: Optional = Optional.None 39 | 40 | operator fun getValue(thisRef: Any?, property: KProperty<*>): T { 41 | return when (val v = value) { 42 | is Optional.Some -> v.value 43 | is Optional.None -> { 44 | val loadedValue = if (fs.exists(path)) { 45 | if (maximumAllowedPermissions != null) { 46 | path.requirePermissions(maximumAllowedPermissions) 47 | } 48 | 49 | val json = fs.source(path).buffer().use { it.readUtf8() } 50 | Json.decodeFromString(serializer, json) 51 | } else { 52 | default() 53 | } 54 | 55 | value = Optional.Some(loadedValue) 56 | 57 | loadedValue 58 | } 59 | } 60 | } 61 | 62 | operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { 63 | fs.createDirectories(path.parent!!) 64 | fs.sink(path).buffer().use { it.writeUtf8(Json.encodeToString(serializer, value)) } 65 | path.setPermissions(createPermissions) 66 | 67 | this.value = Optional.Some(value) 68 | } 69 | 70 | private fun Path.setPermissions(permissions: Permissions) { 71 | when (permissions) { 72 | Permissions.Default -> {} 73 | is Permissions.Posix -> { 74 | // TODO: This won't work on Windows and assumes that the path root is system root. 75 | chmod(this.toString(), permissions.value().intValue.convert()) 76 | } 77 | } 78 | } 79 | 80 | private fun Path.requirePermissions(permissions: Permissions.Posix) { 81 | // TODO: This won't work on Windows and assumes that the path root is system root. 82 | memScoped { 83 | val path = this@requirePermissions 84 | 85 | val stat = alloc() 86 | stat(path.toString(), stat.ptr) 87 | 88 | val maximumPermissions = permissions.value() 89 | val pathPermissions = PosixPermissionsInt(stat.st_mode.toInt()) 90 | 91 | check( 92 | pathPermissions.ownerValue <= maximumPermissions.ownerValue && 93 | pathPermissions.groupValue <= maximumPermissions.groupValue && 94 | pathPermissions.otherValue <= maximumPermissions.otherValue, 95 | ) { 96 | "User configuration file access is too permissive. Please set file mode of $path to be no greater than " + 97 | "'${maximumPermissions.intValue.toString(8)}'." 98 | } 99 | } 100 | } 101 | } 102 | 103 | value class PosixPermissionsInt(val intValue: Int) { 104 | val ownerValue: Int 105 | get() = intValue shr 6 and 0b111 106 | val groupValue: Int 107 | get() = intValue shr 3 and 0b111 108 | val otherValue: Int 109 | get() = intValue and 0b111 110 | } 111 | 112 | sealed interface Permissions { 113 | data object Default : Permissions 114 | 115 | data class Posix( 116 | val permissions: Set, 117 | ) : Permissions { 118 | fun value() = PosixPermissionsInt(permissions.sumOf { it.value }) 119 | 120 | enum class Permission(val value: Int) { 121 | OwnerRead(1 shl 8), 122 | OwnerWrite(1 shl 7), 123 | OwnerExecute(1 shl 6), 124 | GroupRead(1 shl 5), 125 | GroupWrite(1 shl 4), 126 | GroupExecute(1 shl 3), 127 | OtherRead(1 shl 2), 128 | OtherWrite(1 shl 1), 129 | OtherExecute(1), 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/delegates/mutableLazy.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.delegates 2 | 3 | import kotlin.reflect.KProperty 4 | 5 | inline fun mutableLazy( 6 | noinline initializer: () -> T, 7 | ) = MutableLazy(initializer) 8 | 9 | class MutableLazy ( 10 | private val initializer: () -> T, 11 | ) { 12 | private var value: Optional = Optional.None 13 | 14 | operator fun getValue(thisRef: Any?, property: KProperty<*>): T { 15 | return when (val v = value) { 16 | is Optional.Some -> v.value 17 | Optional.None -> initializer().also { value = Optional.Some(it) } 18 | } 19 | } 20 | 21 | operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { 22 | this.value = Optional.Some(value) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/lock/BranchState.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.lock 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class BranchState( 7 | val name: String, 8 | val sha: String, 9 | ) 10 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/lock/Locker.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.lock 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | interface Locker { 6 | fun hasLock(): Boolean 7 | 8 | suspend fun beginOperation( 9 | operation: T, 10 | block: suspend LockScope.() -> Unit, 11 | ) 12 | 13 | suspend fun continueOperation( 14 | block: suspend LockScope.(operation: Operation) -> Unit, 15 | ) 16 | 17 | suspend fun cancelOperation( 18 | block: suspend (operation: Operation) -> Unit, 19 | ) 20 | 21 | interface LockScope { 22 | fun updateOperation(operation: Operation) 23 | } 24 | 25 | @Serializable 26 | sealed interface Operation { 27 | @Serializable 28 | data class Restack( 29 | val startingBranch: String, 30 | val branches: List, 31 | ) : Operation 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/lock/RealLocker.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.lock 2 | 3 | import com.mattprecious.stacker.db.RepoDatabase 4 | import com.mattprecious.stacker.lock.Locker.Operation 5 | import com.mattprecious.stacker.stack.StackManager 6 | import com.mattprecious.stacker.vc.VersionControl 7 | 8 | class RealLocker( 9 | db: RepoDatabase, 10 | private val stackManager: StackManager, 11 | private val vc: VersionControl, 12 | ) : Locker { 13 | private val lockQueries = db.lockQueries 14 | 15 | override fun hasLock(): Boolean { 16 | return lockQueries.hasLock().executeAsOne() 17 | } 18 | 19 | override suspend fun beginOperation( 20 | operation: T, 21 | block: suspend Locker.LockScope.() -> Unit, 22 | ) { 23 | require(!hasLock()) 24 | 25 | lockQueries.lock(operation = operation) 26 | 27 | val scope = object : Locker.LockScope { 28 | override fun updateOperation(operation: Operation) { 29 | lockQueries.updateOperation(operation) 30 | } 31 | } 32 | 33 | with(scope) { block() } 34 | 35 | lockQueries.delete() 36 | } 37 | 38 | override suspend fun continueOperation( 39 | block: suspend Locker.LockScope.(operation: Operation) -> Unit, 40 | ) { 41 | val operation = lockQueries.select().executeAsOne() 42 | 43 | val scope = object : Locker.LockScope { 44 | override fun updateOperation(operation: Operation) { 45 | lockQueries.updateOperation(operation) 46 | } 47 | } 48 | 49 | with(scope) { block(operation) } 50 | 51 | lockQueries.delete() 52 | } 53 | 54 | override suspend fun cancelOperation( 55 | block: suspend (operation: Operation) -> Unit, 56 | ) { 57 | val operation = lockQueries.select().executeAsOne() 58 | block(operation) 59 | lockQueries.delete() 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/remote/GitHubRemote.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.remote 2 | 3 | import com.mattprecious.stacker.config.ConfigManager 4 | import com.mattprecious.stacker.remote.Remote.PrInfo 5 | import com.mattprecious.stacker.remote.Remote.PrResult 6 | import com.mattprecious.stacker.remote.github.CreatePull 7 | import com.mattprecious.stacker.remote.github.GitHubError 8 | import com.mattprecious.stacker.remote.github.Pull 9 | import com.mattprecious.stacker.remote.github.UpdatePull 10 | import io.ktor.client.HttpClient 11 | import io.ktor.client.call.body 12 | import io.ktor.client.request.bearerAuth 13 | import io.ktor.client.request.get 14 | import io.ktor.client.request.parameter 15 | import io.ktor.client.request.patch 16 | import io.ktor.client.request.post 17 | import io.ktor.client.request.setBody 18 | import io.ktor.client.statement.HttpResponse 19 | import io.ktor.http.ContentType 20 | import io.ktor.http.HttpMessageBuilder 21 | import io.ktor.http.contentType 22 | import io.ktor.http.isSuccess 23 | import kotlinx.coroutines.runBlocking 24 | import kotlinx.io.IOException 25 | 26 | class GitHubRemote( 27 | private val client: HttpClient, 28 | private val originUrl: String, 29 | private val configManager: ConfigManager, 30 | ) : Remote { 31 | override val isAuthenticated: Boolean 32 | get() = configManager.githubToken?.let(::isTokenValid) == true 33 | 34 | private val repoOwnerAndName: Pair? by lazy { 35 | val originMatchResult = Regex("""^.+github\.com:(.+)/(.+)\.git$""").matchEntire(originUrl) 36 | originMatchResult?.groupValues?.let { 37 | it[1] to it[2] 38 | } 39 | } 40 | 41 | override val repoName: String? by lazy { 42 | repoOwnerAndName?.let { "${it.first}/${it.second}" } 43 | } 44 | 45 | override val hasRepoAccess: Boolean 46 | get() = runBlocking { client.get("$host/repos/$repoName") { auth() }.status.isSuccess() } 47 | 48 | override fun setToken(token: String): Boolean { 49 | return isTokenValid(token).also { if (it) configManager.githubToken = token } 50 | } 51 | 52 | override fun openOrRetargetPullRequest( 53 | branchName: String, 54 | targetName: String, 55 | prInfo: () -> PrInfo, 56 | ): PrResult = runBlocking { 57 | val pr = client.get("$host/repos/$repoName/pulls") { 58 | auth() 59 | parameter("head", branchName.asHead()) 60 | }.bodyOrThrow>().firstOrNull() 61 | 62 | return@runBlocking if (pr == null) { 63 | val createdPr = client.post("$host/repos/$repoName/pulls") { 64 | auth() 65 | 66 | val info = prInfo() 67 | contentType(ContentType.Application.Json) 68 | setBody( 69 | // TODO: Drafts. Need to somehow know whether drafts are supported in the repo. 70 | CreatePull( 71 | title = info.title, 72 | body = info.body, 73 | head = branchName, 74 | base = targetName, 75 | ), 76 | ) 77 | }.bodyOrThrow() 78 | 79 | PrResult.Created(url = createdPr.html_url, number = createdPr.number) 80 | } else if (pr.base.ref != targetName) { 81 | client.patch("$host/repos/$repoName/pulls/${pr.number}") { 82 | auth() 83 | contentType(ContentType.Application.Json) 84 | setBody(UpdatePull(base = targetName)) 85 | }.requireSuccess() 86 | 87 | PrResult.Updated(url = pr.html_url, number = pr.number) 88 | } else { 89 | PrResult.NoChange(url = pr.html_url, number = pr.number) 90 | } 91 | } 92 | 93 | override fun getPrStatus(branchName: String): Remote.PrStatus = runBlocking { 94 | val pr = client.get("$host/repos/$repoName/pulls") { 95 | auth() 96 | parameter("head", branchName.asHead()) 97 | parameter("state", "all") 98 | }.bodyOrThrow>().firstOrNull() 99 | 100 | return@runBlocking when { 101 | pr == null -> Remote.PrStatus.NotFound 102 | pr.merged_at != null -> Remote.PrStatus.Merged 103 | pr.state == Pull.State.Closed -> Remote.PrStatus.Closed 104 | pr.state == Pull.State.Open -> Remote.PrStatus.Open 105 | else -> throw IllegalStateException("Unable to determine status of PR #${pr.number} for branch $branchName.") 106 | } 107 | } 108 | 109 | private fun String.asHead() = "${repoOwnerAndName!!.first}:$this" 110 | 111 | // TODO: Investigate using the Auth plugin further. It doesn't fit into the current API of this class. 112 | private fun HttpMessageBuilder.auth() = bearerAuth(configManager.githubToken!!) 113 | 114 | private fun isTokenValid(token: String): Boolean = runBlocking { 115 | client.get("$host/rate_limit") { bearerAuth(token) }.status.value != 401 116 | } 117 | 118 | private suspend inline fun HttpResponse.bodyOrThrow(): T { 119 | return if (status.isSuccess()) { 120 | body() 121 | } else { 122 | throw asThrowable() 123 | } 124 | } 125 | 126 | private suspend fun HttpResponse.requireSuccess() { 127 | if (!status.isSuccess()) throw asThrowable() 128 | } 129 | 130 | private suspend fun HttpResponse.asThrowable(): Throwable { 131 | return IOException( 132 | """ 133 | Received ${status.value} when calling ${call.request.url}. 134 | Message: ${body().message} 135 | """.trimIndent(), 136 | ) 137 | } 138 | } 139 | 140 | private const val host = "https://api.github.com" 141 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/remote/NoRemote.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.remote 2 | 3 | class NoRemote : Remote { 4 | override val isAuthenticated: Boolean 5 | get() = throw NotImplementedError() 6 | override val repoName: String? 7 | get() = throw NotImplementedError() 8 | override val hasRepoAccess: Boolean 9 | get() = throw NotImplementedError() 10 | 11 | override fun setToken(token: String): Boolean { 12 | throw NotImplementedError() 13 | } 14 | 15 | override fun getPrStatus(branchName: String): Remote.PrStatus { 16 | throw NotImplementedError() 17 | } 18 | 19 | override fun openOrRetargetPullRequest( 20 | branchName: String, 21 | targetName: String, 22 | prInfo: () -> Remote.PrInfo, 23 | ): Remote.PrResult { 24 | throw NotImplementedError() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/remote/Remote.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.remote 2 | 3 | interface Remote { 4 | val isAuthenticated: Boolean 5 | val repoName: String? 6 | val hasRepoAccess: Boolean 7 | 8 | fun setToken(token: String): Boolean 9 | 10 | fun getPrStatus(branchName: String): PrStatus 11 | 12 | fun openOrRetargetPullRequest( 13 | branchName: String, 14 | targetName: String, 15 | prInfo: () -> PrInfo, 16 | ): PrResult 17 | 18 | data class PrInfo( 19 | val title: String, 20 | val body: String?, 21 | ) 22 | 23 | enum class PrStatus { 24 | NotFound, 25 | Open, 26 | Closed, 27 | Merged, 28 | } 29 | 30 | sealed interface PrResult { 31 | val url: String 32 | val number: Long 33 | 34 | data class Created( 35 | override val url: String, 36 | override val number: Long, 37 | ) : PrResult 38 | 39 | data class Updated( 40 | override val url: String, 41 | override val number: Long, 42 | ) : PrResult 43 | 44 | data class NoChange( 45 | override val url: String, 46 | override val number: Long, 47 | ) : PrResult 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/remote/github/CreatePull.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.remote.github 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class CreatePull( 7 | val title: String, 8 | val body: String?, 9 | val head: String, 10 | val base: String, 11 | ) 12 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/remote/github/GitHubError.kt: -------------------------------------------------------------------------------- 1 | 2 | package com.mattprecious.stacker.remote.github 3 | 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class GitHubError( 8 | val message: String, 9 | ) 10 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/remote/github/Pull.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.remote.github 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class Pull( 7 | val number: Long, 8 | val merged_at: String?, 9 | val state: State, 10 | val html_url: String, 11 | val base: Base, 12 | ) { 13 | @Serializable 14 | enum class State { 15 | Open, 16 | Closed, 17 | } 18 | 19 | @Serializable 20 | data class Base( 21 | val ref: String, 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/remote/github/UpdatePull.kt: -------------------------------------------------------------------------------- 1 | 2 | package com.mattprecious.stacker.remote.github 3 | 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class UpdatePull( 8 | val base: String, 9 | ) 10 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/rendering/printer.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.rendering 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.snapshots.SnapshotStateList 5 | import androidx.compose.runtime.staticCompositionLocalOf 6 | import com.jakewharton.mosaic.text.AnnotatedString 7 | import com.jakewharton.mosaic.ui.Static 8 | import com.jakewharton.mosaic.ui.Text 9 | 10 | val LocalPrinter = staticCompositionLocalOf { throw AssertionError() } 11 | 12 | class Printer { 13 | private val messages = SnapshotStateList() 14 | 15 | fun printStatic(message: String) { 16 | messages += message.toAnnotatedString() 17 | } 18 | fun printStatic(message: AnnotatedString) { 19 | messages += message 20 | } 21 | 22 | @Composable 23 | fun Messages() { 24 | Static(messages) { 25 | Text(it) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/rendering/styles.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.rendering 2 | 3 | import com.jakewharton.mosaic.text.AnnotatedString 4 | import com.jakewharton.mosaic.text.SpanStyle 5 | import com.jakewharton.mosaic.text.buildAnnotatedString 6 | import com.jakewharton.mosaic.text.withStyle 7 | import com.jakewharton.mosaic.ui.Color 8 | import com.jakewharton.mosaic.ui.TextStyle 9 | import com.jakewharton.mosaic.ui.TextStyle.Companion.Italic 10 | 11 | private val branchStyle = SpanStyle( 12 | color = Color(189, 147, 249), 13 | textStyle = Italic, 14 | ) 15 | 16 | private val codeStyle = SpanStyle( 17 | color = Color(97, 175, 239), 18 | ) 19 | 20 | fun AnnotatedString.Builder.branch(content: AnnotatedString.Builder.() -> Unit) { 21 | withStyle(branchStyle, content) 22 | } 23 | 24 | fun AnnotatedString.Builder.code(content: AnnotatedString.Builder.() -> Unit) { 25 | withStyle(codeStyle, content) 26 | } 27 | 28 | fun AnnotatedString.Builder.promptItem( 29 | selected: Boolean, 30 | content: AnnotatedString.Builder.() -> Unit, 31 | ) { 32 | if (selected) { 33 | append("❯ ") 34 | withStyle(SpanStyle(textStyle = TextStyle.Underline), content) 35 | } else { 36 | append(" ") 37 | content() 38 | } 39 | } 40 | fun String.toAnnotatedString() = buildAnnotatedString { append(this@toAnnotatedString) } 41 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/shell/RealShell.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.shell 2 | 3 | import platform.posix.system 4 | 5 | class RealShell : Shell { 6 | override fun exec( 7 | command: String, 8 | vararg args: String, 9 | ) { 10 | system("$command ${args.joinToString(" ")}") 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/shell/Shell.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.shell 2 | 3 | interface Shell { 4 | fun exec( 5 | command: String, 6 | vararg args: String, 7 | ) 8 | } 9 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/stack/RealStackManager.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.stack 2 | 3 | import com.mattprecious.stacker.collections.TreeNode 4 | import com.mattprecious.stacker.collections.all 5 | import com.mattprecious.stacker.collections.treeOf 6 | import com.mattprecious.stacker.db.Branch 7 | import com.mattprecious.stacker.db.RepoDatabase 8 | 9 | class RealStackManager( 10 | db: RepoDatabase, 11 | ) : StackManager { 12 | private val branchQueries = db.branchQueries 13 | 14 | override val trackedBranchNames: List 15 | get() = branchQueries.names().executeAsList() 16 | 17 | override fun getBase(): TreeNode? { 18 | return getTree() 19 | } 20 | 21 | override fun getBranch(branchName: String): TreeNode? { 22 | return getTree()?.all?.firstOrNull { it.value.name == branchName } 23 | } 24 | 25 | override fun trackBranch(branchName: String, parentName: String?, parentSha: String?) { 26 | branchQueries.insert( 27 | name = branchName, 28 | parent = parentName, 29 | parentSha = parentSha, 30 | prNumber = null, 31 | ) 32 | } 33 | 34 | override fun untrackBranch(branch: Branch) { 35 | untrackBranch(branch.name) 36 | } 37 | 38 | override fun untrackBranches(branches: Set) { 39 | branchQueries.transaction { 40 | branches.forEach { 41 | untrackBranch(it) 42 | } 43 | } 44 | } 45 | 46 | private fun untrackBranch(branchName: String) { 47 | branchQueries.transaction { 48 | branchQueries.bypass(branchName) 49 | branchQueries.remove(branchName) 50 | } 51 | } 52 | 53 | override fun renameBranch(branch: Branch, newName: String) { 54 | branchQueries.rename( 55 | oldName = branch.name, 56 | newName = newName, 57 | ) 58 | } 59 | 60 | override fun updateParent(branch: String, parent: String) { 61 | branchQueries.updateParent( 62 | branch = branch, 63 | parent = parent, 64 | ) 65 | } 66 | 67 | override fun updateParentSha(branch: Branch, parentSha: String) { 68 | branchQueries.updateParentSha( 69 | branch = branch.name, 70 | parentSha = parentSha, 71 | ) 72 | } 73 | 74 | override fun updatePrNumber( 75 | branch: Branch, 76 | prNumber: Long, 77 | ) { 78 | branchQueries.updatePrNumber( 79 | branch = branch.name, 80 | prNumber = prNumber, 81 | ) 82 | } 83 | 84 | override fun setHasAskedToDelete(branch: Branch) { 85 | branchQueries.setHasAskedToDelete(branch.name) 86 | } 87 | 88 | private fun getTree(): TreeNode? { 89 | val elements = branchQueries.selectAll().executeAsList() 90 | return if (elements.isEmpty()) { 91 | null 92 | } else { 93 | treeOf( 94 | elements = elements, 95 | keySelector = { it.name }, 96 | parentSelector = { it.parent }, 97 | ) 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/stack/StackManager.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.stack 2 | 3 | import com.mattprecious.stacker.collections.TreeNode 4 | import com.mattprecious.stacker.db.Branch 5 | 6 | interface StackManager { 7 | val trackedBranchNames: List 8 | 9 | fun getBase(): TreeNode? 10 | fun getBranch(branchName: String): TreeNode? 11 | 12 | fun trackBranch( 13 | branchName: String, 14 | parentName: String?, 15 | parentSha: String?, 16 | ) 17 | 18 | fun untrackBranch( 19 | branch: Branch, 20 | ) 21 | 22 | fun untrackBranches( 23 | branches: Set, 24 | ) 25 | 26 | fun renameBranch( 27 | branch: Branch, 28 | newName: String, 29 | ) 30 | 31 | fun updateParent( 32 | branch: Branch, 33 | parent: Branch, 34 | ) = updateParent(branch.name, parent.name) 35 | 36 | fun updateParent( 37 | branch: String, 38 | parent: String, 39 | ) 40 | 41 | fun updateParentSha( 42 | branch: Branch, 43 | parentSha: String, 44 | ) 45 | 46 | fun updatePrNumber( 47 | branch: Branch, 48 | prNumber: Long, 49 | ) 50 | 51 | fun setHasAskedToDelete(branch: Branch) 52 | } 53 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/stacker.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker 2 | 3 | import com.github.ajalt.clikt.core.main 4 | import com.mattprecious.stacker.cli.StackerCli 5 | import com.mattprecious.stacker.config.RealConfigManager 6 | import com.mattprecious.stacker.lock.RealLocker 7 | import com.mattprecious.stacker.remote.GitHubRemote 8 | import com.mattprecious.stacker.remote.NoRemote 9 | import com.mattprecious.stacker.remote.Remote 10 | import com.mattprecious.stacker.shell.RealShell 11 | import com.mattprecious.stacker.stack.RealStackManager 12 | import com.mattprecious.stacker.vc.GitVersionControl 13 | import io.ktor.client.HttpClient 14 | import io.ktor.client.engine.curl.Curl 15 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation 16 | import io.ktor.serialization.kotlinx.json.json 17 | import kotlinx.cinterop.memScoped 18 | import kotlinx.cinterop.toKString 19 | import kotlinx.coroutines.runBlocking 20 | import kotlinx.serialization.ExperimentalSerializationApi 21 | import kotlinx.serialization.json.Json 22 | import okio.FileSystem 23 | import okio.Path.Companion.toPath 24 | import platform.posix.getenv 25 | import kotlin.system.exitProcess 26 | 27 | fun main(args: Array) { 28 | try { 29 | val terminal = memScoped { getenv("TERM")?.toKString() ?: "" } 30 | 31 | runBlocking { 32 | withStacker(useFancySymbols = supportsFancySymbols(terminal)) { 33 | StackerCli(it).main(args) 34 | } 35 | } 36 | } catch (e: RepoNotFoundException) { 37 | println(e.message) 38 | exitProcess(-1) 39 | } 40 | } 41 | 42 | private fun supportsFancySymbols(term: String): Boolean { 43 | return term == "xterm-ghostty" || term == "xterm-kitty" 44 | } 45 | 46 | @OptIn(ExperimentalSerializationApi::class) 47 | internal suspend fun withStacker( 48 | fileSystem: FileSystem = FileSystem.SYSTEM, 49 | remoteOverride: Remote? = null, 50 | useFancySymbols: Boolean = false, 51 | block: suspend (StackerDeps) -> Unit, 52 | ) { 53 | memScoped { 54 | val shell = RealShell() 55 | GitVersionControl(this, fileSystem, shell).use { vc -> 56 | if (!vc.repoDiscovered) { 57 | throw RepoNotFoundException("No repository found at ${fileSystem.canonicalize(".".toPath())}.") 58 | } 59 | 60 | withDatabase(vc.configDirectory / "stacker.db") { db -> 61 | val stackManager = RealStackManager(db) 62 | 63 | // TODO: Do this somewhere else... 64 | stackManager.untrackBranches(vc.checkBranches(stackManager.trackedBranchNames.toSet())) 65 | 66 | val configManager = RealConfigManager(db, fileSystem, stackManager) 67 | val locker = RealLocker(db, stackManager, vc) 68 | val httpClient = HttpClient(Curl) { 69 | install(ContentNegotiation) { 70 | json( 71 | Json { 72 | ignoreUnknownKeys = true 73 | decodeEnumsCaseInsensitive = true 74 | }, 75 | ) 76 | } 77 | } 78 | 79 | val originUrl = vc.originUrl 80 | val remote = when { 81 | remoteOverride != null -> remoteOverride 82 | originUrl != null -> GitHubRemote(httpClient, originUrl, configManager) 83 | else -> NoRemote() 84 | } 85 | 86 | block( 87 | StackerDeps( 88 | configManager = configManager, 89 | locker = locker, 90 | remote = remote, 91 | stackManager = stackManager, 92 | useFancySymbols = useFancySymbols, 93 | vc = vc, 94 | ), 95 | ) 96 | } 97 | } 98 | } 99 | } 100 | 101 | class RepoNotFoundException(message: String) : RuntimeException(message) 102 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/com/mattprecious/stacker/vc/VersionControl.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.vc 2 | 3 | import okio.Path 4 | 5 | interface VersionControl : AutoCloseable { 6 | val repoDiscovered: Boolean 7 | val configDirectory: Path 8 | val currentBranchName: String 9 | val originUrl: String? 10 | val branches: List 11 | 12 | /** The default branch name from git config. Not necessarily the trunk branch in this repo. */ 13 | val defaultBranch: String? 14 | 15 | fun checkBranches(branchNames: Set): Set 16 | 17 | fun checkout(branchName: String) 18 | 19 | enum class BranchCreateResult { 20 | Success, 21 | AlreadyExists, 22 | } 23 | 24 | fun createBranchFromCurrent(branchName: String): BranchCreateResult 25 | 26 | fun renameBranch(branchName: String, newName: String) 27 | 28 | fun delete(branchName: String) 29 | 30 | fun pushBranches(branchNames: List) 31 | 32 | fun pull(branchName: String) 33 | 34 | fun latestCommitInfo(branchName: String): CommitInfo 35 | 36 | fun isAncestor(branchName: String, possibleAncestorName: String): Boolean 37 | 38 | fun restack(branchName: String, parentName: String, parentSha: String): Boolean 39 | 40 | fun getSha(branch: String): String 41 | 42 | fun abortRebase() 43 | 44 | fun continueRebase(branchName: String): Boolean 45 | 46 | data class CommitInfo( 47 | val title: String, 48 | val body: String?, 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /src/commonMain/sqldelight/com/mattprecious/stacker/db/branch.sq: -------------------------------------------------------------------------------- 1 | import kotlin.Boolean; 2 | 3 | CREATE TABLE branch ( 4 | name TEXT NOT NULL PRIMARY KEY, 5 | parent TEXT, 6 | parentSha TEXT, 7 | prNumber INTEGER, 8 | hasAskedToDelete INTEGER AS Boolean NOT NULL DEFAULT 0, 9 | FOREIGN KEY(parent) REFERENCES branch(name) ON DELETE RESTRICT ON UPDATE CASCADE 10 | ); 11 | 12 | insert: 13 | INSERT INTO branch 14 | VALUES (?, ?, ?, ?, 0); 15 | 16 | remove: 17 | DELETE FROM branch 18 | WHERE name = ?; 19 | 20 | removeAllOf: 21 | DELETE FROM branch 22 | WHERE name IN :names; 23 | 24 | bypass: 25 | UPDATE branch 26 | SET parent = (SELECT parent FROM branch WHERE name = :branch) 27 | WHERE parent = :branch; 28 | 29 | rename: 30 | UPDATE branch 31 | SET name = :newName 32 | WHERE name = :oldName; 33 | 34 | updateParent: 35 | UPDATE branch 36 | SET parent = :parent 37 | WHERE name = :branch; 38 | 39 | updateParentSha: 40 | UPDATE branch 41 | SET parentSha = :parentSha 42 | WHERE name = :branch; 43 | 44 | updatePrNumber: 45 | UPDATE branch 46 | SET prNumber = :prNumber 47 | WHERE name = :branch; 48 | 49 | selectAll: 50 | SELECT * 51 | FROM branch; 52 | 53 | select: 54 | SELECT * 55 | FROM branch 56 | WHERE name = ?; 57 | 58 | setHasAskedToDelete: 59 | UPDATE branch 60 | SET hasAskedToDelete = 1 61 | WHERE name = ?; 62 | 63 | names: 64 | SELECT name 65 | FROM branch; 66 | 67 | contains: 68 | SELECT COUNT(*) > 0 69 | FROM branch 70 | WHERE name = ?; 71 | -------------------------------------------------------------------------------- /src/commonMain/sqldelight/com/mattprecious/stacker/db/lock.sq: -------------------------------------------------------------------------------- 1 | import com.mattprecious.stacker.lock.BranchState; 2 | import com.mattprecious.stacker.lock.Locker.Operation; 3 | import kotlin.collections.List; 4 | 5 | CREATE TABLE lock ( 6 | operation Text AS Operation NOT NULL 7 | ); 8 | 9 | hasLock: 10 | SELECT COUNT(*) > 0 11 | FROM lock; 12 | 13 | lock: 14 | INSERT INTO lock 15 | VALUES (?); 16 | 17 | updateOperation: 18 | UPDATE lock 19 | SET operation = ?; 20 | 21 | select: 22 | SELECT * 23 | FROM lock; 24 | 25 | delete: 26 | DELETE FROM lock; 27 | -------------------------------------------------------------------------------- /src/commonMain/sqldelight/com/mattprecious/stacker/db/repoConfig.sq: -------------------------------------------------------------------------------- 1 | CREATE TABLE repoConfig ( 2 | trunk TEXT NOT NULL, 3 | trailingTrunk TEXT 4 | ); 5 | 6 | insert: 7 | INSERT OR REPLACE INTO repoConfig 8 | VALUES (?, ?); 9 | 10 | initialized: 11 | SELECT count(*) > 0 12 | FROM repoConfig; 13 | 14 | select: 15 | SELECT * 16 | FROM repoConfig; 17 | 18 | trunk: 19 | SELECT trunk 20 | FROM repoConfig; 21 | 22 | trailingTrunk: 23 | SELECT trailingTrunk 24 | FROM repoConfig; 25 | -------------------------------------------------------------------------------- /src/commonMain/sqldelight/databases/1.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattprecious/stacker/b2202884478eb9c05fd4e5529206446826469794/src/commonMain/sqldelight/databases/1.db -------------------------------------------------------------------------------- /src/commonMain/sqldelight/migrations/1.sqm: -------------------------------------------------------------------------------- 1 | ALTER TABLE branch ADD COLUMN prNumber INTEGER; 2 | -------------------------------------------------------------------------------- /src/commonMain/sqldelight/migrations/2.sqm: -------------------------------------------------------------------------------- 1 | ALTER TABLE branch ADD COLUMN hasAskedToDelete INTEGER NOT NULL DEFAULT 0; 2 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com/mattprecious/stacker/test/collections/RadiateTest.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.test.collections 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.containsExactly 5 | import com.mattprecious.stacker.collections.radiateFrom 6 | import kotlin.test.Test 7 | import kotlin.test.assertFailsWith 8 | 9 | class RadiateTest { 10 | @Test 11 | fun test() { 12 | // Does not throw until accessed. 13 | listOf().radiateFrom(0) 14 | assertFailsWith { listOf().radiateFrom(0).toList() } 15 | assertFailsWith { listOf("a").radiateFrom(-1).toList() } 16 | assertFailsWith { listOf("a").radiateFrom(1).toList() } 17 | 18 | assertThat(listOf("a").radiateFrom(0)).containsExactly("a") 19 | assertThat(listOf("a", "b").radiateFrom(0)).containsExactly("a", "b") 20 | assertThat(listOf("a", "b").radiateFrom(1)).containsExactly("b", "a") 21 | assertThat(listOf("a", "b", "c").radiateFrom(0)).containsExactly("a", "b", "c") 22 | assertThat(listOf("a", "b", "c").radiateFrom(1)).containsExactly("b", "a", "c") 23 | assertThat(listOf("a", "b", "c").radiateFrom(2)).containsExactly("c", "b", "a") 24 | assertThat(listOf("a", "b", "c", "d", "e", "f", "g", "h").radiateFrom(3)) 25 | .containsExactly("d", "c", "e", "b", "f", "a", "g", "h") 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com/mattprecious/stacker/test/command/BranchBottomTest.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.test.command 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import assertk.assertions.isFalse 6 | import assertk.assertions.isTrue 7 | import com.mattprecious.stacker.command.branch.branchBottom 8 | import com.mattprecious.stacker.command.branch.branchCreate 9 | import com.mattprecious.stacker.command.branch.branchDown 10 | import com.mattprecious.stacker.command.repo.repoInit 11 | import com.mattprecious.stacker.delegates.Optional.None 12 | import com.mattprecious.stacker.delegates.Optional.Some 13 | import com.mattprecious.stacker.test.util.gitCheckoutBranch 14 | import com.mattprecious.stacker.test.util.gitCommit 15 | import com.mattprecious.stacker.test.util.gitCreateAndCheckoutBranch 16 | import com.mattprecious.stacker.test.util.gitCurrentBranch 17 | import com.mattprecious.stacker.test.util.gitInit 18 | import com.mattprecious.stacker.test.util.withTestEnvironment 19 | import kotlin.test.Test 20 | 21 | class BranchBottomTest { 22 | @Test 23 | fun untracked() = withTestEnvironment { 24 | gitInit() 25 | gitCommit("Empty") 26 | testCommand({ repoInit("main", None) }) 27 | gitCreateAndCheckoutBranch("change-a") 28 | 29 | testCommand({ branchDown() }) { 30 | awaitFrame( 31 | static = "Branch change-a is not tracked.", 32 | output = "", 33 | ) 34 | 35 | assertThat(awaitResult()).isFalse() 36 | } 37 | } 38 | 39 | @Test 40 | fun trunk() = withTestEnvironment { 41 | gitInit() 42 | gitCommit("Empty") 43 | testCommand({ repoInit("main", None) }) 44 | 45 | testCommand({ branchBottom() }) { 46 | awaitFrame( 47 | static = "Not in a stack.", 48 | output = "", 49 | ) 50 | 51 | assertThat(awaitResult()).isFalse() 52 | } 53 | } 54 | 55 | @Test 56 | fun trailingTrunk() = withTestEnvironment { 57 | gitInit() 58 | gitCommit("Empty") 59 | gitCreateAndCheckoutBranch("green-main") 60 | testCommand({ repoInit("main", Some("green-main")) }) 61 | 62 | testCommand({ branchBottom() }) { 63 | awaitFrame( 64 | static = "Not in a stack.", 65 | output = "", 66 | ) 67 | 68 | assertThat(awaitResult()).isFalse() 69 | } 70 | 71 | assertThat(gitCurrentBranch()).isEqualTo("green-main") 72 | } 73 | 74 | @Test 75 | fun singleOnTrunk() = withTestEnvironment { 76 | gitInit() 77 | gitCommit("Empty") 78 | testCommand({ repoInit("main", None) }) 79 | testCommand({ branchCreate("change-a") }) 80 | 81 | testCommand({ branchBottom() }) { 82 | awaitFrame("") 83 | assertThat(awaitResult()).isTrue() 84 | } 85 | 86 | assertThat(gitCurrentBranch()).isEqualTo("change-a") 87 | } 88 | 89 | @Test 90 | fun singleOnTrailingTrunk() = withTestEnvironment { 91 | gitInit() 92 | gitCommit("Empty") 93 | gitCreateAndCheckoutBranch("green-main") 94 | testCommand({ repoInit("main", Some("green-main")) }) 95 | testCommand({ branchCreate("change-a") }) 96 | 97 | testCommand({ branchBottom() }) { 98 | awaitFrame("") 99 | assertThat(awaitResult()).isTrue() 100 | } 101 | 102 | assertThat(gitCurrentBranch()).isEqualTo("change-a") 103 | } 104 | 105 | @Test 106 | fun linearStack() = withTestEnvironment { 107 | gitInit() 108 | gitCommit("Empty") 109 | gitCreateAndCheckoutBranch("green-main") 110 | testCommand({ repoInit("main", Some("green-main")) }) 111 | testCommand({ branchCreate("change-a") }) 112 | testCommand({ branchCreate("change-b") }) 113 | testCommand({ branchCreate("change-c") }) 114 | 115 | testCommand({ branchBottom() }) { 116 | awaitFrame("") 117 | assertThat(awaitResult()).isTrue() 118 | } 119 | 120 | assertThat(gitCurrentBranch()).isEqualTo("change-a") 121 | } 122 | 123 | @Test 124 | fun fork() = withTestEnvironment { 125 | gitInit() 126 | gitCommit("Empty") 127 | testCommand({ repoInit("main", None) }) 128 | testCommand({ branchCreate("change-a") }) 129 | gitCheckoutBranch("main") 130 | testCommand({ branchCreate("change-b") }) 131 | testCommand({ branchCreate("change-c") }) 132 | 133 | testCommand({ branchBottom() }) { 134 | awaitFrame("") 135 | assertThat(awaitResult()).isTrue() 136 | } 137 | 138 | assertThat(gitCurrentBranch()).isEqualTo("change-b") 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com/mattprecious/stacker/test/command/BranchCheckoutTest.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.test.command 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import assertk.assertions.isFalse 6 | import assertk.assertions.isTrue 7 | import com.jakewharton.mosaic.layout.KeyEvent 8 | import com.mattprecious.stacker.command.branch.branchCheckout 9 | import com.mattprecious.stacker.command.branch.branchCreate 10 | import com.mattprecious.stacker.command.repo.repoInit 11 | import com.mattprecious.stacker.delegates.Optional 12 | import com.mattprecious.stacker.test.util.gitCheckoutBranch 13 | import com.mattprecious.stacker.test.util.gitCommit 14 | import com.mattprecious.stacker.test.util.gitCreateAndCheckoutBranch 15 | import com.mattprecious.stacker.test.util.gitCurrentBranch 16 | import com.mattprecious.stacker.test.util.gitInit 17 | import com.mattprecious.stacker.test.util.s 18 | import com.mattprecious.stacker.test.util.withTestEnvironment 19 | import kotlin.test.Test 20 | 21 | class BranchCheckoutTest { 22 | @Test 23 | fun single() = withTestEnvironment { 24 | gitInit() 25 | gitCommit("Empty") 26 | testCommand({ repoInit("main", Optional.None) }) 27 | 28 | testCommand({ branchCheckout(null) }) { 29 | awaitFrame("") 30 | assertThat(awaitResult()).isTrue() 31 | } 32 | 33 | assertThat(gitCurrentBranch()).isEqualTo("main") 34 | } 35 | 36 | @Test 37 | fun singleFromUntracked() = withTestEnvironment { 38 | gitInit() 39 | gitCommit("Empty") 40 | testCommand({ repoInit("main", Optional.None) }) 41 | gitCreateAndCheckoutBranch("change-a") 42 | 43 | testCommand({ branchCheckout(null) }) { 44 | awaitFrame("") 45 | assertThat(awaitResult()).isTrue() 46 | } 47 | 48 | assertThat(gitCurrentBranch()).isEqualTo("main") 49 | } 50 | 51 | @Test 52 | fun simple() = withTestEnvironment { 53 | gitInit() 54 | gitCommit("Empty") 55 | testCommand({ repoInit("main", Optional.None) }) 56 | testCommand({ branchCreate("change-a") }) 57 | testCommand({ branchCreate("change-b") }) 58 | gitCheckoutBranch("main") 59 | testCommand({ branchCreate("change-c") }) 60 | gitCheckoutBranch("change-a") 61 | 62 | testCommand({ branchCheckout(null) }) { 63 | awaitFrame( 64 | """ 65 | |Checkout a branch:$s 66 | | ○ change-b $s 67 | |❯ ○ change-a $s 68 | | │ ○ change-c $s 69 | | ○─┘ main $s 70 | """.trimMargin(), 71 | ) 72 | 73 | sendKeyEvent(KeyEvent("ArrowUp")) 74 | sendKeyEvent(KeyEvent("Enter")) 75 | 76 | awaitFrame( 77 | static = "Checkout a branch: change-b", 78 | output = "", 79 | ) 80 | 81 | assertThat(awaitResult()).isTrue() 82 | } 83 | 84 | assertThat(gitCurrentBranch()).isEqualTo("change-b") 85 | } 86 | 87 | @Test 88 | fun simpleWithArgument() = withTestEnvironment { 89 | gitInit() 90 | gitCommit("Empty") 91 | testCommand({ repoInit("main", Optional.None) }) 92 | testCommand({ branchCreate("change-a") }) 93 | testCommand({ branchCreate("change-b") }) 94 | gitCheckoutBranch("main") 95 | testCommand({ branchCreate("change-c") }) 96 | gitCheckoutBranch("change-a") 97 | 98 | testCommand({ branchCheckout("change-c") }) { 99 | awaitFrame("") 100 | assertThat(awaitResult()).isTrue() 101 | } 102 | 103 | assertThat(gitCurrentBranch()).isEqualTo("change-c") 104 | } 105 | 106 | @Test 107 | fun unknownBranch() = withTestEnvironment { 108 | gitInit() 109 | gitCommit("Empty") 110 | testCommand({ repoInit("main", Optional.None) }) 111 | testCommand({ branchCreate("change-a") }) 112 | 113 | testCommand({ branchCheckout("change-c") }) { 114 | awaitFrame( 115 | static = "change-c does not match any branches known to git.", 116 | output = "", 117 | ) 118 | 119 | assertThat(awaitResult()).isFalse() 120 | } 121 | 122 | assertThat(gitCurrentBranch()).isEqualTo("change-a") 123 | } 124 | 125 | @Test 126 | fun filteringIsEnabled() = withTestEnvironment { 127 | gitInit() 128 | gitCommit("Empty") 129 | testCommand({ repoInit("main", Optional.None) }) 130 | testCommand({ branchCreate("change-a") }) 131 | testCommand({ branchCreate("change-b") }) 132 | gitCheckoutBranch("main") 133 | testCommand({ branchCreate("change-c") }) 134 | gitCheckoutBranch("change-a") 135 | 136 | testCommand({ branchCheckout(null) }) { 137 | awaitFrame( 138 | """ 139 | |Checkout a branch:$s 140 | | ○ change-b $s 141 | |❯ ○ change-a $s 142 | | │ ○ change-c $s 143 | | ○─┘ main $s 144 | """.trimMargin(), 145 | ) 146 | 147 | sendText("-c") 148 | 149 | awaitFrame( 150 | """ 151 | |Checkout a branch: -c 152 | |❯ │ ○ change-c $s 153 | """.trimMargin(), 154 | ) 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com/mattprecious/stacker/test/command/BranchCreateTest.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.test.command 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.containsExactly 5 | import assertk.assertions.containsExactlyInAnyOrder 6 | import assertk.assertions.isFalse 7 | import assertk.assertions.isTrue 8 | import com.mattprecious.stacker.command.branch.branchCreate 9 | import com.mattprecious.stacker.command.repo.repoInit 10 | import com.mattprecious.stacker.db.Branch 11 | import com.mattprecious.stacker.delegates.Optional 12 | import com.mattprecious.stacker.test.util.gitCommit 13 | import com.mattprecious.stacker.test.util.gitCreateAndCheckoutBranch 14 | import com.mattprecious.stacker.test.util.gitCreateBranch 15 | import com.mattprecious.stacker.test.util.gitInit 16 | import com.mattprecious.stacker.test.util.withTestEnvironment 17 | import kotlin.test.Test 18 | 19 | class BranchCreateTest { 20 | @Test 21 | fun branchFromTrunk() = withTestEnvironment { 22 | gitInit() 23 | val mainSha = gitCommit("Empty") 24 | testCommand({ repoInit("main", Optional.None) }) 25 | 26 | testCommand({ branchCreate("change-a") }) { 27 | awaitFrame("") 28 | assertThat(awaitResult()).isTrue() 29 | } 30 | 31 | withDatabase { 32 | assertThat(it.branchQueries.selectAll().executeAsList()).containsExactlyInAnyOrder( 33 | Branch( 34 | name = "main", 35 | parent = null, 36 | parentSha = null, 37 | prNumber = null, 38 | hasAskedToDelete = false, 39 | ), 40 | Branch( 41 | name = "change-a", 42 | parent = "main", 43 | parentSha = mainSha.long, 44 | prNumber = null, 45 | hasAskedToDelete = false, 46 | ), 47 | ) 48 | } 49 | } 50 | 51 | @Test 52 | fun branchFromNonTrunkBranch() = withTestEnvironment { 53 | gitInit() 54 | val mainSha = gitCommit("Empty") 55 | testCommand({ repoInit("main", Optional.None) }) 56 | testCommand({ branchCreate("change-a") }) 57 | val parentSha = gitCommit("Change A") 58 | 59 | testCommand({ branchCreate("change-b") }) { 60 | awaitFrame("") 61 | assertThat(awaitResult()).isTrue() 62 | } 63 | 64 | withDatabase { 65 | assertThat(it.branchQueries.selectAll().executeAsList()).containsExactlyInAnyOrder( 66 | Branch( 67 | name = "main", 68 | parent = null, 69 | parentSha = null, 70 | prNumber = null, 71 | hasAskedToDelete = false, 72 | ), 73 | Branch( 74 | name = "change-a", 75 | parent = "main", 76 | parentSha = mainSha.long, 77 | prNumber = null, 78 | hasAskedToDelete = false, 79 | ), 80 | Branch( 81 | name = "change-b", 82 | parent = "change-a", 83 | parentSha = parentSha.long, 84 | prNumber = null, 85 | hasAskedToDelete = false, 86 | ), 87 | ) 88 | } 89 | } 90 | 91 | @Test 92 | fun branchFromUntrackedBranch() = withTestEnvironment { 93 | gitInit() 94 | gitCommit("Empty") 95 | testCommand({ repoInit("main", Optional.None) }) 96 | gitCreateAndCheckoutBranch("change-a") 97 | 98 | testCommand({ branchCreate("change-b") }) { 99 | awaitFrame( 100 | static = "Cannot branch from change-a since it is not tracked. Please track with st branch track.", 101 | output = "", 102 | ) 103 | 104 | assertThat(awaitResult()).isFalse() 105 | } 106 | 107 | withDatabase { 108 | assertThat(it.branchQueries.selectAll().executeAsList().map { it.name }) 109 | .containsExactly("main") 110 | } 111 | } 112 | 113 | @Test 114 | fun duplicateBranch() = withTestEnvironment { 115 | gitInit() 116 | gitCommit("Empty") 117 | testCommand({ repoInit("main", Optional.None) }) 118 | gitCreateBranch("change-a") 119 | 120 | testCommand({ branchCreate("change-a") }) { 121 | awaitFrame( 122 | static = "Branch change-a already exists.", 123 | output = "", 124 | ) 125 | assertThat(awaitResult()).isFalse() 126 | } 127 | 128 | withDatabase { 129 | assertThat(it.branchQueries.selectAll().executeAsList().map { it.name }) 130 | .containsExactly("main") 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com/mattprecious/stacker/test/command/BranchDownTest.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.test.command 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import assertk.assertions.isFalse 6 | import assertk.assertions.isTrue 7 | import com.mattprecious.stacker.command.branch.branchCreate 8 | import com.mattprecious.stacker.command.branch.branchDown 9 | import com.mattprecious.stacker.command.repo.repoInit 10 | import com.mattprecious.stacker.delegates.Optional.None 11 | import com.mattprecious.stacker.delegates.Optional.Some 12 | import com.mattprecious.stacker.test.util.gitCheckoutBranch 13 | import com.mattprecious.stacker.test.util.gitCommit 14 | import com.mattprecious.stacker.test.util.gitCreateAndCheckoutBranch 15 | import com.mattprecious.stacker.test.util.gitCurrentBranch 16 | import com.mattprecious.stacker.test.util.gitInit 17 | import com.mattprecious.stacker.test.util.withTestEnvironment 18 | import kotlin.test.Test 19 | 20 | class BranchDownTest { 21 | @Test 22 | fun untracked() = withTestEnvironment { 23 | gitInit() 24 | gitCommit("Empty") 25 | testCommand({ repoInit("main", None) }) 26 | gitCreateAndCheckoutBranch("change-a") 27 | 28 | testCommand({ branchDown() }) { 29 | awaitFrame( 30 | static = "Branch change-a is not tracked.", 31 | output = "", 32 | ) 33 | 34 | assertThat(awaitResult()).isFalse() 35 | } 36 | } 37 | 38 | @Test 39 | fun singleBranch() = withTestEnvironment { 40 | gitInit() 41 | gitCommit("Empty") 42 | testCommand({ repoInit("main", None) }) 43 | 44 | testCommand({ branchDown() }) { 45 | awaitFrame("") 46 | assertThat(awaitResult()).isTrue() 47 | } 48 | } 49 | 50 | @Test 51 | fun linearStack() = withTestEnvironment { 52 | gitInit() 53 | gitCommit("Empty") 54 | gitCreateAndCheckoutBranch("green-main") 55 | testCommand({ repoInit("main", Some("green-main")) }) 56 | testCommand({ branchCreate("change-a") }) 57 | testCommand({ branchCreate("change-b") }) 58 | 59 | testCommand({ branchDown() }) { 60 | awaitFrame("") 61 | assertThat(awaitResult()).isTrue() 62 | } 63 | 64 | assertThat(gitCurrentBranch()).isEqualTo("change-a") 65 | 66 | testCommand({ branchDown() }) { 67 | awaitFrame("") 68 | assertThat(awaitResult()).isTrue() 69 | } 70 | 71 | assertThat(gitCurrentBranch()).isEqualTo("green-main") 72 | 73 | testCommand({ branchDown() }) { 74 | awaitFrame("") 75 | assertThat(awaitResult()).isTrue() 76 | } 77 | 78 | assertThat(gitCurrentBranch()).isEqualTo("main") 79 | 80 | testCommand({ branchDown() }) { 81 | awaitFrame("") 82 | assertThat(awaitResult()).isTrue() 83 | } 84 | 85 | assertThat(gitCurrentBranch()).isEqualTo("main") 86 | } 87 | 88 | @Test 89 | fun fork() = withTestEnvironment { 90 | gitInit() 91 | gitCommit("Empty") 92 | testCommand({ repoInit("main", None) }) 93 | testCommand({ branchCreate("change-a") }) 94 | gitCheckoutBranch("main") 95 | testCommand({ branchCreate("change-b") }) 96 | 97 | testCommand({ branchDown() }) { 98 | awaitFrame("") 99 | assertThat(awaitResult()).isTrue() 100 | } 101 | 102 | assertThat(gitCurrentBranch()).isEqualTo("main") 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com/mattprecious/stacker/test/command/BranchRenameTest.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.test.command 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.containsExactly 5 | import assertk.assertions.containsExactlyInAnyOrder 6 | import assertk.assertions.isFalse 7 | import assertk.assertions.isTrue 8 | import com.mattprecious.stacker.command.branch.branchCreate 9 | import com.mattprecious.stacker.command.branch.branchRename 10 | import com.mattprecious.stacker.command.repo.repoInit 11 | import com.mattprecious.stacker.db.Branch 12 | import com.mattprecious.stacker.delegates.Optional 13 | import com.mattprecious.stacker.test.util.gitBranches 14 | import com.mattprecious.stacker.test.util.gitCheckoutBranch 15 | import com.mattprecious.stacker.test.util.gitCommit 16 | import com.mattprecious.stacker.test.util.gitCreateAndCheckoutBranch 17 | import com.mattprecious.stacker.test.util.gitInit 18 | import com.mattprecious.stacker.test.util.withTestEnvironment 19 | import kotlin.test.Test 20 | 21 | class BranchRenameTest { 22 | @Test 23 | fun renameLeaf() = withTestEnvironment { 24 | gitInit() 25 | gitCommit("Empty") 26 | testCommand({ repoInit("main", Optional.None) }) 27 | testCommand({ branchCreate("change-a") }) 28 | 29 | testCommand({ branchRename("new-a") }) { 30 | awaitFrame("") 31 | assertThat(awaitResult()).isTrue() 32 | } 33 | 34 | withDatabase { 35 | assertThat(it.branchQueries.selectAll().executeAsList().map { it.name }) 36 | .containsExactly("main", "new-a") 37 | } 38 | 39 | assertThat(gitBranches()).containsExactly( 40 | "main", 41 | "* new-a", 42 | ) 43 | } 44 | 45 | @Test 46 | fun renameWithChildren() = withTestEnvironment { 47 | gitInit() 48 | val sha = gitCommit("Empty") 49 | testCommand({ repoInit("main", Optional.None) }) 50 | testCommand({ branchCreate("change-a") }) 51 | testCommand({ branchCreate("change-b") }) 52 | testCommand({ branchCreate("change-c") }) 53 | gitCheckoutBranch("change-a") 54 | testCommand({ branchCreate("change-d") }) 55 | gitCheckoutBranch("change-a") 56 | 57 | testCommand({ branchRename("new-a") }) { 58 | awaitFrame("") 59 | assertThat(awaitResult()).isTrue() 60 | } 61 | 62 | withDatabase { 63 | assertThat(it.branchQueries.selectAll().executeAsList()).containsExactlyInAnyOrder( 64 | Branch( 65 | name = "main", 66 | parent = null, 67 | parentSha = null, 68 | prNumber = null, 69 | hasAskedToDelete = false, 70 | ), 71 | Branch( 72 | name = "new-a", 73 | parent = "main", 74 | parentSha = sha.long, 75 | prNumber = null, 76 | hasAskedToDelete = false, 77 | ), 78 | Branch( 79 | name = "change-b", 80 | parent = "new-a", 81 | parentSha = sha.long, 82 | prNumber = null, 83 | hasAskedToDelete = false, 84 | ), 85 | Branch( 86 | name = "change-c", 87 | parent = "change-b", 88 | parentSha = sha.long, 89 | prNumber = null, 90 | hasAskedToDelete = false, 91 | ), 92 | Branch( 93 | name = "change-d", 94 | parent = "new-a", 95 | parentSha = sha.long, 96 | prNumber = null, 97 | hasAskedToDelete = false, 98 | ), 99 | ) 100 | } 101 | 102 | assertThat(gitBranches()).containsExactly( 103 | "change-b", 104 | "change-c", 105 | "change-d", 106 | "main", 107 | "* new-a", 108 | ) 109 | } 110 | 111 | @Test 112 | fun cannotRenameTrunk() = withTestEnvironment { 113 | gitInit() 114 | gitCommit("Empty") 115 | testCommand({ repoInit("main", Optional.None) }) 116 | 117 | testCommand({ branchRename("trunk") }) { 118 | awaitFrame( 119 | static = "Cannot rename a trunk branch.", 120 | output = "", 121 | ) 122 | assertThat(awaitResult()).isFalse() 123 | } 124 | 125 | withDatabase { 126 | assertThat(it.branchQueries.selectAll().executeAsList().map { it.name }) 127 | .containsExactly("main") 128 | } 129 | 130 | assertThat(gitBranches()).containsExactly("* main") 131 | } 132 | 133 | @Test 134 | fun cannotRenameTrailingTrunk() = withTestEnvironment { 135 | gitInit() 136 | gitCommit("Empty") 137 | gitCreateAndCheckoutBranch("green-main") 138 | testCommand({ repoInit("main", Optional.Some("green-main")) }) 139 | 140 | testCommand({ branchRename("green-trunk") }) { 141 | awaitFrame( 142 | static = "Cannot rename a trunk branch.", 143 | output = "", 144 | ) 145 | assertThat(awaitResult()).isFalse() 146 | } 147 | 148 | withDatabase { 149 | assertThat(it.branchQueries.selectAll().executeAsList().map { it.name }) 150 | .containsExactlyInAnyOrder("main", "green-main") 151 | } 152 | 153 | assertThat(gitBranches()).containsExactly( 154 | "* green-main", 155 | "main", 156 | ) 157 | } 158 | 159 | @Test 160 | fun renameUntracked() = withTestEnvironment { 161 | gitInit() 162 | gitCommit("Empty") 163 | testCommand({ repoInit("main", Optional.None) }) 164 | gitCreateAndCheckoutBranch("change-a") 165 | 166 | testCommand({ branchRename("new-a") }) { 167 | awaitFrame("") 168 | assertThat(awaitResult()).isTrue() 169 | } 170 | 171 | withDatabase { 172 | assertThat(it.branchQueries.selectAll().executeAsList().map { it.name }) 173 | .containsExactly("main") 174 | } 175 | 176 | assertThat(gitBranches()).containsExactly( 177 | "main", 178 | "* new-a", 179 | ) 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com/mattprecious/stacker/test/command/BranchTopTest.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.test.command 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import assertk.assertions.isFalse 6 | import assertk.assertions.isTrue 7 | import com.jakewharton.mosaic.layout.KeyEvent 8 | import com.mattprecious.stacker.command.branch.branchCreate 9 | import com.mattprecious.stacker.command.branch.branchTop 10 | import com.mattprecious.stacker.command.repo.repoInit 11 | import com.mattprecious.stacker.delegates.Optional.None 12 | import com.mattprecious.stacker.delegates.Optional.Some 13 | import com.mattprecious.stacker.test.util.gitCheckoutBranch 14 | import com.mattprecious.stacker.test.util.gitCommit 15 | import com.mattprecious.stacker.test.util.gitCreateAndCheckoutBranch 16 | import com.mattprecious.stacker.test.util.gitCurrentBranch 17 | import com.mattprecious.stacker.test.util.gitInit 18 | import com.mattprecious.stacker.test.util.s 19 | import com.mattprecious.stacker.test.util.withTestEnvironment 20 | import kotlin.test.Test 21 | 22 | class BranchTopTest { 23 | @Test 24 | fun untracked() = withTestEnvironment { 25 | gitInit() 26 | gitCommit("Empty") 27 | testCommand({ repoInit("main", None) }) 28 | gitCreateAndCheckoutBranch("change-a") 29 | 30 | testCommand({ branchTop() }) { 31 | awaitFrame( 32 | static = "Branch change-a is not tracked.", 33 | output = "", 34 | ) 35 | 36 | assertThat(awaitResult()).isFalse() 37 | } 38 | } 39 | 40 | @Test 41 | fun singleBranch() = withTestEnvironment { 42 | gitInit() 43 | gitCommit("Empty") 44 | testCommand({ repoInit("main", None) }) 45 | 46 | testCommand({ branchTop() }) { 47 | awaitFrame("") 48 | assertThat(awaitResult()).isTrue() 49 | } 50 | } 51 | 52 | @Test 53 | fun linearStack() = withTestEnvironment { 54 | gitInit() 55 | gitCommit("Empty") 56 | gitCreateAndCheckoutBranch("green-main") 57 | testCommand({ repoInit("main", Some("green-main")) }) 58 | testCommand({ branchCreate("change-a") }) 59 | testCommand({ branchCreate("change-b") }) 60 | gitCheckoutBranch("main") 61 | 62 | testCommand({ branchTop() }) { 63 | awaitFrame("") 64 | assertThat(awaitResult()).isTrue() 65 | } 66 | 67 | assertThat(gitCurrentBranch()).isEqualTo("change-b") 68 | 69 | testCommand({ branchTop() }) { 70 | awaitFrame("") 71 | assertThat(awaitResult()).isTrue() 72 | } 73 | 74 | assertThat(gitCurrentBranch()).isEqualTo("change-b") 75 | } 76 | 77 | @Test 78 | fun fork() = withTestEnvironment { 79 | gitInit() 80 | gitCommit("Empty") 81 | testCommand({ repoInit("main", None) }) 82 | testCommand({ branchCreate("change-a") }) 83 | gitCheckoutBranch("main") 84 | testCommand({ branchCreate("change-b") }) 85 | gitCheckoutBranch("main") 86 | 87 | testCommand({ branchTop() }) { 88 | awaitFrame( 89 | """ 90 | |Move up to:$s 91 | |❯ change-a $s 92 | | change-b $s 93 | """.trimMargin(), 94 | ) 95 | 96 | sendKeyEvent(KeyEvent("Enter")) 97 | 98 | awaitFrame( 99 | static = "Move up to: change-a", 100 | output = "", 101 | ) 102 | 103 | assertThat(awaitResult()).isTrue() 104 | } 105 | 106 | assertThat(gitCurrentBranch()).isEqualTo("change-a") 107 | 108 | gitCheckoutBranch("main") 109 | 110 | testCommand({ branchTop() }) { 111 | awaitFrame( 112 | """ 113 | |Move up to:$s 114 | |❯ change-a $s 115 | | change-b $s 116 | """.trimMargin(), 117 | ) 118 | 119 | sendKeyEvent(KeyEvent("ArrowDown")) 120 | 121 | awaitFrame( 122 | """ 123 | |Move up to:$s 124 | | change-a $s 125 | |❯ change-b $s 126 | """.trimMargin(), 127 | ) 128 | 129 | sendKeyEvent(KeyEvent("Enter")) 130 | 131 | awaitFrame( 132 | static = "Move up to: change-b", 133 | output = "", 134 | ) 135 | 136 | assertThat(awaitResult()).isTrue() 137 | } 138 | 139 | assertThat(gitCurrentBranch()).isEqualTo("change-b") 140 | } 141 | 142 | @Test 143 | fun multiFork() = withTestEnvironment { 144 | gitInit() 145 | gitCommit("Empty") 146 | testCommand({ repoInit("main", None) }) 147 | testCommand({ branchCreate("change-a") }) 148 | gitCheckoutBranch("main") 149 | testCommand({ branchCreate("change-b") }) 150 | testCommand({ branchCreate("change-c") }) 151 | testCommand({ branchCreate("change-d") }) 152 | gitCheckoutBranch("change-c") 153 | testCommand({ branchCreate("change-e") }) 154 | gitCheckoutBranch("change-c") 155 | testCommand({ branchCreate("change-f") }) 156 | gitCheckoutBranch("main") 157 | testCommand({ branchCreate("change-g") }) 158 | gitCheckoutBranch("main") 159 | 160 | testCommand({ branchTop() }) { 161 | awaitFrame( 162 | """ 163 | |Move up to:$s 164 | |❯ change-a $s 165 | | change-d $s 166 | | change-e $s 167 | | change-f $s 168 | | change-g $s 169 | """.trimMargin(), 170 | ) 171 | 172 | sendKeyEvent(KeyEvent("Enter")) 173 | 174 | awaitFrame( 175 | static = "Move up to: change-a", 176 | output = "", 177 | ) 178 | 179 | assertThat(awaitResult()).isTrue() 180 | } 181 | 182 | assertThat(gitCurrentBranch()).isEqualTo("change-a") 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com/mattprecious/stacker/test/command/BranchTrackTest.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.test.command 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.containsExactly 5 | import assertk.assertions.isTrue 6 | import com.jakewharton.mosaic.layout.KeyEvent 7 | import com.mattprecious.stacker.command.branch.branchTrack 8 | import com.mattprecious.stacker.command.repo.repoInit 9 | import com.mattprecious.stacker.db.Branch 10 | import com.mattprecious.stacker.delegates.Optional 11 | import com.mattprecious.stacker.test.util.gitCheckoutBranch 12 | import com.mattprecious.stacker.test.util.gitCommit 13 | import com.mattprecious.stacker.test.util.gitCreateAndCheckoutBranch 14 | import com.mattprecious.stacker.test.util.gitInit 15 | import com.mattprecious.stacker.test.util.s 16 | import com.mattprecious.stacker.test.util.withTestEnvironment 17 | import kotlin.test.Test 18 | 19 | class BranchTrackTest { 20 | @Test 21 | fun alreadyTracked() = withTestEnvironment { 22 | gitInit() 23 | gitCommit("Empty") 24 | testCommand({ repoInit("main", Optional.None) }) 25 | 26 | testCommand({ branchTrack(null) }) { 27 | awaitFrame( 28 | static = "Branch main is already tracked.", 29 | output = "", 30 | ) 31 | 32 | assertThat(awaitResult()).isTrue() 33 | } 34 | 35 | testCommand({ branchTrack("main") }) { 36 | awaitFrame( 37 | static = "Branch main is already tracked.", 38 | output = "", 39 | ) 40 | 41 | assertThat(awaitResult()).isTrue() 42 | } 43 | 44 | withDatabase { 45 | assertThat(it.branchQueries.selectAll().executeAsList()).containsExactly( 46 | Branch( 47 | name = "main", 48 | parent = null, 49 | parentSha = null, 50 | prNumber = null, 51 | hasAskedToDelete = false, 52 | ), 53 | ) 54 | } 55 | } 56 | 57 | @Test 58 | fun withOnlyTrunkAsAncestor() = withTestEnvironment { 59 | gitInit() 60 | val mainSha = gitCommit("Empty") 61 | testCommand({ repoInit("main", Optional.None) }) 62 | gitCreateAndCheckoutBranch("change-a") 63 | gitCommit("Change A") 64 | 65 | testCommand({ branchTrack(null) }) { 66 | awaitFrame("") 67 | assertThat(awaitResult()).isTrue() 68 | } 69 | 70 | withDatabase { 71 | assertThat(it.branchQueries.selectAll().executeAsList()).containsExactly( 72 | Branch( 73 | name = "main", 74 | parent = null, 75 | parentSha = null, 76 | prNumber = null, 77 | hasAskedToDelete = false, 78 | ), 79 | Branch( 80 | name = "change-a", 81 | parent = "main", 82 | parentSha = mainSha.long, 83 | prNumber = null, 84 | hasAskedToDelete = false, 85 | ), 86 | ) 87 | } 88 | } 89 | 90 | @Test 91 | fun multipleAncestors() = withTestEnvironment { 92 | gitInit() 93 | val mainSha = gitCommit("Empty") 94 | testCommand({ repoInit("main", Optional.None) }) 95 | gitCreateAndCheckoutBranch("change-a") 96 | val parentSha = gitCommit("Change A") 97 | testCommand({ branchTrack(null) }) 98 | gitCreateAndCheckoutBranch("change-b") 99 | gitCommit("Change B") 100 | 101 | testCommand({ branchTrack(null) }) { 102 | awaitFrame( 103 | """ 104 | |Choose a parent branch for change-b:$s 105 | | ○ change-a $s 106 | |❯ ○ main $s 107 | """.trimMargin(), 108 | ) 109 | 110 | sendKeyEvent(KeyEvent("ArrowUp")) 111 | sendKeyEvent(KeyEvent("Enter")) 112 | 113 | awaitFrame( 114 | static = "Choose a parent branch for change-b: change-a", 115 | output = "", 116 | ) 117 | 118 | assertThat(awaitResult()).isTrue() 119 | } 120 | 121 | withDatabase { 122 | assertThat(it.branchQueries.selectAll().executeAsList()).containsExactly( 123 | Branch( 124 | name = "main", 125 | parent = null, 126 | parentSha = null, 127 | prNumber = null, 128 | hasAskedToDelete = false, 129 | ), 130 | Branch( 131 | name = "change-a", 132 | parent = "main", 133 | parentSha = mainSha.long, 134 | prNumber = null, 135 | hasAskedToDelete = false, 136 | ), 137 | Branch( 138 | name = "change-b", 139 | parent = "change-a", 140 | parentSha = parentSha.long, 141 | prNumber = null, 142 | hasAskedToDelete = false, 143 | ), 144 | ) 145 | } 146 | } 147 | 148 | @Test 149 | fun nonAncestorsAreFilteredOut() = withTestEnvironment { 150 | gitInit() 151 | val mainSha = gitCommit("Empty") 152 | testCommand({ repoInit("main", Optional.None) }) 153 | gitCreateAndCheckoutBranch("change-a") 154 | gitCommit("Change A") 155 | testCommand({ branchTrack(null) }) 156 | gitCheckoutBranch("main") 157 | gitCreateAndCheckoutBranch("change-b") 158 | gitCommit("Change B") 159 | 160 | testCommand({ branchTrack(null) }) { 161 | awaitFrame("") 162 | assertThat(awaitResult()).isTrue() 163 | } 164 | 165 | withDatabase { 166 | assertThat(it.branchQueries.selectAll().executeAsList()).containsExactly( 167 | Branch( 168 | name = "main", 169 | parent = null, 170 | parentSha = null, 171 | prNumber = null, 172 | hasAskedToDelete = false, 173 | ), 174 | Branch( 175 | name = "change-a", 176 | parent = "main", 177 | parentSha = mainSha.long, 178 | prNumber = null, 179 | hasAskedToDelete = false, 180 | ), 181 | Branch( 182 | name = "change-b", 183 | parent = "main", 184 | parentSha = mainSha.long, 185 | prNumber = null, 186 | hasAskedToDelete = false, 187 | ), 188 | ) 189 | } 190 | } 191 | 192 | @Test 193 | fun trunkIsAlwaysIncluded() = withTestEnvironment { 194 | gitInit() 195 | val mainSha = gitCommit("Empty") 196 | gitCreateAndCheckoutBranch("change-a") 197 | gitCommit("Change A") 198 | gitCheckoutBranch("main") 199 | gitCommit("Main update") 200 | gitCreateAndCheckoutBranch("green-main") 201 | testCommand({ repoInit("main", Optional.Some("green-main")) }) 202 | 203 | testCommand({ branchTrack("change-a") }) { 204 | awaitFrame( 205 | """ 206 | |Choose a parent branch for change-a:$s 207 | |❯ ○ green-main $s 208 | | ○ main $s 209 | """.trimMargin(), 210 | ) 211 | } 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com/mattprecious/stacker/test/command/BranchUntrackTest.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.test.command 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.containsExactly 5 | import assertk.assertions.containsExactlyInAnyOrder 6 | import assertk.assertions.isEqualTo 7 | import assertk.assertions.isFalse 8 | import assertk.assertions.isTrue 9 | import com.mattprecious.stacker.command.branch.branchCreate 10 | import com.mattprecious.stacker.command.branch.branchUntrack 11 | import com.mattprecious.stacker.command.repo.repoInit 12 | import com.mattprecious.stacker.delegates.Optional 13 | import com.mattprecious.stacker.test.util.gitCommit 14 | import com.mattprecious.stacker.test.util.gitCreateAndCheckoutBranch 15 | import com.mattprecious.stacker.test.util.gitCreateBranch 16 | import com.mattprecious.stacker.test.util.gitCurrentBranch 17 | import com.mattprecious.stacker.test.util.gitInit 18 | import com.mattprecious.stacker.test.util.withTestEnvironment 19 | import kotlin.test.Test 20 | 21 | class BranchUntrackTest { 22 | @Test 23 | fun notTracked() = withTestEnvironment { 24 | gitInit() 25 | gitCommit("Empty") 26 | testCommand({ repoInit("main", Optional.None) }) 27 | gitCreateAndCheckoutBranch("change-a") 28 | 29 | testCommand({ branchUntrack(null) }) { 30 | awaitFrame( 31 | static = "Branch change-a is already not tracked.", 32 | output = "", 33 | ) 34 | 35 | assertThat(awaitResult()).isTrue() 36 | } 37 | 38 | withDatabase { 39 | assertThat(it.branchQueries.selectAll().executeAsList().map { it.name }) 40 | .containsExactly("main") 41 | } 42 | } 43 | 44 | @Test 45 | fun withChildren() = withTestEnvironment { 46 | gitInit() 47 | gitCommit("Empty") 48 | testCommand({ repoInit("main", Optional.None) }) 49 | testCommand({ branchCreate("change-a") }) 50 | testCommand({ branchCreate("change-b") }) 51 | 52 | testCommand({ branchUntrack("change-a") }) { 53 | awaitFrame( 54 | static = "Branch change-a has children. Please retarget or untrack them.", 55 | output = "", 56 | ) 57 | 58 | assertThat(awaitResult()).isFalse() 59 | } 60 | 61 | withDatabase { 62 | assertThat(it.branchQueries.selectAll().executeAsList().map { it.name }) 63 | .containsExactlyInAnyOrder("main", "change-a", "change-b") 64 | } 65 | } 66 | 67 | @Test 68 | fun singleBranch() = withTestEnvironment { 69 | gitInit() 70 | gitCommit("Empty") 71 | testCommand({ repoInit("main", Optional.None) }) 72 | testCommand({ branchCreate("change-a") }) 73 | 74 | testCommand({ branchUntrack(null) }) { 75 | assertThat(awaitResult()).isTrue() 76 | } 77 | 78 | withDatabase { 79 | assertThat(it.branchQueries.selectAll().executeAsList().map { it.name }) 80 | .containsExactly("main") 81 | } 82 | 83 | // Does not change branch. 84 | assertThat(gitCurrentBranch()).isEqualTo("change-a") 85 | } 86 | 87 | @Test 88 | fun cannotUntrackTrunk() = withTestEnvironment { 89 | gitInit() 90 | gitCommit("Empty") 91 | testCommand({ repoInit("main", Optional.None) }) 92 | 93 | testCommand({ branchUntrack(null) }) { 94 | awaitFrame( 95 | static = "Cannot untrack trunk branch.", 96 | output = "", 97 | ) 98 | 99 | assertThat(awaitResult()).isFalse() 100 | } 101 | 102 | withDatabase { 103 | assertThat(it.branchQueries.selectAll().executeAsList().map { it.name }) 104 | .containsExactly("main") 105 | } 106 | } 107 | 108 | @Test 109 | fun cannotUntrackTrailingTrunk() = withTestEnvironment { 110 | gitInit() 111 | gitCommit("Empty") 112 | gitCreateBranch("green-main") 113 | testCommand({ repoInit("main", Optional.Some("green-main")) }) 114 | 115 | testCommand({ branchUntrack("green-main") }) { 116 | awaitFrame( 117 | static = "Cannot untrack trailing trunk branch.", 118 | output = "", 119 | ) 120 | 121 | assertThat(awaitResult()).isFalse() 122 | } 123 | 124 | withDatabase { 125 | assertThat(it.branchQueries.selectAll().executeAsList().map { it.name }) 126 | .containsExactly("main", "green-main") 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com/mattprecious/stacker/test/command/BranchUpTest.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.test.command 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import assertk.assertions.isFalse 6 | import assertk.assertions.isTrue 7 | import com.jakewharton.mosaic.layout.KeyEvent 8 | import com.mattprecious.stacker.command.branch.branchCreate 9 | import com.mattprecious.stacker.command.branch.branchUp 10 | import com.mattprecious.stacker.command.repo.repoInit 11 | import com.mattprecious.stacker.delegates.Optional.None 12 | import com.mattprecious.stacker.delegates.Optional.Some 13 | import com.mattprecious.stacker.test.util.gitCheckoutBranch 14 | import com.mattprecious.stacker.test.util.gitCommit 15 | import com.mattprecious.stacker.test.util.gitCreateAndCheckoutBranch 16 | import com.mattprecious.stacker.test.util.gitCurrentBranch 17 | import com.mattprecious.stacker.test.util.gitInit 18 | import com.mattprecious.stacker.test.util.s 19 | import com.mattprecious.stacker.test.util.withTestEnvironment 20 | import kotlin.test.Test 21 | 22 | class BranchUpTest { 23 | @Test 24 | fun untracked() = withTestEnvironment { 25 | gitInit() 26 | gitCommit("Empty") 27 | testCommand({ repoInit("main", None) }) 28 | gitCreateAndCheckoutBranch("change-a") 29 | 30 | testCommand({ branchUp() }) { 31 | awaitFrame( 32 | static = "Branch change-a is not tracked.", 33 | output = "", 34 | ) 35 | 36 | assertThat(awaitResult()).isFalse() 37 | } 38 | } 39 | 40 | @Test 41 | fun singleBranch() = withTestEnvironment { 42 | gitInit() 43 | gitCommit("Empty") 44 | testCommand({ repoInit("main", None) }) 45 | 46 | testCommand({ branchUp() }) { 47 | awaitFrame("") 48 | assertThat(awaitResult()).isTrue() 49 | } 50 | } 51 | 52 | @Test 53 | fun linearStack() = withTestEnvironment { 54 | gitInit() 55 | gitCommit("Empty") 56 | gitCreateAndCheckoutBranch("green-main") 57 | testCommand({ repoInit("main", Some("green-main")) }) 58 | testCommand({ branchCreate("change-a") }) 59 | testCommand({ branchCreate("change-b") }) 60 | gitCheckoutBranch("main") 61 | 62 | testCommand({ branchUp() }) { 63 | awaitFrame("") 64 | assertThat(awaitResult()).isTrue() 65 | } 66 | 67 | assertThat(gitCurrentBranch()).isEqualTo("green-main") 68 | 69 | testCommand({ branchUp() }) { 70 | awaitFrame("") 71 | assertThat(awaitResult()).isTrue() 72 | } 73 | 74 | assertThat(gitCurrentBranch()).isEqualTo("change-a") 75 | 76 | testCommand({ branchUp() }) { 77 | awaitFrame("") 78 | assertThat(awaitResult()).isTrue() 79 | } 80 | 81 | assertThat(gitCurrentBranch()).isEqualTo("change-b") 82 | 83 | testCommand({ branchUp() }) { 84 | awaitFrame("") 85 | assertThat(awaitResult()).isTrue() 86 | } 87 | 88 | assertThat(gitCurrentBranch()).isEqualTo("change-b") 89 | } 90 | 91 | @Test 92 | fun fork() = withTestEnvironment { 93 | gitInit() 94 | gitCommit("Empty") 95 | testCommand({ repoInit("main", None) }) 96 | testCommand({ branchCreate("change-a") }) 97 | gitCheckoutBranch("main") 98 | testCommand({ branchCreate("change-b") }) 99 | gitCheckoutBranch("main") 100 | 101 | testCommand({ branchUp() }) { 102 | awaitFrame( 103 | """ 104 | |Move up to:$s 105 | |❯ change-a $s 106 | | change-b $s 107 | """.trimMargin(), 108 | ) 109 | 110 | sendKeyEvent(KeyEvent("Enter")) 111 | 112 | awaitFrame( 113 | static = "Move up to: change-a", 114 | output = "", 115 | ) 116 | 117 | assertThat(awaitResult()).isTrue() 118 | } 119 | 120 | assertThat(gitCurrentBranch()).isEqualTo("change-a") 121 | 122 | gitCheckoutBranch("main") 123 | 124 | testCommand({ branchUp() }) { 125 | awaitFrame( 126 | """ 127 | |Move up to:$s 128 | |❯ change-a $s 129 | | change-b $s 130 | """.trimMargin(), 131 | ) 132 | 133 | sendKeyEvent(KeyEvent("ArrowDown")) 134 | 135 | awaitFrame( 136 | """ 137 | |Move up to:$s 138 | | change-a $s 139 | |❯ change-b $s 140 | """.trimMargin(), 141 | ) 142 | 143 | sendKeyEvent(KeyEvent("Enter")) 144 | 145 | awaitFrame( 146 | static = "Move up to: change-b", 147 | output = "", 148 | ) 149 | 150 | assertThat(awaitResult()).isTrue() 151 | } 152 | 153 | assertThat(gitCurrentBranch()).isEqualTo("change-b") 154 | } 155 | 156 | @Test 157 | fun multiFork() = withTestEnvironment { 158 | gitInit() 159 | gitCommit("Empty") 160 | testCommand({ repoInit("main", None) }) 161 | testCommand({ branchCreate("change-a") }) 162 | gitCheckoutBranch("main") 163 | testCommand({ branchCreate("change-b") }) 164 | testCommand({ branchCreate("change-c") }) 165 | testCommand({ branchCreate("change-d") }) 166 | gitCheckoutBranch("change-c") 167 | testCommand({ branchCreate("change-e") }) 168 | gitCheckoutBranch("change-c") 169 | testCommand({ branchCreate("change-f") }) 170 | gitCheckoutBranch("main") 171 | testCommand({ branchCreate("change-g") }) 172 | gitCheckoutBranch("main") 173 | 174 | testCommand({ branchUp() }) { 175 | awaitFrame( 176 | """ 177 | |Move up to:$s 178 | |❯ change-a $s 179 | | change-b $s 180 | | change-g $s 181 | """.trimMargin(), 182 | ) 183 | 184 | sendKeyEvent(KeyEvent("ArrowDown")) 185 | sendKeyEvent(KeyEvent("Enter")) 186 | 187 | awaitFrame( 188 | static = "Move up to: change-b", 189 | output = "", 190 | ) 191 | 192 | assertThat(awaitResult()).isTrue() 193 | } 194 | 195 | assertThat(gitCurrentBranch()).isEqualTo("change-b") 196 | 197 | testCommand({ branchUp() }) { 198 | awaitFrame("") 199 | assertThat(awaitResult()).isTrue() 200 | } 201 | 202 | assertThat(gitCurrentBranch()).isEqualTo("change-c") 203 | 204 | testCommand({ branchUp() }) { 205 | awaitFrame( 206 | """ 207 | |Move up to:$s 208 | |❯ change-d $s 209 | | change-e $s 210 | | change-f $s 211 | """.trimMargin(), 212 | ) 213 | 214 | sendKeyEvent(KeyEvent("Enter")) 215 | 216 | awaitFrame( 217 | static = "Move up to: change-d", 218 | output = "", 219 | ) 220 | 221 | assertThat(awaitResult()).isTrue() 222 | } 223 | 224 | assertThat(gitCurrentBranch()).isEqualTo("change-d") 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com/mattprecious/stacker/test/command/LogShortTest.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.test.command 2 | 3 | import app.cash.burst.Burst 4 | import com.mattprecious.stacker.command.branch.branchCreate 5 | import com.mattprecious.stacker.command.log.logShort 6 | import com.mattprecious.stacker.command.repo.repoInit 7 | import com.mattprecious.stacker.delegates.Optional 8 | import com.mattprecious.stacker.delegates.Optional.Some 9 | import com.mattprecious.stacker.test.util.gitCheckoutBranch 10 | import com.mattprecious.stacker.test.util.gitCommit 11 | import com.mattprecious.stacker.test.util.gitCreateBranch 12 | import com.mattprecious.stacker.test.util.gitInit 13 | import com.mattprecious.stacker.test.util.withTestEnvironment 14 | import kotlin.test.Test 15 | 16 | @Burst 17 | class LogShortTest { 18 | @Test 19 | fun errorsIfNotInitialized() = withTestEnvironment { 20 | gitInit() 21 | 22 | testCommand({ logShort() }) { 23 | awaitFrame( 24 | static = "Stacker must be initialized first. Please run st repo init.", 25 | output = "", 26 | ) 27 | } 28 | } 29 | 30 | private enum class NoRestackCase( 31 | val branches: List>, 32 | val headBranch: String = "main", 33 | val output: String, 34 | ) { 35 | One( 36 | branches = listOf("main" to null), 37 | output = "● main", 38 | ), 39 | Two( 40 | branches = listOf( 41 | "main" to null, 42 | "a" to "main", 43 | ), 44 | output = """ 45 | |○ a 46 | |● main 47 | """.trimMargin(), 48 | ), 49 | TwoWithSecondCheckedOut( 50 | branches = listOf( 51 | "main" to null, 52 | "a" to "main", 53 | ), 54 | headBranch = "a", 55 | output = """ 56 | |● a 57 | |○ main 58 | """.trimMargin(), 59 | ), 60 | Three( 61 | branches = listOf( 62 | "main" to null, 63 | "a" to "main", 64 | "b" to "a", 65 | ), 66 | output = """ 67 | |○ b 68 | |○ a 69 | |● main 70 | """.trimMargin(), 71 | ), 72 | TwoChildren( 73 | branches = listOf( 74 | "main" to null, 75 | "a" to "main", 76 | "b" to "main", 77 | ), 78 | output = """ 79 | |○ a 80 | |│ ○ b 81 | |●─┘ main 82 | """.trimMargin(), 83 | ), 84 | ThreeChildren( 85 | branches = listOf( 86 | "main" to null, 87 | "a" to "main", 88 | "b" to "main", 89 | "c" to "main", 90 | ), 91 | output = """ 92 | |○ a 93 | |│ ○ b 94 | |│ │ ○ c 95 | |●─┴─┘ main 96 | """.trimMargin(), 97 | ), 98 | Complex( 99 | branches = listOf( 100 | "main" to null, 101 | "a" to "main", 102 | "b" to "main", 103 | "c" to "main", 104 | "d" to "a", 105 | "e" to "b", 106 | "f" to "c", 107 | "g" to "f", 108 | "h" to "f", 109 | "i" to "d", 110 | "j" to "e", 111 | "k" to "j", 112 | ), 113 | headBranch = "f", 114 | output = """ 115 | |○ i 116 | |○ d 117 | |○ a 118 | |│ ○ k 119 | |│ ○ j 120 | |│ ○ e 121 | |│ ○ b 122 | |│ │ ○ g 123 | |│ │ │ ○ h 124 | |│ │ ●─┘ f 125 | |│ │ ○ c 126 | |○─┴─┘ main 127 | """.trimMargin(), 128 | ), 129 | } 130 | 131 | @Test 132 | private fun noRestacksRequired( 133 | case: NoRestackCase, 134 | ) = withTestEnvironment { 135 | gitInit() 136 | gitCommit("Empty") 137 | testCommand({ repoInit("main", Optional.None) }) 138 | 139 | case.branches.forEach { 140 | val parent = it.second 141 | if (parent != null) { 142 | gitCheckoutBranch(parent) 143 | testCommand({ branchCreate(it.first) }) 144 | } 145 | } 146 | 147 | gitCheckoutBranch(case.headBranch) 148 | 149 | testCommand({ logShort() }) { 150 | awaitFrame( 151 | static = case.output, 152 | output = "", 153 | ) 154 | } 155 | } 156 | 157 | @Test 158 | fun oneChildNeedsRestack() = withTestEnvironment { 159 | gitInit() 160 | gitCommit("Empty") 161 | testCommand({ repoInit("main", Optional.None) }) 162 | testCommand({ branchCreate("a") }) 163 | gitCheckoutBranch("main") 164 | gitCommit("Second") 165 | 166 | testCommand({ logShort() }) { 167 | awaitFrame( 168 | static = """ 169 | |○ a (needs restack) 170 | |● main 171 | """.trimMargin(), 172 | output = "", 173 | ) 174 | } 175 | } 176 | 177 | @Test 178 | fun restackRequiredDoesNotPropagateUpwards() = withTestEnvironment { 179 | gitInit() 180 | gitCommit("Empty") 181 | testCommand({ repoInit("main", Optional.None) }) 182 | testCommand({ branchCreate("a") }) 183 | gitCommit("Second") 184 | testCommand({ branchCreate("b") }) 185 | gitCommit("Third") 186 | gitCheckoutBranch("main") 187 | gitCommit("Fourth") 188 | 189 | testCommand({ logShort() }) { 190 | awaitFrame( 191 | static = """ 192 | |○ b 193 | |○ a (needs restack) 194 | |● main 195 | """.trimMargin(), 196 | output = "", 197 | ) 198 | } 199 | } 200 | 201 | @Test 202 | fun trunkBranchesNeverNeedRestack() = withTestEnvironment { 203 | gitInit() 204 | gitCommit("Empty") 205 | gitCreateBranch("green-main") 206 | testCommand({ repoInit("main", Some("green-main")) }) 207 | gitCommit("Second") 208 | 209 | testCommand({ logShort() }) { 210 | awaitFrame( 211 | static = """ 212 | |○ green-main 213 | |● main 214 | """.trimMargin(), 215 | output = "", 216 | ) 217 | } 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com/mattprecious/stacker/test/remote/FakeRemote.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.remote 2 | 3 | class FakeRemote : Remote { 4 | override val isAuthenticated: Boolean 5 | get() = TODO("Not yet implemented") 6 | override val repoName: String? 7 | get() = TODO("Not yet implemented") 8 | override val hasRepoAccess: Boolean 9 | get() = TODO("Not yet implemented") 10 | 11 | override fun setToken(token: String): Boolean { 12 | TODO("Not yet implemented") 13 | } 14 | 15 | override fun getPrStatus(branchName: String): Remote.PrStatus { 16 | TODO("Not yet implemented") 17 | } 18 | 19 | override fun openOrRetargetPullRequest( 20 | branchName: String, 21 | targetName: String, 22 | prInfo: () -> Remote.PrInfo, 23 | ): Remote.PrResult { 24 | TODO("Not yet implemented") 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com/mattprecious/stacker/test/rendering/PromptTest.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.test.rendering 2 | 3 | import androidx.compose.runtime.LaunchedEffect 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.mutableIntStateOf 6 | import androidx.compose.runtime.setValue 7 | import assertk.assertThat 8 | import assertk.assertions.isEmpty 9 | import assertk.assertions.isEqualTo 10 | import assertk.assertions.isNotNull 11 | import com.jakewharton.mosaic.layout.KeyEvent 12 | import com.jakewharton.mosaic.testing.MosaicSnapshots 13 | import com.jakewharton.mosaic.testing.runMosaicTest 14 | import com.mattprecious.stacker.rendering.Prompt 15 | import com.mattprecious.stacker.test.util.matches 16 | import com.mattprecious.stacker.test.util.sendText 17 | import com.mattprecious.stacker.test.util.setContentWithStatics 18 | import kotlinx.coroutines.test.runTest 19 | import kotlin.test.Test 20 | 21 | class PromptTest { 22 | @Test 23 | fun emptyInputIsAllowed() = runTest { 24 | var result: String? = null 25 | 26 | runMosaicTest(MosaicSnapshots) { 27 | val first = setContentWithStatics { 28 | Prompt( 29 | message = "Enter your name", 30 | hideInput = false, 31 | onSubmit = { result = it }, 32 | ) 33 | } 34 | 35 | assertThat(first).matches("Enter your name: ") 36 | 37 | sendKeyEvent(KeyEvent("Enter")) 38 | 39 | assertThat(awaitSnapshot()).matches(static = "Enter your name: ") 40 | } 41 | 42 | assertThat(result).isNotNull().isEmpty() 43 | } 44 | 45 | @Test 46 | fun nonEmptyInput() = runTest { 47 | var result: String? = null 48 | 49 | runMosaicTest(MosaicSnapshots) { 50 | val first = setContentWithStatics { 51 | Prompt( 52 | message = "Enter your name", 53 | hideInput = false, 54 | onSubmit = { result = it }, 55 | ) 56 | } 57 | 58 | assertThat(first).matches("Enter your name: ") 59 | 60 | sendText("Mattt") 61 | assertThat(awaitSnapshot()).matches("Enter your name: Mattt") 62 | 63 | sendKeyEvent(KeyEvent("Backspace")) 64 | assertThat(awaitSnapshot()).matches("Enter your name: Matt") 65 | 66 | sendKeyEvent(KeyEvent("Enter")) 67 | assertThat(awaitSnapshot()).matches(static = "Enter your name: Matt") 68 | } 69 | 70 | assertThat(result).isNotNull().isEqualTo("Matt") 71 | } 72 | 73 | @Test 74 | fun hiddenInput() = runTest { 75 | var result: String? = null 76 | 77 | runMosaicTest(MosaicSnapshots) { 78 | var forceRecompose by mutableIntStateOf(0) 79 | val first = setContentWithStatics { 80 | LaunchedEffect(forceRecompose) {} 81 | Prompt( 82 | message = "Enter your name", 83 | hideInput = true, 84 | onSubmit = { result = it }, 85 | ) 86 | } 87 | 88 | assertThat(first).matches("Enter your name: ") 89 | 90 | sendText("Matt") 91 | forceRecompose++ 92 | assertThat(awaitSnapshot()).matches("Enter your name: ") 93 | 94 | sendKeyEvent(KeyEvent("Enter")) 95 | assertThat(awaitSnapshot()).matches(static = "Enter your name: ") 96 | } 97 | 98 | assertThat(result).isNotNull().isEqualTo("Matt") 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com/mattprecious/stacker/test/rendering/YesNoPromptTest.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.test.rendering 2 | 3 | import androidx.compose.runtime.LaunchedEffect 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.mutableIntStateOf 6 | import androidx.compose.runtime.setValue 7 | import app.cash.burst.Burst 8 | import assertk.assertThat 9 | import assertk.assertions.isEqualTo 10 | import assertk.assertions.isFalse 11 | import assertk.assertions.isNotNull 12 | import assertk.assertions.isNull 13 | import assertk.assertions.isTrue 14 | import com.jakewharton.mosaic.layout.KeyEvent 15 | import com.jakewharton.mosaic.testing.MosaicSnapshots 16 | import com.jakewharton.mosaic.testing.runMosaicTest 17 | import com.mattprecious.stacker.rendering.YesNoPrompt 18 | import com.mattprecious.stacker.test.util.matches 19 | import com.mattprecious.stacker.test.util.sendText 20 | import com.mattprecious.stacker.test.util.setContentWithStatics 21 | import kotlinx.coroutines.test.runTest 22 | import kotlin.test.Test 23 | 24 | @Burst 25 | class YesNoPromptTest { 26 | @Test 27 | fun invalidInputsAreIgnored() = runTest { 28 | var result: Boolean? = null 29 | 30 | runMosaicTest(MosaicSnapshots) { 31 | var forceRecompose by mutableIntStateOf(0) 32 | val first = setContentWithStatics { 33 | LaunchedEffect(forceRecompose) {} 34 | YesNoPrompt( 35 | message = "Yes or no?", 36 | default = null, 37 | onSubmit = { result = it }, 38 | ) 39 | } 40 | 41 | assertThat(first).matches("Yes or no? [y/n]: ") 42 | 43 | sendKeyEvent(KeyEvent("Enter")) 44 | forceRecompose++ 45 | 46 | assertThat(awaitSnapshot()).matches("Yes or no? [y/n]: ") 47 | 48 | sendText("a") 49 | assertThat(awaitSnapshot()).matches("Yes or no? [y/n]: a") 50 | 51 | sendKeyEvent(KeyEvent("Enter")) 52 | assertThat(awaitSnapshot()).matches("Yes or no? [y/n]: ") 53 | 54 | sendText("1") 55 | assertThat(awaitSnapshot()).matches("Yes or no? [y/n]: 1") 56 | 57 | sendKeyEvent(KeyEvent("Enter")) 58 | assertThat(awaitSnapshot()).matches("Yes or no? [y/n]: ") 59 | 60 | sendText("yes") 61 | assertThat(awaitSnapshot()).matches("Yes or no? [y/n]: yes") 62 | 63 | sendKeyEvent(KeyEvent("Enter")) 64 | assertThat(awaitSnapshot()).matches("Yes or no? [y/n]: ") 65 | } 66 | 67 | assertThat(result).isNull() 68 | } 69 | 70 | enum class ValidInputCase( 71 | val input: String, 72 | val result: Boolean, 73 | ) { 74 | LowerY("y", true), 75 | UpperY("Y", true), 76 | LowerN("n", false), 77 | UpperN("N", false), 78 | } 79 | 80 | @Test 81 | fun validInputs( 82 | case: ValidInputCase, 83 | ) = runTest { 84 | var result: Boolean? = null 85 | 86 | runMosaicTest(MosaicSnapshots) { 87 | val first = setContentWithStatics { 88 | YesNoPrompt( 89 | message = "Yes or no?", 90 | default = null, 91 | onSubmit = { result = it }, 92 | ) 93 | } 94 | 95 | assertThat(first).matches("Yes or no? [y/n]: ") 96 | 97 | sendText(case.input) 98 | 99 | assertThat(awaitSnapshot()).matches("Yes or no? [y/n]: ${case.input}") 100 | 101 | sendKeyEvent(KeyEvent("Enter")) 102 | 103 | assertThat(awaitSnapshot()).matches(static = "Yes or no? [y/n]: ${case.input}") 104 | } 105 | 106 | assertThat(result).isEqualTo(case.result) 107 | } 108 | 109 | @Test 110 | fun defaultTrue() = runTest { 111 | var result: Boolean? = null 112 | 113 | runMosaicTest(MosaicSnapshots) { 114 | val first = setContentWithStatics { 115 | YesNoPrompt( 116 | message = "Yes or no?", 117 | default = true, 118 | onSubmit = { result = it }, 119 | ) 120 | } 121 | 122 | assertThat(first).matches("Yes or no? [Y/n]: ") 123 | 124 | sendKeyEvent(KeyEvent("Enter")) 125 | 126 | assertThat(awaitSnapshot()).matches(static = "Yes or no? [Y/n]: ") 127 | } 128 | 129 | assertThat(result).isNotNull().isTrue() 130 | } 131 | 132 | @Test 133 | fun defaultFalse() = runTest { 134 | var result: Boolean? = null 135 | 136 | runMosaicTest(MosaicSnapshots) { 137 | val first = setContentWithStatics { 138 | YesNoPrompt( 139 | message = "Yes or no?", 140 | default = false, 141 | onSubmit = { result = it }, 142 | ) 143 | } 144 | 145 | assertThat(first).matches("Yes or no? [y/N]: ") 146 | 147 | sendKeyEvent(KeyEvent("Enter")) 148 | 149 | assertThat(awaitSnapshot()).matches(static = "Yes or no? [y/N]: ") 150 | } 151 | 152 | assertThat(result).isNotNull().isFalse() 153 | } 154 | 155 | @Test 156 | fun backspace() = runTest { 157 | var result: Boolean? = null 158 | 159 | runMosaicTest(MosaicSnapshots) { 160 | val first = setContentWithStatics { 161 | YesNoPrompt( 162 | message = "Yes or no?", 163 | default = null, 164 | onSubmit = { result = it }, 165 | ) 166 | } 167 | 168 | assertThat(first).matches("Yes or no? [y/n]: ") 169 | 170 | sendText("y") 171 | assertThat(awaitSnapshot()).matches("Yes or no? [y/n]: y") 172 | 173 | sendKeyEvent(KeyEvent("Backspace")) 174 | assertThat(awaitSnapshot()).matches("Yes or no? [y/n]: ") 175 | } 176 | 177 | assertThat(result).isNull() 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com/mattprecious/stacker/test/util/EnvironmentTest.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.test.util 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEmpty 5 | import assertk.assertions.isEqualTo 6 | import assertk.assertions.isFalse 7 | import assertk.assertions.isTrue 8 | import assertk.assertions.message 9 | import com.mattprecious.stacker.RepoNotFoundException 10 | import okio.Path.Companion.toPath 11 | import kotlin.test.Test 12 | import kotlin.test.assertFailsWith 13 | 14 | class EnvironmentTest { 15 | @Test 16 | fun throwsErrorIfNoRepositoryFound() = withTestEnvironment { 17 | val t = assertFailsWith { testInit() } 18 | assertThat(t).message() 19 | .isEqualTo("No repository found at ${fileSystem.canonicalize(".".toPath())}.") 20 | 21 | assertThat(fileSystem.list(".".toPath())).isEmpty() 22 | } 23 | 24 | @Test 25 | fun environmentSetupMakesGitShaDeterministic() = withTestEnvironment { 26 | gitInit() 27 | environment.exec("touch hello.txt") 28 | gitAdd("hello.txt".toPath()) 29 | gitCommit("Testing") 30 | assertThat(gitSha().long).isEqualTo("f8cdffa9a5c120b21a0042138806a930e72af88f") 31 | } 32 | 33 | @Test 34 | fun dbIsCreatedInGitDirectory() = withTestEnvironment { 35 | gitInit() 36 | assertThat(fileSystem.exists(defaultDbPath)).isFalse() 37 | 38 | testInit() 39 | assertThat(fileSystem.exists(defaultDbPath)).isTrue() 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com/mattprecious/stacker/test/util/command.kt: -------------------------------------------------------------------------------- 1 | 2 | import androidx.compose.runtime.snapshotFlow 3 | import assertk.assertThat 4 | import com.jakewharton.mosaic.Mosaic 5 | import com.jakewharton.mosaic.Terminal 6 | import com.jakewharton.mosaic.layout.KeyEvent 7 | import com.jakewharton.mosaic.testing.MosaicSnapshots 8 | import com.jakewharton.mosaic.testing.TestMosaic 9 | import com.jakewharton.mosaic.testing.runMosaicTest 10 | import com.jakewharton.mosaic.ui.unit.IntSize 11 | import com.mattprecious.stacker.command.StackerCommand 12 | import com.mattprecious.stacker.command.StackerCommand.State 13 | import com.mattprecious.stacker.command.StackerCommand.WorkState 14 | import com.mattprecious.stacker.test.util.matches 15 | import com.mattprecious.stacker.test.util.sendText 16 | import kotlinx.coroutines.flow.filter 17 | import kotlinx.coroutines.flow.first 18 | import kotlinx.coroutines.withTimeout 19 | import kotlin.time.Duration.Companion.seconds 20 | 21 | suspend fun StackerCommand.test( 22 | validate: suspend CommandTestScope.() -> Unit, 23 | ) { 24 | runMosaicTest(MosaicSnapshots) { 25 | CommandTestScope(command = this@test, mosaic = this).validate() 26 | } 27 | } 28 | 29 | class CommandTestScope internal constructor( 30 | command: StackerCommand, 31 | private val mosaic: TestMosaic, 32 | ) { 33 | private var result: Boolean? = null 34 | 35 | private val state = WorkState() 36 | 37 | init { 38 | val snapshot = mosaic.setContentAndSnapshot { 39 | command.Work(workState = state, onFinish = { result = it }) 40 | } 41 | 42 | // Because of our state machine internals, the first snapshot will always be blank. 43 | assertThat(snapshot).matches("") 44 | } 45 | 46 | fun sendText(text: String) { 47 | mosaic.sendText(text) 48 | } 49 | 50 | fun sendKeyEvent(keyEvent: KeyEvent) { 51 | mosaic.sendKeyEvent(keyEvent) 52 | } 53 | 54 | fun setSize(size: IntSize) { 55 | mosaic.terminalState.value = Terminal(size) 56 | } 57 | 58 | suspend fun awaitFrame( 59 | output: String, 60 | static: String = "", 61 | ) { 62 | withTimeout(1.seconds) { 63 | snapshotFlow { state.state } 64 | .filter { it is State.Rendering<*> || it is State.TerminalState } 65 | .first() 66 | } 67 | 68 | assertThat(mosaic.awaitSnapshot()).matches(output, static) 69 | } 70 | 71 | suspend fun awaitResult(): Boolean { 72 | result?.let { return it } 73 | 74 | withTimeout(1.seconds) { 75 | snapshotFlow { state.state } 76 | .filter { it is State.TerminalState } 77 | .first() 78 | } 79 | 80 | // The internal state machine of StackerCommand requires an extra composition in order to 81 | // terminate. This composition will emit an empty frame. 82 | awaitFrame("") 83 | 84 | return result!! 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com/mattprecious/stacker/test/util/environment.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.test.util 2 | 3 | import CommandTestScope 4 | import com.mattprecious.stacker.StackerDeps 5 | import com.mattprecious.stacker.command.StackerCommand 6 | import com.mattprecious.stacker.db.RepoDatabase 7 | import com.mattprecious.stacker.remote.FakeRemote 8 | import com.mattprecious.stacker.withStacker 9 | import kotlinx.cinterop.convert 10 | import kotlinx.cinterop.memScoped 11 | import kotlinx.cinterop.refTo 12 | import kotlinx.cinterop.toKString 13 | import kotlinx.coroutines.runBlocking 14 | import okio.ByteString.Companion.toByteString 15 | import okio.FileSystem 16 | import okio.Path 17 | import okio.Path.Companion.toPath 18 | import platform.posix.chdir 19 | import platform.posix.fgets 20 | import platform.posix.getcwd 21 | import platform.posix.getenv 22 | import platform.posix.pclose 23 | import platform.posix.popen 24 | import platform.posix.setenv 25 | import platform.posix.unsetenv 26 | import test 27 | import kotlin.random.Random 28 | import kotlin.reflect.KProperty 29 | import kotlin.test.fail 30 | 31 | internal fun withTestEnvironment( 32 | validate: suspend TestEnvironment.() -> Unit, 33 | ) { 34 | val environment = Environment() 35 | val fileSystem = FileSystem.SYSTEM 36 | val remote = FakeRemote() 37 | val tmpPath = tmpPath("StackerTest") 38 | 39 | try { 40 | fileSystem.createDirectories(tmpPath, mustCreate = true) 41 | 42 | runBlocking { 43 | environment.withSnapshot { 44 | environment.workingDirectory = fileSystem.canonicalize(tmpPath).toString() 45 | environment.setGitNames("Stacker") 46 | environment.setGitEmails("stacker@example.com") 47 | environment.setGitDates("2020-01-01T12:00:00Z") 48 | 49 | TestEnvironment(environment, fileSystem, remote).validate() 50 | } 51 | } 52 | } finally { 53 | fileSystem.deleteRecursively(tmpPath) 54 | } 55 | } 56 | 57 | private fun tmpPath(name: String): Path { 58 | return FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "$name-${randomToken(16)}" 59 | } 60 | 61 | private fun randomToken(length: Int) = Random.nextBytes(length).toByteString(0, length).hex() 62 | 63 | class TestEnvironment( 64 | val environment: Environment, 65 | val fileSystem: FileSystem, 66 | val remote: FakeRemote, 67 | ) { 68 | val defaultDbPath = ".git/stacker.db".toPath() 69 | 70 | suspend fun testInit() { 71 | withStacker { } 72 | } 73 | 74 | suspend fun withDatabase( 75 | path: Path = defaultDbPath, 76 | block: (RepoDatabase) -> Unit, 77 | ) { 78 | require(fileSystem.exists(path)) 79 | com.mattprecious.stacker.withDatabase(path, block) 80 | } 81 | 82 | suspend fun testCommand( 83 | commandBuilder: StackerDeps.() -> StackerCommand, 84 | validate: suspend CommandTestScope.() -> Unit = { awaitResult() }, 85 | ) { 86 | withStacker { 87 | it.commandBuilder().test(validate) 88 | } 89 | } 90 | 91 | private suspend fun withStacker( 92 | block: suspend (StackerDeps) -> Unit, 93 | ) { 94 | withStacker( 95 | fileSystem = fileSystem, 96 | remoteOverride = remote, 97 | block = block, 98 | ) 99 | } 100 | } 101 | 102 | class Environment { 103 | var workingDirectory: String 104 | get() = memScoped { getcwd(null, 0.convert())!!.toKString() } 105 | set(value) = check(chdir(value) == 0) 106 | 107 | var gitAuthorDate: String? by Variable("GIT_AUTHOR_DATE") 108 | var gitAuthorEmail: String? by Variable("GIT_AUTHOR_EMAIL") 109 | var gitAuthorName: String? by Variable("GIT_AUTHOR_NAME") 110 | var gitCommitterDate: String? by Variable("GIT_COMMITTER_DATE") 111 | var gitCommitterEmail: String? by Variable("GIT_COMMITTER_EMAIL") 112 | var gitCommitterName: String? by Variable("GIT_COMMITTER_NAME") 113 | 114 | fun setGitDates(date: String) { 115 | gitAuthorDate = date 116 | gitCommitterDate = date 117 | } 118 | 119 | fun setGitEmails(email: String) { 120 | gitAuthorEmail = email 121 | gitCommitterEmail = email 122 | } 123 | 124 | fun setGitNames(name: String) { 125 | gitAuthorName = name 126 | gitCommitterName = name 127 | } 128 | 129 | /** Executes [command] and returns both the standard output and standard error. */ 130 | fun exec(command: String): String = memScoped { 131 | val stream = popen("$command 2>&1", "r") ?: fail("Command ($command) failed.") 132 | 133 | return buildString { 134 | val buffer = ByteArray(4096) 135 | while (true) { 136 | val input = fgets(buffer.refTo(0), buffer.size, stream) ?: break 137 | append(input.toKString()) 138 | } 139 | 140 | val status = pclose(stream) 141 | if (status != 0) { 142 | fail("Command ($command) failed with status: $status.") 143 | } 144 | }.trim() 145 | } 146 | 147 | /** 148 | * Captures the existing values for all the mutable properties in [Environment] and restores them 149 | * after [block] returns. 150 | */ 151 | suspend fun withSnapshot(block: suspend () -> Unit) { 152 | val snapshot = snapshot() 153 | try { 154 | block() 155 | } finally { 156 | restore(snapshot) 157 | } 158 | } 159 | 160 | private fun snapshot() = Snapshot( 161 | workingDirectory = workingDirectory, 162 | gitAuthorDate = gitAuthorDate, 163 | gitAuthorEmail = gitAuthorEmail, 164 | gitAuthorName = gitAuthorName, 165 | gitCommitterDate = gitCommitterDate, 166 | gitCommitterEmail = gitCommitterEmail, 167 | gitCommitterName = gitCommitterName, 168 | ) 169 | 170 | private fun restore(snapshot: Snapshot) { 171 | workingDirectory = snapshot.workingDirectory 172 | gitAuthorDate = snapshot.gitAuthorDate 173 | gitAuthorEmail = snapshot.gitAuthorEmail 174 | gitAuthorName = snapshot.gitAuthorName 175 | gitCommitterDate = snapshot.gitCommitterDate 176 | gitCommitterEmail = snapshot.gitCommitterEmail 177 | gitCommitterName = snapshot.gitCommitterName 178 | } 179 | 180 | private class Variable(private val name: String) { 181 | operator fun getValue(thisRef: Any?, property: KProperty<*>): String? { 182 | return memScoped { getenv(name)?.toKString() } 183 | } 184 | 185 | operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String?) { 186 | if (value == null) { 187 | unsetenv(name) 188 | } else { 189 | setenv(name, value, 1) 190 | } 191 | } 192 | } 193 | 194 | private class Snapshot( 195 | val workingDirectory: String, 196 | val gitAuthorName: String?, 197 | val gitCommitterName: String?, 198 | val gitAuthorEmail: String?, 199 | val gitCommitterEmail: String?, 200 | val gitAuthorDate: String?, 201 | val gitCommitterDate: String?, 202 | ) 203 | } 204 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com/mattprecious/stacker/test/util/git.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.test.util 2 | 3 | import okio.Path 4 | 5 | value class Sha(val long: String) { 6 | val short: String 7 | get() = long.substring(0, 7) 8 | } 9 | 10 | fun TestEnvironment.gitInit() { 11 | environment.exec("git init --initial-branch='main'") 12 | gitSetDefaultBranch("main") 13 | } 14 | 15 | fun TestEnvironment.gitAdd( 16 | vararg files: Path, 17 | ) { 18 | environment.exec("git add ${files.joinToString(" ")}") 19 | } 20 | 21 | fun TestEnvironment.gitBranches(): Sequence { 22 | return environment.exec("git branch").splitToSequence('\n').map { it.trim() } 23 | } 24 | 25 | fun TestEnvironment.gitCommit( 26 | message: String, 27 | ): Sha { 28 | // TODO: Escaping. 29 | environment.exec("git commit --allow-empty -m \"$message\"") 30 | return gitSha() 31 | } 32 | 33 | fun TestEnvironment.gitCheckoutBranch( 34 | name: String, 35 | ) { 36 | environment.exec("git checkout $name") 37 | } 38 | 39 | fun TestEnvironment.gitCreateBranch( 40 | name: String, 41 | startPoint: String = "HEAD", 42 | ) { 43 | environment.exec("git branch $name $startPoint") 44 | } 45 | 46 | fun TestEnvironment.gitCreateAndCheckoutBranch( 47 | name: String, 48 | ) { 49 | environment.exec("git checkout -b $name") 50 | } 51 | 52 | fun TestEnvironment.gitCurrentBranch(): String { 53 | return environment.exec("git rev-parse --abbrev-ref HEAD") 54 | } 55 | 56 | fun TestEnvironment.gitLog(path: String = "HEAD"): Sequence { 57 | return environment.exec("git log --format=format:'%h %s' $path").splitToSequence('\n') 58 | } 59 | 60 | fun TestEnvironment.gitSetDefaultBranch( 61 | name: String, 62 | ) { 63 | environment.exec("git config set init.defaultBranch '$name'") 64 | } 65 | 66 | fun TestEnvironment.gitSha(rev: String = "HEAD"): Sha { 67 | return Sha(environment.exec("git rev-parse $rev")) 68 | } 69 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/com/mattprecious/stacker/test/util/mosaic.kt: -------------------------------------------------------------------------------- 1 | package com.mattprecious.stacker.test.util 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.CompositionLocalProvider 5 | import assertk.Assert 6 | import assertk.assertions.isEqualTo 7 | import assertk.assertions.prop 8 | import com.jakewharton.mosaic.Mosaic 9 | import com.jakewharton.mosaic.layout.KeyEvent 10 | import com.jakewharton.mosaic.testing.TestMosaic 11 | import com.jakewharton.mosaic.ui.AnsiLevel 12 | import com.mattprecious.stacker.rendering.LocalPrinter 13 | import com.mattprecious.stacker.rendering.Printer 14 | 15 | // So the IDE doesn't trim trailing spaces in test assertions... 16 | val s = " " 17 | 18 | // Workaround for mosaic bug that is fixed in 0.17. 19 | val reset = "\u001B[0m" 20 | 21 | fun TestMosaic.setContentWithStatics( 22 | content: @Composable () -> Unit, 23 | ): Mosaic { 24 | return setContentAndSnapshot { 25 | CompositionLocalProvider( 26 | LocalPrinter provides Printer(), 27 | ) { 28 | LocalPrinter.current.Messages() 29 | content() 30 | } 31 | } 32 | } 33 | 34 | fun Assert.matches( 35 | output: String? = null, 36 | static: String = "", 37 | ) { 38 | hasStaticsEqualTo(static) 39 | output?.let(::hasOutputEqualTo) 40 | } 41 | 42 | fun Assert.hasOutputEqualTo(expected: String) { 43 | prop("output") { it.paint().render(AnsiLevel.NONE) }.isEqualTo(expected) 44 | } 45 | 46 | fun Assert.hasStaticsEqualTo(expected: String) { 47 | prop("statics") { it.paintStatics().joinToString("\n") { it.render(AnsiLevel.NONE) } } 48 | .isEqualTo(expected) 49 | } 50 | 51 | fun TestMosaic<*>.sendText(text: String) { 52 | text.forEach { sendKeyEvent(KeyEvent("$it")) } 53 | } 54 | --------------------------------------------------------------------------------