├── .changes ├── header.tpl.md ├── unreleased │ └── .gitkeep ├── v0.1.0.md ├── v0.1.1.md ├── v0.1.2.md ├── v0.1.3.md ├── v0.2.0.md ├── v0.3.0.md ├── v0.3.1.md ├── v0.4.0.md ├── v0.5.0.md ├── v0.5.1.md ├── v0.5.2.md ├── v0.5.3.md ├── v0.5.4.md ├── v0.6.0.md ├── v0.6.1.md ├── v0.7.0.md └── v0.8.0.md ├── .changie.yaml ├── .gitattributes ├── .github └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── Makefile ├── README.md ├── codecov.yml ├── fixtures ├── bin │ └── add_break.sh ├── empty.sh ├── empty.tar.xz ├── empty_commit.sh ├── empty_commit.tar.xz ├── empty_commit_comment_char_auto.sh ├── empty_commit_comment_char_auto.tar.xz ├── mid_rebase.sh ├── mid_rebase.tar.xz ├── simple_many_branches.sh ├── simple_many_branches.tar.xz ├── simple_stack.sh ├── simple_stack.tar.xz ├── simple_stack_comment_char.sh ├── simple_stack_comment_char.tar.xz ├── simple_stack_comment_string.sh └── simple_stack_comment_string.tar.xz ├── renovate.json ├── rust-toolchain ├── rustfmt.toml ├── src ├── edit.rs ├── edit.sh ├── exec.rs ├── git.rs ├── git │ └── shell.rs ├── gitscript.rs ├── io.rs ├── main.rs ├── restack.rs ├── restack │ └── tests.rs └── setup.rs ├── tests ├── edit │ └── mod.rs ├── integration.rs └── setup │ └── mod.rs └── tools ├── release ├── PKGBUILD-bin.tmpl ├── PKGBUILD.tmpl ├── bottle.tmpl └── genpkgspec.sh └── test ├── Cargo.toml └── src ├── gitscript.rs └── lib.rs /.changes/header.tpl.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), 6 | and is generated by [Changie](https://github.com/miniscruff/changie). 7 | -------------------------------------------------------------------------------- /.changes/unreleased/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abhinav/restack/4bfb5d365755cbc00e5d282ec456a675d82dd34d/.changes/unreleased/.gitkeep -------------------------------------------------------------------------------- /.changes/v0.1.0.md: -------------------------------------------------------------------------------- 1 | ## v0.1.0 (2017-10-14) 2 | - Initial release. 3 | -------------------------------------------------------------------------------- /.changes/v0.1.1.md: -------------------------------------------------------------------------------- 1 | ## v0.1.1 (2017-10-14) 2 | - setup: Allow relocating `.gitconfig` by removing hardcoded `$HOME`. 3 | -------------------------------------------------------------------------------- /.changes/v0.1.2.md: -------------------------------------------------------------------------------- 1 | ## v0.1.2 (2017-10-29) 2 | - Include ARM binaries. 3 | -------------------------------------------------------------------------------- /.changes/v0.1.3.md: -------------------------------------------------------------------------------- 1 | ## v0.1.3 (2019-02-27) 2 | - Interpret `GIT_EDITOR` using a shell. 3 | -------------------------------------------------------------------------------- /.changes/v0.2.0.md: -------------------------------------------------------------------------------- 1 | ## v0.2.0 (2020-03-13) 2 | - Migrate to Go modules. 3 | -------------------------------------------------------------------------------- /.changes/v0.3.0.md: -------------------------------------------------------------------------------- 1 | ## v0.3.0 (2020-04-27) 2 | - Write the full edit script path to git configuration. 3 | - Handle short-forms instructions. 4 | - Handle `fixup` and `squash` instructions. 5 | -------------------------------------------------------------------------------- /.changes/v0.3.1.md: -------------------------------------------------------------------------------- 1 | ## v0.3.1 (2020-04-28) 2 | - Fix CLI parsing breakage. 3 | - Don't kill editor after 1 second. 4 | -------------------------------------------------------------------------------- /.changes/v0.4.0.md: -------------------------------------------------------------------------------- 1 | ## v0.4.0 (2021-08-15) 2 | - Simplify command line argument parsing. 3 | -------------------------------------------------------------------------------- /.changes/v0.5.0.md: -------------------------------------------------------------------------------- 1 | ## v0.5.0 (2021-09-13) 2 | - Release Homebrew bottles. 3 | -------------------------------------------------------------------------------- /.changes/v0.5.1.md: -------------------------------------------------------------------------------- 1 | ## v0.5.1 (2021-09-18) 2 | - On Linux, don't fail to edit if `/tmp` is mounted on a different partition. 3 | -------------------------------------------------------------------------------- /.changes/v0.5.2.md: -------------------------------------------------------------------------------- 1 | ## v0.5.2 (2021-10-28) 2 | - Homebrew formula: Conform to new format. 3 | -------------------------------------------------------------------------------- /.changes/v0.5.3.md: -------------------------------------------------------------------------------- 1 | ## v0.5.3 (2022-02-19) 2 | - Include binaries for Linux 32-bit ARM. 3 | -------------------------------------------------------------------------------- /.changes/v0.5.4.md: -------------------------------------------------------------------------------- 1 | ## v0.5.4 (2022-08-30) 2 | - Fix branches and push directives skipped when there's an empty line 3 | and a comment right after the instruction list. 4 | -------------------------------------------------------------------------------- /.changes/v0.6.0.md: -------------------------------------------------------------------------------- 1 | ## v0.6.0 (2022-09-24) 2 | - Reduce binary size significantly. 3 | -------------------------------------------------------------------------------- /.changes/v0.6.1.md: -------------------------------------------------------------------------------- 1 | ## v0.6.1 (2022-11-07) 2 | ### Fixed 3 | - Linux binaries should be statically linked. 4 | -------------------------------------------------------------------------------- /.changes/v0.7.0.md: -------------------------------------------------------------------------------- 1 | ## v0.7.0 - 2023-07-05 2 | ### Added 3 | - Support core.commentChar during restacking. 4 | ### Changed 5 | - Relicense to GPL-2.0. 6 | -------------------------------------------------------------------------------- /.changes/v0.8.0.md: -------------------------------------------------------------------------------- 1 | ## v0.8.0 - 2024-08-02 2 | ### Added 3 | - Support core.commentString during restacking. 4 | ### Changed 5 | - linux-arm64: Binary is no longer statically linked. 6 | -------------------------------------------------------------------------------- /.changie.yaml: -------------------------------------------------------------------------------- 1 | changesDir: .changes 2 | unreleasedDir: unreleased 3 | headerPath: header.tpl.md 4 | changelogPath: CHANGELOG.md 5 | versionExt: md 6 | versionFormat: '## {{.Version}} - {{.Time.Format "2006-01-02"}}' 7 | kindFormat: '### {{.Kind}}' 8 | changeFormat: '- {{.Body}}' 9 | kinds: 10 | - label: Added 11 | auto: minor 12 | - label: Changed 13 | auto: major 14 | - label: Deprecated 15 | auto: minor 16 | - label: Removed 17 | auto: major 18 | - label: Fixed 19 | auto: patch 20 | - label: Security 21 | auto: patch 22 | newlines: 23 | afterChangelogHeader: 0 24 | beforeChangelogVersion: 1 25 | endOfVersion: 1 26 | envPrefix: CHANGIE_ 27 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.tar.xz filter=lfs diff=lfs merge=lfs -text 2 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: ['*'] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | 15 | - name: Checkout code 16 | uses: actions/checkout@v4 17 | with: 18 | # Test fixtures live in LFS. 19 | lfs: true 20 | 21 | - name: Determine toolchain 22 | id: rust-toolchain 23 | run: | 24 | echo "value=$(cat rust-toolchain)" >> "$GITHUB_OUTPUT" 25 | 26 | - name: Setup Rust 27 | uses: dtolnay/rust-toolchain@v1 28 | with: 29 | components: rustfmt, clippy, llvm-tools-preview 30 | toolchain: ${{ steps.rust-toolchain.outputs.value }} 31 | 32 | - name: Setup Rust cache 33 | uses: Swatinem/rust-cache@v2 34 | 35 | - name: Install testing tools 36 | uses: taiki-e/install-action@v2 37 | with: 38 | tool: nextest@0.9.36,cargo-llvm-cov@0.5.0 39 | 40 | - name: Lint 41 | run: make lint 42 | 43 | - name: Build 44 | run: make build 45 | 46 | - name: Test 47 | run: make cover 48 | 49 | - name: Upload coverage data 50 | uses: codecov/codecov-action@v5 51 | with: 52 | files: lcov.info 53 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: ['v*'] 6 | 7 | # Support running this manually without uploading to test out the workflow. 8 | workflow_dispatch: 9 | inputs: 10 | version: 11 | description: "Version we're pretending to release, e.g. 0.6.0" 12 | required: true 13 | type: string 14 | 15 | jobs: 16 | 17 | build: 18 | runs-on: ${{ matrix.os }} 19 | 20 | strategy: 21 | matrix: 22 | include: 23 | # Linux 24 | - slug: linux-amd64 25 | target: x86_64-unknown-linux-musl 26 | os: ubuntu-latest 27 | strip: x86_64-linux-musl-strip 28 | 29 | - slug: linux-arm64 30 | target: aarch64-unknown-linux-gnu 31 | os: ubuntu-latest 32 | strip: aarch64-linux-gnu-strip 33 | 34 | - slug: linux-armv7 35 | target: armv7-unknown-linux-musleabihf 36 | os: ubuntu-latest 37 | strip: arm-linux-musleabihf-strip 38 | 39 | # macOS 40 | - slug: darwin-amd64 41 | target: x86_64-apple-darwin 42 | os: macos-latest 43 | - slug: darwin-arm64 44 | target: aarch64-apple-darwin 45 | os: macos-latest 46 | 47 | env: 48 | CARGO: cargo 49 | CROSS_VERSION: v0.2.5 50 | 51 | steps: 52 | 53 | - name: Checkout code 54 | uses: actions/checkout@v4 55 | 56 | - name: Determine toolchain 57 | id: rust-toolchain 58 | run: | 59 | echo "value=$(cat rust-toolchain)" >> "$GITHUB_OUTPUT" 60 | 61 | - name: Setup Rust 62 | run: | 63 | if ! command -v rustup &>/dev/null; then 64 | curl --proto '=https' --tlsv1.2 --retry 10 --retry-connrefused --location --silent --show-error --fail "https://sh.rustup.rs" | sh -s -- --default-toolchain none -y 65 | echo "$CARGO_HOME/bin" >> "$GITHUB_PATH" 66 | fi 67 | 68 | rustup toolchain install ${{ steps.rust-toolchain.outputs.value }} \ 69 | --target ${{ matrix.target }} \ 70 | --component rust-src \ 71 | --profile minimal \ 72 | --no-self-update 73 | - uses: Swatinem/rust-cache@v2 74 | 75 | - name: Install Cross 76 | if: matrix.os == 'ubuntu-latest' && matrix.target != '' 77 | run: | 78 | dir="$RUNNER_TEMP/cross-download" 79 | mkdir "$dir" 80 | echo "$dir" >> "$GITHUB_PATH" 81 | cd "$dir" 82 | curl -LO "https://github.com/cross-rs/cross/releases/download/$CROSS_VERSION/cross-x86_64-unknown-linux-musl.tar.gz" 83 | tar xf cross-x86_64-unknown-linux-musl.tar.gz 84 | echo "CARGO=cross" >> "$GITHUB_ENV" 85 | 86 | - name: Build 87 | run: | 88 | ${{ env.CARGO }} build \ 89 | --target ${{ matrix.target }} \ 90 | --locked \ 91 | --release 92 | 93 | - name: Strip release binary (macos) 94 | if: matrix.os == 'macos-latest' 95 | run: | 96 | strip=${{ matrix.strip || 'strip' }} 97 | exe=target/${{ matrix.target }}/release/restack 98 | echo "Before: $(wc -c < "$exe") bytes" 99 | "$strip" target/${{ matrix.target }}/release/restack 100 | echo "After: $(wc -c < "$exe") bytes" 101 | 102 | - name: Strip release binary (cross) 103 | if: env.CARGO == 'cross' 104 | shell: bash 105 | run: | 106 | docker run --rm -v \ 107 | "$PWD/target:/target:Z" \ 108 | "ghcr.io/cross-rs/${{ matrix.target }}:main" \ 109 | "${{ matrix.strip }}" \ 110 | "/target/${{ matrix.target }}/release/restack" 111 | 112 | - name: Prepare archive 113 | run: | 114 | tar -cvzf restack-${{ matrix.slug }}.tar.gz \ 115 | -C target/${{ matrix.target }}/release \ 116 | restack 117 | 118 | - name: Upload archive 119 | uses: actions/upload-artifact@v4 120 | with: 121 | name: restack-${{ matrix.slug }} 122 | path: restack-*.tar.gz 123 | 124 | release: 125 | name: Release 126 | runs-on: ubuntu-latest 127 | needs: build 128 | steps: 129 | 130 | - name: Install parse-changelog 131 | uses: taiki-e/install-action@v2 132 | with: 133 | tool: parse-changelog@0.5.1 134 | 135 | - name: Checkout code 136 | uses: actions/checkout@v4 137 | 138 | - name: Determine toolchain 139 | id: rust-toolchain 140 | run: | 141 | echo "value=$(cat rust-toolchain)" >> "$GITHUB_OUTPUT" 142 | 143 | - name: Checkout Homebrew tap 144 | uses: actions/checkout@v4 145 | with: 146 | repository: abhinav/homebrew-tap 147 | token: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} 148 | path: homebrew-tap 149 | 150 | - name: Setup Rust 151 | uses: dtolnay/rust-toolchain@v1 152 | with: 153 | toolchain: ${{ steps.rust-toolchain.outputs.value }} 154 | 155 | - name: Download archives 156 | uses: actions/download-artifact@v4 157 | with: 158 | path: . 159 | pattern: restack-* 160 | merge-multiple: true 161 | 162 | - name: Determine version number (push) 163 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') 164 | run: | 165 | echo "VERSION=${REF#refs/tags/v}" >> "$GITHUB_ENV" 166 | env: 167 | REF: ${{ github.ref }} 168 | 169 | - name: Determine version number (workflow_dispatch) 170 | if: github.event_name == 'workflow_dispatch' 171 | run: | 172 | echo "VERSION=${INPUT_VERSION#v}" >> "$GITHUB_ENV" 173 | env: 174 | INPUT_VERSION: ${{ inputs.version }} 175 | 176 | - name: Generate package specs 177 | run: | 178 | tools/release/genpkgspec.sh ${{ env.VERSION }} restack-*.tar.gz 179 | 180 | echo ::group::Homebrew Formula 181 | cat target/formula/restack.rb 182 | echo ::endgroup:: 183 | 184 | echo ::group::restack-bin/PKGBUILD 185 | cat target/aur-bin/PKGBUILD 186 | echo ::endgroup:: 187 | 188 | - name: Extract changelog 189 | run: | 190 | parse-changelog CHANGELOG.md ${{ env.VERSION }} > ${{ github.workspace }}-CHANGELOG.txt 191 | echo ::group::CHANGELOG 192 | cat ${{ github.workspace }}-CHANGELOG.txt 193 | echo ::endgroup:: 194 | 195 | - name: Publish Release 196 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') 197 | uses: softprops/action-gh-release@v2 198 | with: 199 | files: 'restack-*.tar.gz' 200 | body_path: ${{ github.workspace }}-CHANGELOG.txt 201 | token: ${{ secrets.GITHUB_TOKEN }} 202 | 203 | - name: Publish Homebrew tap 204 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') 205 | working-directory: homebrew-tap 206 | run: | 207 | git config --local user.name "$COMMIT_USERNAME" 208 | git config --local user.email "$COMMIT_EMAIL" 209 | 210 | cp ../target/formula/restack.rb . 211 | git add restack.rb 212 | git commit -m "restack ${{ env.VERSION }}" 213 | git push 214 | env: 215 | COMMIT_USERNAME: ${{ secrets.AUR_USERNAME }} 216 | COMMIT_EMAIL: ${{ secrets.AUR_EMAIL }} 217 | 218 | - name: Publish AUR package (binary) 219 | uses: KSXGitHub/github-actions-deploy-aur@v4.1.1 220 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') 221 | with: 222 | pkgname: restack-bin 223 | pkgbuild: ./target/aur-bin/PKGBUILD 224 | commit_username: ${{ secrets.AUR_USERNAME }} 225 | commit_email: ${{ secrets.AUR_EMAIL }} 226 | ssh_private_key: ${{ secrets.AUR_KEY }} 227 | commit_message: restack ${{ env.VERSION }} 228 | allow_empty_commits: false 229 | 230 | - name: Release Cargo crate 231 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') 232 | run: | 233 | perl -p -i -e "s/^version = .*/version = \"$VERSION\"/" Cargo.toml 234 | cargo login ${{ secrets.CARGO_TOKEN }} 235 | echo ::group::Publishing files 236 | cargo package --list 237 | echo ::endgroup:: 238 | cargo publish 239 | 240 | - name: Publish AUR package (source) 241 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') 242 | uses: KSXGitHub/github-actions-deploy-aur@v4.1.1 243 | with: 244 | pkgname: restack 245 | pkgbuild: ./target/aur/PKGBUILD 246 | commit_username: ${{ secrets.AUR_USERNAME }} 247 | commit_email: ${{ secrets.AUR_EMAIL }} 248 | ssh_private_key: ${{ secrets.AUR_KEY }} 249 | commit_message: restack ${{ env.VERSION }} 250 | allow_empty_commits: false 251 | updpkgsums: true 252 | 253 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /*.profraw 3 | /lcov.info 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), 6 | and is generated by [Changie](https://github.com/miniscruff/changie). 7 | 8 | ## v0.8.0 - 2024-08-02 9 | ### Added 10 | - Support core.commentString during restacking. 11 | ### Changed 12 | - linux-arm64: Binary is no longer statically linked. 13 | 14 | ## v0.7.0 - 2023-07-05 15 | ### Added 16 | - Support core.commentChar during restacking. 17 | ### Changed 18 | - Relicense to GPL-2.0. 19 | 20 | ## v0.6.1 (2022-11-07) 21 | ### Fixed 22 | - Linux binaries should be statically linked. 23 | 24 | ## v0.6.0 (2022-09-24) 25 | - Reduce binary size significantly. 26 | 27 | ## v0.5.4 (2022-08-30) 28 | - Fix branches and push directives skipped when there's an empty line 29 | and a comment right after the instruction list. 30 | 31 | ## v0.5.3 (2022-02-19) 32 | - Include binaries for Linux 32-bit ARM. 33 | 34 | ## v0.5.2 (2021-10-28) 35 | - Homebrew formula: Conform to new format. 36 | 37 | ## v0.5.1 (2021-09-18) 38 | - On Linux, don't fail to edit if `/tmp` is mounted on a different partition. 39 | 40 | ## v0.5.0 (2021-09-13) 41 | - Release Homebrew bottles. 42 | 43 | ## v0.4.0 (2021-08-15) 44 | - Simplify command line argument parsing. 45 | 46 | ## v0.3.1 (2020-04-28) 47 | - Fix CLI parsing breakage. 48 | - Don't kill editor after 1 second. 49 | 50 | ## v0.3.0 (2020-04-27) 51 | - Write the full edit script path to git configuration. 52 | - Handle short-forms instructions. 53 | - Handle `fixup` and `squash` instructions. 54 | 55 | ## v0.2.0 (2020-03-13) 56 | - Migrate to Go modules. 57 | 58 | ## v0.1.3 (2019-02-27) 59 | - Interpret `GIT_EDITOR` using a shell. 60 | 61 | ## v0.1.2 (2017-10-29) 62 | - Include ARM binaries. 63 | 64 | ## v0.1.1 (2017-10-14) 65 | - setup: Allow relocating `.gitconfig` by removing hardcoded `$HOME`. 66 | 67 | ## v0.1.0 (2017-10-14) 68 | - Initial release. 69 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "1.1.3" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "anyhow" 16 | version = "1.0.98" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" 19 | 20 | [[package]] 21 | name = "autocfg" 22 | version = "1.4.0" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 25 | 26 | [[package]] 27 | name = "bitflags" 28 | version = "2.9.0" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" 31 | 32 | [[package]] 33 | name = "block-buffer" 34 | version = "0.10.4" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" 37 | dependencies = [ 38 | "generic-array", 39 | ] 40 | 41 | [[package]] 42 | name = "cc" 43 | version = "1.2.20" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "04da6a0d40b948dfc4fa8f5bbf402b0fc1a64a28dbf7d12ffd683550f2c1b63a" 46 | dependencies = [ 47 | "shlex", 48 | ] 49 | 50 | [[package]] 51 | name = "cfg-if" 52 | version = "1.0.0" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 55 | 56 | [[package]] 57 | name = "cpufeatures" 58 | version = "0.2.17" 59 | source = "registry+https://github.com/rust-lang/crates.io-index" 60 | checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" 61 | dependencies = [ 62 | "libc", 63 | ] 64 | 65 | [[package]] 66 | name = "crypto-common" 67 | version = "0.1.6" 68 | source = "registry+https://github.com/rust-lang/crates.io-index" 69 | checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" 70 | dependencies = [ 71 | "generic-array", 72 | "typenum", 73 | ] 74 | 75 | [[package]] 76 | name = "diff" 77 | version = "0.1.13" 78 | source = "registry+https://github.com/rust-lang/crates.io-index" 79 | checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" 80 | 81 | [[package]] 82 | name = "digest" 83 | version = "0.10.7" 84 | source = "registry+https://github.com/rust-lang/crates.io-index" 85 | checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" 86 | dependencies = [ 87 | "block-buffer", 88 | "crypto-common", 89 | ] 90 | 91 | [[package]] 92 | name = "duct" 93 | version = "0.13.7" 94 | source = "registry+https://github.com/rust-lang/crates.io-index" 95 | checksum = "e4ab5718d1224b63252cd0c6f74f6480f9ffeb117438a2e0f5cf6d9a4798929c" 96 | dependencies = [ 97 | "libc", 98 | "once_cell", 99 | "os_pipe", 100 | "shared_child", 101 | ] 102 | 103 | [[package]] 104 | name = "equivalent" 105 | version = "1.0.2" 106 | source = "registry+https://github.com/rust-lang/crates.io-index" 107 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 108 | 109 | [[package]] 110 | name = "errno" 111 | version = "0.3.11" 112 | source = "registry+https://github.com/rust-lang/crates.io-index" 113 | checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" 114 | dependencies = [ 115 | "libc", 116 | "windows-sys", 117 | ] 118 | 119 | [[package]] 120 | name = "fastrand" 121 | version = "2.3.0" 122 | source = "registry+https://github.com/rust-lang/crates.io-index" 123 | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 124 | 125 | [[package]] 126 | name = "file-lock" 127 | version = "2.1.11" 128 | source = "registry+https://github.com/rust-lang/crates.io-index" 129 | checksum = "040b48f80a749da50292d0f47a1e2d5bf1d772f52836c07f64bfccc62ba6e664" 130 | dependencies = [ 131 | "cc", 132 | "libc", 133 | ] 134 | 135 | [[package]] 136 | name = "filetime" 137 | version = "0.2.25" 138 | source = "registry+https://github.com/rust-lang/crates.io-index" 139 | checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" 140 | dependencies = [ 141 | "cfg-if", 142 | "libc", 143 | "libredox", 144 | "windows-sys", 145 | ] 146 | 147 | [[package]] 148 | name = "futures-core" 149 | version = "0.3.31" 150 | source = "registry+https://github.com/rust-lang/crates.io-index" 151 | checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" 152 | 153 | [[package]] 154 | name = "futures-macro" 155 | version = "0.3.31" 156 | source = "registry+https://github.com/rust-lang/crates.io-index" 157 | checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" 158 | dependencies = [ 159 | "proc-macro2", 160 | "quote", 161 | "syn", 162 | ] 163 | 164 | [[package]] 165 | name = "futures-task" 166 | version = "0.3.31" 167 | source = "registry+https://github.com/rust-lang/crates.io-index" 168 | checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" 169 | 170 | [[package]] 171 | name = "futures-timer" 172 | version = "3.0.3" 173 | source = "registry+https://github.com/rust-lang/crates.io-index" 174 | checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" 175 | 176 | [[package]] 177 | name = "futures-util" 178 | version = "0.3.31" 179 | source = "registry+https://github.com/rust-lang/crates.io-index" 180 | checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" 181 | dependencies = [ 182 | "futures-core", 183 | "futures-macro", 184 | "futures-task", 185 | "pin-project-lite", 186 | "pin-utils", 187 | "slab", 188 | ] 189 | 190 | [[package]] 191 | name = "generic-array" 192 | version = "0.14.7" 193 | source = "registry+https://github.com/rust-lang/crates.io-index" 194 | checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" 195 | dependencies = [ 196 | "typenum", 197 | "version_check", 198 | ] 199 | 200 | [[package]] 201 | name = "getrandom" 202 | version = "0.3.2" 203 | source = "registry+https://github.com/rust-lang/crates.io-index" 204 | checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" 205 | dependencies = [ 206 | "cfg-if", 207 | "libc", 208 | "r-efi", 209 | "wasi", 210 | ] 211 | 212 | [[package]] 213 | name = "glob" 214 | version = "0.3.2" 215 | source = "registry+https://github.com/rust-lang/crates.io-index" 216 | checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" 217 | 218 | [[package]] 219 | name = "hashbrown" 220 | version = "0.15.2" 221 | source = "registry+https://github.com/rust-lang/crates.io-index" 222 | checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" 223 | 224 | [[package]] 225 | name = "indexmap" 226 | version = "2.9.0" 227 | source = "registry+https://github.com/rust-lang/crates.io-index" 228 | checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" 229 | dependencies = [ 230 | "equivalent", 231 | "hashbrown", 232 | ] 233 | 234 | [[package]] 235 | name = "indoc" 236 | version = "2.0.6" 237 | source = "registry+https://github.com/rust-lang/crates.io-index" 238 | checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" 239 | 240 | [[package]] 241 | name = "lazy_static" 242 | version = "1.5.0" 243 | source = "registry+https://github.com/rust-lang/crates.io-index" 244 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 245 | 246 | [[package]] 247 | name = "lexopt" 248 | version = "0.3.1" 249 | source = "registry+https://github.com/rust-lang/crates.io-index" 250 | checksum = "9fa0e2a1fcbe2f6be6c42e342259976206b383122fc152e872795338b5a3f3a7" 251 | 252 | [[package]] 253 | name = "libc" 254 | version = "0.2.172" 255 | source = "registry+https://github.com/rust-lang/crates.io-index" 256 | checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" 257 | 258 | [[package]] 259 | name = "libredox" 260 | version = "0.1.3" 261 | source = "registry+https://github.com/rust-lang/crates.io-index" 262 | checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" 263 | dependencies = [ 264 | "bitflags", 265 | "libc", 266 | "redox_syscall", 267 | ] 268 | 269 | [[package]] 270 | name = "linux-raw-sys" 271 | version = "0.9.4" 272 | source = "registry+https://github.com/rust-lang/crates.io-index" 273 | checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" 274 | 275 | [[package]] 276 | name = "lzma-sys" 277 | version = "0.1.20" 278 | source = "registry+https://github.com/rust-lang/crates.io-index" 279 | checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" 280 | dependencies = [ 281 | "cc", 282 | "libc", 283 | "pkg-config", 284 | ] 285 | 286 | [[package]] 287 | name = "memchr" 288 | version = "2.7.4" 289 | source = "registry+https://github.com/rust-lang/crates.io-index" 290 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 291 | 292 | [[package]] 293 | name = "once_cell" 294 | version = "1.21.3" 295 | source = "registry+https://github.com/rust-lang/crates.io-index" 296 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 297 | 298 | [[package]] 299 | name = "os_pipe" 300 | version = "1.2.1" 301 | source = "registry+https://github.com/rust-lang/crates.io-index" 302 | checksum = "5ffd2b0a5634335b135d5728d84c5e0fd726954b87111f7506a61c502280d982" 303 | dependencies = [ 304 | "libc", 305 | "windows-sys", 306 | ] 307 | 308 | [[package]] 309 | name = "pin-project-lite" 310 | version = "0.2.16" 311 | source = "registry+https://github.com/rust-lang/crates.io-index" 312 | checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 313 | 314 | [[package]] 315 | name = "pin-utils" 316 | version = "0.1.0" 317 | source = "registry+https://github.com/rust-lang/crates.io-index" 318 | checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 319 | 320 | [[package]] 321 | name = "pkg-config" 322 | version = "0.3.32" 323 | source = "registry+https://github.com/rust-lang/crates.io-index" 324 | checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 325 | 326 | [[package]] 327 | name = "pretty_assertions" 328 | version = "1.4.1" 329 | source = "registry+https://github.com/rust-lang/crates.io-index" 330 | checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" 331 | dependencies = [ 332 | "diff", 333 | "yansi", 334 | ] 335 | 336 | [[package]] 337 | name = "proc-macro-crate" 338 | version = "3.3.0" 339 | source = "registry+https://github.com/rust-lang/crates.io-index" 340 | checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" 341 | dependencies = [ 342 | "toml_edit", 343 | ] 344 | 345 | [[package]] 346 | name = "proc-macro2" 347 | version = "1.0.95" 348 | source = "registry+https://github.com/rust-lang/crates.io-index" 349 | checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" 350 | dependencies = [ 351 | "unicode-ident", 352 | ] 353 | 354 | [[package]] 355 | name = "quote" 356 | version = "1.0.40" 357 | source = "registry+https://github.com/rust-lang/crates.io-index" 358 | checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" 359 | dependencies = [ 360 | "proc-macro2", 361 | ] 362 | 363 | [[package]] 364 | name = "r-efi" 365 | version = "5.2.0" 366 | source = "registry+https://github.com/rust-lang/crates.io-index" 367 | checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" 368 | 369 | [[package]] 370 | name = "redox_syscall" 371 | version = "0.5.11" 372 | source = "registry+https://github.com/rust-lang/crates.io-index" 373 | checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3" 374 | dependencies = [ 375 | "bitflags", 376 | ] 377 | 378 | [[package]] 379 | name = "regex" 380 | version = "1.11.1" 381 | source = "registry+https://github.com/rust-lang/crates.io-index" 382 | checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 383 | dependencies = [ 384 | "aho-corasick", 385 | "memchr", 386 | "regex-automata", 387 | "regex-syntax", 388 | ] 389 | 390 | [[package]] 391 | name = "regex-automata" 392 | version = "0.4.9" 393 | source = "registry+https://github.com/rust-lang/crates.io-index" 394 | checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" 395 | dependencies = [ 396 | "aho-corasick", 397 | "memchr", 398 | "regex-syntax", 399 | ] 400 | 401 | [[package]] 402 | name = "regex-syntax" 403 | version = "0.8.5" 404 | source = "registry+https://github.com/rust-lang/crates.io-index" 405 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 406 | 407 | [[package]] 408 | name = "relative-path" 409 | version = "1.9.3" 410 | source = "registry+https://github.com/rust-lang/crates.io-index" 411 | checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" 412 | 413 | [[package]] 414 | name = "restack" 415 | version = "0.8.0" 416 | dependencies = [ 417 | "anyhow", 418 | "duct", 419 | "indoc", 420 | "lazy_static", 421 | "lexopt", 422 | "pretty_assertions", 423 | "restack-testtools", 424 | "rstest", 425 | "tempfile", 426 | ] 427 | 428 | [[package]] 429 | name = "restack-testtools" 430 | version = "0.1.0" 431 | dependencies = [ 432 | "anyhow", 433 | "duct", 434 | "file-lock", 435 | "lazy_static", 436 | "sha2", 437 | "tar", 438 | "tempfile", 439 | "thiserror", 440 | "xz2", 441 | ] 442 | 443 | [[package]] 444 | name = "rstest" 445 | version = "0.25.0" 446 | source = "registry+https://github.com/rust-lang/crates.io-index" 447 | checksum = "6fc39292f8613e913f7df8fa892b8944ceb47c247b78e1b1ae2f09e019be789d" 448 | dependencies = [ 449 | "futures-timer", 450 | "futures-util", 451 | "rstest_macros", 452 | "rustc_version", 453 | ] 454 | 455 | [[package]] 456 | name = "rstest_macros" 457 | version = "0.25.0" 458 | source = "registry+https://github.com/rust-lang/crates.io-index" 459 | checksum = "1f168d99749d307be9de54d23fd226628d99768225ef08f6ffb52e0182a27746" 460 | dependencies = [ 461 | "cfg-if", 462 | "glob", 463 | "proc-macro-crate", 464 | "proc-macro2", 465 | "quote", 466 | "regex", 467 | "relative-path", 468 | "rustc_version", 469 | "syn", 470 | "unicode-ident", 471 | ] 472 | 473 | [[package]] 474 | name = "rustc_version" 475 | version = "0.4.1" 476 | source = "registry+https://github.com/rust-lang/crates.io-index" 477 | checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" 478 | dependencies = [ 479 | "semver", 480 | ] 481 | 482 | [[package]] 483 | name = "rustix" 484 | version = "1.0.5" 485 | source = "registry+https://github.com/rust-lang/crates.io-index" 486 | checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf" 487 | dependencies = [ 488 | "bitflags", 489 | "errno", 490 | "libc", 491 | "linux-raw-sys", 492 | "windows-sys", 493 | ] 494 | 495 | [[package]] 496 | name = "semver" 497 | version = "1.0.26" 498 | source = "registry+https://github.com/rust-lang/crates.io-index" 499 | checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" 500 | 501 | [[package]] 502 | name = "sha2" 503 | version = "0.10.8" 504 | source = "registry+https://github.com/rust-lang/crates.io-index" 505 | checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" 506 | dependencies = [ 507 | "cfg-if", 508 | "cpufeatures", 509 | "digest", 510 | ] 511 | 512 | [[package]] 513 | name = "shared_child" 514 | version = "1.0.1" 515 | source = "registry+https://github.com/rust-lang/crates.io-index" 516 | checksum = "09fa9338aed9a1df411814a5b2252f7cd206c55ae9bf2fa763f8de84603aa60c" 517 | dependencies = [ 518 | "libc", 519 | "windows-sys", 520 | ] 521 | 522 | [[package]] 523 | name = "shlex" 524 | version = "1.3.0" 525 | source = "registry+https://github.com/rust-lang/crates.io-index" 526 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 527 | 528 | [[package]] 529 | name = "slab" 530 | version = "0.4.9" 531 | source = "registry+https://github.com/rust-lang/crates.io-index" 532 | checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" 533 | dependencies = [ 534 | "autocfg", 535 | ] 536 | 537 | [[package]] 538 | name = "syn" 539 | version = "2.0.101" 540 | source = "registry+https://github.com/rust-lang/crates.io-index" 541 | checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" 542 | dependencies = [ 543 | "proc-macro2", 544 | "quote", 545 | "unicode-ident", 546 | ] 547 | 548 | [[package]] 549 | name = "tar" 550 | version = "0.4.44" 551 | source = "registry+https://github.com/rust-lang/crates.io-index" 552 | checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" 553 | dependencies = [ 554 | "filetime", 555 | "libc", 556 | "xattr", 557 | ] 558 | 559 | [[package]] 560 | name = "tempfile" 561 | version = "3.20.0" 562 | source = "registry+https://github.com/rust-lang/crates.io-index" 563 | checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" 564 | dependencies = [ 565 | "fastrand", 566 | "getrandom", 567 | "once_cell", 568 | "rustix", 569 | "windows-sys", 570 | ] 571 | 572 | [[package]] 573 | name = "thiserror" 574 | version = "1.0.69" 575 | source = "registry+https://github.com/rust-lang/crates.io-index" 576 | checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 577 | dependencies = [ 578 | "thiserror-impl", 579 | ] 580 | 581 | [[package]] 582 | name = "thiserror-impl" 583 | version = "1.0.69" 584 | source = "registry+https://github.com/rust-lang/crates.io-index" 585 | checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 586 | dependencies = [ 587 | "proc-macro2", 588 | "quote", 589 | "syn", 590 | ] 591 | 592 | [[package]] 593 | name = "toml_datetime" 594 | version = "0.6.9" 595 | source = "registry+https://github.com/rust-lang/crates.io-index" 596 | checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3" 597 | 598 | [[package]] 599 | name = "toml_edit" 600 | version = "0.22.25" 601 | source = "registry+https://github.com/rust-lang/crates.io-index" 602 | checksum = "10558ed0bd2a1562e630926a2d1f0b98c827da99fabd3fe20920a59642504485" 603 | dependencies = [ 604 | "indexmap", 605 | "toml_datetime", 606 | "winnow", 607 | ] 608 | 609 | [[package]] 610 | name = "typenum" 611 | version = "1.18.0" 612 | source = "registry+https://github.com/rust-lang/crates.io-index" 613 | checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" 614 | 615 | [[package]] 616 | name = "unicode-ident" 617 | version = "1.0.18" 618 | source = "registry+https://github.com/rust-lang/crates.io-index" 619 | checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" 620 | 621 | [[package]] 622 | name = "version_check" 623 | version = "0.9.5" 624 | source = "registry+https://github.com/rust-lang/crates.io-index" 625 | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 626 | 627 | [[package]] 628 | name = "wasi" 629 | version = "0.14.2+wasi-0.2.4" 630 | source = "registry+https://github.com/rust-lang/crates.io-index" 631 | checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" 632 | dependencies = [ 633 | "wit-bindgen-rt", 634 | ] 635 | 636 | [[package]] 637 | name = "windows-sys" 638 | version = "0.59.0" 639 | source = "registry+https://github.com/rust-lang/crates.io-index" 640 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 641 | dependencies = [ 642 | "windows-targets", 643 | ] 644 | 645 | [[package]] 646 | name = "windows-targets" 647 | version = "0.52.6" 648 | source = "registry+https://github.com/rust-lang/crates.io-index" 649 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 650 | dependencies = [ 651 | "windows_aarch64_gnullvm", 652 | "windows_aarch64_msvc", 653 | "windows_i686_gnu", 654 | "windows_i686_gnullvm", 655 | "windows_i686_msvc", 656 | "windows_x86_64_gnu", 657 | "windows_x86_64_gnullvm", 658 | "windows_x86_64_msvc", 659 | ] 660 | 661 | [[package]] 662 | name = "windows_aarch64_gnullvm" 663 | version = "0.52.6" 664 | source = "registry+https://github.com/rust-lang/crates.io-index" 665 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 666 | 667 | [[package]] 668 | name = "windows_aarch64_msvc" 669 | version = "0.52.6" 670 | source = "registry+https://github.com/rust-lang/crates.io-index" 671 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 672 | 673 | [[package]] 674 | name = "windows_i686_gnu" 675 | version = "0.52.6" 676 | source = "registry+https://github.com/rust-lang/crates.io-index" 677 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 678 | 679 | [[package]] 680 | name = "windows_i686_gnullvm" 681 | version = "0.52.6" 682 | source = "registry+https://github.com/rust-lang/crates.io-index" 683 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 684 | 685 | [[package]] 686 | name = "windows_i686_msvc" 687 | version = "0.52.6" 688 | source = "registry+https://github.com/rust-lang/crates.io-index" 689 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 690 | 691 | [[package]] 692 | name = "windows_x86_64_gnu" 693 | version = "0.52.6" 694 | source = "registry+https://github.com/rust-lang/crates.io-index" 695 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 696 | 697 | [[package]] 698 | name = "windows_x86_64_gnullvm" 699 | version = "0.52.6" 700 | source = "registry+https://github.com/rust-lang/crates.io-index" 701 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 702 | 703 | [[package]] 704 | name = "windows_x86_64_msvc" 705 | version = "0.52.6" 706 | source = "registry+https://github.com/rust-lang/crates.io-index" 707 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 708 | 709 | [[package]] 710 | name = "winnow" 711 | version = "0.7.7" 712 | source = "registry+https://github.com/rust-lang/crates.io-index" 713 | checksum = "6cb8234a863ea0e8cd7284fcdd4f145233eb00fee02bbdd9861aec44e6477bc5" 714 | dependencies = [ 715 | "memchr", 716 | ] 717 | 718 | [[package]] 719 | name = "wit-bindgen-rt" 720 | version = "0.39.0" 721 | source = "registry+https://github.com/rust-lang/crates.io-index" 722 | checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" 723 | dependencies = [ 724 | "bitflags", 725 | ] 726 | 727 | [[package]] 728 | name = "xattr" 729 | version = "1.5.0" 730 | source = "registry+https://github.com/rust-lang/crates.io-index" 731 | checksum = "0d65cbf2f12c15564212d48f4e3dfb87923d25d611f2aed18f4cb23f0413d89e" 732 | dependencies = [ 733 | "libc", 734 | "rustix", 735 | ] 736 | 737 | [[package]] 738 | name = "xz2" 739 | version = "0.1.7" 740 | source = "registry+https://github.com/rust-lang/crates.io-index" 741 | checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" 742 | dependencies = [ 743 | "lzma-sys", 744 | ] 745 | 746 | [[package]] 747 | name = "yansi" 748 | version = "1.0.1" 749 | source = "registry+https://github.com/rust-lang/crates.io-index" 750 | checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" 751 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "restack" 3 | version = "0.8.0" 4 | edition = "2024" 5 | description = "Teaches git rebase --interactive about your branches." 6 | homepage = "https://github.com/abhinav/restack" 7 | documentation = "https://github.com/abhinav/restack/blob/main/README.md" 8 | repository = "https://github.com/abhinav/restack" 9 | license = "GPL-2.0" 10 | 11 | [[bin]] 12 | name = "restack" 13 | path = "src/main.rs" 14 | doctest = false 15 | test = true 16 | 17 | [dependencies] 18 | anyhow = "1.0" 19 | lexopt = "0.3.0" 20 | tempfile = "3.5.0" 21 | 22 | [dev-dependencies] 23 | duct = "1.0.0" 24 | indoc = "2.0.1" 25 | lazy_static = "1.4.0" 26 | pretty_assertions = "1.3.0" 27 | restack-testtools = { path = "./tools/test" } 28 | rstest = "0.25.0" 29 | 30 | [profile.release] 31 | lto = true 32 | 33 | [workspace] 34 | members = [".", "./tools/test"] 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | RELEASE ?= 0 2 | TEST_ARGS ?= 3 | 4 | ifeq ($(RELEASE),1) 5 | _BUILD_RELEASE_FLAGS = --release 6 | else 7 | _BUILD_RELEASE_FLAGS = 8 | endif 9 | 10 | BUILD_FLAGS = $(_BUILD_RELEASE_FLAGS) 11 | 12 | .PHONY: build 13 | build: ## build the binary 14 | cargo build --locked $(BUILD_FLAGS) 15 | 16 | .PHONY: test 17 | test: ## run all tests 18 | GIT_CONFIG_GLOBAL= cargo nextest run --locked --workspace --no-fail-fast $(TEST_ARGS) 19 | 20 | _COV_FLAGS = --hide-instantiations 21 | 22 | .PHONY: cover 23 | cover: ## generate a coverage report 24 | cargo llvm-cov nextest --workspace --locked --lcov --output-path lcov.info --no-fail-fast 25 | cargo llvm-cov report $(_COV_FLAGS) 26 | 27 | .PHONY: cover-html 28 | cover-html: ## generate an HTML coverage report 29 | cover-html: cover 30 | cargo llvm-cov report $(_COV_FLAGS) --html 31 | 32 | .PHONY: fmt 33 | fmt: ## reformat code 34 | cargo fmt 35 | 36 | .PHONY: lint 37 | lint: fmt-check clippy 38 | 39 | .PHONY: fmt-check 40 | fmt-check: 41 | cargo fmt --check 42 | 43 | .PHONY: clippy 44 | clippy: 45 | cargo clippy --workspace 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📚 restack [![CI](https://github.com/abhinav/restack/actions/workflows/build.yml/badge.svg)](https://github.com/abhinav/restack/actions/workflows/build.yml) [![Crate](https://img.shields.io/crates/v/restack.svg)](https://crates.io/crates/restack) [![codecov](https://codecov.io/gh/abhinav/restack/branch/main/graph/badge.svg)](https://codecov.io/gh/abhinav/restack/tree/main) 2 | 3 | restack augments the experience of performing an interactive Git rebase to make 4 | it more friendly to workflows that involve lots of interdependent branches. 5 | 6 | For more background on why this exists and the workflow it facilitates, see 7 | [Automatically Restacking Git Branches][1]. 8 | 9 | [1]: https://abhinavg.net/posts/restacking-branches/ 10 | 11 | ## Installation 12 | 13 | Use one of the following options to install restack. 14 | 15 | - If you use **Homebrew** on macOS or **Linuxbrew** on Linux, run the following 16 | command to download and install a pre-built binary. 17 | 18 | ``` 19 | brew install abhinav/tap/restack 20 | ``` 21 | 22 | - If you use **ArchLinux**, install it from AUR 23 | using the [restack-bin package] package for a pre-built binary 24 | or the [restack package] package to build it from source. 25 | 26 | ``` 27 | git clone https://aur.archlinux.org/restack-bin.git 28 | cd restack-bin 29 | makepkg -si 30 | ``` 31 | 32 | With an AUR helper like [yay], run the following instead: 33 | 34 | ``` 35 | yay -S restack-bin # pre-built binary 36 | yay -S restack # build from source 37 | ``` 38 | 39 | - Download a pre-built binary from the [GitHub Releases] page and place it on 40 | your `$PATH`. 41 | 42 | - Build it from source if you have Rust installed. 43 | 44 | ``` 45 | cargo install restack 46 | ``` 47 | 48 | [restack-bin package]: https://aur.archlinux.org/packages/restack-bin 49 | [restack package]: https://aur.archlinux.org/packages/restack 50 | [yay]: https://github.com/Jguer/yay 51 | [GitHub Releases]: https://github.com/abhinav/restack/releases 52 | 53 | ## Setup 54 | 55 | restack works by installing itself as a Git [`sequence.editor`]. 56 | You can set this up manually or let restack do it for you automatically. 57 | 58 | [`sequence.editor`]: https://git-scm.com/docs/git-config#Documentation/git-config.txt-sequenceeditor 59 | 60 | ### Automatic Setup 61 | 62 | Run `restack setup` to configure `git` to use `restack`. 63 | 64 | restack setup 65 | 66 | ### Manual Setup 67 | 68 | If you would rather not have restack change your `.gitconfig`, 69 | you can set it up manually by running: 70 | 71 | ``` 72 | git config sequence.editor "restack edit" 73 | ``` 74 | 75 | See `restack edit --help` for the different options accepted by `restack edit`. 76 | 77 | ## Usage 78 | 79 | restack automatically recognizes branches being touched by the rebase and adds 80 | rebase instructions which update these branches as their heads move. 81 | 82 | The generated instruction list also includes an opt-in commented-out section 83 | that will push these branches to the remote. 84 | 85 | For example, given, 86 | 87 | o master 88 | \ 89 | o A 90 | | 91 | o B (feature1) 92 | \ 93 | o C 94 | | 95 | o D (feature2) 96 | \ 97 | o E 98 | | 99 | o F 100 | | 101 | o G (feature3) 102 | \ 103 | o H (feature4, HEAD) 104 | 105 | Running `git rebase -i master` from branch `feature4` will give you the 106 | following instruction list. 107 | 108 | pick A 109 | pick B 110 | exec git branch -f feature1 111 | 112 | pick C 113 | pick D 114 | exec git branch -f feature2 115 | 116 | pick E 117 | pick F 118 | pick G 119 | exec git branch -f feature3 120 | 121 | pick H 122 | 123 | # Uncomment this section to push the changes. 124 | # exec git push -f origin feature1 125 | # exec git push -f origin feature2 126 | # exec git push -f origin feature3 127 | 128 | So any changes made before each `exec git branch -f` will become part of that 129 | branch and all following changes will be made on top of that. 130 | 131 | ## Credits 132 | 133 | Thanks to [@kriskowal] for the initial implementation of this tool as a 134 | script. 135 | 136 | [@kriskowal]: https://github.com/kriskowal 137 | 138 | ## FAQ 139 | 140 | ### Can I make restacking opt-in? 141 | 142 | If you don't want restack to do its thing on every `git rebase`, 143 | you can make it opt-in by introducing a new Git command. 144 | 145 | To do this, first make sure you don't have restack set up for the regular 146 | `git rebase`: 147 | 148 | ``` 149 | git config --global --unset sequence.editor 150 | ``` 151 | 152 | Next, create a file named `git-restack` with the following contents. 153 | 154 | ```bash 155 | #!/bin/bash 156 | exec git -c sequence.editor="restack edit" rebase -i "$@" 157 | ``` 158 | 159 | Mark it as executable and place it somewhere on `$PATH`. 160 | 161 | ``` 162 | chmod +x git-restack 163 | mv git-restack ~/bin/git-restack 164 | ``` 165 | 166 | Going forward, you can run `git rebase` for a plain rebase, 167 | and `git restack` to run a rebase with support for branch restacking. 168 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | informational: true 6 | patch: 7 | default: 8 | informational: true 9 | 10 | -------------------------------------------------------------------------------- /fixtures/bin/add_break.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | # USAGE: 6 | # add_break.sh PATH 7 | # Adds a line "break" to the top of the file at PATH. 8 | 9 | out=$(mktemp) 10 | echo break > "$out" 11 | while read -r line; do 12 | echo "$line" >> "$out" 13 | done < "$1" 14 | mv "$out" "$1" 15 | -------------------------------------------------------------------------------- /fixtures/empty.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | -------------------------------------------------------------------------------- /fixtures/empty.tar.xz: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:65277d94fcfe4b1f3dd62346b4f42bae83fe814ba3ce471eaa2d45d981626bf8 3 | size 9100 4 | -------------------------------------------------------------------------------- /fixtures/empty_commit.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | git commit --allow-empty -m "empty commit" 4 | -------------------------------------------------------------------------------- /fixtures/empty_commit.tar.xz: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:0951cddf3cf2df4f68a9e1668d3cbfc9c75182032954f8d84d3d16b055656ccb 3 | size 9684 4 | -------------------------------------------------------------------------------- /fixtures/empty_commit_comment_char_auto.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | git config core.commentChar 'auto' 4 | git commit --allow-empty -m "empty commit" 5 | -------------------------------------------------------------------------------- /fixtures/empty_commit_comment_char_auto.tar.xz: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:a4242292b3e1196f39dcd0a48a075308d1d282bfb6e2d64b27aee06f7a472aca 3 | size 10396 4 | -------------------------------------------------------------------------------- /fixtures/mid_rebase.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | die() { 4 | echo >&2 "$@" 5 | exit 1 6 | } 7 | 8 | add_and_commit() { 9 | [[ $# -eq 1 ]] || die "add_and_commit expects one argument" 10 | 11 | echo "$1" > "$1" 12 | git add "$1" 13 | git commit -m "add $1" 14 | } 15 | 16 | add_and_commit foo 17 | git checkout -b feature1 18 | add_and_commit bar 19 | git checkout -b feature2 20 | add_and_commit baz 21 | add_and_commit qux 22 | 23 | GIT_SEQUENCE_EDITOR=add_break.sh \ 24 | git rebase -i feature1 25 | -------------------------------------------------------------------------------- /fixtures/mid_rebase.tar.xz: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:e0e4e8903a313b58029d8e0ca39b95be5161a201253af11024afba299ef548c7 3 | size 11968 4 | -------------------------------------------------------------------------------- /fixtures/simple_many_branches.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | git commit --allow-empty -m "empty commit" 4 | git branch foo 5 | git branch bar 6 | git branch baz 7 | 8 | touch a 9 | git add a 10 | git commit -m "Add a" 11 | 12 | git branch qux 13 | git branch quux 14 | -------------------------------------------------------------------------------- /fixtures/simple_many_branches.tar.xz: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:fa0155ab2f44300542087cb89c1c04a481a85d88453424d8ebf3f0169dddd14a 3 | size 10256 4 | -------------------------------------------------------------------------------- /fixtures/simple_stack.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # This fixture contains a simple stack of commits. 4 | # 5 | # o [main] initial commit 6 | # | 7 | # o [foo] foo 8 | # | 9 | # o [bar] bar 10 | # | 11 | # o [baz, wip] baz 12 | 13 | die() { 14 | echo >&2 "$@" 15 | exit 1 16 | } 17 | 18 | add_and_commit() { 19 | [[ $# -eq 1 ]] || die "add_and_commit expects one argument" 20 | 21 | echo "$1" > "$1" 22 | git add "$1" 23 | git commit -m "add $1" 24 | } 25 | 26 | git commit --allow-empty -m "empty commit" 27 | 28 | git checkout -b foo 29 | add_and_commit foo 30 | 31 | git checkout -b bar 32 | add_and_commit bar 33 | 34 | git checkout -b baz 35 | add_and_commit baz 36 | 37 | git checkout -b wip 38 | 39 | -------------------------------------------------------------------------------- /fixtures/simple_stack.tar.xz: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:43aea0262d399b7325c35dd956cf0a96d5f825e3b30d2572bbe2662281f058b9 3 | size 11148 4 | -------------------------------------------------------------------------------- /fixtures/simple_stack_comment_char.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # This fixture contains a simple stack of commits. 4 | # 5 | # o [main] initial commit 6 | # | 7 | # o [foo] foo 8 | # | 9 | # o [bar] bar 10 | # | 11 | # o [baz, wip] baz 12 | # 13 | # It also uses ';' as the comment character. 14 | 15 | die() { 16 | echo >&2 "$@" 17 | exit 1 18 | } 19 | 20 | add_and_commit() { 21 | [[ $# -eq 1 ]] || die "add_and_commit expects one argument" 22 | 23 | echo "$1" > "$1" 24 | git add "$1" 25 | git commit -m "add $1" 26 | } 27 | 28 | git commit --allow-empty -m "empty commit" 29 | git config core.commentChar ';' 30 | 31 | git checkout -b foo 32 | add_and_commit foo 33 | 34 | git checkout -b bar 35 | add_and_commit bar 36 | 37 | git checkout -b baz 38 | add_and_commit baz 39 | 40 | git checkout -b wip 41 | -------------------------------------------------------------------------------- /fixtures/simple_stack_comment_char.tar.xz: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:b9744955fe3c7c62ae19dab0943d8f27576e58164130450dcd09c715bf0c7c9c 3 | size 11892 4 | -------------------------------------------------------------------------------- /fixtures/simple_stack_comment_string.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # This fixture contains a simple stack of commits. 4 | # 5 | # o [main] initial commit 6 | # | 7 | # o [foo] foo 8 | # | 9 | # o [bar] bar 10 | # | 11 | # o [baz, wip] baz 12 | # 13 | # It also uses '#:' as the comment string. 14 | 15 | die() { 16 | echo >&2 "$@" 17 | exit 1 18 | } 19 | 20 | add_and_commit() { 21 | [[ $# -eq 1 ]] || die "add_and_commit expects one argument" 22 | 23 | echo "$1" > "$1" 24 | git add "$1" 25 | git commit -m "add $1" 26 | } 27 | 28 | git commit --allow-empty -m "empty commit" 29 | git config core.commentString "#:" 30 | 31 | git checkout -b foo 32 | add_and_commit foo 33 | 34 | git checkout -b bar 35 | add_and_commit bar 36 | 37 | git checkout -b baz 38 | add_and_commit baz 39 | 40 | git checkout -b wip 41 | -------------------------------------------------------------------------------- /fixtures/simple_stack_comment_string.tar.xz: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:b2ac91e798675c0f391c91b3d770e67324daa25b7c91102e49715abfdd40bad8 3 | size 11908 4 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "local>abhinav/renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /rust-toolchain: -------------------------------------------------------------------------------- 1 | 1.86.0 2 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2021" 2 | 3 | match_block_trailing_comma = true 4 | use_field_init_shorthand = true 5 | use_try_shorthand = true 6 | -------------------------------------------------------------------------------- /src/edit.rs: -------------------------------------------------------------------------------- 1 | //! Implements the `restack edit` command. 2 | 3 | use std::borrow::Cow; 4 | use std::{env, fs, path, process}; 5 | 6 | use anyhow::{Context, Result, anyhow, bail}; 7 | 8 | use crate::exec::ExitStatusExt; 9 | use crate::{git, restack}; 10 | 11 | const USAGE: &str = "\ 12 | USAGE: 13 | restack edit [OPTIONS] 14 | 15 | Edits the provided rebase instruction list, 16 | adding commands to move affected branches in the stack during the rebase. 17 | 18 | To use this command, set it up as your sequence.editor in Git. 19 | See https://github.com/abhinav/restack#setup for more information. 20 | 21 | ARGS: 22 | 23 | Path to the rebase instruction list 24 | 25 | OPTIONS: 26 | -e, --editor 27 | Editor to use for rebase instructions. 28 | Defaults to $EDITOR. 29 | 30 | -h, --help 31 | Print help information. 32 | "; 33 | 34 | /// Arguments for the "restack edit" command. 35 | #[derive(Debug, PartialEq, Eq)] 36 | struct Args { 37 | /// Editor to use, if any. 38 | /// Defaults to $EDITOR, and if that's not set, to "vim". 39 | editor: Option, 40 | 41 | /// Path to the rebase instruction list. 42 | file: path::PathBuf, 43 | } 44 | 45 | /// Runs the `restack edit` command. 46 | pub fn run(mut parser: lexopt::Parser) -> Result<()> { 47 | let args = { 48 | let mut editor: Option = None; 49 | let mut file: Option = None; 50 | 51 | while let Some(arg) = parser.next()? { 52 | match arg { 53 | lexopt::Arg::Short('e') | lexopt::Arg::Long("editor") => { 54 | let value = parser.value()?; 55 | let s = value 56 | .to_str() 57 | .ok_or_else(|| anyhow!("--editor argument is not a valid string"))?; 58 | editor = Some(s.to_string()); 59 | }, 60 | lexopt::Arg::Short('h') | lexopt::Arg::Long("help") => { 61 | eprint!("{}", USAGE); 62 | return Ok(()); 63 | }, 64 | lexopt::Arg::Value(value) => { 65 | file = Some(value.into()); 66 | }, 67 | _ => return Err(arg.unexpected().into()), 68 | } 69 | } 70 | 71 | let Some(file) = file else { 72 | bail!("Please provide a file name"); 73 | }; 74 | 75 | Args { editor, file } 76 | }; 77 | 78 | let cwd = env::current_dir().context("Could not determine current working directory")?; 79 | let temp_dir = tempfile::tempdir().context("Failed to create temporary directory")?; 80 | 81 | // TODO: Check core.editor, GIT_EDITOR, and then EDITOR. 82 | let editor: Cow = match args.editor.as_ref() { 83 | Some(s) if !s.is_empty() => Cow::Borrowed(s), 84 | _ => match env::var("EDITOR") { 85 | Ok(s) if !s.is_empty() => Cow::Owned(s), 86 | Err(env::VarError::NotPresent) => Cow::Borrowed("vim"), 87 | Err(err) => return Err(err).context("Unable to look up EDITOR"), 88 | _ => { 89 | bail!("No editor specified: please use --editor or set EDITOR") 90 | }, 91 | }, 92 | }; 93 | 94 | let git_shell = git::Shell::new(); 95 | 96 | // The file should be named git-rebase-todo to make file-type detection 97 | // in different editors work correctly. 98 | let outfile_path = temp_dir.path().join("git-rebase-todo"); 99 | { 100 | let infile = fs::File::open(&args.file).context("Failed while reading git-rebase-todo")?; 101 | let outfile = 102 | fs::File::create(&outfile_path).context("Failed to create new git-rebase-todo")?; 103 | let cfg = restack::Config::new(&cwd, git_shell); 104 | // TODO: determine remote 105 | cfg.restack(Some("origin"), infile, outfile)?; 106 | }; 107 | 108 | // GIT_EDITOR/EDITOR can be any shell command, including FOO=bar $some_editor. 109 | // So we need to use `sh` to interpret it instead of executing it directly. 110 | // We baically run, 111 | // sh -c "$GIT_EDITOR $1" "restack" $FILE 112 | process::Command::new("sh") 113 | .arg("-c") 114 | .arg(format!("{} \"$1\"", editor)) 115 | .arg("restack") 116 | .arg(&outfile_path) 117 | .status() 118 | .context("Could not run EDITOR")? 119 | .exit_ok() 120 | .context("Editor returned non-zero status")?; 121 | 122 | crate::io::rename(&outfile_path, &args.file).with_context(|| { 123 | format!( 124 | "Could not overwrite {} with {}", 125 | &args.file.display(), 126 | &outfile_path.display() 127 | ) 128 | }) 129 | } 130 | -------------------------------------------------------------------------------- /src/edit.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | editor=$(git var GIT_EDITOR) 4 | restack=$(command -v restack || echo "") 5 | 6 | # $GOPATH/bin is not on $PATH but restack is installed. 7 | if [ -z "$restack" ]; then 8 | if [ -n "$GOPATH" ] && [ -x "$GOPATH/bin/restack" ]; then 9 | restack="$GOPATH/bin/restack" 10 | fi 11 | fi 12 | 13 | if [ -n "$restack" ]; then 14 | "$restack" edit --editor="$editor" "$@" 15 | else 16 | echo "WARNING:" >&2 17 | echo " Could not find restack. Falling back to $editor." >&2 18 | echo " To install restack, see https://github.com/abhinav/restack#installation" >&2 19 | echo "" >&2 20 | 21 | "$editor" "$@" 22 | fi 23 | -------------------------------------------------------------------------------- /src/exec.rs: -------------------------------------------------------------------------------- 1 | //! exec adds an exit_ok method to process::ExitStatus. 2 | //! This method returns an Error if the status code is non-zero. 3 | //! 4 | //! This is our own stable copy of the exit_status_error unstable feature. 5 | 6 | use std::process; 7 | 8 | // In lieu of exit_status_error stabilization, 9 | // extend ExitStatus with our own exit_ok method. 10 | 11 | /// Error returned if a process exits with a non-zero status code. 12 | #[derive(Debug)] 13 | pub struct Error { 14 | /// The exit code of the process. 15 | /// 16 | /// Guaranteed to be non-zero. 17 | pub code: i32, 18 | } 19 | 20 | impl std::error::Error for Error { 21 | fn description(&self) -> &str { 22 | "exited with a non-zero status code" 23 | } 24 | } 25 | 26 | impl std::fmt::Display for Error { 27 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 28 | write!(f, "exited with status code: {}", self.code) 29 | } 30 | } 31 | 32 | /// Extend ExitStatus with an exit_ok method. 33 | pub trait ExitStatusExt { 34 | /// Require that the process exited successfully. 35 | /// If the process did not exit successfully, return an Error. 36 | fn exit_ok(self) -> Result<(), Error>; 37 | } 38 | 39 | impl ExitStatusExt for process::ExitStatus { 40 | fn exit_ok(self) -> Result<(), Error> { 41 | if self.success() { 42 | Ok(()) 43 | } else { 44 | Err(Error { 45 | code: self.code().unwrap_or(1), 46 | }) 47 | } 48 | } 49 | } 50 | 51 | #[cfg(test)] 52 | mod tests { 53 | use pretty_assertions::assert_eq; 54 | 55 | use super::*; 56 | 57 | #[test] 58 | fn test_exit_ok() -> anyhow::Result<()> { 59 | let status = process::Command::new("true").status()?; 60 | status.exit_ok()?; 61 | 62 | Ok(()) 63 | } 64 | 65 | #[test] 66 | fn test_exit_err() -> anyhow::Result<()> { 67 | let status = process::Command::new("false").status()?; 68 | let got_err = status.exit_ok().expect_err("expected error"); 69 | 70 | assert_eq!(1, got_err.code); 71 | assert_eq!("exited with status code: 1", got_err.to_string()); 72 | 73 | Ok(()) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/git.rs: -------------------------------------------------------------------------------- 1 | //! Gates access to Git for the rest of restack. 2 | //! None of the production code in restack should call git directly. 3 | 4 | use std::io::{self, Read}; 5 | use std::{ffi, fs, path}; 6 | 7 | use anyhow::{Context, Result, bail}; 8 | 9 | mod shell; 10 | 11 | pub use self::shell::*; 12 | 13 | /// A branch in the repository. 14 | #[derive(Clone, Debug, PartialEq, Eq)] 15 | pub struct Branch { 16 | /// Name of the branch. 17 | pub name: String, 18 | /// Abbreviated hash of the branch commit. 19 | pub shorthash: String, 20 | } 21 | 22 | /// Provides access to Git. 23 | pub trait Git { 24 | /// Modifies the current user's global git configuration. 25 | fn set_global_config_str(&self, k: K, v: V) -> Result<()> 26 | where 27 | K: AsRef, 28 | V: AsRef; 29 | 30 | /// Reports the path to the ".git" directory of the working tree at the 31 | /// specified path. 32 | fn git_dir(&self, dir: &path::Path) -> Result; 33 | 34 | /// Returns a list of branches inside the repository at the given path. 35 | fn list_branches(&self, dir: &path::Path) -> Result>; 36 | 37 | /// Reports the name of the branch currently being rebased at the given path, if any. 38 | fn rebase_head_name(&self, dir: &path::Path) -> Result { 39 | let git_dir = self.git_dir(dir).context("Failed to find .git directory")?; 40 | rebase_head_name(&git_dir) 41 | } 42 | 43 | /// Reports the string prefix used for comments in the repository at the given path. 44 | fn comment_string(&self, dir: &path::Path) -> Result; 45 | } 46 | 47 | const REBASE_STATE_DIRS: &[&str] = &["rebase-apply", "rebase-merge"]; 48 | 49 | /// Reports the branch currently being rebased. 50 | /// 51 | /// This functionality is not supported natively by the `git` command 52 | /// so we inspect internals. 53 | /// 54 | /// git stores the name of the rebase branch in a file named "head-name" 55 | /// inside either the .git/rebase-apply directory or .git/rebase-merge. 56 | /// The logic was borrowed from `git`'s [own implementation][1]. 57 | /// 58 | /// [1]: https://github.com/git/git/blob/2f0e14e649d69f9535ad6a086c1b1b2d04436ef5/wt-status.c#L1473 59 | fn rebase_head_name(git_dir: &path::Path) -> Result { 60 | for state_dir in REBASE_STATE_DIRS { 61 | let head_file = git_dir.join(state_dir).join("head-name"); 62 | match fs::File::open(&head_file) { 63 | Err(err) => { 64 | if err.kind() != io::ErrorKind::NotFound { 65 | return Err(err) 66 | .with_context(|| format!("Failed to open {}", head_file.display())); 67 | } 68 | }, 69 | Ok(mut f) => { 70 | let mut name = String::new(); 71 | f.read_to_string(&mut name).with_context(|| { 72 | format!("Failed to read rebase state from {}", head_file.display()) 73 | })?; 74 | 75 | let name = name.trim(); 76 | return Ok(name.strip_prefix("refs/heads/").unwrap_or(name).to_string()); 77 | }, 78 | } 79 | } 80 | 81 | bail!("Repository is not currently rebasing") 82 | } 83 | 84 | #[cfg(test)] 85 | mod tests { 86 | use std::os::unix::prelude::PermissionsExt; 87 | 88 | use super::*; 89 | use crate::git::Shell; 90 | use crate::gitscript; 91 | 92 | #[test] 93 | fn not_a_repository() -> Result<()> { 94 | let tempdir = tempfile::tempdir()?; 95 | let dir = tempdir.path(); 96 | 97 | let git = Shell::new(); 98 | let err = git.rebase_head_name(dir).unwrap_err(); 99 | assert!( 100 | format!("{}", err).contains("find .git directory"), 101 | "got error: {}", 102 | err 103 | ); 104 | 105 | Ok(()) 106 | } 107 | 108 | #[test] 109 | fn not_currently_rebasing() -> Result<()> { 110 | let fixture = gitscript::open("empty_commit.sh")?; 111 | 112 | let git = Shell::new(); 113 | let err = git.rebase_head_name(fixture.dir()).unwrap_err(); 114 | assert!( 115 | format!("{}", err).contains("is not currently rebasing"), 116 | "got error: {}", 117 | err 118 | ); 119 | 120 | Ok(()) 121 | } 122 | 123 | #[test] 124 | fn corrpt_rebase_state_unable_to_open() -> Result<()> { 125 | let fixture = gitscript::open("empty_commit.sh")?; 126 | { 127 | let mut path = fixture.dir().join(".git/rebase-apply"); 128 | fs::create_dir(&path)?; 129 | 130 | path.push("head-name"); 131 | std::fs::write(&path, [])?; 132 | 133 | let mut perm = fs::metadata(&path)?.permissions(); 134 | perm.set_mode(0o200); 135 | fs::set_permissions(&path, perm)?; 136 | } 137 | 138 | let git = Shell::new(); 139 | let err = git.rebase_head_name(fixture.dir()).unwrap_err(); 140 | assert!( 141 | format!("{}", err).contains("Failed to open"), 142 | "got error: {}", 143 | err 144 | ); 145 | 146 | Ok(()) 147 | } 148 | 149 | #[test] 150 | fn corrupt_rebase_state_not_a_file() -> Result<()> { 151 | let fixture = gitscript::open("empty_commit.sh")?; 152 | { 153 | let path = fixture.dir().join(".git/rebase-apply/head-name"); 154 | fs::create_dir_all(&path)?; 155 | } 156 | 157 | let git = Shell::new(); 158 | let err = git.rebase_head_name(fixture.dir()).unwrap_err(); 159 | assert!( 160 | format!("{}", err).contains("Failed to read rebase state"), 161 | "got error: {}", 162 | err 163 | ); 164 | 165 | Ok(()) 166 | } 167 | 168 | #[test] 169 | fn mid_rebase() -> Result<()> { 170 | let fixture = gitscript::open("mid_rebase.sh")?; 171 | 172 | let git_shell = Shell::new(); 173 | let rebase_head = git_shell.rebase_head_name(fixture.dir())?; 174 | 175 | assert_eq!(rebase_head, "feature2"); 176 | 177 | Ok(()) 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/git/shell.rs: -------------------------------------------------------------------------------- 1 | //! Implements the Git trait by shelling out to git. 2 | 3 | use std::io::{self, BufRead}; 4 | use std::{ffi, path, process}; 5 | 6 | use anyhow::{Context, Result}; 7 | 8 | use super::{Branch, Git}; 9 | use crate::exec::ExitStatusExt; 10 | 11 | /// Shell provides access to the git CLI. 12 | pub struct Shell {} 13 | 14 | impl Shell { 15 | /// Builds a new Shell, searching `$PATH` for a git executable. 16 | pub fn new() -> Self { 17 | Self {} 18 | } 19 | 20 | /// Builds a `process::Command` for internal use. 21 | fn cmd(&self) -> process::Command { 22 | let mut cmd = process::Command::new("git"); 23 | cmd.stderr(process::Stdio::inherit()); 24 | 25 | cmd 26 | } 27 | } 28 | 29 | impl Git for Shell { 30 | fn set_global_config_str(&self, k: K, v: V) -> Result<()> 31 | where 32 | K: AsRef, 33 | V: AsRef, 34 | { 35 | self.cmd() 36 | .args(["config", "--global"]) 37 | .arg(k) 38 | .arg(v) 39 | .status() 40 | .context("Unable to run git config")? 41 | .exit_ok() 42 | .context("git config failed") 43 | } 44 | 45 | /// `git_dir` reports the path to the .git directory for the provided directory. 46 | fn git_dir(&self, dir: &path::Path) -> Result { 47 | let out = self 48 | .cmd() 49 | .args(["rev-parse", "--git-dir"]) 50 | .current_dir(dir) 51 | .output() 52 | .context("Failed to run git rev-parse")?; 53 | 54 | out.status.exit_ok().context("git rev-parse failed")?; 55 | 56 | let output = std::str::from_utf8(out.stdout.trim_ascii_end()) 57 | .context("Output of git rev-parse is not valid UTF-8")?; 58 | 59 | let mut git_dir = path::PathBuf::from(output); 60 | if git_dir.is_relative() { 61 | git_dir = dir.join(git_dir); 62 | } 63 | 64 | Ok(git_dir) 65 | } 66 | 67 | fn list_branches(&self, dir: &path::Path) -> Result> { 68 | let mut cmd = self.cmd(); 69 | cmd.args(["show-ref", "--heads", "--abbrev"]) 70 | .current_dir(dir) 71 | .stdout(process::Stdio::piped()); 72 | let mut child = cmd.spawn().context("Unable to run git show-ref")?; 73 | 74 | let mut branches: Vec = Vec::new(); 75 | let Some(stdout) = child.stdout.take() else { 76 | unreachable!("Stdio::piped() always sets child.stdout"); 77 | }; 78 | { 79 | let rdr = io::BufReader::new(stdout); 80 | for line in rdr.lines() { 81 | let line = line.context("Could not read 'git show-ref' output")?; 82 | let mut parts = line.split(' '); 83 | // Output of git show-ref is in the form, 84 | // $hash1 refs/heads/$name1 85 | // $hash2 refs/heads/$name2 86 | 87 | let Some(hash) = parts.next() else { continue }; 88 | let Some(refname) = parts.next() else { 89 | continue; 90 | }; 91 | let Some(name) = refname.strip_prefix("refs/heads/") else { 92 | continue; 93 | }; 94 | 95 | branches.push(Branch { 96 | name: name.to_string(), 97 | shorthash: hash.to_string(), 98 | }); 99 | } 100 | } 101 | 102 | child 103 | .wait() 104 | .context("Unable to start git show-ref")? 105 | .exit_ok() 106 | .context("git show-ref failed")?; 107 | 108 | Ok(branches) 109 | } 110 | 111 | fn comment_string(&self, dir: &path::Path) -> Result { 112 | let out = self 113 | .cmd() 114 | // Looking up git config for a field that is unset 115 | // will return a non-zero exit code 116 | // if we don't specify a default value. 117 | .args(["config", "--get", "core.commentString"]) 118 | .current_dir(dir) 119 | .output() 120 | .context("Failed to run git config")?; 121 | let output = match out.status.code() { 122 | Some(0) => std::str::from_utf8(out.stdout.trim_ascii_end()) 123 | .context("Output of git config is not valid UTF-8")? 124 | .to_string(), 125 | 126 | _ => { 127 | // Fall back to core.commentChar if core.commentString is unset. 128 | let out = self 129 | .cmd() 130 | // Looking up git config for a field that is unset 131 | // will return a non-zero exit code 132 | // if we don't specify a default value. 133 | .args(["config", "--get", "--default=#", "core.commentChar"]) 134 | .current_dir(dir) 135 | .output() 136 | .context("Failed to run git config")?; 137 | out.status.exit_ok().context("git config failed")?; 138 | 139 | std::str::from_utf8(out.stdout.trim_ascii_end()) 140 | .context("Output of git config is not valid UTF-8")? 141 | .to_string() 142 | }, 143 | }; 144 | 145 | match output.as_str() { 146 | // In auto, git will pick an unused character from a pre-defined list. 147 | // This might be useful to support in the future. 148 | "auto" => anyhow::bail!( 149 | "core.commentChar=auto is not supported yet. \ 150 | Please set core.commentChar to a single character \ 151 | or disable restack by unsetting sequence.editor." 152 | ), 153 | 154 | // Unreachable but easy enough to handle. 155 | "" => Ok("#".to_string()), 156 | 157 | _ => Ok(output), 158 | } 159 | } 160 | } 161 | 162 | #[cfg(test)] 163 | mod tests { 164 | use pretty_assertions::assert_eq; 165 | 166 | use super::*; 167 | use crate::gitscript; 168 | 169 | #[test] 170 | fn git_dir() -> Result<()> { 171 | let fixture = gitscript::open("empty_commit.sh")?; 172 | 173 | let shell = Shell::new(); 174 | let git_dir = shell.git_dir(fixture.dir())?; 175 | 176 | assert_eq!(git_dir, fixture.dir().join(".git")); 177 | Ok(()) 178 | } 179 | 180 | #[test] 181 | fn git_dir_not_a_repository() -> Result<()> { 182 | let tempdir = tempfile::tempdir()?; 183 | let dir = tempdir.path(); 184 | 185 | let shell = Shell::new(); 186 | let err = shell.git_dir(dir).unwrap_err(); 187 | 188 | assert!( 189 | format!("{}", err).contains("rev-parse failed"), 190 | "got error: {}", 191 | err 192 | ); 193 | 194 | Ok(()) 195 | } 196 | 197 | #[test] 198 | fn set_global_config_str() -> Result<()> { 199 | let workdir = tempfile::tempdir()?; 200 | let homedir = tempfile::tempdir()?; 201 | let home = homedir.path(); 202 | 203 | // This is hacky but it's the only way to prevent 204 | // any other environment variables from leaking 205 | // into the Git subprocess 206 | unsafe { 207 | std::env::vars().for_each(|(k, _)| { 208 | std::env::remove_var(k); 209 | }); 210 | std::env::set_var("HOME", home); 211 | } 212 | 213 | let shell = Shell::new(); 214 | shell.set_global_config_str("user.name", "Test User")?; 215 | 216 | let stdout = duct::cmd!("git", "config", "user.name") 217 | .dir(workdir.path()) 218 | .read()?; 219 | 220 | assert_eq!(stdout.trim(), "Test User"); 221 | 222 | Ok(()) 223 | } 224 | 225 | #[test] 226 | fn list_branches_empty_repo() -> Result<()> { 227 | let fixture = gitscript::open("empty.sh")?; 228 | 229 | let shell = Shell::new(); 230 | let res = shell.list_branches(fixture.dir()); 231 | 232 | assert!(res.is_err(), "expected error, got {:?}", res.unwrap()); 233 | 234 | Ok(()) 235 | } 236 | 237 | #[test] 238 | fn list_branches_single() -> Result<()> { 239 | let fixture = gitscript::open("empty_commit.sh")?; 240 | 241 | let shell = Shell::new(); 242 | let branches = shell.list_branches(fixture.dir())?; 243 | assert!( 244 | branches.len() == 1, 245 | "expected a single item, got {:?}", 246 | branches 247 | ); 248 | 249 | let branch = &branches[0]; 250 | assert_eq!(branch.name, "main"); 251 | assert!(!branch.shorthash.is_empty(), "hash should not be empty"); 252 | 253 | Ok(()) 254 | } 255 | 256 | #[test] 257 | fn list_branches_many() -> Result<()> { 258 | let fixture = gitscript::open("simple_many_branches.sh")?; 259 | 260 | let shell = Shell::new(); 261 | let branches = shell.list_branches(fixture.dir())?; 262 | 263 | let mut branch_names = branches 264 | .iter() 265 | .map(|b| b.name.as_ref()) 266 | .collect::>(); 267 | branch_names.sort_unstable(); 268 | 269 | assert_eq!( 270 | &["bar", "baz", "foo", "main", "quux", "qux"], 271 | branch_names.as_slice() 272 | ); 273 | 274 | Ok(()) 275 | } 276 | 277 | #[test] 278 | fn list_branches_not_a_repository() -> Result<()> { 279 | let tempdir = tempfile::tempdir()?; 280 | let dir = tempdir.path(); 281 | 282 | let shell = Shell::new(); 283 | let err = shell.list_branches(dir).unwrap_err(); 284 | 285 | assert!( 286 | format!("{}", err).contains("git show-ref failed"), 287 | "got error: {}", 288 | err 289 | ); 290 | 291 | Ok(()) 292 | } 293 | 294 | #[test] 295 | fn comment_char_default() -> Result<()> { 296 | let fixture = gitscript::open("simple_stack.sh")?; 297 | 298 | let shell = Shell::new(); 299 | let comment_char = shell.comment_string(fixture.dir())?; 300 | assert!( 301 | comment_char.as_str() == "#", 302 | "unexpected comment char: '{}'", 303 | comment_char 304 | ); 305 | 306 | Ok(()) 307 | } 308 | 309 | #[test] 310 | fn comment_char() -> Result<()> { 311 | let fixture = gitscript::open("simple_stack_comment_char.sh")?; 312 | 313 | let shell = Shell::new(); 314 | let comment_char = shell.comment_string(fixture.dir())?; 315 | assert!( 316 | comment_char.as_str() == ";", 317 | "unexpected comment char: '{}'", 318 | comment_char 319 | ); 320 | 321 | Ok(()) 322 | } 323 | 324 | #[test] 325 | fn comment_string() -> Result<()> { 326 | let fixture = gitscript::open("simple_stack_comment_string.sh")?; 327 | 328 | let shell = Shell::new(); 329 | let comment_str = shell.comment_string(fixture.dir())?; 330 | assert!( 331 | comment_str == "#:", 332 | "unexpected comment string: '{}'", 333 | &comment_str, 334 | ); 335 | 336 | Ok(()) 337 | } 338 | 339 | #[test] 340 | fn comment_char_auto() -> Result<()> { 341 | let fixture = gitscript::open("empty_commit_comment_char_auto.sh")?; 342 | 343 | let shell = Shell::new(); 344 | 345 | // Should return an error. 346 | let err = match shell.comment_string(fixture.dir()) { 347 | Ok(v) => panic!("expected an error, got {:?}", v), 348 | Err(err) => err, 349 | }; 350 | 351 | assert!( 352 | format!("{}", err).contains("core.commentChar=auto"), 353 | "got error: {}", 354 | err 355 | ); 356 | 357 | Ok(()) 358 | } 359 | } 360 | -------------------------------------------------------------------------------- /src/gitscript.rs: -------------------------------------------------------------------------------- 1 | use std::{env, path}; 2 | 3 | use anyhow::Result; 4 | use lazy_static::lazy_static; 5 | use restack_testtools::gitscript; 6 | 7 | /// Root of the project directory. 8 | /// 9 | /// All fixtures and generated archives are in a "fixtures" directory relative 10 | /// to this path. 11 | const CARGO_MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR"); 12 | 13 | type Fixture<'a> = gitscript::Fixture<'a>; 14 | 15 | lazy_static! { 16 | static ref DEFAULT_GROUP: gitscript::Group = 17 | gitscript::Group::new(path::PathBuf::from(CARGO_MANIFEST_DIR).join("fixtures")); 18 | } 19 | 20 | /// Opens and returns a Fixture for the script at `script_path`, 21 | /// ensuring that the archive for the script exists and is up to date. 22 | /// 23 | /// The path must be relative to the "fixtures" directory of the repository. 24 | pub fn open>(script_path: P) -> Result> { 25 | DEFAULT_GROUP.open(script_path).map_err(anyhow::Error::from) 26 | } 27 | -------------------------------------------------------------------------------- /src/io.rs: -------------------------------------------------------------------------------- 1 | use std::{fs, io, path}; 2 | 3 | use anyhow::{Context, Result}; 4 | 5 | /// rename is a version of fs::rename that can 6 | /// gracefully recover from cross-device rename errors on Linux. 7 | pub fn rename(src: &path::Path, dst: &path::Path) -> Result<()> { 8 | rename_impl(|src, dst| fs::rename(src, dst), src, dst) 9 | } 10 | 11 | const EXDEV: i32 = 18; 12 | 13 | fn rename_impl(fs_rename: RenameFn, src: &path::Path, dst: &path::Path) -> Result<()> 14 | where 15 | RenameFn: Fn(&path::Path, &path::Path) -> io::Result<()>, 16 | { 17 | match (fs_rename)(src, dst) { 18 | Ok(_) => Ok(()), 19 | Err(err) => { 20 | // If /tmp is mounted to a different partition (it often is), 21 | // attempting to move the file will cause the error: 22 | // invalid cross-device link 23 | // 24 | // For that case, fall back to copying the file and 25 | // deleting the temporary file. 26 | // 27 | // This is not the default because move is atomic. 28 | if err.raw_os_error() == Some(EXDEV) { 29 | unsafe_rename(src, dst) 30 | } else { 31 | Err(anyhow::Error::new(err)) 32 | } 33 | }, 34 | } 35 | } 36 | 37 | /// Renames a file by copying its contents into a new file non-atomically, 38 | /// and deleting the original file. 39 | /// 40 | /// This is necessary because on Linux, we cannot move the file across 41 | /// filesystem boundaries, and /tmp is often mounted on a different file system 42 | /// than the user's working directory. 43 | fn unsafe_rename(src: &path::Path, dst: &path::Path) -> Result<()> { 44 | let md = fs::metadata(src).with_context(|| format!("Failed to inspect {}", src.display()))?; 45 | 46 | { 47 | let mut r = fs::File::open(src).context("Failed to open source")?; 48 | let mut w = fs::File::create(dst).context("Failed to open destination")?; 49 | io::copy(&mut r, &mut w).context("Failed to copy file contents")?; 50 | } 51 | 52 | fs::set_permissions(dst, md.permissions()) 53 | .context("Failed to update destination permissions")?; 54 | fs::remove_file(src).context("Failed to delete source file")?; 55 | 56 | Ok(()) 57 | } 58 | 59 | #[cfg(test)] 60 | mod tests { 61 | use anyhow::Result; 62 | use rstest::rstest; 63 | 64 | use super::*; 65 | 66 | #[rstest] 67 | #[case::simple(&rename)] 68 | #[case::explicit_unsafe(&unsafe_rename)] 69 | #[case::cross_device(&cross_device_fail)] 70 | fn rename_end_to_end( 71 | #[case] rename_fn: &dyn Fn(&path::Path, &path::Path) -> Result<()>, 72 | ) -> Result<()> { 73 | let tempdir = tempfile::tempdir()?; 74 | 75 | let from = tempdir.path().join("foo.txt"); 76 | fs::write(&from, "bar").context("Failed to create starting file")?; 77 | 78 | let to = tempdir.path().join("bar.txt"); 79 | rename_fn(&from, &to).context("Failed to rename")?; 80 | 81 | assert!(to.exists(), "destination does not exist"); 82 | assert!(!from.exists(), "source should not exist"); 83 | 84 | Ok(()) 85 | } 86 | 87 | fn cross_device_fail(from: &path::Path, to: &path::Path) -> Result<()> { 88 | rename_impl(|_, _| Err(io::Error::from_raw_os_error(EXDEV)), from, to) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | //! restack is a command line tool that sits between `git rebase -i` 2 | //! and your editor, with the intent of making the default rebase smarter. 3 | //! 4 | //! It doess so by making `git rebase -i` aware of intermediate branches 5 | //! attached to commits you're changing during a rebase. 6 | //! If it finds commits with associated branches, 7 | //! it introduces new instructions to the rebase instruction list 8 | //! that will update these branches if the commits move. 9 | //! 10 | //! Read more in the [README][1] and the [associated blog post][2]. 11 | //! 12 | //! [1]: https://github.com/abhinav/restack/blob/main/README.md 13 | //! [2]: https://abhinavg.net/posts/restacking-branches/ 14 | 15 | #![warn(missing_docs)] 16 | 17 | use anyhow::{Result, anyhow, bail}; 18 | 19 | mod edit; 20 | mod exec; 21 | mod git; 22 | mod io; 23 | mod restack; 24 | mod setup; 25 | 26 | #[cfg(test)] 27 | mod gitscript; 28 | 29 | const USAGE: &str = "\ 30 | USAGE: 31 | restack 32 | 33 | Teaches git rebase --interactive about your branches. 34 | 35 | OPTIONS: 36 | -h, --help Print help information. 37 | -V, --version Print version information. 38 | 39 | SUBCOMMANDS: 40 | edit Edits the provided rebase instruction list 41 | setup Configures Git to use restack during an interactive rebase 42 | "; 43 | 44 | fn main() -> Result<()> { 45 | let mut parser = lexopt::Parser::from_env(); 46 | let Some(arg) = parser.next()? else { 47 | bail!("Please provide a subcommand. See restack --help for more information."); 48 | }; 49 | 50 | match arg { 51 | lexopt::Arg::Short('h') | lexopt::Arg::Long("help") => { 52 | eprint!("{}", USAGE); 53 | Ok(()) 54 | }, 55 | lexopt::Arg::Short('V') | lexopt::Arg::Long("version") => { 56 | println!("restack {}", env!("CARGO_PKG_VERSION")); 57 | println!("Copyright (C) 2023 Abhinav Gupta"); 58 | println!(" "); 59 | println!("restack comes with ABSOLUTELY NO WARRANTY."); 60 | println!("This is free software, and you are welcome to redistribute it"); 61 | println!("under certain conditions. See source for details."); 62 | Ok(()) 63 | }, 64 | lexopt::Arg::Value(ref cmd) => { 65 | let cmd = cmd 66 | .to_str() 67 | .ok_or_else(|| anyhow!("{:?} is not a valid unicode string", cmd))?; 68 | match cmd { 69 | "edit" => edit::run(parser), 70 | "setup" => setup::run(parser), 71 | _ => { 72 | bail!("Unrecognized command '{}'", cmd); 73 | }, 74 | } 75 | }, 76 | _ => Err(arg.unexpected().into()), 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/restack.rs: -------------------------------------------------------------------------------- 1 | //! Implements the core restacking logic. 2 | 3 | use std::collections::{HashMap, LinkedList}; 4 | use std::io::{self, BufRead, Write}; 5 | use std::path; 6 | 7 | use anyhow::{Context, Result}; 8 | 9 | use crate::git; 10 | 11 | #[cfg(test)] 12 | mod tests; 13 | 14 | /// Configures a Restack operation and provides the ability to run it. 15 | pub struct Config<'a, Git> 16 | where 17 | Git: git::Git, 18 | { 19 | /// Defines how to talk to Git during the restack. 20 | git: Git, 21 | 22 | /// Current working directory inside which restack is running. 23 | cwd: &'a path::Path, 24 | } 25 | 26 | impl<'a, Git> Config<'a, Git> 27 | where 28 | Git: git::Git, 29 | { 30 | /// Builds a new restack configuration operating inside the given directory 31 | /// using the provided git object. 32 | pub fn new(cwd: &'a path::Path, git: Git) -> Self { 33 | Self { git, cwd } 34 | } 35 | 36 | /// Reads a git rebase instruction list from `src` 37 | /// and writes a new instruction list to `dst` 38 | /// that updates branches found at intermediate commits to their new positions. 39 | /// 40 | /// If `remote_name` is specified, includes an opt-in section 41 | /// at the bottom of the instruction list that 42 | /// updates the remotes for all affected branches. 43 | pub fn restack( 44 | &self, 45 | remote_name: Option<&str>, 46 | src: I, 47 | dst: O, 48 | ) -> Result<()> { 49 | let comment_str = match self.git.comment_string(self.cwd) { 50 | Ok(c) => c, 51 | Err(e) => { 52 | eprintln!("WARN: {}", e); 53 | eprintln!("WARN: Falling back to `#` as comment character"); 54 | "#".to_string() 55 | }, 56 | }; 57 | 58 | let rebase_branch_name = self 59 | .git 60 | .rebase_head_name(self.cwd) 61 | .context("Could not determine rebase head name")?; 62 | let all_branches = self 63 | .git 64 | .list_branches(self.cwd) 65 | .context("Unable to list branches")?; 66 | 67 | let mut known_branches: HashMap<&str, LinkedList<&git::Branch>> = Default::default(); 68 | all_branches.iter().for_each(|b| { 69 | known_branches.entry(&b.shorthash).or_default().push_back(b); 70 | }); 71 | 72 | let src = io::BufReader::new(src); 73 | let mut restack = Restack { 74 | remote_name, 75 | comment_str: &comment_str, 76 | rebase_branch_name: &rebase_branch_name, 77 | dst: io::BufWriter::new(dst), 78 | known_branches: &known_branches, 79 | last_line_branches: Vec::new(), 80 | updated_branches: Vec::new(), 81 | wrote_push: false, 82 | }; 83 | 84 | for line in src.lines() { 85 | let line = line.context("Failed while reading input")?; 86 | restack.process(&line)?; 87 | } 88 | 89 | restack.update_previous_branches()?; 90 | restack.write_push_section(true, false)?; 91 | 92 | Ok(()) 93 | } 94 | } 95 | 96 | /// Holds state for an ongoing restack operation. 97 | struct Restack<'a, O: io::Write> { 98 | /// Name of the remote, if any. 99 | remote_name: Option<&'a str>, 100 | 101 | /// Name of the branch we're rebasing. 102 | rebase_branch_name: &'a str, 103 | 104 | /// Character that starts a comment. 105 | comment_str: &'a str, 106 | 107 | /// Destination writer. 108 | dst: io::BufWriter, 109 | 110 | /// Known branches, keyed by their short hashes. 111 | known_branches: &'a HashMap<&'a str, LinkedList<&'a git::Branch>>, 112 | 113 | last_line_branches: Vec<&'a git::Branch>, 114 | updated_branches: Vec<&'a git::Branch>, 115 | wrote_push: bool, 116 | } 117 | 118 | impl Restack<'_, O> { 119 | pub fn process(&mut self, line: &str) -> Result<()> { 120 | if line.is_empty() { 121 | // Empty lines delineate sections. 122 | // Write pending "git branch -x" statements 123 | // before going on to the next section. 124 | if !self.update_previous_branches()? { 125 | // update_previous_branches adds a trailing newline 126 | // only if git branch statements were added. 127 | // So if it didn't do anything, re-add the empty line. 128 | self.write_line("")?; 129 | } 130 | return Ok(()); 131 | } 132 | 133 | // Comments usually mark the end of instructions. 134 | // Flush optional "git push" statements. 135 | if line.starts_with(self.comment_str) { 136 | self.update_previous_branches()?; 137 | self.write_push_section(false, true) 138 | .context("Could not write 'git push' section")?; 139 | } 140 | 141 | // (p[ick]|f[ixup]|s[quash]) hash ... 142 | let mut parts = line.splitn(3, ' '); 143 | 144 | let cmd = parts.next(); 145 | if let Some(cmd) = cmd { 146 | match cmd { 147 | "f" | "fixup" | "s" | "squash" => {}, // do nothing 148 | _ => { 149 | self.update_previous_branches()?; 150 | }, 151 | } 152 | } 153 | 154 | // Most lines go as-is. 155 | self.write_line(line)?; 156 | 157 | let Some(cmd) = cmd else { 158 | return Ok(()); 159 | }; 160 | let hash = match cmd { 161 | "p" | "pick" | "r" | "reword" | "e" | "edit" => match parts.next() { 162 | Some(s) => s, 163 | None => return Ok(()), 164 | }, 165 | _ => return Ok(()), 166 | }; 167 | 168 | if let Some(branches) = self.known_branches.get(hash) { 169 | self.last_line_branches.extend(branches); 170 | } 171 | 172 | Ok(()) 173 | } 174 | 175 | fn write_push_section(&mut self, pad_before: bool, pad_after: bool) -> Result<()> { 176 | if self.wrote_push { 177 | return Ok(()); 178 | } 179 | self.wrote_push = true; 180 | 181 | if self.updated_branches.is_empty() { 182 | return Ok(()); 183 | } 184 | 185 | let Some(remote_name) = self.remote_name else { 186 | return Ok(()); 187 | }; 188 | 189 | if pad_before { 190 | writeln!(self.dst)?; 191 | } 192 | writeln!( 193 | self.dst, 194 | "{} Uncomment this section to push the changes.", 195 | self.comment_str 196 | )?; 197 | for br in &self.updated_branches { 198 | writeln!( 199 | self.dst, 200 | "{} exec git push -f {} {}", 201 | self.comment_str, remote_name, br.name 202 | )?; 203 | } 204 | if pad_after { 205 | writeln!(self.dst)?; 206 | } 207 | 208 | Ok(()) 209 | } 210 | 211 | /// Adds "exec git branch -f" statements to the instruction list. 212 | /// Reports whether any statements were added. 213 | fn update_previous_branches(&mut self) -> Result { 214 | let mut updated = false; 215 | for b in self.last_line_branches.drain(0..) { 216 | if b.name.as_str() == self.rebase_branch_name { 217 | continue; 218 | } 219 | 220 | writeln!(self.dst, "exec git branch -f {}", b.name)?; 221 | self.updated_branches.push(b); 222 | updated = true; 223 | } 224 | 225 | if updated { 226 | writeln!(self.dst)?; 227 | } 228 | 229 | Ok(updated) 230 | } 231 | 232 | fn write_line(&mut self, line: &str) -> Result<()> { 233 | writeln!(self.dst, "{}", line).map_err(Into::into) 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/restack/tests.rs: -------------------------------------------------------------------------------- 1 | use std::path; 2 | 3 | use anyhow::Result; 4 | use indoc::indoc; 5 | use pretty_assertions::assert_eq; 6 | 7 | use super::Config; 8 | use crate::git; 9 | 10 | struct StubGit { 11 | branches: Vec, 12 | rebase_head_name: String, 13 | comment_char: Option, 14 | comment_string: Option, 15 | } 16 | 17 | impl git::Git for StubGit { 18 | fn set_global_config_str(&self, _: K, _: V) -> Result<()> 19 | where 20 | K: AsRef, 21 | V: AsRef, 22 | { 23 | unreachable!("set_global_config_str not expected") 24 | } 25 | 26 | fn git_dir(&self, _: &path::Path) -> Result { 27 | unreachable!("git_dir not expected") 28 | } 29 | 30 | fn list_branches(&self, _: &path::Path) -> Result> { 31 | Ok(self.branches.clone()) 32 | } 33 | 34 | fn rebase_head_name(&self, _: &path::Path) -> Result { 35 | Ok(self.rebase_head_name.clone()) 36 | } 37 | 38 | fn comment_string(&self, _: &path::Path) -> Result { 39 | if let Some(s) = &self.comment_string { 40 | return Ok(s.to_string()); 41 | } 42 | 43 | Ok(self.comment_char.unwrap_or('#').to_string()) 44 | } 45 | } 46 | 47 | struct TestBranch<'a> { 48 | name: &'a str, 49 | hash: &'a str, 50 | } 51 | 52 | struct TestCase<'a> { 53 | remote_name: Option<&'a str>, 54 | rebase_head_name: &'a str, 55 | branches: &'a [TestBranch<'a>], 56 | comment_char: Option, 57 | give: &'a str, 58 | want: &'a str, 59 | } 60 | 61 | fn restack_test(test: &TestCase) -> Result<()> { 62 | let tempdir = tempfile::tempdir()?; 63 | let branches = test 64 | .branches 65 | .iter() 66 | .map(|b| git::Branch { 67 | name: b.name.to_owned(), 68 | shorthash: b.hash.to_owned(), 69 | }) 70 | .collect(); 71 | let stub_git = StubGit { 72 | branches, 73 | rebase_head_name: test.rebase_head_name.to_owned(), 74 | comment_char: test.comment_char, 75 | comment_string: None, 76 | }; 77 | 78 | let cfg = Config::new(tempdir.path(), stub_git); 79 | 80 | let got = { 81 | let mut got: Vec = Vec::new(); 82 | cfg.restack(test.remote_name, test.give.as_bytes(), &mut got)?; 83 | String::from_utf8(got)? 84 | }; 85 | 86 | assert_eq!(test.want, &got); 87 | 88 | Ok(()) 89 | } 90 | 91 | macro_rules! testcase { 92 | ($name:ident, $test: expr) => { 93 | #[test] 94 | fn $name() -> Result<()> { 95 | let test = $test; 96 | restack_test(&test) 97 | } 98 | }; 99 | } 100 | 101 | fn branch<'a>(name: &'a str, hash: &'a str) -> TestBranch<'a> { 102 | TestBranch { name, hash } 103 | } 104 | 105 | testcase!( 106 | no_matches, 107 | TestCase { 108 | remote_name: Some("origin"), 109 | rebase_head_name: "feature", 110 | comment_char: None, 111 | branches: &[branch("feature", "hash1")], 112 | give: indoc! {" 113 | pick hash0 Do something 114 | exec make test 115 | pick hash1 Implement feature 116 | "}, 117 | want: indoc! {" 118 | pick hash0 Do something 119 | exec make test 120 | pick hash1 Implement feature 121 | "}, 122 | } 123 | ); 124 | 125 | testcase!( 126 | bad_pick_instruction, 127 | TestCase { 128 | remote_name: None, 129 | rebase_head_name: "foo", 130 | comment_char: None, 131 | branches: &[], 132 | give: indoc! {" 133 | pick 134 | "}, 135 | want: indoc! {" 136 | pick 137 | "}, 138 | } 139 | ); 140 | 141 | testcase!( 142 | branch_at_rebase_head, 143 | TestCase { 144 | remote_name: Some("origin"), 145 | rebase_head_name: "feature/wip", 146 | comment_char: None, 147 | branches: &[branch("feature/1", "hash1"), branch("feature/wip", "hash1"),], 148 | give: indoc! {" 149 | pick hash0 Do something 150 | exec make test 151 | pick hash1 Implement feature 152 | exec make test 153 | 154 | # Rebase instructions 155 | "}, 156 | want: indoc! {" 157 | pick hash0 Do something 158 | exec make test 159 | pick hash1 Implement feature 160 | exec git branch -f feature/1 161 | 162 | exec make test 163 | 164 | # Uncomment this section to push the changes. 165 | # exec git push -f origin feature/1 166 | 167 | # Rebase instructions 168 | "}, 169 | } 170 | ); 171 | 172 | testcase!( 173 | comment_char_set, 174 | TestCase { 175 | remote_name: Some("origin"), 176 | rebase_head_name: "feature/wip", 177 | comment_char: Some(';'), 178 | branches: &[branch("feature/1", "hash1"), branch("feature/wip", "hash1"),], 179 | give: indoc! {" 180 | pick hash0 Do something 181 | exec make test 182 | pick hash1 Implement feature 183 | exec make test 184 | 185 | ; Rebase instructions 186 | "}, 187 | want: indoc! {" 188 | pick hash0 Do something 189 | exec make test 190 | pick hash1 Implement feature 191 | exec git branch -f feature/1 192 | 193 | exec make test 194 | 195 | ; Uncomment this section to push the changes. 196 | ; exec git push -f origin feature/1 197 | 198 | ; Rebase instructions 199 | "}, 200 | } 201 | ); 202 | 203 | testcase!( 204 | rebase_instructions_comment_missing, 205 | TestCase { 206 | remote_name: Some("origin"), 207 | rebase_head_name: "feature/wip", 208 | comment_char: None, 209 | branches: &[ 210 | branch("feature/1", "hash1"), 211 | branch("feature/2", "hash3"), 212 | branch("feature/3", "hash7"), 213 | branch("feature/wip", "hash9"), 214 | ], 215 | give: indoc! {" 216 | pick hash0 Do something 0 217 | pick hash1 Implement feature1 218 | pick hash2 Do something 219 | pick hash3 Implement feature2 220 | pick hash4 Do something 4 221 | pick hash5 Do something 5 222 | pick hash6 Do something 6 223 | pick hash7 Implement feature3 224 | pick hash8 Do something 8 225 | pick hash9 Do something 9 226 | "}, 227 | want: indoc! {" 228 | pick hash0 Do something 0 229 | pick hash1 Implement feature1 230 | exec git branch -f feature/1 231 | 232 | pick hash2 Do something 233 | pick hash3 Implement feature2 234 | exec git branch -f feature/2 235 | 236 | pick hash4 Do something 4 237 | pick hash5 Do something 5 238 | pick hash6 Do something 6 239 | pick hash7 Implement feature3 240 | exec git branch -f feature/3 241 | 242 | pick hash8 Do something 8 243 | pick hash9 Do something 9 244 | 245 | # Uncomment this section to push the changes. 246 | # exec git push -f origin feature/1 247 | # exec git push -f origin feature/2 248 | # exec git push -f origin feature/3 249 | "}, 250 | } 251 | ); 252 | 253 | testcase!( 254 | fixup_commit, 255 | TestCase { 256 | remote_name: None, 257 | rebase_head_name: "b", 258 | comment_char: None, 259 | branches: &[branch("a", "hash1"), branch("b", "hash3")], 260 | give: indoc! {" 261 | pick hash0 do thing 262 | pick hash1 another thing 263 | fixup hash2 stuff 264 | pick hash3 whatever 265 | "}, 266 | want: indoc! {" 267 | pick hash0 do thing 268 | pick hash1 another thing 269 | fixup hash2 stuff 270 | exec git branch -f a 271 | 272 | pick hash3 whatever 273 | "}, 274 | } 275 | ); 276 | 277 | testcase!( 278 | squash_commit, 279 | TestCase { 280 | remote_name: None, 281 | rebase_head_name: "b", 282 | comment_char: None, 283 | branches: &[branch("a", "hash1"), branch("b", "hash3")], 284 | give: indoc! {" 285 | pick hash0 do thing 286 | pick hash1 another thing 287 | squash hash2 stuff 288 | pick hash3 whatever 289 | "}, 290 | want: indoc! {" 291 | pick hash0 do thing 292 | pick hash1 another thing 293 | squash hash2 stuff 294 | exec git branch -f a 295 | 296 | pick hash3 whatever 297 | "}, 298 | } 299 | ); 300 | 301 | testcase!( 302 | issue_41, // https://github.com/abhinav/restack/issues/41 303 | TestCase { 304 | remote_name: Some("origin"), 305 | rebase_head_name: "stack", 306 | comment_char: None, 307 | branches: &[ 308 | branch("5601-connection-refused", "29b83a30c"), 309 | branch("5460-publish-bundle", "ae23c4203"), 310 | branch("5460-publish-bundle-client", "ea9b3946b"), 311 | ], 312 | give: indoc! {" 313 | pick eaed5a16a fix(agoric-cli): Thread rpcAddresses for Cosmos publishBundle 314 | pick bd49c28ed fix(agoric-cli): Follow-up: thread random as power 315 | pick da0626a7e fix(agoric-cli): Follow-up: conditionally coerce RPC addresses 316 | pick 29b83a30c fix(agoric-cli): Follow-up: heuristic for distinguishing bare hostnames from URLs (5601-connection-refused) 317 | pick e2b9551ba fix(cosmic-swingset): Publish installation success and failure topic 318 | pick d8685a017 fix(cosmic-swingset): Follow-up: use new pubsub mechanism 319 | pick a8ca6046f fix(cosmic-swingset): Follow-up comment from Richard Gibson 320 | pick 33445b5b7 fix(cosmic-swingset): Follow-up: Publish base value to installation topic 321 | pick 021edf49e fix(cosmic-swingset): Follow-up: prettier 322 | pick d0af526c4 fix(cosmic-swingset): Follow-up: should publish sequences 323 | pick ae23c4203 refactor: Thread solo home directory more generally (5460-publish-bundle) 324 | pick c8090b4ab refactor(agoric-cli): Publish with RPC instead of agd subshell 325 | pick 8cd36f76e fix(casting): iterateLatest erroneously adapted getEachIterable 326 | pick fe113b6bc fix(casting): Release all I/O handles between yield and next 327 | pick 00d84a3e1 feat(agoric-cli): Reveal block heights to agoric follow, opt-in for lossy 328 | pick 2c04f46aa chore: Update yarn.lock 329 | pick ea9b3946b chore: yarn deduplicate (HEAD -> stack, 5460-publish-bundle-client) 330 | 331 | # Rebase comment starts here 332 | "}, 333 | want: indoc! {" 334 | pick eaed5a16a fix(agoric-cli): Thread rpcAddresses for Cosmos publishBundle 335 | pick bd49c28ed fix(agoric-cli): Follow-up: thread random as power 336 | pick da0626a7e fix(agoric-cli): Follow-up: conditionally coerce RPC addresses 337 | pick 29b83a30c fix(agoric-cli): Follow-up: heuristic for distinguishing bare hostnames from URLs (5601-connection-refused) 338 | exec git branch -f 5601-connection-refused 339 | 340 | pick e2b9551ba fix(cosmic-swingset): Publish installation success and failure topic 341 | pick d8685a017 fix(cosmic-swingset): Follow-up: use new pubsub mechanism 342 | pick a8ca6046f fix(cosmic-swingset): Follow-up comment from Richard Gibson 343 | pick 33445b5b7 fix(cosmic-swingset): Follow-up: Publish base value to installation topic 344 | pick 021edf49e fix(cosmic-swingset): Follow-up: prettier 345 | pick d0af526c4 fix(cosmic-swingset): Follow-up: should publish sequences 346 | pick ae23c4203 refactor: Thread solo home directory more generally (5460-publish-bundle) 347 | exec git branch -f 5460-publish-bundle 348 | 349 | pick c8090b4ab refactor(agoric-cli): Publish with RPC instead of agd subshell 350 | pick 8cd36f76e fix(casting): iterateLatest erroneously adapted getEachIterable 351 | pick fe113b6bc fix(casting): Release all I/O handles between yield and next 352 | pick 00d84a3e1 feat(agoric-cli): Reveal block heights to agoric follow, opt-in for lossy 353 | pick 2c04f46aa chore: Update yarn.lock 354 | pick ea9b3946b chore: yarn deduplicate (HEAD -> stack, 5460-publish-bundle-client) 355 | exec git branch -f 5460-publish-bundle-client 356 | 357 | # Uncomment this section to push the changes. 358 | # exec git push -f origin 5601-connection-refused 359 | # exec git push -f origin 5460-publish-bundle 360 | # exec git push -f origin 5460-publish-bundle-client 361 | 362 | # Rebase comment starts here 363 | "}, 364 | } 365 | ); 366 | 367 | testcase!( 368 | comment_after_instructions, 369 | TestCase { 370 | remote_name: Some("origin"), 371 | rebase_head_name: "feature/wip", 372 | comment_char: None, 373 | branches: &[branch("feature/1", "hash1"), branch("feature/2", "hash2")], 374 | give: indoc! {" 375 | pick hash1 Implement feature 1 376 | pick hash2 Implement feature 2 377 | # Rebase instructions 378 | "}, 379 | want: indoc! {" 380 | pick hash1 Implement feature 1 381 | exec git branch -f feature/1 382 | 383 | pick hash2 Implement feature 2 384 | exec git branch -f feature/2 385 | 386 | # Uncomment this section to push the changes. 387 | # exec git push -f origin feature/1 388 | # exec git push -f origin feature/2 389 | 390 | # Rebase instructions 391 | "}, 392 | } 393 | ); 394 | -------------------------------------------------------------------------------- /src/setup.rs: -------------------------------------------------------------------------------- 1 | //! Implements the `restack setup` command. 2 | 3 | use std::io::{self, Write}; 4 | use std::os::unix::fs::OpenOptionsExt; 5 | use std::{env, fs, path}; 6 | 7 | use anyhow::{Context, Result}; 8 | 9 | use crate::git::{self, Git}; 10 | 11 | const USAGE: &str = "\ 12 | USAGE: 13 | restack setup [OPTIONS] 14 | 15 | Configures Git to use restack during an interactive rebase. 16 | If you prefer to configure Git manually, see, 17 | https://github.com/abhinav/restack#manual-setup 18 | If you want restack to run on an opt-in basis, see, 19 | https://github.com/abhinav/restack#can-i-make-restacking-opt-in 20 | 21 | OPTIONS: 22 | -h, --help 23 | Print help information. 24 | 25 | --print-edit-script 26 | Print the shell script without setting it up. 27 | 28 | This shell script is used as the editor for interactive rebases. 29 | It invokes 'restack 30 | edit' on the rebase instructions. 31 | "; 32 | 33 | /// Arguments for the "restack setup" command. 34 | #[derive(Debug, PartialEq, Eq)] 35 | struct Args { 36 | /// If set, the shell script will be printed instead of being installed. 37 | print_script: bool, 38 | } 39 | 40 | /// Shell script to run as the sequence editor. 41 | static EDIT_SCRIPT: &[u8] = include_bytes!("edit.sh"); 42 | 43 | /// Runs the `restack setup` command. 44 | pub fn run(mut parser: lexopt::Parser) -> Result<()> { 45 | let args = { 46 | let mut args = Args { 47 | print_script: false, 48 | }; 49 | 50 | while let Some(arg) = parser.next()? { 51 | match arg { 52 | lexopt::Arg::Long("print-edit-script") => { 53 | args.print_script = true; 54 | }, 55 | lexopt::Arg::Short('h') | lexopt::Arg::Long("help") => { 56 | eprint!("{}", USAGE); 57 | return Ok(()); 58 | }, 59 | _ => return Err(arg.unexpected().into()), 60 | } 61 | } 62 | 63 | args 64 | }; 65 | 66 | if args.print_script { 67 | return io::stdout() 68 | .write_all(EDIT_SCRIPT) 69 | .context("Could nto print edit script"); 70 | } 71 | 72 | let home = 73 | path::PathBuf::from(env::var("HOME").context("Could not determine $HOME is not defined")?); 74 | 75 | let edit_path = { 76 | // TODO: Consider using xdg-home instead. 77 | let mut path = home.join(".restack"); 78 | fs::create_dir_all(&path).context("Unable to create $HOME/.restack")?; 79 | 80 | path.push("edit.sh"); 81 | fs::OpenOptions::new() 82 | .create(true) 83 | .truncate(true) 84 | .write(true) 85 | .mode(0o755) 86 | .open(&path) 87 | .and_then(|mut f| f.write_all(EDIT_SCRIPT)) 88 | .context("Failed to write .restack/edit.sh")?; 89 | 90 | path 91 | }; 92 | 93 | let git_shell = git::Shell::new(); 94 | git_shell 95 | .set_global_config_str("sequence.editor", edit_path) 96 | .context("Could not update sequence.editor")?; 97 | 98 | eprintln!("restack has been successfully set up"); 99 | 100 | Ok(()) 101 | } 102 | -------------------------------------------------------------------------------- /tests/edit/mod.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Write; 2 | use std::{fs, path}; 3 | 4 | use anyhow::{Context, Result}; 5 | use lazy_static::lazy_static; 6 | use pretty_assertions::assert_eq; 7 | use restack_testtools::gitscript; 8 | use rstest::rstest; 9 | 10 | const RESTACK: &str = env!("CARGO_BIN_EXE_restack"); 11 | 12 | lazy_static! { 13 | static ref FIXTURES_DIR: path::PathBuf = 14 | path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures"); 15 | static ref DEFAULT_GITSCRIPT_GROUP: gitscript::Group = 16 | gitscript::Group::new(FIXTURES_DIR.as_path()); 17 | } 18 | 19 | fn open_fixture

(script_path: P) -> Result> 20 | where 21 | P: AsRef, 22 | { 23 | DEFAULT_GITSCRIPT_GROUP 24 | .open(script_path) 25 | .map_err(anyhow::Error::from) 26 | } 27 | 28 | #[rstest] 29 | #[case::editor_flag(true, "simple_stack.sh")] 30 | #[case::editor_env(false, "simple_stack.sh")] 31 | #[case::comment_char(false, "simple_stack_comment_char.sh")] 32 | #[case::comment_char(false, "simple_stack_comment_string.sh")] 33 | fn simple_stack(#[case] editor_flag: bool, #[case] fixture: &str) -> Result<()> { 34 | let repo_fixture = open_fixture(fixture)?; 35 | 36 | let editor = FIXTURES_DIR.join("bin/add_break.sh"); 37 | 38 | let mut seq_editor = format!("{} edit", RESTACK); 39 | if editor_flag { 40 | write!(&mut seq_editor, " --editor {}", editor.display())?; 41 | } 42 | 43 | duct::cmd!("git", "config", "sequence.editor", seq_editor) 44 | .dir(repo_fixture.dir()) 45 | .run()?; 46 | 47 | duct::cmd!("git", "rebase", "--interactive", "main") 48 | .env("EDITOR", FIXTURES_DIR.join("bin/add_break.sh")) 49 | .dir(repo_fixture.dir()) 50 | .run()?; 51 | 52 | // add_break.sh should have seen a rebase list 53 | // with instructions to update branches. 54 | // To verify this, introduce a new commit at the top of the stack, 55 | // and verify that that file is present in all branches after the rebase finishes. 56 | 57 | fs::write(repo_fixture.dir().join("README"), "wait for me")?; 58 | duct::cmd!( 59 | "bash", 60 | "-c", 61 | "git add README && 62 | git commit -m 'add README' && 63 | git rebase --continue" 64 | ) 65 | .dir(repo_fixture.dir()) 66 | .run()?; 67 | 68 | let branches = &["foo", "bar", "baz"]; 69 | for br in branches { 70 | let got = duct::cmd!("git", "show", format!("{}:README", br)) 71 | .dir(repo_fixture.dir()) 72 | .read() 73 | .with_context(|| format!("Unable to print {}:README", br))?; 74 | 75 | assert_eq!( 76 | "wait for me", &got, 77 | "Contents of {}:README do not match", 78 | br 79 | ); 80 | } 81 | 82 | Ok(()) 83 | } 84 | 85 | #[rstest] 86 | #[case::empty("", "No editor specified: please use --editor")] 87 | #[case::non_zero_status("false", "Editor returned non-zero status")] 88 | #[case::malicious("rm", "Could not overwrite")] 89 | fn editor_error(#[case] editor: &str, #[case] msg: &str) -> Result<()> { 90 | let repo_fixture = open_fixture("simple_stack.sh")?; 91 | 92 | let seq_editor = format!("{} edit", RESTACK); 93 | duct::cmd!("git", "config", "sequence.editor", seq_editor) 94 | .dir(repo_fixture.dir()) 95 | .run()?; 96 | 97 | let out = duct::cmd!("git", "rebase", "--interactive", "main") 98 | .env("EDITOR", editor) 99 | .dir(repo_fixture.dir()) 100 | .stderr_capture() 101 | .unchecked() 102 | .run()?; 103 | 104 | assert!(!out.status.success()); 105 | let stderr = String::from_utf8(out.stderr)?; 106 | assert!( 107 | stderr.contains(msg), 108 | "unexpected stderr, must contain '{}':\n{}", 109 | msg, 110 | &stderr 111 | ); 112 | 113 | Ok(()) 114 | } 115 | -------------------------------------------------------------------------------- /tests/integration.rs: -------------------------------------------------------------------------------- 1 | mod edit; 2 | mod setup; 3 | -------------------------------------------------------------------------------- /tests/setup/mod.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::ffi::OsString; 3 | use std::os::unix::fs::PermissionsExt; 4 | use std::{env, fs}; 5 | 6 | use anyhow::{Context, Result}; 7 | use tempfile::tempdir; 8 | 9 | const RESTACK: &str = env!("CARGO_BIN_EXE_restack"); 10 | 11 | #[test] 12 | fn prints_edit_script() -> Result<()> { 13 | let stdout = duct::cmd!(RESTACK, "setup", "--print-edit-script").read()?; 14 | let first_line = stdout.lines().next().expect("non empty output"); 15 | assert_eq!(first_line, "#!/bin/sh -e"); 16 | 17 | Ok(()) 18 | } 19 | 20 | #[test] 21 | fn setup_restack() -> Result<()> { 22 | let home_dir = tempdir().context("Failed to make temporary directory")?; 23 | 24 | let mut env_map: HashMap = HashMap::new(); 25 | env_map.insert("HOME".into(), home_dir.path().into()); 26 | 27 | duct::cmd!(RESTACK, "setup").full_env(&env_map).run()?; 28 | 29 | let edit_script = home_dir.path().join(".restack/edit.sh"); 30 | assert!(edit_script.exists(), "edit script does not exist"); 31 | { 32 | let mode = edit_script.metadata()?.permissions().mode(); 33 | assert_ne!(mode & 0o111, 0, "file should be executable, got {}", mode); 34 | } 35 | 36 | let stdout = duct::cmd!("git", "config", "--global", "sequence.editor") 37 | .full_env(&env_map) 38 | .read()?; 39 | assert_eq!(edit_script.to_str().unwrap(), stdout.trim_end()); 40 | 41 | Ok(()) 42 | } 43 | 44 | #[test] 45 | fn update_old_setup() -> Result<()> { 46 | let home_dir = tempdir().context("Failed to make temporary directory")?; 47 | let edit_script = home_dir.path().join(".restack/edit.sh"); 48 | 49 | let mut env_map: HashMap = HashMap::new(); 50 | env_map.insert("HOME".into(), home_dir.path().into()); 51 | 52 | // Outdated setup: 53 | fs::create_dir(edit_script.parent().unwrap())?; 54 | fs::write(&edit_script, "old script".as_bytes())?; 55 | duct::cmd!("git", "config", "--global", "sequence.editor", "nvim") 56 | .full_env(&env_map) 57 | .run()?; 58 | 59 | // Overwrite it. 60 | duct::cmd!(RESTACK, "setup").full_env(&env_map).run()?; 61 | let stdout = duct::cmd!("git", "config", "--global", "sequence.editor") 62 | .full_env(&env_map) 63 | .read()?; 64 | assert_eq!(edit_script.to_str().unwrap(), stdout.trim_end()); 65 | 66 | Ok(()) 67 | } 68 | -------------------------------------------------------------------------------- /tools/release/PKGBUILD-bin.tmpl: -------------------------------------------------------------------------------- 1 | # Maintainer: Abhinav Gupta 2 | 3 | pkgname='restack-bin' 4 | pkgver=${VERSION} 5 | pkgrel=1 6 | pkgdesc='Makes interactive Git rebase aware of intermediate branches.' 7 | url='https://github.com/abhinav/restack' 8 | arch=('aarch64' 'armv7h' 'x86_64') 9 | license=('GPL-2.0') 10 | provides=('restack') 11 | conflicts=('restack') 12 | 13 | source_aarch64=("${pkgname}_${pkgver}_aarch64.tar.gz::https://github.com/abhinav/restack/releases/download/v${VERSION}/restack-linux-arm64.tar.gz") 14 | sha256sums_aarch64=('$SHASUM_linux_arm64') 15 | 16 | source_armv7h=("${pkgname}_${pkgver}_armv7h.tar.gz::https://github.com/abhinav/restack/releases/download/v${VERSION}/restack-linux-armv7.tar.gz") 17 | sha256sums_armv7h=('$SHASUM_linux_armv7') 18 | 19 | source_x86_64=("${pkgname}_${pkgver}_x86_64.tar.gz::https://github.com/abhinav/restack/releases/download/v${VERSION}/restack-linux-amd64.tar.gz") 20 | sha256sums_x86_64=('$SHASUM_linux_amd64') 21 | 22 | package() { 23 | install -Dm755 "./restack" "${pkgdir}/usr/bin/restack" 24 | } 25 | -------------------------------------------------------------------------------- /tools/release/PKGBUILD.tmpl: -------------------------------------------------------------------------------- 1 | # Maintainer: Abhinav Gupta 2 | 3 | pkgname=restack 4 | pkgver=${VERSION} 5 | pkgrel=1 6 | pkgdesc='Makes interactive Git rebase aware of intermediate branches.' 7 | arch=(any) 8 | url="https://github.com/abhinav/restack" 9 | license=('GPL-2.0') 10 | makedepends=('cargo') 11 | source=("$pkgname-$pkgver.tar.gz::https://static.crates.io/crates/$pkgname/$pkgname-$pkgver.crate") 12 | sha256sums=('a6e6381a632104cb2d1a2e2104a98fdab40c23e9f222bdff53641b7bfb3d8513') 13 | 14 | prepare() { 15 | ( cd "$pkgname-$pkgver" && 16 | cargo fetch --locked --target "$CARCH-unknown-linux-gnu" ) 17 | } 18 | 19 | build() { 20 | export RUSTUP_TOOLCHAIN=1.81.0 21 | export CARGO_TARGET_DIR=target 22 | ( cd "$pkgname-$pkgver" && 23 | cargo build --frozen --release \ 24 | --target "$CARCH-unknown-linux-gnu" && 25 | strip "target/$CARCH-unknown-linux-gnu/release/restack" ) 26 | } 27 | 28 | check() { 29 | "$pkgname-$pkgver/target/$CARCH-unknown-linux-gnu/release/restack" --version 30 | } 31 | 32 | package() { 33 | install -Dm0755 -t "$pkgdir/usr/bin/$pkgname" "$pkgname-$pkgver/target/$CARCH-unknown-linux-gnu/release/restack" 34 | } 35 | -------------------------------------------------------------------------------- /tools/release/bottle.tmpl: -------------------------------------------------------------------------------- 1 | # typed: false 2 | # frozen_string_literal: true 3 | 4 | class Restack < Formula 5 | desc "Makes interactive Git rebase aware of your branches." 6 | homepage "https://github.com/abhinav/restack" 7 | version "${VERSION}" 8 | license "GPL-2.0" 9 | 10 | depends_on "git" 11 | 12 | on_macos do 13 | if Hardware::CPU.intel? 14 | url "https://github.com/abhinav/restack/releases/download/v${VERSION}/restack-darwin-amd64.tar.gz" 15 | sha256 "$SHASUM_darwin_amd64" 16 | 17 | def install 18 | bin.install "restack" 19 | end 20 | end 21 | if Hardware::CPU.arm? 22 | url "https://github.com/abhinav/restack/releases/download/v${VERSION}/restack-darwin-arm64.tar.gz" 23 | sha256 "$SHASUM_darwin_arm64" 24 | 25 | def install 26 | bin.install "restack" 27 | end 28 | end 29 | end 30 | 31 | on_linux do 32 | if Hardware::CPU.arm? && Hardware::CPU.is_64_bit? 33 | url "https://github.com/abhinav/restack/releases/download/v${VERSION}/restack-linux-arm64.tar.gz" 34 | sha256 "$SHASUM_linux_arm64" 35 | 36 | def install 37 | bin.install "restack" 38 | end 39 | end 40 | if Hardware::CPU.intel? 41 | url "https://github.com/abhinav/restack/releases/download/v${VERSION}/restack-linux-amd64.tar.gz" 42 | sha256 "$SHASUM_linux_amd64" 43 | 44 | def install 45 | bin.install "restack" 46 | end 47 | end 48 | if Hardware::CPU.arm? && !Hardware::CPU.is_64_bit? 49 | url "https://github.com/abhinav/restack/releases/download/v${VERSION}/restack-linux-armv7.tar.gz" 50 | sha256 "$SHASUM_linux_armv7" 51 | 52 | def install 53 | bin.install "restack" 54 | end 55 | end 56 | end 57 | 58 | test do 59 | system "#{bin}/restack -version" 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /tools/release/genpkgspec.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | # genpkgspec.sh VERSION TARBALLS 6 | # 7 | # Run this with paths to tarballs named restack-$os-$arch.tar.gz. 8 | # This will generate target/formula/ and target/aur-bin. 9 | 10 | DIR=$(dirname "$0") 11 | BOTTLE_TMPL="$DIR/bottle.tmpl" 12 | BIN_PKGBUILD_TMPL="$DIR/PKGBUILD-bin.tmpl" 13 | PKGBUILD_TMPL="$DIR/PKGBUILD.tmpl" 14 | 15 | err() { 16 | echo >&2 "$@" 17 | } 18 | 19 | if [[ $# -lt 2 ]]; then 20 | err "USAGE: $0 VERSION FILES ..." 21 | exit 1 22 | fi 23 | 24 | export VERSION="$1"; shift 25 | while [[ $# -gt 0 ]]; do 26 | FILE="$1"; shift 27 | SHA=$(sha256sum "$FILE" | awk '{print $1}') 28 | # This is absolutely horrendous, 29 | # but it'll work with all versions of Bash. 30 | eval "$(perl -se ' 31 | $file =~ /(darwin|linux)[-_](amd64|arm64|armv7)/ 32 | or die "Could not match: $file"; 33 | print "export SHASUM_$1_$2=$shasum\n" 34 | ' -- -file="$FILE" -shasum="$SHA")" 35 | done 36 | 37 | VARS=( 38 | VERSION 39 | SHASUM_darwin_amd64 40 | SHASUM_darwin_arm64 41 | SHASUM_linux_amd64 42 | SHASUM_linux_arm64 43 | SHASUM_linux_armv7 44 | ) 45 | shellformat="" 46 | for VAR in "${VARS[@]}"; do 47 | if [[ -z "${!VAR:-}" ]]; then 48 | err "Unset variable $VAR" 49 | exit 1 50 | fi 51 | shellformat="$shellformat \$$VAR" 52 | done 53 | 54 | mkdir -p target/formula 55 | envsubst "$shellformat" < "$BOTTLE_TMPL" > target/formula/restack.rb 56 | 57 | mkdir -p target/aur-bin 58 | envsubst "$shellformat" < "$BIN_PKGBUILD_TMPL" > target/aur-bin/PKGBUILD 59 | 60 | mkdir -p target/aur 61 | envsubst "$shellformat" < "$PKGBUILD_TMPL" > target/aur/PKGBUILD 62 | -------------------------------------------------------------------------------- /tools/test/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "restack-testtools" 3 | version = "0.1.0" 4 | edition = "2021" 5 | description = "Testing utilities for restack." 6 | 7 | [lib] 8 | doctest = false 9 | test = true 10 | 11 | [dependencies] 12 | anyhow = "1.0" 13 | duct = "0.13.6" 14 | file-lock = "2.1.9" 15 | lazy_static = "1.4.0" 16 | sha2 = "0.10.6" 17 | tar = "0.4.38" 18 | tempfile = "3.5.0" 19 | thiserror = "1.0.40" 20 | xz2 = "0.1.7" 21 | -------------------------------------------------------------------------------- /tools/test/src/gitscript.rs: -------------------------------------------------------------------------------- 1 | //! Builds directories from shell scripts. 2 | //! 3 | //! With gitscript, you can write a shell script that produces a directory, 4 | //! and have it recorded in the repository for later reuse. 5 | //! 6 | //! Each "fixture" has two pieces: 7 | //! 8 | //! - a shell script 9 | //! - an archive that holds the directory generated by the script 10 | //! 11 | //! The module ensures that these two are in-sync: 12 | //! if the archive doesn't exist or becomes out-of-date, 13 | //! gitscript replaces it with a new one that matches the current script's contents. 14 | //! 15 | //! gitscript specifically aims to address building test Git repositories, 16 | //! so it sets a number of Git-specific environment variables 17 | //! when running the shell scripts. 18 | //! 19 | //! All scripts are run with an already initalized repositories. 20 | //! 21 | //! This approach is heavily inspired by Gitoxide's integration testing system. 22 | 23 | use std::borrow::Cow; 24 | use std::collections::HashMap; 25 | use std::fmt::{self, Debug}; 26 | use std::{env, ffi, fs, io, path}; 27 | 28 | use anyhow::{anyhow, Context}; 29 | use sha2::Digest; 30 | 31 | /// Version of the format used by gitscript in generated archives. 32 | /// This helps ensure that we can change the format later. 33 | const VERSION: &str = "2"; 34 | 35 | /// Error returned by operations in this package. 36 | #[derive(thiserror::Error, Debug)] 37 | pub enum Error { 38 | /// Indicates that the archive was out of date. 39 | /// 40 | /// Externally, this is only ever returned if gitscript is run in CI 41 | /// with an outdated or missing archive: 42 | /// all archives must be generated and checked in locally. 43 | #[error("Archive {path:?} is outdated")] 44 | OutdatedArchive { 45 | /// Path to the archive that was outdated. 46 | path: path::PathBuf, 47 | }, 48 | 49 | /// Returned when a fixture script failed to execute. 50 | #[error("Script {path:?} failed")] 51 | ScriptFailed { 52 | /// Path to the script that failed. 53 | path: path::PathBuf, 54 | /// Error returned by the script when it failed. 55 | source: anyhow::Error, 56 | }, 57 | 58 | /// All other failures from this module. 59 | #[error(transparent)] 60 | Other(#[from] anyhow::Error), 61 | } 62 | 63 | /// Handle to a directory generated by gitscript. 64 | /// 65 | /// The directory will be deleted automatically when this is dropped. 66 | pub struct Fixture<'a> { 67 | tempdir: tempfile::TempDir, 68 | sha: Vec, 69 | version: Cow<'a, str>, 70 | } 71 | 72 | impl Debug for Fixture<'_> { 73 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 74 | f.debug_struct("Fixture") 75 | .field("tempdir", &self.tempdir.path()) 76 | .field("sha", &format!("{:0x?}", &self.sha)) 77 | .field("version", &self.version) 78 | .finish() 79 | } 80 | } 81 | 82 | impl Fixture<'_> { 83 | /// Reports the directory generated by gitscript. 84 | #[must_use] 85 | pub fn dir(&self) -> &path::Path { 86 | self.tempdir.path() 87 | } 88 | 89 | fn open_archive(path: &path::Path, want_sha: &[u8]) -> Result { 90 | let tempdir = tempfile::tempdir().context("Failed to create temporary directory")?; 91 | 92 | { 93 | let f = fs::File::open(path).context("Failed to open archive")?; 94 | let xz_dec = xz2::read::XzDecoder::new(f); 95 | tar::Archive::new(xz_dec) 96 | .unpack(tempdir.path()) 97 | .context("Failed to extract archive")?; 98 | } 99 | 100 | let sha = fs::read(tempdir.path().join("SHA256")).context("Failed to read script hash")?; 101 | let version = fs::read_to_string(tempdir.path().join("VERSION")) 102 | .context("Failed to read fixture version")?; 103 | 104 | if version != VERSION || sha.as_slice() != want_sha { 105 | return Err(Error::OutdatedArchive { 106 | path: path.to_path_buf(), 107 | }); 108 | } 109 | 110 | Ok(Fixture { 111 | tempdir, 112 | sha, 113 | version: Cow::Owned(version), 114 | }) 115 | } 116 | 117 | fn prepare_dir(sha: &[u8]) -> anyhow::Result { 118 | let tempdir = tempfile::tempdir().context("Failed to create temporary directory")?; 119 | 120 | fs::write(tempdir.path().join("SHA256"), sha).context("Failed to write script hash")?; 121 | fs::write(tempdir.path().join("VERSION"), VERSION.as_bytes()) 122 | .context("Failed to write fixture version")?; 123 | 124 | Ok(Fixture { 125 | tempdir, 126 | sha: sha.to_vec(), 127 | version: Cow::Borrowed(VERSION), 128 | }) 129 | } 130 | 131 | fn write_archive(&self, dst: &path::Path) -> anyhow::Result<()> { 132 | let opts = file_lock::FileOptions::new() 133 | .write(true) 134 | .create(true) 135 | .truncate(true); 136 | let flock = file_lock::FileLock::lock(dst, true, opts)?; 137 | 138 | let out = xz2::write::XzEncoder::new(&flock.file, /* level */ 3); 139 | let mut ar = tar::Builder::new(out); 140 | ar.append_dir_all(".", self.dir())?; 141 | 142 | Ok(()) 143 | } 144 | } 145 | 146 | /// getenv is the default implementation of Group.getenv. 147 | fn getenv(s: &str) -> Result { 148 | env::var(s) 149 | } 150 | 151 | /// Group is a group of fixtures rooted in the same directory. 152 | /// This exists mostly for testing -- externally, the `DEFAULT_GROUP` is used. 153 | pub struct Group { 154 | dir: path::PathBuf, 155 | getenv: fn(&str) -> Result, 156 | } 157 | 158 | impl Group { 159 | /// Builds a new fixture group rooted inside the given directory. 160 | /// All generated fixtures will reside inside the directory. 161 | pub fn new>(p: P) -> Self { 162 | Self { 163 | dir: p.as_ref().to_path_buf(), 164 | getenv, 165 | } 166 | } 167 | 168 | /// Reports the absolute path to the given file/folder inside the fixtures/ 169 | /// directory. 170 | fn fixture_path>(&self, p: P) -> path::PathBuf { 171 | self.dir.join(p) 172 | } 173 | 174 | /// Opens and returns a Fixture for the script at `script_path`, 175 | /// ensuring that the archive for the script exists and is up to date. 176 | /// 177 | /// The path must be relative to the "fixtures" directory of the repository. 178 | pub fn open>(&self, script_name: P) -> Result { 179 | let script_path = self.fixture_path(&script_name); 180 | let archive_path = script_path.with_extension("tar.xz"); 181 | 182 | let script_sha = hash_file(&script_path).context("Failed to hash script")?; 183 | 184 | if archive_path.exists() { 185 | match Fixture::open_archive(&archive_path, &script_sha) { 186 | Ok(fix) => return Ok(fix), 187 | Err(Error::OutdatedArchive { path }) => { 188 | eprintln!("archive {} is outdated; regenerating", path.display()); 189 | }, 190 | Err(err) => { 191 | let err = Error::Other(anyhow::Error::from(err).context(format!( 192 | "Could not load fixture archive {}", 193 | archive_path.display() 194 | ))); 195 | return Err(err); 196 | }, 197 | } 198 | } 199 | 200 | // Fail if the archive wasn't checked in and we're already in CI. 201 | // GitHub workflows always sets "CI=true". 202 | if let Ok(ci) = (self.getenv)("CI") { 203 | if ci.as_str() == "true" { 204 | eprintln!("cannot generate archive {} in CI", archive_path.display()); 205 | eprintln!("please run the test locally and check the archive in."); 206 | return Err(Error::OutdatedArchive { path: archive_path }); 207 | } 208 | } 209 | 210 | let fix = Fixture::prepare_dir(&script_sha)?; 211 | 212 | let mut new_path = ffi::OsString::new(); 213 | if let Ok(bin) = self.fixture_path("bin").canonicalize() { 214 | new_path.push(bin); 215 | } 216 | new_path.push(":"); 217 | new_path.push((self.getenv)("PATH").context("Cannot read PATH")?); 218 | 219 | let env = { 220 | let mut env: HashMap<_, _> = std::env::vars().collect(); 221 | env.remove("GIT_DIR"); 222 | 223 | let mut push = |k: &str, v: &str| { 224 | env.insert(k.to_string(), v.to_string()); 225 | }; 226 | 227 | push("GIT_AUTHOR_DATE", "2000-01-01 00:00:00 +0000"); 228 | push("GIT_COMMITTER_DATE", "2000-01-02 00:00:00 +0000"); 229 | push( 230 | "PATH", 231 | new_path 232 | .to_str() 233 | .ok_or_else(|| anyhow!("bad path {:?}", &new_path))?, 234 | ); 235 | 236 | env 237 | }; 238 | 239 | duct::cmd!( 240 | "bash", 241 | "-c", 242 | "git init -b main && 243 | git config user.name author && 244 | git config user.email author@example.com" 245 | ) 246 | .dir(fix.dir()) 247 | .full_env(&env) 248 | .run() 249 | .context("Eror preparing fixture directory")?; 250 | 251 | duct::cmd!( 252 | "bash", 253 | "-euo", 254 | "pipefail", 255 | &script_path 256 | .canonicalize() 257 | .context("Error normalizing script path")? 258 | ) 259 | .dir(fix.dir()) 260 | .full_env(&env) 261 | .run() 262 | .map_err(|err| Error::ScriptFailed { 263 | path: script_path, 264 | source: err.into(), 265 | })?; 266 | 267 | fix.write_archive(&archive_path) 268 | .with_context(|| format!("Failed to write archive {}", archive_path.display()))?; 269 | 270 | Ok(fix) 271 | } 272 | } 273 | 274 | /// Hashes the file at the given location. 275 | fn hash_file(p: &path::Path) -> anyhow::Result> { 276 | let mut f = fs::File::open(p).context("Failed to open file")?; 277 | let mut hasher = sha2::Sha256::new(); 278 | io::copy(&mut f, &mut hasher).context("Could not hash file contents")?; 279 | Ok(hasher.finalize().to_vec()) 280 | } 281 | 282 | #[cfg(test)] 283 | mod tests { 284 | use std::result::Result as StdResult; 285 | 286 | use anyhow::Result; 287 | use tempfile; 288 | 289 | use super::*; 290 | 291 | fn getenv(s: &str) -> StdResult { 292 | match s { 293 | "CI" => Err(env::VarError::NotPresent), 294 | _ => env::var(s), 295 | } 296 | } 297 | 298 | #[test] 299 | fn open_end_to_end() -> Result<()> { 300 | let tempdir = tempfile::tempdir()?; 301 | let fixtures = tempdir.path(); 302 | let grp = Group { 303 | dir: fixtures.to_path_buf(), 304 | getenv, 305 | }; 306 | 307 | let assert_fixture_contents = |want: &str| -> Result<()> { 308 | let fix = grp.open("test.sh").context("Could not open fixture")?; 309 | let got = fs::read_to_string(fix.dir().join("bar")) 310 | .context("Unable to read generated file")?; 311 | assert_eq!(want, got, "contents of generated file did not match"); 312 | 313 | Ok(()) 314 | }; 315 | 316 | // Create a fixture for the first time. 317 | fs::write(fixtures.join("test.sh"), "echo foo > bar") 318 | .context("Failed to write test file")?; 319 | assert_fixture_contents("foo\n").context("Unable to create initial fixture from script")?; 320 | 321 | // Verify archive exists and read from it. 322 | let archive_hash = 323 | hash_file(&fixtures.join("test.tar.xz")).context("Failed to hash generated archive")?; 324 | assert_fixture_contents("foo\n").context("Unable to load fixture from archive")?; 325 | 326 | // Invalidate the archive and reopen. 327 | fs::write(fixtures.join("test.sh"), "echo 'baz qux' > bar") 328 | .context("Failed to overwrite test file")?; 329 | assert_fixture_contents("baz qux\n").context("Unable to overwrite outdated archive")?; 330 | let new_archive_hash = 331 | hash_file(&fixtures.join("test.tar.xz")).context("Failed to hash updated archive")?; 332 | 333 | assert_ne!( 334 | archive_hash, new_archive_hash, 335 | "generated archive should be updated when fixture changes" 336 | ); 337 | 338 | Ok(()) 339 | } 340 | 341 | #[test] 342 | fn bad_fixture_script() -> Result<()> { 343 | let tempdir = tempfile::tempdir()?; 344 | let fixtures = tempdir.path(); 345 | let grp = Group { 346 | dir: fixtures.to_path_buf(), 347 | getenv, 348 | }; 349 | 350 | fs::write(fixtures.join("test.sh"), "false")?; 351 | let err = grp 352 | .open("test.sh") 353 | .expect_err("fixture execution should fail"); 354 | assert!( 355 | format!("{}", err).contains(r#"test.sh" failed"#), 356 | "unexpected message: {}", 357 | err 358 | ); 359 | 360 | Ok(()) 361 | } 362 | 363 | fn getenv_ci(s: &str) -> StdResult { 364 | match s { 365 | "CI" => Ok("true".to_string()), 366 | _ => env::var(s), 367 | } 368 | } 369 | 370 | #[test] 371 | fn new_archives_not_allowed_in_ci() -> Result<()> { 372 | let tempdir = tempfile::tempdir()?; 373 | let fixtures = tempdir.path(); 374 | let grp = Group { 375 | dir: fixtures.to_path_buf(), 376 | getenv: getenv_ci, 377 | }; 378 | 379 | fs::write(fixtures.join("test.sh"), "echo foo > bar")?; 380 | let err = grp 381 | .open("test.sh") 382 | .expect_err("fixture execution should fail"); 383 | assert!( 384 | format!("{}", err).contains(r#"test.tar.xz" is outdated"#), 385 | "unexpected message: {}", 386 | err 387 | ); 388 | 389 | Ok(()) 390 | } 391 | } 392 | -------------------------------------------------------------------------------- /tools/test/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! restack_testtools provides functionality used by 2 | //! restack's tests and integration tests. 3 | 4 | #![warn(missing_docs)] 5 | 6 | pub mod gitscript; 7 | --------------------------------------------------------------------------------